Loading feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/DisplayInAppNotificationFlag.kt 0 → 100644 +14 −0 Original line number Original line Diff line number Diff line package net.thunderbird.feature.notification.api.ui.host import kotlinx.collections.immutable.toPersistentSet enum class DisplayInAppNotificationFlag { BannerGlobalNotifications, BannerInlineNotifications, SnackbarNotifications, ; companion object { val AllNotifications = DisplayInAppNotificationFlag.entries.toPersistentSet() } } feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolder.kt 0 → 100644 +141 −0 Original line number Original line Diff line number Diff line package net.thunderbird.feature.notification.api.ui.host import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import net.thunderbird.feature.notification.api.content.InAppNotification import net.thunderbird.feature.notification.api.ui.host.visual.BannerGlobalVisual import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual import net.thunderbird.feature.notification.api.ui.host.visual.InAppNotificationHostState import net.thunderbird.feature.notification.api.ui.host.visual.InAppNotificationVisual import net.thunderbird.feature.notification.api.ui.host.visual.SnackbarVisual @Stable class InAppNotificationHostStateHolder(private val enabled: ImmutableSet<DisplayInAppNotificationFlag>) { private val internalState = MutableStateFlow<InAppNotificationHostStateImpl>(value = InAppNotificationHostStateImpl()) internal val currentInAppNotificationHostState: StateFlow<InAppNotificationHostState> = internalState.asStateFlow() fun showInAppNotification( notification: InAppNotification, ) { val newData = notification.toInAppNotificationData() // TODO(#9572): If global is already present, show the one with the highest priority // show the previous one back once the higher priority has fixed and the // other wasn't internalState.update { newData.bannerGlobalVisual.showIfNeeded( ifFlagEnabled = DisplayInAppNotificationFlag.BannerGlobalNotifications, select = { bannerGlobalVisual }, transformIfDifferent = { copy(bannerGlobalVisual = it) }, ) } internalState.update { newData.bannerInlineVisuals.showIfNeeded() } internalState.update { newData.snackbarVisual.showIfNeeded( ifFlagEnabled = DisplayInAppNotificationFlag.SnackbarNotifications, select = { snackbarVisual }, transformIfDifferent = { copy(snackbarVisual = it) }, ) } } private fun ImmutableSet<BannerInlineVisual>.showIfNeeded(): InAppNotificationHostStateImpl { if (!isEnabled(flag = DisplayInAppNotificationFlag.BannerInlineNotifications)) { return internalState.value } val new = this val current = internalState.value return if (!current.bannerInlineVisuals.containsAll(new)) { current.copy( bannerInlineVisuals = (current.bannerInlineVisuals + new).toPersistentSet(), ) } else { current } } private inline fun <TVisual : InAppNotificationVisual> TVisual?.showIfNeeded( ifFlagEnabled: DisplayInAppNotificationFlag, select: InAppNotificationHostStateImpl.() -> TVisual?, transformIfDifferent: InAppNotificationHostStateImpl.(TVisual) -> InAppNotificationHostStateImpl, ): InAppNotificationHostStateImpl { if (!isEnabled(ifFlagEnabled)) { return internalState.value } val new = this val current = internalState.value return if (new != null && new != current.select()) { current.transformIfDifferent(new) } else { current } } /** * Dismisses the given in-app notification visual. * * This function is responsible for removing the specified notification visual * from the display. * * @param visual The [InAppNotificationVisual] to dismiss. */ fun dismiss(visual: InAppNotificationVisual) { internalState.update { current -> current.copy( bannerGlobalVisual = visual.nullIfDifferent(otherwise = current.bannerGlobalVisual), bannerInlineVisuals = (visual as? BannerInlineVisual)?.let { bannerInlineVisual -> (current.bannerInlineVisuals - bannerInlineVisual).toPersistentSet() } ?: current.bannerInlineVisuals, snackbarVisual = visual.nullIfDifferent(otherwise = current.snackbarVisual), ) } } private inline fun <reified T : InAppNotificationVisual> InAppNotificationVisual?.nullIfDifferent( otherwise: T?, ): T? { val current = (this as? T?) ?: otherwise return if (this == current) null else otherwise } private fun isEnabled(flag: DisplayInAppNotificationFlag): Boolean { return enabled.any { it == flag } || enabled == DisplayInAppNotificationFlag.AllNotifications } private data class InAppNotificationHostStateImpl( override val bannerGlobalVisual: BannerGlobalVisual? = null, override val bannerInlineVisuals: ImmutableSet<BannerInlineVisual> = persistentSetOf(), override val snackbarVisual: SnackbarVisual? = null, private val onDismissVisual: (InAppNotificationVisual) -> Unit = {}, ) : InAppNotificationHostState private fun InAppNotification.toInAppNotificationData(): InAppNotificationHostState = InAppNotificationHostStateImpl( bannerGlobalVisual = BannerGlobalVisual.from(notification = this), bannerInlineVisuals = BannerInlineVisual.from(notification = this).toPersistentSet(), snackbarVisual = SnackbarVisual.from(notification = this), ) override fun toString(): String { return "InAppNotificationHostState(" + "enabled=$enabled, currentInAppNotificationData=${currentInAppNotificationHostState.value})" } } @Composable fun rememberInAppNotificationHostState( enabled: ImmutableSet<DisplayInAppNotificationFlag> = DisplayInAppNotificationFlag.AllNotifications, ): InAppNotificationHostStateHolder { return remember { InAppNotificationHostStateHolder(enabled) } } feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/visual/InAppNotificationHostState.kt 0 → 100644 +35 −0 Original line number Original line Diff line number Diff line package net.thunderbird.feature.notification.api.ui.host.visual import androidx.compose.runtime.Stable import kotlinx.collections.immutable.ImmutableSet /** * Defines the visual representation of in-app notifications. * * This interface holds the visual data for different types of in-app notifications * that can be displayed to the user. It allows for a structured way to manage * and present notification information. */ @Stable internal interface InAppNotificationHostState { /** * The visual representation of a global banner notification. * * This property holds a [BannerGlobalVisual] object if a global banner is * currently active, or `null` if no global banner is being shown. */ val bannerGlobalVisual: BannerGlobalVisual? /** * A set of inline banner visuals that are currently active. */ val bannerInlineVisuals: ImmutableSet<BannerInlineVisual> /** * The visual representation of a snackbar notification. * * This property holds a [SnackbarVisual] object if a snackbar notification * is currently active, or `null` if no snackbar is being displayed. */ val snackbarVisual: SnackbarVisual? } feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/visual/InAppNotificationVisual.kt 0 → 100644 +221 −0 Original line number Original line Diff line number Diff line package net.thunderbird.feature.notification.api.ui.host.visual import androidx.compose.runtime.Stable import androidx.compose.ui.util.fastFilter import kotlin.time.Duration import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList import net.thunderbird.feature.notification.api.NotificationSeverity import net.thunderbird.feature.notification.api.content.InAppNotification import net.thunderbird.feature.notification.api.ui.action.NotificationAction import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_SUPPORTING_TEXT_LENGTH import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_TITLE_LENGTH import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle sealed interface InAppNotificationVisual /** * Represents the visual appearance of a [InAppNotificationStyle.BannerGlobalNotification]. * * @property message The text content to be displayed in the banner global. This can be any CharSequence, * allowing for formatted text. * @property severity The [NotificationSeverity] of the notification, indicating its importance or type. * Used to determine which BannerGlobal visual style to use. * @property action An optional [NotificationAction] that the user can perform in response to the notification. * If null, no action is available. * @property priority An integer representing the priority of the notification. Higher values typically indicate * higher priority. Used to determine which notification to display, in case of multiples * [InAppNotificationStyle.BannerGlobalNotification]. * @see InAppNotificationVisual * @see InAppNotificationStyle.BannerGlobalNotification */ @Stable data class BannerGlobalVisual( val message: CharSequence, val severity: NotificationSeverity, val action: NotificationAction?, val priority: Int, ) : InAppNotificationVisual { internal companion object { /** * Creates a [BannerGlobalVisual] from an [InAppNotification]. * * This function attempts to convert an [InAppNotification] into a [BannerGlobalVisual]. * It expects the notification to have a style of [InAppNotificationStyle.BannerGlobalNotification]. * * It performs the following checks: * - The `contentText` of the notification must not be null. * - The notification must have zero or one action. * * If the notification has a matching style and passes all checks, a [BannerGlobalVisual] is created. * Otherwise, this function returns `null`. * * @param notification The [InAppNotification] to convert. * @return A [BannerGlobalVisual] if the conversion is successful, `null` otherwise. * @throws IllegalStateException fails the check validations. */ fun from(notification: InAppNotification): BannerGlobalVisual? = notification.toVisuals<InAppNotificationStyle.BannerGlobalNotification, BannerGlobalVisual> { style -> BannerGlobalVisual( message = checkNotNull(notification.contentText) { "A notification with a BannerGlobalNotification style must have a contentText not null" }, severity = notification.severity, action = notification .actions .let { actions -> check(actions.size in 0..1) { "A notification with a BannerGlobalNotification style must have at zero or one action" } actions.toPersistentList() } .firstOrNull(), priority = style.priority, ) }.singleOrNull() } } /** * Represents the visual appearance of a [InAppNotificationStyle.BannerInlineNotification]. * * @property title The title of the notification. * @property supportingText The main content/message of the notification. * @property severity The [NotificationSeverity] of the notification, indicating its importance or type. * Used to determine which BannerGlobal visual style to use. * @property actions An immutable list of [NotificationAction] objects representing actions * the user can take in response to the notification. * @see InAppNotificationVisual * @see InAppNotificationStyle.BannerInlineNotification */ @Stable data class BannerInlineVisual( val title: CharSequence, val supportingText: CharSequence, val severity: NotificationSeverity, val actions: ImmutableList<NotificationAction>, ) : InAppNotificationVisual { internal companion object { internal const val MAX_TITLE_LENGTH = 100 internal const val MAX_SUPPORTING_TEXT_LENGTH = 200 /** * Creates a [BannerInlineVisual] from an [InAppNotification]. * * This function attempts to convert an [InAppNotification] into a [BannerInlineVisual]. * It expects the notification to have a style of [InAppNotificationStyle.BannerInlineNotification]. * * It performs the following checks: * - The `title` of the notification must have at least 1 and at most [MAX_TITLE_LENGTH] chars. * - The `contentText` of the notification must not be null and have at least 1 and at most * [MAX_SUPPORTING_TEXT_LENGTH] chars. * - The notification must have 1 or 2 actions. * * If the notification has a matching style and passes all checks, a [BannerInlineVisual] is created. * Otherwise, this function returns an empty list. * * @param notification The [InAppNotification] to convert. * @return A list containing a [BannerInlineVisual] if the conversion is successful, an empty list otherwise. * @throws IllegalStateException if any of the validation checks fail. */ fun from(notification: InAppNotification): List<BannerInlineVisual> = notification.toVisuals<InAppNotificationStyle.BannerInlineNotification, BannerInlineVisual> { style -> BannerInlineVisual( title = checkTitle(notification.title), supportingText = checkContentText(notification.contentText), severity = notification.severity, actions = notification .actions .let { actions -> check(actions.size in 1..2) { "A notification with a BannerInlineNotification style must have at one or two actions" } actions.toPersistentList() }, ) } private fun checkTitle(title: String): String { check(title.length in 1..MAX_TITLE_LENGTH) { "A notification with a BannerInlineNotification style must have a title length of 1 to " + "$MAX_TITLE_LENGTH characters." } return title } private fun checkContentText(contentText: String?): String { checkNotNull(contentText) { "A notification with a BannerInlineNotification style must have a contentText not null" } check(contentText.length in 1..MAX_SUPPORTING_TEXT_LENGTH) { "A notification with a BannerInlineNotification style must have a contentText length of 1 to " + "$MAX_SUPPORTING_TEXT_LENGTH characters." } return contentText } } } /** * Represents the visual appearance of a [InAppNotificationStyle.SnackbarNotification]. * * @property message The text message to be displayed in the snackbar. * @property action An optional [NotificationAction] that the user can perform. This is typically a * single action like "Undo" or "Dismiss". If null, no action button is shown. * @property duration The [Duration] for which the snackbar will be visible. * @see InAppNotificationVisual * @see InAppNotificationStyle.SnackbarNotification */ @Stable data class SnackbarVisual( val message: String, val action: NotificationAction?, val duration: Duration, ) : InAppNotificationVisual { internal companion object { /** * Creates a [SnackbarVisual] from an [InAppNotification]. * * This function attempts to convert an [InAppNotification] into a [SnackbarVisual]. * It expects the notification to have a style of [InAppNotificationStyle.SnackbarNotification]. * * It performs the following checks: * - The `contentText` of the notification must not be null. * - The notification must have exactly one action. * * If the notification has a matching style and passes all checks, a [SnackbarVisual] is created. * Otherwise, this function returns `null`. * * @param notification The [InAppNotification] to convert. * @return A [SnackbarVisual] if the conversion is successful, `null` otherwise. * @throws IllegalStateException if `contentText` is null or if the number of actions is not 1 * when the style is [InAppNotificationStyle.SnackbarNotification]. */ fun from(notification: InAppNotification): SnackbarVisual? = notification.toVisuals<InAppNotificationStyle.SnackbarNotification, SnackbarVisual> { style -> SnackbarVisual( message = checkNotNull(notification.contentText) { "A notification with a SnackbarNotification style must have a contentText not null" }, action = checkNotNull(notification.actions.singleOrNull()) { "A notification with a SnackbarNotification style must have exactly one action" }, duration = style.duration, ) }.singleOrNull() } } private inline fun < reified TStyle : InAppNotificationStyle, reified TVisual : InAppNotificationVisual, > InAppNotification.toVisuals( transform: (TStyle) -> TVisual, ): List<TVisual> { return inAppNotificationStyles .fastFilter { style -> style is TStyle } .map { style -> check(style is TStyle) transform(style) } } feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/style/InAppNotificationStyle.kt +3 −1 Original line number Original line Diff line number Diff line Loading @@ -27,7 +27,9 @@ sealed interface InAppNotificationStyle { /** /** * @see InAppNotificationStyleBuilder.bannerGlobal * @see InAppNotificationStyleBuilder.bannerGlobal */ */ data object BannerGlobalNotification : InAppNotificationStyle data class BannerGlobalNotification( val priority: Int, ) : InAppNotificationStyle /** /** * @see [InAppNotificationStyleBuilder.snackbar] * @see [InAppNotificationStyleBuilder.snackbar] Loading Loading
feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/DisplayInAppNotificationFlag.kt 0 → 100644 +14 −0 Original line number Original line Diff line number Diff line package net.thunderbird.feature.notification.api.ui.host import kotlinx.collections.immutable.toPersistentSet enum class DisplayInAppNotificationFlag { BannerGlobalNotifications, BannerInlineNotifications, SnackbarNotifications, ; companion object { val AllNotifications = DisplayInAppNotificationFlag.entries.toPersistentSet() } }
feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolder.kt 0 → 100644 +141 −0 Original line number Original line Diff line number Diff line package net.thunderbird.feature.notification.api.ui.host import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import net.thunderbird.feature.notification.api.content.InAppNotification import net.thunderbird.feature.notification.api.ui.host.visual.BannerGlobalVisual import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual import net.thunderbird.feature.notification.api.ui.host.visual.InAppNotificationHostState import net.thunderbird.feature.notification.api.ui.host.visual.InAppNotificationVisual import net.thunderbird.feature.notification.api.ui.host.visual.SnackbarVisual @Stable class InAppNotificationHostStateHolder(private val enabled: ImmutableSet<DisplayInAppNotificationFlag>) { private val internalState = MutableStateFlow<InAppNotificationHostStateImpl>(value = InAppNotificationHostStateImpl()) internal val currentInAppNotificationHostState: StateFlow<InAppNotificationHostState> = internalState.asStateFlow() fun showInAppNotification( notification: InAppNotification, ) { val newData = notification.toInAppNotificationData() // TODO(#9572): If global is already present, show the one with the highest priority // show the previous one back once the higher priority has fixed and the // other wasn't internalState.update { newData.bannerGlobalVisual.showIfNeeded( ifFlagEnabled = DisplayInAppNotificationFlag.BannerGlobalNotifications, select = { bannerGlobalVisual }, transformIfDifferent = { copy(bannerGlobalVisual = it) }, ) } internalState.update { newData.bannerInlineVisuals.showIfNeeded() } internalState.update { newData.snackbarVisual.showIfNeeded( ifFlagEnabled = DisplayInAppNotificationFlag.SnackbarNotifications, select = { snackbarVisual }, transformIfDifferent = { copy(snackbarVisual = it) }, ) } } private fun ImmutableSet<BannerInlineVisual>.showIfNeeded(): InAppNotificationHostStateImpl { if (!isEnabled(flag = DisplayInAppNotificationFlag.BannerInlineNotifications)) { return internalState.value } val new = this val current = internalState.value return if (!current.bannerInlineVisuals.containsAll(new)) { current.copy( bannerInlineVisuals = (current.bannerInlineVisuals + new).toPersistentSet(), ) } else { current } } private inline fun <TVisual : InAppNotificationVisual> TVisual?.showIfNeeded( ifFlagEnabled: DisplayInAppNotificationFlag, select: InAppNotificationHostStateImpl.() -> TVisual?, transformIfDifferent: InAppNotificationHostStateImpl.(TVisual) -> InAppNotificationHostStateImpl, ): InAppNotificationHostStateImpl { if (!isEnabled(ifFlagEnabled)) { return internalState.value } val new = this val current = internalState.value return if (new != null && new != current.select()) { current.transformIfDifferent(new) } else { current } } /** * Dismisses the given in-app notification visual. * * This function is responsible for removing the specified notification visual * from the display. * * @param visual The [InAppNotificationVisual] to dismiss. */ fun dismiss(visual: InAppNotificationVisual) { internalState.update { current -> current.copy( bannerGlobalVisual = visual.nullIfDifferent(otherwise = current.bannerGlobalVisual), bannerInlineVisuals = (visual as? BannerInlineVisual)?.let { bannerInlineVisual -> (current.bannerInlineVisuals - bannerInlineVisual).toPersistentSet() } ?: current.bannerInlineVisuals, snackbarVisual = visual.nullIfDifferent(otherwise = current.snackbarVisual), ) } } private inline fun <reified T : InAppNotificationVisual> InAppNotificationVisual?.nullIfDifferent( otherwise: T?, ): T? { val current = (this as? T?) ?: otherwise return if (this == current) null else otherwise } private fun isEnabled(flag: DisplayInAppNotificationFlag): Boolean { return enabled.any { it == flag } || enabled == DisplayInAppNotificationFlag.AllNotifications } private data class InAppNotificationHostStateImpl( override val bannerGlobalVisual: BannerGlobalVisual? = null, override val bannerInlineVisuals: ImmutableSet<BannerInlineVisual> = persistentSetOf(), override val snackbarVisual: SnackbarVisual? = null, private val onDismissVisual: (InAppNotificationVisual) -> Unit = {}, ) : InAppNotificationHostState private fun InAppNotification.toInAppNotificationData(): InAppNotificationHostState = InAppNotificationHostStateImpl( bannerGlobalVisual = BannerGlobalVisual.from(notification = this), bannerInlineVisuals = BannerInlineVisual.from(notification = this).toPersistentSet(), snackbarVisual = SnackbarVisual.from(notification = this), ) override fun toString(): String { return "InAppNotificationHostState(" + "enabled=$enabled, currentInAppNotificationData=${currentInAppNotificationHostState.value})" } } @Composable fun rememberInAppNotificationHostState( enabled: ImmutableSet<DisplayInAppNotificationFlag> = DisplayInAppNotificationFlag.AllNotifications, ): InAppNotificationHostStateHolder { return remember { InAppNotificationHostStateHolder(enabled) } }
feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/visual/InAppNotificationHostState.kt 0 → 100644 +35 −0 Original line number Original line Diff line number Diff line package net.thunderbird.feature.notification.api.ui.host.visual import androidx.compose.runtime.Stable import kotlinx.collections.immutable.ImmutableSet /** * Defines the visual representation of in-app notifications. * * This interface holds the visual data for different types of in-app notifications * that can be displayed to the user. It allows for a structured way to manage * and present notification information. */ @Stable internal interface InAppNotificationHostState { /** * The visual representation of a global banner notification. * * This property holds a [BannerGlobalVisual] object if a global banner is * currently active, or `null` if no global banner is being shown. */ val bannerGlobalVisual: BannerGlobalVisual? /** * A set of inline banner visuals that are currently active. */ val bannerInlineVisuals: ImmutableSet<BannerInlineVisual> /** * The visual representation of a snackbar notification. * * This property holds a [SnackbarVisual] object if a snackbar notification * is currently active, or `null` if no snackbar is being displayed. */ val snackbarVisual: SnackbarVisual? }
feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/visual/InAppNotificationVisual.kt 0 → 100644 +221 −0 Original line number Original line Diff line number Diff line package net.thunderbird.feature.notification.api.ui.host.visual import androidx.compose.runtime.Stable import androidx.compose.ui.util.fastFilter import kotlin.time.Duration import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList import net.thunderbird.feature.notification.api.NotificationSeverity import net.thunderbird.feature.notification.api.content.InAppNotification import net.thunderbird.feature.notification.api.ui.action.NotificationAction import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_SUPPORTING_TEXT_LENGTH import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_TITLE_LENGTH import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle sealed interface InAppNotificationVisual /** * Represents the visual appearance of a [InAppNotificationStyle.BannerGlobalNotification]. * * @property message The text content to be displayed in the banner global. This can be any CharSequence, * allowing for formatted text. * @property severity The [NotificationSeverity] of the notification, indicating its importance or type. * Used to determine which BannerGlobal visual style to use. * @property action An optional [NotificationAction] that the user can perform in response to the notification. * If null, no action is available. * @property priority An integer representing the priority of the notification. Higher values typically indicate * higher priority. Used to determine which notification to display, in case of multiples * [InAppNotificationStyle.BannerGlobalNotification]. * @see InAppNotificationVisual * @see InAppNotificationStyle.BannerGlobalNotification */ @Stable data class BannerGlobalVisual( val message: CharSequence, val severity: NotificationSeverity, val action: NotificationAction?, val priority: Int, ) : InAppNotificationVisual { internal companion object { /** * Creates a [BannerGlobalVisual] from an [InAppNotification]. * * This function attempts to convert an [InAppNotification] into a [BannerGlobalVisual]. * It expects the notification to have a style of [InAppNotificationStyle.BannerGlobalNotification]. * * It performs the following checks: * - The `contentText` of the notification must not be null. * - The notification must have zero or one action. * * If the notification has a matching style and passes all checks, a [BannerGlobalVisual] is created. * Otherwise, this function returns `null`. * * @param notification The [InAppNotification] to convert. * @return A [BannerGlobalVisual] if the conversion is successful, `null` otherwise. * @throws IllegalStateException fails the check validations. */ fun from(notification: InAppNotification): BannerGlobalVisual? = notification.toVisuals<InAppNotificationStyle.BannerGlobalNotification, BannerGlobalVisual> { style -> BannerGlobalVisual( message = checkNotNull(notification.contentText) { "A notification with a BannerGlobalNotification style must have a contentText not null" }, severity = notification.severity, action = notification .actions .let { actions -> check(actions.size in 0..1) { "A notification with a BannerGlobalNotification style must have at zero or one action" } actions.toPersistentList() } .firstOrNull(), priority = style.priority, ) }.singleOrNull() } } /** * Represents the visual appearance of a [InAppNotificationStyle.BannerInlineNotification]. * * @property title The title of the notification. * @property supportingText The main content/message of the notification. * @property severity The [NotificationSeverity] of the notification, indicating its importance or type. * Used to determine which BannerGlobal visual style to use. * @property actions An immutable list of [NotificationAction] objects representing actions * the user can take in response to the notification. * @see InAppNotificationVisual * @see InAppNotificationStyle.BannerInlineNotification */ @Stable data class BannerInlineVisual( val title: CharSequence, val supportingText: CharSequence, val severity: NotificationSeverity, val actions: ImmutableList<NotificationAction>, ) : InAppNotificationVisual { internal companion object { internal const val MAX_TITLE_LENGTH = 100 internal const val MAX_SUPPORTING_TEXT_LENGTH = 200 /** * Creates a [BannerInlineVisual] from an [InAppNotification]. * * This function attempts to convert an [InAppNotification] into a [BannerInlineVisual]. * It expects the notification to have a style of [InAppNotificationStyle.BannerInlineNotification]. * * It performs the following checks: * - The `title` of the notification must have at least 1 and at most [MAX_TITLE_LENGTH] chars. * - The `contentText` of the notification must not be null and have at least 1 and at most * [MAX_SUPPORTING_TEXT_LENGTH] chars. * - The notification must have 1 or 2 actions. * * If the notification has a matching style and passes all checks, a [BannerInlineVisual] is created. * Otherwise, this function returns an empty list. * * @param notification The [InAppNotification] to convert. * @return A list containing a [BannerInlineVisual] if the conversion is successful, an empty list otherwise. * @throws IllegalStateException if any of the validation checks fail. */ fun from(notification: InAppNotification): List<BannerInlineVisual> = notification.toVisuals<InAppNotificationStyle.BannerInlineNotification, BannerInlineVisual> { style -> BannerInlineVisual( title = checkTitle(notification.title), supportingText = checkContentText(notification.contentText), severity = notification.severity, actions = notification .actions .let { actions -> check(actions.size in 1..2) { "A notification with a BannerInlineNotification style must have at one or two actions" } actions.toPersistentList() }, ) } private fun checkTitle(title: String): String { check(title.length in 1..MAX_TITLE_LENGTH) { "A notification with a BannerInlineNotification style must have a title length of 1 to " + "$MAX_TITLE_LENGTH characters." } return title } private fun checkContentText(contentText: String?): String { checkNotNull(contentText) { "A notification with a BannerInlineNotification style must have a contentText not null" } check(contentText.length in 1..MAX_SUPPORTING_TEXT_LENGTH) { "A notification with a BannerInlineNotification style must have a contentText length of 1 to " + "$MAX_SUPPORTING_TEXT_LENGTH characters." } return contentText } } } /** * Represents the visual appearance of a [InAppNotificationStyle.SnackbarNotification]. * * @property message The text message to be displayed in the snackbar. * @property action An optional [NotificationAction] that the user can perform. This is typically a * single action like "Undo" or "Dismiss". If null, no action button is shown. * @property duration The [Duration] for which the snackbar will be visible. * @see InAppNotificationVisual * @see InAppNotificationStyle.SnackbarNotification */ @Stable data class SnackbarVisual( val message: String, val action: NotificationAction?, val duration: Duration, ) : InAppNotificationVisual { internal companion object { /** * Creates a [SnackbarVisual] from an [InAppNotification]. * * This function attempts to convert an [InAppNotification] into a [SnackbarVisual]. * It expects the notification to have a style of [InAppNotificationStyle.SnackbarNotification]. * * It performs the following checks: * - The `contentText` of the notification must not be null. * - The notification must have exactly one action. * * If the notification has a matching style and passes all checks, a [SnackbarVisual] is created. * Otherwise, this function returns `null`. * * @param notification The [InAppNotification] to convert. * @return A [SnackbarVisual] if the conversion is successful, `null` otherwise. * @throws IllegalStateException if `contentText` is null or if the number of actions is not 1 * when the style is [InAppNotificationStyle.SnackbarNotification]. */ fun from(notification: InAppNotification): SnackbarVisual? = notification.toVisuals<InAppNotificationStyle.SnackbarNotification, SnackbarVisual> { style -> SnackbarVisual( message = checkNotNull(notification.contentText) { "A notification with a SnackbarNotification style must have a contentText not null" }, action = checkNotNull(notification.actions.singleOrNull()) { "A notification with a SnackbarNotification style must have exactly one action" }, duration = style.duration, ) }.singleOrNull() } } private inline fun < reified TStyle : InAppNotificationStyle, reified TVisual : InAppNotificationVisual, > InAppNotification.toVisuals( transform: (TStyle) -> TVisual, ): List<TVisual> { return inAppNotificationStyles .fastFilter { style -> style is TStyle } .map { style -> check(style is TStyle) transform(style) } }
feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/style/InAppNotificationStyle.kt +3 −1 Original line number Original line Diff line number Diff line Loading @@ -27,7 +27,9 @@ sealed interface InAppNotificationStyle { /** /** * @see InAppNotificationStyleBuilder.bannerGlobal * @see InAppNotificationStyleBuilder.bannerGlobal */ */ data object BannerGlobalNotification : InAppNotificationStyle data class BannerGlobalNotification( val priority: Int, ) : InAppNotificationStyle /** /** * @see [InAppNotificationStyleBuilder.snackbar] * @see [InAppNotificationStyleBuilder.snackbar] Loading