diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index 925f7e6de2..086a6a9f79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -43,7 +43,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.layout.PaneAdaptedValue @@ -83,6 +82,7 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.signal.core.ui.compose.Snackbars import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.getSerializableCompat import org.signal.core.util.logging.Log @@ -325,7 +325,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner mainNavigationViewModel.snackbarRegistry.emit( SnackbarState( message = getString(R.string.CallQualitySheet__thanks_for_your_feedback), - duration = SnackbarDuration.Short, + duration = Snackbars.Duration.SHORT, hostKey = MainSnackbarHostKey.Chat, fallbackKey = MainSnackbarHostKey.MainChrome ) @@ -903,7 +903,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner mainNavigationViewModel.snackbarRegistry.emit( SnackbarState( message = getString(R.string.VerifyBackupKey__backup_key_correct), - duration = SnackbarDuration.Short, + duration = Snackbars.Duration.SHORT, hostKey = MainSnackbarHostKey.MainChrome ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt index b700490f50..9050a8d516 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -7,7 +7,6 @@ import android.view.View import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog -import androidx.compose.material3.SnackbarDuration import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -22,6 +21,7 @@ import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.kotlin.Flowables import io.reactivex.rxjava3.kotlin.subscribeBy import kotlinx.coroutines.launch +import org.signal.core.ui.compose.Snackbars import org.signal.core.util.DimensionUnit import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.concurrent.addTo @@ -458,7 +458,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal mainNavigationViewModel.snackbarRegistry.emit( SnackbarState( message = snackbarMessage, - duration = SnackbarDuration.Short, + duration = Snackbars.Duration.SHORT, hostKey = MainSnackbarHostKey.MainChrome ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index e9961f9885..34b77ce6b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -13,7 +13,6 @@ import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar import org.signal.core.util.AppUtil import org.signal.core.util.ThreadUtil import org.signal.core.util.concurrent.SignalExecutors @@ -33,6 +32,8 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.app.privacy.advanced.AdvancedPrivacySettingsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.components.snackbars.SnackbarState +import org.thoughtcrime.securesms.components.snackbars.makeSnackbar import org.thoughtcrime.securesms.conversation.ConversationIntents import org.thoughtcrime.securesms.database.JobDatabase import org.thoughtcrime.securesms.database.LocalMetricsDatabase @@ -100,7 +101,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter setFragmentResultListener(CallQualityBottomSheetFragment.REQUEST_KEY) { _, bundle -> if (bundle.getBoolean(CallQualityBottomSheetFragment.REQUEST_KEY, false)) { - Snackbar.make(requireView(), R.string.CallQualitySheet__thanks_for_your_feedback, Snackbar.LENGTH_SHORT).show() + makeSnackbar( + SnackbarState( + message = getString(R.string.CallQualitySheet__thanks_for_your_feedback) + ) + ) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/pnp/PhoneNumberPrivacySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/pnp/PhoneNumberPrivacySettingsFragment.kt index 1d5f4fb8a2..0524fb3311 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/pnp/PhoneNumberPrivacySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/pnp/PhoneNumberPrivacySettingsFragment.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -31,7 +30,9 @@ import org.signal.core.ui.compose.Dividers import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Rows import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.Snackbars import org.signal.core.ui.compose.Texts +import org.signal.core.ui.compose.showSnackbar import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection @@ -73,7 +74,7 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() { lifecycleScope.launch { snackbarHostState.showSnackbar( message = snackbarMessage, - duration = SnackbarDuration.Short + duration = Snackbars.Duration.SHORT ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarExtensions.kt index dc311d49b7..3c513c71a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarExtensions.kt @@ -5,9 +5,12 @@ package org.thoughtcrime.securesms.components.snackbars -import androidx.compose.material3.SnackbarDuration import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.signal.core.ui.compose.Snackbars fun Fragment.makeSnackbar(state: SnackbarState) { if (view == null) { @@ -18,9 +21,10 @@ fun Fragment.makeSnackbar(state: SnackbarState) { requireView(), state.message, when (state.duration) { - SnackbarDuration.Short -> Snackbar.LENGTH_SHORT - SnackbarDuration.Long -> Snackbar.LENGTH_LONG - SnackbarDuration.Indefinite -> Snackbar.LENGTH_INDEFINITE + Snackbars.Duration.SHORT -> Snackbar.LENGTH_SHORT + Snackbars.Duration.LONG -> Snackbar.LENGTH_LONG + Snackbars.Duration.INDEFINITE -> Snackbar.LENGTH_INDEFINITE + else -> Snackbar.LENGTH_INDEFINITE } ) @@ -30,4 +34,11 @@ fun Fragment.makeSnackbar(state: SnackbarState) { } snackbar.show() + + if (state.duration is Snackbars.Duration.Custom) { + viewLifecycleOwner.lifecycleScope.launch { + delay(state.duration.duration) + snackbar.dismiss() + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarState.kt index f9c6138a3e..9d66057006 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarState.kt @@ -6,7 +6,7 @@ package org.thoughtcrime.securesms.components.snackbars import androidx.annotation.ColorRes -import androidx.compose.material3.SnackbarDuration +import org.signal.core.ui.compose.Snackbars import org.thoughtcrime.securesms.R /** @@ -16,15 +16,15 @@ import org.thoughtcrime.securesms.R * @property actionState Optional action button configuration. * @property showProgress Whether to show a progress indicator in the snackbar. * @property duration How long the snackbar should be displayed. - * @property hostKey The target host where this snackbar should be displayed. + * @property hostKey The target host where this snackbar should be displayed. Defaults to [SnackbarHostKey.Global] * @property fallbackKey Optional host to fallback upon if the host key is not registered. Defaults to the Global key. */ data class SnackbarState( val message: String, val actionState: ActionState? = null, val showProgress: Boolean = false, - val duration: SnackbarDuration = SnackbarDuration.Long, - val hostKey: SnackbarHostKey, + val duration: Snackbars.Duration = Snackbars.Duration.SHORT, + val hostKey: SnackbarHostKey = SnackbarHostKey.Global, val fallbackKey: SnackbarHostKey? = SnackbarHostKey.Global ) { /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java index 8317d4af69..f329ea8cab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java @@ -26,7 +26,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.annotation.WorkerThread; -import androidx.compose.material3.SnackbarDuration; +import org.signal.core.ui.compose.Snackbars; import androidx.recyclerview.widget.RecyclerView; import org.signal.core.util.concurrent.LifecycleDisposable; @@ -152,7 +152,7 @@ public class ConversationListArchiveFragment extends ConversationListFragment } ), false, - SnackbarDuration.Long, + Snackbars.Duration.LONG, MainSnackbarHostKey.MainChrome.INSTANCE, null )); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 63dcfc575d..4b5024ca10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -42,7 +42,7 @@ import androidx.annotation.Nullable; import androidx.annotation.PluralsRes; import androidx.annotation.WorkerThread; import androidx.appcompat.content.res.AppCompatResources; -import androidx.compose.material3.SnackbarDuration; +import org.signal.core.ui.compose.Snackbars; import androidx.compose.ui.platform.ComposeView; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.content.ContextCompat; @@ -991,7 +991,7 @@ public class ConversationListFragment extends MainFragment implements Conversati } ), showProgress, - SnackbarDuration.Long, + Snackbars.Duration.LONG, MainSnackbarHostKey.MainChrome.INSTANCE, null )); @@ -1075,7 +1075,7 @@ public class ConversationListFragment extends MainFragment implements Conversati getString(R.string.conversation_list__you_can_only_pin_up_to_d_chats, MAXIMUM_PINNED_CONVERSATIONS), null, false, - SnackbarDuration.Long, + Snackbars.Duration.LONG, MainSnackbarHostKey.MainChrome.INSTANCE, null )); @@ -1441,7 +1441,7 @@ public class ConversationListFragment extends MainFragment implements Conversati } ), false, - SnackbarDuration.Long, + Snackbars.Duration.LONG, MainSnackbarHostKey.MainChrome.INSTANCE, null )); diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt index e63a62fd37..d1c7d81025 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt @@ -24,6 +24,7 @@ import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Snackbars +import org.signal.core.ui.compose.showSnackbar import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey import org.thoughtcrime.securesms.components.snackbars.SnackbarState import org.thoughtcrime.securesms.components.snackbars.rememberSnackbarState diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinSettingsFragment.kt index 749e7bd325..fa02a8ab29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPinSettingsFragment.kt @@ -9,7 +9,6 @@ import android.content.Intent import android.os.Bundle import android.view.View import androidx.compose.foundation.layout.padding -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -34,6 +33,7 @@ import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Rows import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.Snackbars +import org.signal.core.ui.compose.showSnackbar import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyDisplayFragment import org.thoughtcrime.securesms.compose.ComposeFragment @@ -146,7 +146,7 @@ class AdvancedPinSettingsFragment : ComposeFragment() { viewLifecycleOwner.lifecycleScope.launch { viewModel.snackbarHostState.showSnackbar( message = getString(R.string.ApplicationPreferencesActivity_pin_disabled), - duration = SnackbarDuration.Long + duration = Snackbars.Duration.LONG ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.kt index 81cfa8cfdf..dd8a1db813 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.kt @@ -31,7 +31,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SecondaryTabRow -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Tab @@ -78,6 +77,7 @@ import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent import org.signal.core.ui.compose.copied.androidx.compose.DraggableItem import org.signal.core.ui.compose.copied.androidx.compose.dragContainer import org.signal.core.ui.compose.copied.androidx.compose.rememberDragDropState +import org.signal.core.ui.compose.showSnackbar import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.menu.ActionItem @@ -625,7 +625,7 @@ private fun SnackbarHost( if (snackbarMessage != null) { val result = hostState.showSnackbar( message = snackbarMessage, - duration = SnackbarDuration.Short, + duration = Snackbars.Duration.SHORT, withDismissAction = false ) diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/snackbars/SnackbarStateConsumerRegistryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/snackbars/SnackbarStateConsumerRegistryTest.kt index c2bfa7869f..89a6a3b71b 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/snackbars/SnackbarStateConsumerRegistryTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/snackbars/SnackbarStateConsumerRegistryTest.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.components.snackbars -import androidx.compose.material3.SnackbarDuration import androidx.core.util.Consumer import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -10,6 +9,7 @@ import io.mockk.mockk import io.mockk.verify import org.junit.Before import org.junit.Test +import org.signal.core.ui.compose.Snackbars class SnackbarStateConsumerRegistryTest { @@ -256,7 +256,7 @@ class SnackbarStateConsumerRegistryTest { message = "Test message", hostKey = hostKey, fallbackKey = fallbackKey, - duration = SnackbarDuration.Short + duration = Snackbars.Duration.SHORT ) } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Snackbars.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Snackbars.kt index 685adeefce..ef91418d09 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/Snackbars.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Snackbars.kt @@ -10,10 +10,16 @@ import androidx.compose.material3.SnackbarData import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarVisuals import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.signal.core.ui.compose.theme.LocalSnackbarColors +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds /** * Properly themed Snackbars. Since these use internal color state, we need to @@ -21,6 +27,48 @@ import org.signal.core.ui.compose.theme.LocalSnackbarColors * allow for quick and easy access to the proper theming for snackbars. */ object Snackbars { + + /** + * Snackbar duration that allows specifying exact durations or using predefined + * durations that match view-based snackbar timings. + * + * Compose's default [SnackbarDuration] values are significantly longer than view-based snackbars: + * - Compose Short = 4000ms vs View-based LENGTH_SHORT = 1500ms + * - Compose Long = 10000ms vs View-based LENGTH_LONG = 2750ms + * + * This sealed class provides durations that match view-based behavior for consistency. + */ + sealed class Duration { + abstract val duration: kotlin.time.Duration? + + companion object { + /** 1500ms - matches view-based Snackbar.LENGTH_SHORT */ + @JvmField + val SHORT: Duration = Short + + /** 2750ms - matches view-based Snackbar.LENGTH_LONG */ + @JvmField + val LONG: Duration = Long + + @JvmField + val INDEFINITE: Duration = Indefinite + } + + private data object Short : Duration() { + override val duration: kotlin.time.Duration = 1500.milliseconds + } + + private data object Long : Duration() { + override val duration: kotlin.time.Duration = 2750.milliseconds + } + + private data object Indefinite : Duration() { + override val duration: kotlin.time.Duration? = null + } + + data class Custom(override val duration: kotlin.time.Duration) : Duration() + } + @Composable fun Host(snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier) { SnackbarHost(hostState = snackbarHostState, modifier = modifier) { @@ -42,6 +90,55 @@ object Snackbars { } } +/** + * Shows a snackbar with custom duration support. + * + * Unlike the standard [SnackbarHostState.showSnackbar] which only supports Compose's + * [SnackbarDuration] (Short=4s, Long=10s, Indefinite), this function allows specifying + * exact durations via [Snackbars.Duration], including durations that match view-based snackbars. + * + * @param message The message to display in the snackbar. + * @param actionLabel Optional action label to show. If null, no action button is shown. + * @param withDismissAction Whether to show a dismiss action (X button). + * @param duration The duration to show the snackbar. Defaults to [Snackbars.Duration.SHORT] (1500ms). + * @return [SnackbarResult] indicating whether the snackbar was dismissed or the action was performed. + */ +suspend fun SnackbarHostState.showSnackbar( + message: String, + actionLabel: String? = null, + withDismissAction: Boolean = false, + duration: Snackbars.Duration = Snackbars.Duration.SHORT +): SnackbarResult { + val durationValue = duration.duration + + return if (durationValue == null) { + showSnackbar( + message = message, + actionLabel = actionLabel, + withDismissAction = withDismissAction, + duration = SnackbarDuration.Indefinite + ) + } else { + coroutineScope { + val dismissJob = launch { + delay(durationValue) + currentSnackbarData?.dismiss() + } + + val result = showSnackbar( + message = message, + actionLabel = actionLabel, + withDismissAction = withDismissAction, + duration = SnackbarDuration.Indefinite + ) + + dismissJob.cancel() + + result + } + } +} + @DayNightPreviews @Composable private fun SnackbarPreview() {