diff --git a/app/src/main/java/com/tangem/tap/routing/RootContent.kt b/app/src/main/java/com/tangem/tap/routing/RootContent.kt index d0640e59aa..077688613f 100644 --- a/app/src/main/java/com/tangem/tap/routing/RootContent.kt +++ b/app/src/main/java/com/tangem/tap/routing/RootContent.kt @@ -66,9 +66,6 @@ internal fun RootContent( }, ) - EventMessageEffect( - messageHandler = uiDependencies.eventMessageHandler, - snackbarHostState = snackbarHostState, - ) + EventMessageEffect() } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt index 462a81383e..2f709d7943 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt @@ -1,6 +1,5 @@ package com.tangem.tap.routing.component.impl -import androidx.compose.material3.SnackbarDuration import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -99,10 +98,10 @@ internal class DefaultRoutingComponent @AssistedInject constructor( val message = SnackbarMessage( message = text, duration = when (length) { - Snackbar.LENGTH_SHORT -> SnackbarDuration.Short - Snackbar.LENGTH_LONG -> SnackbarDuration.Long - Snackbar.LENGTH_INDEFINITE -> SnackbarDuration.Indefinite - else -> SnackbarDuration.Short + Snackbar.LENGTH_SHORT -> SnackbarMessage.Duration.Short + Snackbar.LENGTH_LONG -> SnackbarMessage.Duration.Long + Snackbar.LENGTH_INDEFINITE -> SnackbarMessage.Duration.Indefinite + else -> SnackbarMessage.Duration.Short }, actionLabel = buttonTitle, action = action, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt new file mode 100644 index 0000000000..bc1f77ae7f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt @@ -0,0 +1,156 @@ +package com.tangem.core.ui.components.bottomsheets.message + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.isNullOrEmpty +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +fun MessageBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet(config) { notification: MessageBottomSheetUM -> + Content(model = notification) + } +} + +@Composable +internal fun Content(model: MessageBottomSheetUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(modifier = Modifier) + + if (model.iconResId != null) { + Icon( + modifier = Modifier.size(48.dp), + painter = painterResource(model.iconResId), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + } + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (!model.title.isNullOrEmpty()) { + Text( + text = model.title.resolveReference(), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + } + + Text( + text = model.message.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + } + + Column( + modifier = Modifier + .padding(bottom = 16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (model.primaryAction != null) { + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = model.primaryAction.text.resolveReference(), + enabled = model.primaryAction.enabled, + onClick = model.primaryAction.onClick, + ) + } + + if (model.secondaryAction != null) { + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + text = model.secondaryAction.text.resolveReference(), + enabled = model.secondaryAction.enabled, + onClick = model.secondaryAction.onClick, + ) + } + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_NotificationBottomSheet( + @PreviewParameter(MessageBottomSheetUMPreviewProvider::class) params: MessageBottomSheetUM, +) { + TangemThemePreview { + MessageBottomSheet( + config = TangemBottomSheetConfig( + content = params, + isShow = true, + onDismissRequest = {}, + ), + ) + } +} + +private class MessageBottomSheetUMPreviewProvider : PreviewParameterProvider { + val balancesHiddenMessage = MessageBottomSheetUM( + iconResId = R.drawable.ic_eye_off_outline_24, + title = resourceReference(R.string.balance_hidden_title), + message = resourceReference(R.string.balance_hidden_description), + primaryAction = MessageBottomSheetUM.ActionUM( + text = resourceReference(R.string.balance_hidden_got_it_button), + onClick = {}, + ), + secondaryAction = MessageBottomSheetUM.ActionUM( + text = resourceReference(R.string.balance_hidden_do_not_show_button), + onClick = {}, + ), + ) + + override val values: Sequence + get() = sequenceOf( + balancesHiddenMessage, + balancesHiddenMessage.copy(title = null), + balancesHiddenMessage.copy(iconResId = null), + balancesHiddenMessage.copy(primaryAction = null), + balancesHiddenMessage.copy(secondaryAction = null), + balancesHiddenMessage.copy( + primaryAction = null, + secondaryAction = null, + ), + balancesHiddenMessage.copy( + title = null, + iconResId = null, + primaryAction = null, + secondaryAction = null, + ), + ) +} +// endregion Preview \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUM.kt new file mode 100644 index 0000000000..88a40bd846 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetUM.kt @@ -0,0 +1,20 @@ +package com.tangem.core.ui.components.bottomsheets.message + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference + +data class MessageBottomSheetUM( + @DrawableRes val iconResId: Int?, + val title: TextReference?, + val message: TextReference, + val primaryAction: ActionUM?, + val secondaryAction: ActionUM?, +) : TangemBottomSheetConfigContent { + + data class ActionUM( + val text: TextReference, + val enabled: Boolean = true, + val onClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt index f500f27e90..0828f90e8d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessage.kt @@ -1,11 +1,14 @@ package com.tangem.core.ui.message -import androidx.compose.material3.SnackbarDuration +import androidx.annotation.DrawableRes import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.tangem.core.decompose.ui.UiMessage +import com.tangem.core.ui.R import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.SnackbarMessage.Duration /** * Event that is used to show a message in the UI. @@ -19,16 +22,40 @@ sealed interface EventMessage : UiMessage * Shows a snackbar. * * @param message The message to show. - * @param duration The duration of the snackbar. - * @param actionLabel The label of the action button. - * @param action The action to perform when the action button is clicked. + * @param duration The duration of the snackbar. Optional, [Duration.Short] by default. + * @param onDismissRequest The action to perform when the snackbar is dismissed. Optional, empty lambda by default. + * @param actionLabel The label of the action button. Optional, `null` by default. + * @param action The action to perform when the action button is clicked. Optional, `null` by default. * */ data class SnackbarMessage( val message: TextReference, - val duration: SnackbarDuration = SnackbarDuration.Short, + val duration: Duration = Duration.Short, + val onDismissRequest: () -> Unit = {}, val actionLabel: TextReference? = null, val action: (() -> Unit)? = null, -) : EventMessage +) : EventMessage { + + /** + * The duration of the snackbar. + * */ + enum class Duration { + + /** + * Shows the snackbar for a short period of time. + * */ + Short, + + /** + * Shows the snackbar for a long period of time. + * */ + Long, + + /** + * Shows the snackbar indefinitely, until dismissed or action performed. + * */ + Indefinite, + } +} /** * Shows a [content] in the UI. @@ -44,4 +71,170 @@ data class ContentMessage(val content: Content) : EventMessage { @Composable operator fun invoke(onDismiss: () -> Unit) } +} + +/** + * Shows an alert dialog. + * + * @param message The message to show. + * @param title The title of the dialog. Optional, `null` by default. + * @param firstAction The first dialog action to perform. + * @param secondAction The second dialog action to perform. Optional, `null` by default. + * Performing the second action always dismisses the dialog. + * @param isDismissable If `false` then dialog can not be dismissed by back button click or by outside click. + * @param dismissOnFirstAction If `true` then dialog is dismissed when first action is performed, + * ignoring lambda passed to [EventMessageAction.onClick]. + * `true` by default. + * @param onDismissRequest The action to perform when the dialog is dismissed. + * */ +data class DialogMessage( + val message: TextReference, + val title: TextReference? = null, + val firstAction: EventMessageAction, + val secondAction: EventMessageAction? = null, + val isDismissable: Boolean = true, + val dismissOnFirstAction: Boolean = true, + val onDismissRequest: () -> Unit = {}, +) : EventMessage { + + companion object { + + /** + * Builder for [DialogMessage]. + * + * @param message The message to show. + * @param title The title of the dialog. Optional, `null` by default. + * @param isDismissable If `false` then dialog can not be dismissed by back button click or by outside click. + * `true` by default. + * @param dismissOnFirstAction If `true` then dialog is dismissed when first action is performed, + * ignoring lambda passed to [EventMessageAction.onClick]. + * `true` by default. + * @param onDismissRequest The action to perform when the dialog is dismissed. + * @param firstActionBuilder The builder for the first action. + * By default, it builds an action with title "OK". + * @param secondActionBuilder The builder for the second action. Optional, `null` by default. + * Performing the second action always dismisses the dialog. + * + * @return [DialogMessage] with the specified parameters. + * */ + operator fun invoke( + message: TextReference, + title: TextReference? = null, + isDismissable: Boolean = true, + dismissOnFirstAction: Boolean = true, + onDismissRequest: () -> Unit = {}, + firstActionBuilder: EventMessageAction.BuilderScope.() -> EventMessageAction = { okAction() }, + secondActionBuilder: (EventMessageAction.BuilderScope.() -> EventMessageAction)? = null, + ): DialogMessage { + val buttonsScope = EventMessageAction.BuilderScope(onDismissRequest) + + return DialogMessage( + message = message, + title = title, + isDismissable = isDismissable, + dismissOnFirstAction = dismissOnFirstAction, + onDismissRequest = onDismissRequest, + firstAction = firstActionBuilder(buttonsScope), + secondAction = secondActionBuilder?.invoke(buttonsScope), + ) + } + } +} + +/** + * Shows a bottom sheet. + * + * @param iconResId The icon to show in the bottom sheet. + * Optional, `null` by default. + * @param title The title of the bottom sheet. Optional, `null` by default. + * @param message The message to show in the bottom sheet. + * @param firstAction The first action to perform. Optional, `null` by default. + * @param secondAction The second action to perform. Optional, `null` by default. + * @param onDismissRequest The action to perform when the bottom sheet is dismissed. + * */ +data class BottomSheetMessage( + @DrawableRes val iconResId: Int? = null, + val title: TextReference? = null, + val message: TextReference, + val firstAction: EventMessageAction? = null, + val secondAction: EventMessageAction? = null, + val onDismissRequest: () -> Unit = {}, +) : EventMessage { + + companion object { + + /** + * Builder for [BottomSheetMessage]. + * + * @param iconResId The icon to show in the bottom sheet. Optional, `null` by default. + * @param title The title of the bottom sheet. Optional, `null` by default. + * @param message The message to show in the bottom sheet. + * @param onDismissRequest The action to perform when the bottom sheet is dismissed. + * @param firstActionBuilder The builder for the first action. Optional, `null` by default. + * @param secondActionBuilder The builder for the second action. Optional, `null` by default. + * */ + operator fun invoke( + @DrawableRes iconResId: Int?, + title: TextReference?, + message: TextReference, + onDismissRequest: () -> Unit = {}, + firstActionBuilder: (EventMessageAction.BuilderScope.() -> EventMessageAction)? = null, + secondActionBuilder: (EventMessageAction.BuilderScope.() -> EventMessageAction)? = null, + ): BottomSheetMessage { + val buttonsScope = EventMessageAction.BuilderScope(onDismissRequest) + + return BottomSheetMessage( + iconResId = iconResId, + title = title, + message = message, + onDismissRequest = onDismissRequest, + firstAction = firstActionBuilder?.invoke(buttonsScope), + secondAction = secondActionBuilder?.invoke(buttonsScope), + ) + } + } +} + +/** + * Represents an action button in the dialog. + * + * @param title The title of the action. + * @param warning If `true` then the action is highlighted as a warning. + * @param enabled If `false` then the action is disabled. + * @param onClick The action to perform when the button is clicked. + * */ +data class EventMessageAction( + val title: TextReference, + val warning: Boolean = false, + val enabled: Boolean = true, + val onClick: () -> Unit, +) { + + /** + * Scope for building buttons for the message. + * + * @param onDismissRequest The action to perform when the dialog is dismissed. + * */ + class BuilderScope internal constructor(val onDismissRequest: () -> Unit) { + + /** + * Builds an action with title "OK". + * + * @param onClick The action to perform when the button is clicked. By default, it dismisses the message. + * */ + fun okAction(onClick: () -> Unit = onDismissRequest) = EventMessageAction( + title = resourceReference(id = R.string.common_ok), + onClick = onClick, + ) + + /** + * Builds an action with title "Cancel". + * + * @param onClick The action to perform when the button is clicked. By default, it dismisses the message. + * */ + fun cancelAction(onClick: () -> Unit = onDismissRequest) = EventMessageAction( + title = resourceReference(id = R.string.common_cancel), + onClick = onClick, + ) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt index 41475b987a..0943fb1d50 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/message/EventMessageEffect.kt @@ -1,26 +1,49 @@ package com.tangem.core.ui.message import android.content.Context +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalContext +import com.tangem.core.ui.components.BasicDialog +import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.LocalEventMessageHandler +import com.tangem.core.ui.res.LocalSnackbarHostState @Composable -fun EventMessageEffect(messageHandler: EventMessageHandler, snackbarHostState: SnackbarHostState) { +fun EventMessageEffect( + messageHandler: EventMessageHandler = LocalEventMessageHandler.current, + snackbarHostState: SnackbarHostState = LocalSnackbarHostState.current, + onShowSnackbar: suspend (SnackbarMessage, Context) -> Unit = { message, context -> + showSnackbar(snackbarHostState, message, context) + }, +) { val messageEvent by messageHandler.collectAsState() val context = LocalContext.current + var contentMessage: ContentMessage? by remember { mutableStateOf(value = null) } + var dialogMessage: DialogMessage? by remember { mutableStateOf(value = null) } + var bottomSheetMessage: BottomSheetMessage? by remember { mutableStateOf(value = null) } EventEffect(event = messageEvent) { message -> when (message) { + is SnackbarMessage -> { + onShowSnackbar(message, context) + } is ContentMessage -> { contentMessage = message } - is SnackbarMessage -> { - showSnackbar(snackbarHostState, message, context) + is DialogMessage -> { + dialogMessage = message + } + is BottomSheetMessage -> { + bottomSheetMessage = message } } } @@ -28,16 +51,110 @@ fun EventMessageEffect(messageHandler: EventMessageHandler, snackbarHostState: S contentMessage?.content?.invoke( onDismiss = { contentMessage = null }, ) + + dialogMessage?.let { message -> + MessageDialog( + message = message, + onDismissRequest = { + dialogMessage = null + message.onDismissRequest() + }, + ) + } + + bottomSheetMessage?.let { message -> + MessageBottomSheet( + message = message, + onDismissRequest = { + bottomSheetMessage = null + message.onDismissRequest() + }, + ) + } +} + +@Composable +private fun MessageBottomSheet(message: BottomSheetMessage, onDismissRequest: () -> Unit) { + val config = TangemBottomSheetConfig( + isShow = true, + content = MessageBottomSheetUM( + iconResId = message.iconResId, + title = message.title, + message = message.message, + primaryAction = message.firstAction?.let { action -> + MessageBottomSheetUM.ActionUM( + text = action.title, + enabled = action.enabled, + onClick = { + action.onClick() + onDismissRequest() + }, + ) + }, + secondaryAction = message.secondAction?.let { action -> + MessageBottomSheetUM.ActionUM( + text = action.title, + enabled = action.enabled, + onClick = { + action.onClick() + onDismissRequest() + }, + ) + }, + ), + onDismissRequest = onDismissRequest, + ) + + MessageBottomSheet(config) +} + +@Composable +private fun MessageDialog(message: DialogMessage, onDismissRequest: () -> Unit) { + BasicDialog( + message = message.message.resolveReference(), + title = message.title?.resolveReference(), + confirmButton = message.firstAction.let { action -> + DialogButtonUM( + title = action.title.resolveReference(), + warning = action.warning, + enabled = action.enabled, + onClick = { + action.onClick() + + if (message.dismissOnFirstAction) { + onDismissRequest() + } + }, + ) + }, + dismissButton = message.secondAction?.let { action -> + DialogButtonUM( + title = action.title.resolveReference(), + warning = action.warning, + enabled = action.enabled, + onClick = { + action.onClick() + onDismissRequest() + }, + ) + }, + onDismissDialog = onDismissRequest, + ) } private suspend fun showSnackbar(snackbarHostState: SnackbarHostState, message: SnackbarMessage, context: Context) { val result = snackbarHostState.showSnackbar( message = message.message.resolveReference(context.resources), actionLabel = message.actionLabel?.resolveReference(context.resources), - duration = message.duration, + duration = when (message.duration) { + SnackbarMessage.Duration.Short -> SnackbarDuration.Short + SnackbarMessage.Duration.Long -> SnackbarDuration.Long + SnackbarMessage.Duration.Indefinite -> SnackbarDuration.Indefinite + }, ) - if (result == SnackbarResult.ActionPerformed) { - message.action?.invoke() + when (result) { + SnackbarResult.Dismissed -> message.onDismissRequest() + SnackbarResult.ActionPerformed -> message.action?.invoke() } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index 313b6088d6..821dcd5bde 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -17,6 +17,7 @@ import com.tangem.core.ui.components.TangemShimmer import com.tangem.core.ui.haptic.DefaultHapticManager import com.tangem.core.ui.haptic.HapticManager import com.tangem.core.ui.haptic.VibratorHapticManager +import com.tangem.core.ui.message.EventMessageHandler import com.tangem.core.ui.windowsize.WindowSize import com.tangem.core.ui.windowsize.rememberWindowSize import com.tangem.domain.apptheme.model.AppThemeMode @@ -39,6 +40,7 @@ fun TangemTheme( windowSize = windowSize, vibratorHapticManager = uiDependencies.vibratorHapticManager, snackbarHostState = uiDependencies.globalSnackbarHostState, + eventMessageHandler = uiDependencies.eventMessageHandler, overrideSystemBarColors = overrideSystemBarColors, typography = typography, dimens = dimens, @@ -53,6 +55,7 @@ fun TangemTheme( typography: TangemTypography = TangemTheme.typography, dimens: TangemDimens = TangemTheme.dimens, vibratorHapticManager: VibratorHapticManager? = null, + eventMessageHandler: EventMessageHandler = remember { EventMessageHandler() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, overrideSystemBarColors: Boolean = true, content: @Composable () -> Unit, @@ -93,6 +96,7 @@ fun TangemTheme( LocalIsInDarkTheme provides isDark, LocalHapticManager provides hapticManager, LocalSnackbarHostState provides snackbarHostState, + LocalEventMessageHandler provides eventMessageHandler, LocalWindowSize provides windowSize, ) { CompositionLocalProvider( @@ -285,6 +289,10 @@ val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") } +val LocalEventMessageHandler = staticCompositionLocalOf { + error("No EventMessageHandler provided") +} + val LocalWindowSize = staticCompositionLocalOf { error("No WindowSize provided") }