diff --git a/app/src/main/kotlin/org/cru/godtools/ui/banner/Banner.kt b/app/src/main/kotlin/org/cru/godtools/ui/banner/Banner.kt index e648479cdb..cdc5e9773d 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/banner/Banner.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/banner/Banner.kt @@ -1,169 +1,11 @@ package org.cru.godtools.ui.banner -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalMinimumInteractiveComponentSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.layoutId -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.constrain -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.offset +import com.slack.circuit.runtime.CircuitUiState -@Composable -internal fun Banner( - text: String, - primaryButton: String, - modifier: Modifier = Modifier, - primaryAction: () -> Unit = {}, - secondaryButton: String? = null, - secondaryAction: () -> Unit = {}, - icon: Painter? = null, - iconTint: Color = if (icon != null) LocalContentColor.current else Color.Unspecified, -) = Surface(modifier = modifier.fillMaxWidth()) { - Column { - CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { - val iconNode = "icon" - val textNode = "text" - val primaryActionNode = "primaryAction" - val secondaryActionNode = "secondaryAction" +object Banner { + enum class Type { TOOL_LIST_FAVORITES, TUTORIAL_FEATURES } - Layout({ - Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.layoutId(textNode)) - TextButton( - onClick = primaryAction, - modifier = Modifier - .layoutId(primaryActionNode) - .heightIn(min = 36.dp) - ) { Text(primaryButton) } - if (secondaryButton != null) { - TextButton( - onClick = secondaryAction, - modifier = Modifier - .layoutId(secondaryActionNode) - .heightIn(min = 36.dp) - ) { Text(secondaryButton) } - } - if (icon != null) { - Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.layoutId(iconNode)) - } - }) { measurables, constraints -> - require(constraints.hasBoundedWidth) { "Banner requires a bounded width" } - - val textMargin = 16.dp.roundToPx() - val actionMargin = 8.dp.roundToPx() - val iconSize = 40.dp.roundToPx() - - val iconPlaceable = measurables.firstOrNull { it.layoutId == iconNode } - ?.measure(constraints.constrain(Constraints.fixed(iconSize, iconSize))) - val textPlaceable = measurables.first { it.layoutId == textNode }.measure( - constraints - .offset(horizontal = iconPlaceable?.let { 0 - textMargin - it.width } ?: 0) - .offset(horizontal = -2 * textMargin) - ) - val primaryActionPlaceable = measurables.first { it.layoutId == primaryActionNode } - .measure(constraints.offset(horizontal = -2 * actionMargin)) - val secondaryActionPlaceable = measurables.firstOrNull { it.layoutId == secondaryActionNode } - ?.measure(constraints.offset(horizontal = -2 * actionMargin)) - - val bannerWidth = constraints.maxWidth - - when { - // single line layout - iconPlaceable == null && - textMargin + textPlaceable.width + 36.dp.roundToPx() + - primaryActionPlaceable.width + actionMargin + - (secondaryActionPlaceable?.let { it.width + actionMargin } ?: 0) < bannerWidth -> { - val bannerHeight = maxOf( - textPlaceable.height, - primaryActionPlaceable.height, - secondaryActionPlaceable?.height ?: 0 - ) + 10.dp.roundToPx() + actionMargin - - // calculate placeable positions - val centerLine = (bannerHeight + 2.dp.roundToPx()) / 2 - val primaryActionPosition = IntOffset( - bannerWidth - actionMargin - primaryActionPlaceable.width, - centerLine - (primaryActionPlaceable.height / 2) - ) - val secondaryActionPosition = IntOffset( - primaryActionPosition.x - actionMargin - (secondaryActionPlaceable?.width ?: 0), - centerLine - ((secondaryActionPlaceable?.height ?: 0) / 2) - ) - - layout(bannerWidth, bannerHeight) { - textPlaceable.placeRelative(textMargin, centerLine - (textPlaceable.height / 2)) - primaryActionPlaceable.placeRelative(primaryActionPosition) - secondaryActionPlaceable?.placeRelative(secondaryActionPosition) - } - } - - // default layout - else -> { - val iconPosition = when (iconPlaceable) { - null -> IntOffset.Zero - else -> IntOffset(textMargin, textMargin) - } - val textPosition = when (iconPlaceable) { - null -> IntOffset(textMargin, textMargin) - else -> IntOffset(iconPosition.x + iconPlaceable.width + textMargin, textMargin) - } - val primaryActionPosition = IntOffset( - bannerWidth - actionMargin - primaryActionPlaceable.width, - maxOf( - iconPlaceable?.let { iconPosition.y + it.height } ?: 0, - textPosition.y + textPlaceable.height, - ) + 12.dp.roundToPx() - ) - val secondaryActionPosition = when (secondaryActionPlaceable) { - null -> IntOffset.Zero - - else -> { - val sameLinePosition = IntOffset( - primaryActionPosition.x - actionMargin - secondaryActionPlaceable.width, - primaryActionPosition.y - ) - val nextLinePosition = IntOffset( - bannerWidth - actionMargin - secondaryActionPlaceable.width, - primaryActionPosition.y + primaryActionPlaceable.height + actionMargin - ) - sameLinePosition.takeUnless { it.x < actionMargin } ?: nextLinePosition - } - } - - val bannerHeight = maxOf( - iconPlaceable?.let { iconPosition.y + it.height + textMargin } ?: 0, - textPosition.y + textPlaceable.height + textMargin, - primaryActionPosition.y + primaryActionPlaceable.height + actionMargin, - secondaryActionPlaceable?.let { secondaryActionPosition.y + it.height + actionMargin } ?: 0, - ) - - layout(bannerWidth, bannerHeight) { - iconPlaceable?.placeRelative(iconPosition) - textPlaceable.placeRelative(textPosition) - primaryActionPlaceable.placeRelative(primaryActionPosition) - secondaryActionPlaceable?.placeRelative(secondaryActionPosition) - } - } - } - } - } - HorizontalDivider(modifier = Modifier.alpha(0.12f)) + interface UiState : CircuitUiState { + val type: Type } } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/banner/BannerModule.kt b/app/src/main/kotlin/org/cru/godtools/ui/banner/BannerModule.kt new file mode 100644 index 0000000000..81596013d7 --- /dev/null +++ b/app/src/main/kotlin/org/cru/godtools/ui/banner/BannerModule.kt @@ -0,0 +1,22 @@ +package org.cru.godtools.ui.banner + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerPresenter +import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerPresenter + +@Module +@InstallIn(SingletonComponent::class) +interface BannerModule { + @Binds + fun favoriteToolsBannerPresenter( + presenter: FavoriteToolsBannerPresenter, + ): BannerPresenter + + @Binds + fun tutorialFeaturesBannerPresenter( + presenter: TutorialFeaturesBannerPresenter, + ): BannerPresenter +} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/banner/BannerPresenter.kt b/app/src/main/kotlin/org/cru/godtools/ui/banner/BannerPresenter.kt new file mode 100644 index 0000000000..a004a45506 --- /dev/null +++ b/app/src/main/kotlin/org/cru/godtools/ui/banner/BannerPresenter.kt @@ -0,0 +1,8 @@ +package org.cru.godtools.ui.banner + +import androidx.compose.runtime.Composable + +interface BannerPresenter { + @Composable + fun present(): T? +} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/banner/BannerType.kt b/app/src/main/kotlin/org/cru/godtools/ui/banner/BannerType.kt deleted file mode 100644 index 34648f1841..0000000000 --- a/app/src/main/kotlin/org/cru/godtools/ui/banner/BannerType.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.cru.godtools.ui.banner - -enum class BannerType { TOOL_LIST_FAVORITES, TUTORIAL_FEATURES } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/banner/Banners.kt b/app/src/main/kotlin/org/cru/godtools/ui/banner/Banners.kt index 4a3e5e02c3..88799df8ae 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/banner/Banners.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/banner/Banners.kt @@ -9,19 +9,24 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerLayout +import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerPresenter +import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerLayout +import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerPresenter @Composable -internal fun Banners(banner: () -> BannerType?, modifier: Modifier = Modifier) = Box(modifier.heightIn(min = 1.dp)) { +internal fun Banners(state: Banner.UiState?, modifier: Modifier = Modifier) = Box(modifier.heightIn(min = 1.dp)) { AnimatedContent( - targetState = banner(), + targetState = state, transitionSpec = { slideInVertically(initialOffsetY = { -it }) togetherWith slideOutVertically(targetOffsetY = { -it }) }, label = "Banner Visibility", + contentKey = { it?.type }, ) { when (it) { - BannerType.TOOL_LIST_FAVORITES -> FavoriteToolsBanner() - BannerType.TUTORIAL_FEATURES -> TutorialFeaturesBanner() + is FavoriteToolsBannerPresenter.UiState -> FavoriteToolsBannerLayout(it) + is TutorialFeaturesBannerPresenter.UiState -> TutorialFeaturesBannerLayout(it) else -> Unit } } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/banner/FavoriteToolsBanner.kt b/app/src/main/kotlin/org/cru/godtools/ui/banner/FavoriteToolsBanner.kt deleted file mode 100644 index ecf8c0fd6d..0000000000 --- a/app/src/main/kotlin/org/cru/godtools/ui/banner/FavoriteToolsBanner.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.cru.godtools.ui.banner - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.viewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import org.cru.godtools.R -import org.cru.godtools.base.Settings - -@Composable -internal fun FavoriteToolsBanner(modifier: Modifier = Modifier, viewModel: FavoriteToolsBannerViewModel = viewModel()) { - Banner( - text = stringResource(R.string.tools_list_favorites_banner_text), - primaryButton = stringResource(R.string.tools_list_favorites_banner_action_dismiss), - primaryAction = { viewModel.dismiss() }, - icon = painterResource(R.drawable.ic_favorite_24dp), - iconTint = MaterialTheme.colorScheme.primary, - modifier = modifier - ) -} - -@HiltViewModel -internal class FavoriteToolsBannerViewModel @Inject constructor(val settings: Settings) : ViewModel() { - fun dismiss() { - settings.setFeatureDiscovered(Settings.FEATURE_TOOL_FAVORITE) - } -} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/banner/Banner+Preview.kt b/app/src/main/kotlin/org/cru/godtools/ui/banner/MaterialBanner+Preview.kt similarity index 75% rename from app/src/main/kotlin/org/cru/godtools/ui/banner/Banner+Preview.kt rename to app/src/main/kotlin/org/cru/godtools/ui/banner/MaterialBanner+Preview.kt index ae7b88b587..ca7d25c83c 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/banner/Banner+Preview.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/banner/MaterialBanner+Preview.kt @@ -9,11 +9,15 @@ import org.cru.godtools.R @Composable @Preview(showBackground = true) -private fun SingleLineBanner() = Banner("Single Line", primaryButton = "Confirm", secondaryButton = "Dismiss") +private fun SingleLineMaterialBanner() = MaterialBanner( + "Single Line", + primaryButton = "Confirm", + secondaryButton = "Dismiss" +) @Composable @Preview(showBackground = true) -private fun DefaultBanner() = Banner( +private fun DefaultMaterialBanner() = MaterialBanner( LoremIpsum(20).values.first().replace("\n", " "), primaryButton = "Confirm", secondaryButton = "Dismiss", @@ -21,7 +25,7 @@ private fun DefaultBanner() = Banner( @Composable @Preview(showBackground = true) -private fun BannerWithActionsOnSeparateLines() = Banner( +private fun MaterialBannerWithActionsOnSeparateLines() = MaterialBanner( "Short Message", primaryButton = "Confirm Primary Action", secondaryButton = LoremIpsum(7).values.first(), @@ -29,7 +33,7 @@ private fun BannerWithActionsOnSeparateLines() = Banner( @Composable @Preview(showBackground = true) -private fun BannerIcon() = Banner( +private fun MaterialBannerIcon() = MaterialBanner( LoremIpsum(20).values.first().replace("\n", " "), primaryButton = "Confirm", secondaryButton = "Dismiss", @@ -39,7 +43,7 @@ private fun BannerIcon() = Banner( @Composable @Preview(showBackground = true) -private fun BannerIconShortText() = Banner( +private fun MaterialBannerIconShortText() = MaterialBanner( "Single Line", primaryButton = "Confirm", secondaryButton = "Dismiss", @@ -49,7 +53,7 @@ private fun BannerIconShortText() = Banner( @Composable @Preview(showBackground = true) -private fun BannerIconShortTextLongActions() = Banner( +private fun MaterialBannerIconShortTextLongActions() = MaterialBanner( "Single Line", primaryButton = "Confirm Primary Action", secondaryButton = "Dismiss Secondary Action", diff --git a/app/src/main/kotlin/org/cru/godtools/ui/banner/MaterialBanner.kt b/app/src/main/kotlin/org/cru/godtools/ui/banner/MaterialBanner.kt new file mode 100644 index 0000000000..9f0b26c29c --- /dev/null +++ b/app/src/main/kotlin/org/cru/godtools/ui/banner/MaterialBanner.kt @@ -0,0 +1,169 @@ +package org.cru.godtools.ui.banner + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.constrain +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset + +@Composable +internal fun MaterialBanner( + text: String, + primaryButton: String, + modifier: Modifier = Modifier, + primaryAction: () -> Unit = {}, + secondaryButton: String? = null, + secondaryAction: () -> Unit = {}, + icon: Painter? = null, + iconTint: Color = LocalContentColor.current, +) = Surface(modifier = modifier.fillMaxWidth()) { + Column { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + val iconNode = "icon" + val textNode = "text" + val primaryActionNode = "primaryAction" + val secondaryActionNode = "secondaryAction" + + Layout({ + Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.layoutId(textNode)) + TextButton( + onClick = primaryAction, + modifier = Modifier + .layoutId(primaryActionNode) + .heightIn(min = 36.dp) + ) { Text(primaryButton) } + if (secondaryButton != null) { + TextButton( + onClick = secondaryAction, + modifier = Modifier + .layoutId(secondaryActionNode) + .heightIn(min = 36.dp) + ) { Text(secondaryButton) } + } + if (icon != null) { + Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.layoutId(iconNode)) + } + }) { measurables, constraints -> + require(constraints.hasBoundedWidth) { "Banner requires a bounded width" } + + val textMargin = 16.dp.roundToPx() + val actionMargin = 8.dp.roundToPx() + val iconSize = 40.dp.roundToPx() + + val iconPlaceable = measurables.firstOrNull { it.layoutId == iconNode } + ?.measure(constraints.constrain(Constraints.fixed(iconSize, iconSize))) + val textPlaceable = measurables.first { it.layoutId == textNode }.measure( + constraints + .offset(horizontal = iconPlaceable?.let { 0 - textMargin - it.width } ?: 0) + .offset(horizontal = -2 * textMargin) + ) + val primaryActionPlaceable = measurables.first { it.layoutId == primaryActionNode } + .measure(constraints.offset(horizontal = -2 * actionMargin)) + val secondaryActionPlaceable = measurables.firstOrNull { it.layoutId == secondaryActionNode } + ?.measure(constraints.offset(horizontal = -2 * actionMargin)) + + val bannerWidth = constraints.maxWidth + + when { + // single line layout + iconPlaceable == null && + textMargin + textPlaceable.width + 36.dp.roundToPx() + + primaryActionPlaceable.width + actionMargin + + (secondaryActionPlaceable?.let { it.width + actionMargin } ?: 0) < bannerWidth -> { + val bannerHeight = maxOf( + textPlaceable.height, + primaryActionPlaceable.height, + secondaryActionPlaceable?.height ?: 0 + ) + 10.dp.roundToPx() + actionMargin + + // calculate placeable positions + val centerLine = (bannerHeight + 2.dp.roundToPx()) / 2 + val primaryActionPosition = IntOffset( + bannerWidth - actionMargin - primaryActionPlaceable.width, + centerLine - (primaryActionPlaceable.height / 2) + ) + val secondaryActionPosition = IntOffset( + primaryActionPosition.x - actionMargin - (secondaryActionPlaceable?.width ?: 0), + centerLine - ((secondaryActionPlaceable?.height ?: 0) / 2) + ) + + layout(bannerWidth, bannerHeight) { + textPlaceable.placeRelative(textMargin, centerLine - (textPlaceable.height / 2)) + primaryActionPlaceable.placeRelative(primaryActionPosition) + secondaryActionPlaceable?.placeRelative(secondaryActionPosition) + } + } + + // default layout + else -> { + val iconPosition = when (iconPlaceable) { + null -> IntOffset.Zero + else -> IntOffset(textMargin, textMargin) + } + val textPosition = when (iconPlaceable) { + null -> IntOffset(textMargin, textMargin) + else -> IntOffset(iconPosition.x + iconPlaceable.width + textMargin, textMargin) + } + val primaryActionPosition = IntOffset( + bannerWidth - actionMargin - primaryActionPlaceable.width, + maxOf( + iconPlaceable?.let { iconPosition.y + it.height } ?: 0, + textPosition.y + textPlaceable.height, + ) + 12.dp.roundToPx() + ) + val secondaryActionPosition = when (secondaryActionPlaceable) { + null -> IntOffset.Zero + + else -> { + val sameLinePosition = IntOffset( + primaryActionPosition.x - actionMargin - secondaryActionPlaceable.width, + primaryActionPosition.y + ) + val nextLinePosition = IntOffset( + bannerWidth - actionMargin - secondaryActionPlaceable.width, + primaryActionPosition.y + primaryActionPlaceable.height + actionMargin + ) + sameLinePosition.takeUnless { it.x < actionMargin } ?: nextLinePosition + } + } + + val bannerHeight = maxOf( + iconPlaceable?.let { iconPosition.y + it.height + textMargin } ?: 0, + textPosition.y + textPlaceable.height + textMargin, + primaryActionPosition.y + primaryActionPlaceable.height + actionMargin, + secondaryActionPlaceable?.let { secondaryActionPosition.y + it.height + actionMargin } ?: 0, + ) + + layout(bannerWidth, bannerHeight) { + iconPlaceable?.placeRelative(iconPosition) + textPlaceable.placeRelative(textPosition) + primaryActionPlaceable.placeRelative(primaryActionPosition) + secondaryActionPlaceable?.placeRelative(secondaryActionPosition) + } + } + } + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)) + } +} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/banner/TutorialFeaturesBanner.kt b/app/src/main/kotlin/org/cru/godtools/ui/banner/TutorialFeaturesBanner.kt deleted file mode 100644 index 54af7485d4..0000000000 --- a/app/src/main/kotlin/org/cru/godtools/ui/banner/TutorialFeaturesBanner.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.cru.godtools.ui.banner - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.viewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import org.cru.godtools.base.Settings -import org.cru.godtools.base.ui.circuit.startCircuitActivity -import org.cru.godtools.shared.analytics.TutorialAnalyticsActionNames -import org.cru.godtools.tutorial.PageSet -import org.cru.godtools.tutorial.R -import org.cru.godtools.tutorial.analytics.model.TutorialAnalyticsActionEvent -import org.cru.godtools.tutorial.layout.TutorialScreen -import org.greenrobot.eventbus.EventBus - -@Composable -internal fun TutorialFeaturesBanner( - modifier: Modifier = Modifier, - viewModel: TutorialFeaturesBannerViewModel = viewModel() -) { - val context = LocalContext.current - - Banner( - text = stringResource(R.string.tutorial_features_banner_text), - primaryButton = stringResource(R.string.tutorial_features_banner_action_open), - primaryAction = { context.startCircuitActivity(TutorialScreen(PageSet.FEATURES)) }, - secondaryButton = stringResource(R.string.tutorial_features_banner_action_dismiss), - secondaryAction = { viewModel.dismiss() }, - modifier = modifier - ) -} - -@HiltViewModel -internal class TutorialFeaturesBannerViewModel @Inject constructor(val eventBus: EventBus, val settings: Settings) : - ViewModel() { - fun dismiss() { - eventBus.post(TutorialAnalyticsActionEvent(TutorialAnalyticsActionNames.BANNER_DISMISS)) - settings.setFeatureDiscovered(Settings.FEATURE_TUTORIAL_FEATURES) - } -} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/banner/favoritetools/FavoriteToolsBannerLayout.kt b/app/src/main/kotlin/org/cru/godtools/ui/banner/favoritetools/FavoriteToolsBannerLayout.kt new file mode 100644 index 0000000000..21c7c1743b --- /dev/null +++ b/app/src/main/kotlin/org/cru/godtools/ui/banner/favoritetools/FavoriteToolsBannerLayout.kt @@ -0,0 +1,22 @@ +package org.cru.godtools.ui.banner.favoritetools + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import org.cru.godtools.R +import org.cru.godtools.ui.banner.MaterialBanner +import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerPresenter.UiState + +@Composable +internal fun FavoriteToolsBannerLayout(state: UiState, modifier: Modifier = Modifier) { + MaterialBanner( + text = stringResource(R.string.tools_list_favorites_banner_text), + primaryButton = stringResource(R.string.tools_list_favorites_banner_action_dismiss), + primaryAction = { state.eventSink(FavoriteToolsBannerPresenter.UiEvent.Dismiss) }, + icon = painterResource(R.drawable.ic_favorite_24dp), + iconTint = MaterialTheme.colorScheme.primary, + modifier = modifier + ) +} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/banner/favoritetools/FavoriteToolsBannerPresenter.kt b/app/src/main/kotlin/org/cru/godtools/ui/banner/favoritetools/FavoriteToolsBannerPresenter.kt new file mode 100644 index 0000000000..7ea8b067f9 --- /dev/null +++ b/app/src/main/kotlin/org/cru/godtools/ui/banner/favoritetools/FavoriteToolsBannerPresenter.kt @@ -0,0 +1,35 @@ +package org.cru.godtools.ui.banner.favoritetools + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.slack.circuit.runtime.CircuitUiEvent +import javax.inject.Inject +import org.cru.godtools.base.Settings +import org.cru.godtools.ui.banner.Banner +import org.cru.godtools.ui.banner.BannerPresenter +import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerPresenter.UiState + +class FavoriteToolsBannerPresenter @Inject constructor(private val settings: Settings) : BannerPresenter { + data class UiState(val eventSink: (UiEvent) -> Unit = {}) : Banner.UiState { + override val type = Banner.Type.TOOL_LIST_FAVORITES + } + + sealed interface UiEvent : CircuitUiEvent { + data object Dismiss : UiEvent + } + + @Composable + override fun present(): UiState? { + val isFeatureDiscovered by remember { settings.isFeatureDiscoveredFlow(Settings.FEATURE_TOOL_FAVORITE) } + .collectAsState(initial = false) + if (isFeatureDiscovered) return null + + return UiState { event -> + when (event) { + is UiEvent.Dismiss -> settings.setFeatureDiscovered(Settings.FEATURE_TOOL_FAVORITE) + } + } + } +} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/banner/tutorial/TutorialFeaturesBannerLayout.kt b/app/src/main/kotlin/org/cru/godtools/ui/banner/tutorial/TutorialFeaturesBannerLayout.kt new file mode 100644 index 0000000000..a5dabd9625 --- /dev/null +++ b/app/src/main/kotlin/org/cru/godtools/ui/banner/tutorial/TutorialFeaturesBannerLayout.kt @@ -0,0 +1,21 @@ +package org.cru.godtools.ui.banner.tutorial + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import org.cru.godtools.tutorial.R +import org.cru.godtools.ui.banner.MaterialBanner +import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerPresenter.UiEvent +import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerPresenter.UiState + +@Composable +internal fun TutorialFeaturesBannerLayout(state: UiState, modifier: Modifier = Modifier) { + MaterialBanner( + text = stringResource(R.string.tutorial_features_banner_text), + primaryButton = stringResource(R.string.tutorial_features_banner_action_open), + primaryAction = { state.eventSink(UiEvent.OpenTutorial) }, + secondaryButton = stringResource(R.string.tutorial_features_banner_action_dismiss), + secondaryAction = { state.eventSink(UiEvent.Dismiss) }, + modifier = modifier + ) +} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/banner/tutorial/TutorialFeaturesBannerPresenter.kt b/app/src/main/kotlin/org/cru/godtools/ui/banner/tutorial/TutorialFeaturesBannerPresenter.kt new file mode 100644 index 0000000000..e71244f71c --- /dev/null +++ b/app/src/main/kotlin/org/cru/godtools/ui/banner/tutorial/TutorialFeaturesBannerPresenter.kt @@ -0,0 +1,55 @@ +package org.cru.godtools.ui.banner.tutorial + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.slack.circuit.runtime.CircuitUiEvent +import javax.inject.Inject +import org.cru.godtools.base.Settings +import org.cru.godtools.base.ui.circuit.startCircuitActivity +import org.cru.godtools.shared.analytics.TutorialAnalyticsActionNames +import org.cru.godtools.tutorial.PageSet +import org.cru.godtools.tutorial.analytics.model.TutorialAnalyticsActionEvent +import org.cru.godtools.tutorial.layout.TutorialScreen +import org.cru.godtools.ui.banner.Banner +import org.cru.godtools.ui.banner.BannerPresenter +import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerPresenter.UiState +import org.greenrobot.eventbus.EventBus + +class TutorialFeaturesBannerPresenter @Inject internal constructor( + private val eventBus: EventBus, + private val settings: Settings, +) : BannerPresenter { + data class UiState(val eventSink: (UiEvent) -> Unit = {}) : Banner.UiState { + override val type = Banner.Type.TUTORIAL_FEATURES + } + + sealed interface UiEvent : CircuitUiEvent { + data object OpenTutorial : UiEvent + data object Dismiss : UiEvent + } + + @Composable + override fun present(): UiState? { + val isDiscovered by remember { settings.isFeatureDiscoveredFlow(Settings.FEATURE_TUTORIAL_FEATURES) } + .collectAsState(false) + if (isDiscovered) return null + + val context = LocalContext.current + return UiState { event -> + when (event) { + UiEvent.OpenTutorial -> { + // TODO: switch to using the navigator once the dashboard is fully Circuit + context.startCircuitActivity(TutorialScreen(PageSet.FEATURES)) + } + + UiEvent.Dismiss -> { + eventBus.post(TutorialAnalyticsActionEvent(TutorialAnalyticsActionNames.BANNER_DISMISS)) + settings.setFeatureDiscovered(Settings.FEATURE_TUTORIAL_FEATURES) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/home/HomeLayout.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/home/HomeLayout.kt index 22ca11b92f..fe7d06a677 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/home/HomeLayout.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/home/HomeLayout.kt @@ -45,15 +45,14 @@ private val PADDING_HORIZONTAL = 16.dp internal fun HomeLayout(state: UiState, modifier: Modifier = Modifier) { val columnState = rememberLazyListState() - val banner by rememberUpdatedState(state.banner) - LaunchedEffect(banner) { if (banner != null) columnState.animateScrollToItem(0) } + LaunchedEffect(state.banner?.type) { if (state.banner != null) columnState.animateScrollToItem(0) } LazyColumn(state = columnState, contentPadding = PaddingValues(bottom = 16.dp), modifier = modifier) { if (!state.dataLoaded) return@LazyColumn item("banners", "banners") { Banners( - { banner }, + state.banner, modifier = Modifier .animateItem() .fillMaxWidth() diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/home/HomePresenter.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/home/HomePresenter.kt index 3698ba1603..b756d3b976 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/home/HomePresenter.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/home/HomePresenter.kt @@ -17,7 +17,6 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import org.cru.godtools.BuildConfig import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent @@ -27,9 +26,10 @@ import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTIO import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURCE_FAVORITE import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURCE_FEATURED import org.cru.godtools.base.CONFIG_UI_DASHBOARD_HOME_FAVORITE_TOOLS -import org.cru.godtools.base.Settings import org.cru.godtools.db.repository.ToolsRepository -import org.cru.godtools.ui.banner.BannerType +import org.cru.godtools.ui.banner.Banner +import org.cru.godtools.ui.banner.BannerPresenter +import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerPresenter import org.cru.godtools.ui.dashboard.home.HomePresenter.UiState import org.cru.godtools.ui.dashboard.tools.ToolsScreen import org.cru.godtools.ui.tooldetails.ToolDetailsScreen @@ -43,16 +43,15 @@ class HomePresenter @AssistedInject constructor( private val context: Context, private val eventBus: EventBus, private val remoteConfig: FirebaseRemoteConfig, - private val settings: Settings, private val toolCardPresenter: ToolCardPresenter, private val toolsRepository: ToolsRepository, - @Assisted - private val navigator: Navigator, + private val tutorialFeaturesBannerPresenter: BannerPresenter, + @Assisted private val navigator: Navigator, ) : Presenter { // region UiState / UiEvent data class UiState( val dataLoaded: Boolean = true, - val banner: BannerType? = null, + val banner: Banner.UiState? = null, val spotlightLessons: List = emptyList(), val favoriteTools: List = emptyList(), val eventSink: (UiEvent) -> Unit = {}, @@ -71,7 +70,7 @@ class HomePresenter @AssistedInject constructor( return UiState( dataLoaded = spotlightLessons != null && favoriteTools != null, - banner = rememberBanner(), + banner = tutorialFeaturesBannerPresenter.present(), spotlightLessons = spotlightLessons.orEmpty(), favoriteTools = favoriteTools.orEmpty(), ) { @@ -82,17 +81,6 @@ class HomePresenter @AssistedInject constructor( } } - @Composable - private fun rememberBanner() = remember { - settings.isFeatureDiscoveredFlow(Settings.FEATURE_TUTORIAL_FEATURES) - .combine(settings.appLanguageFlow) { discovered, language -> - when { - !discovered -> BannerType.TUTORIAL_FEATURES - else -> null - } - } - }.collectAsState(null).value - @Composable private fun rememberSpotlightLessons() = remember { toolsRepository.getLessonsFlow().map { it.filter { !it.isHidden && it.isSpotlight } } } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsLayout.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsLayout.kt index 0a1794619e..c86db27707 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsLayout.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsLayout.kt @@ -33,20 +33,19 @@ internal val MARGIN_TOOLS_LAYOUT_HORIZONTAL = 16.dp @Composable @CircuitInject(ToolsScreen::class, SingletonComponent::class) internal fun ToolsLayout(state: UiState, modifier: Modifier = Modifier) { - val banner by rememberUpdatedState(state.banner) val spotlightTools by rememberUpdatedState(state.spotlightTools) val filters by rememberUpdatedState(state.filters) val tools by rememberUpdatedState(state.tools) val columnState = rememberLazyListState() - LaunchedEffect(banner) { if (banner != null) columnState.animateScrollToItem(0) } + LaunchedEffect(state.banner?.type) { if (state.banner != null) columnState.animateScrollToItem(0) } LazyColumn(state = columnState, modifier = modifier) { if (!state.dataLoaded) return@LazyColumn item("banners", "banners") { Banners( - { banner }, + state.banner, modifier = Modifier .animateItem() .fillMaxWidth() diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt index d5944ac732..42ede577db 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt @@ -20,11 +20,12 @@ import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_TOOL_DETAILS import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURCE_ALL_TOOLS import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURCE_SPOTLIGHT -import org.cru.godtools.base.Settings import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.model.Language import org.cru.godtools.model.Tool -import org.cru.godtools.ui.banner.BannerType +import org.cru.godtools.ui.banner.Banner +import org.cru.godtools.ui.banner.BannerPresenter +import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerPresenter import org.cru.godtools.ui.dashboard.tools.ToolFiltersStateProducer.Filters import org.cru.godtools.ui.dashboard.tools.ToolsPresenter.UiState import org.cru.godtools.ui.tooldetails.ToolDetailsScreen @@ -34,16 +35,16 @@ import org.greenrobot.eventbus.EventBus class ToolsPresenter @AssistedInject internal constructor( private val eventBus: EventBus, - private val settings: Settings, private val toolCardPresenter: ToolCardPresenter, private val toolsRepository: ToolsRepository, + private val favoriteToolsBannerPresenter: BannerPresenter, private val filteredToolsFlowProducer: FilteredToolsFlowProducer, private val toolFiltersStateProducer: ToolFiltersStateProducer, @Assisted private val navigator: Navigator, ) : Presenter { // region UiState / UiEvent data class UiState( - val banner: BannerType? = null, + val banner: Banner.UiState? = null, val dataLoaded: Boolean = true, val spotlightTools: List = emptyList(), val filters: Filters = Filters(), @@ -85,7 +86,7 @@ class ToolsPresenter @AssistedInject internal constructor( ) return UiState( - banner = rememberBanner(), + banner = favoriteToolsBannerPresenter.present(), dataLoaded = spotlightTools != null && tools != null, spotlightTools = spotlightTools.orEmpty(), filters = filters, @@ -94,12 +95,6 @@ class ToolsPresenter @AssistedInject internal constructor( ) } - @Composable - private fun rememberBanner() = remember { - settings.isFeatureDiscoveredFlow(Settings.FEATURE_TOOL_FAVORITE) - .map { if (!it) BannerType.TOOL_LIST_FAVORITES else null } - }.collectAsState(null).value - @Composable private fun rememberSpotlightTools( secondLanguage: Language?, diff --git a/app/src/test/kotlin/org/cru/godtools/ui/banner/FakeBannerPresenter.kt b/app/src/test/kotlin/org/cru/godtools/ui/banner/FakeBannerPresenter.kt new file mode 100644 index 0000000000..3355422e55 --- /dev/null +++ b/app/src/test/kotlin/org/cru/godtools/ui/banner/FakeBannerPresenter.kt @@ -0,0 +1,16 @@ +package org.cru.godtools.ui.banner + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeBannerPresenter(initialState: T? = null) : BannerPresenter { + private val uiState = MutableStateFlow(initialState) + + fun updateState(state: T?) { + uiState.value = state + } + + @Composable + override fun present(): T? = uiState.collectAsState().value +} diff --git a/app/src/test/kotlin/org/cru/godtools/ui/banner/favoritetools/FavoriteToolsBannerPresenterTest.kt b/app/src/test/kotlin/org/cru/godtools/ui/banner/favoritetools/FavoriteToolsBannerPresenterTest.kt new file mode 100644 index 0000000000..513335b781 --- /dev/null +++ b/app/src/test/kotlin/org/cru/godtools/ui/banner/favoritetools/FavoriteToolsBannerPresenterTest.kt @@ -0,0 +1,75 @@ +package org.cru.godtools.ui.banner.favoritetools + +import android.app.Application +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.cru.godtools.base.Settings +import org.cru.godtools.ui.banner.Banner +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = Application::class) +class FavoriteToolsBannerPresenterTest { + private val featureDiscoveredFlow = MutableStateFlow(false) + private val settings: Settings = mockk { + every { isFeatureDiscoveredFlow(Settings.FEATURE_TOOL_FAVORITE) } returns featureDiscoveredFlow + every { setFeatureDiscovered(any()) } just Runs + } + + private val presenter = FavoriteToolsBannerPresenter(settings) + + private fun presenterFlow() = moleculeFlow(RecompositionMode.Immediate) { presenter.present() } + + // region present() + @Test + fun `present - returns UiState when feature not yet discovered`() = runTest { + presenterFlow().test { + assertNotNull(awaitItem()) { + assertEquals(Banner.Type.TOOL_LIST_FAVORITES, it.type) + } + } + } + + @Test + fun `present - returns null when feature is already discovered`() = runTest { + featureDiscoveredFlow.value = true + presenterFlow().test { + assertNull(expectMostRecentItem()) + } + } + + @Test + fun `present - transitions to null when feature becomes discovered`() = runTest { + presenterFlow().test { + assertNotNull(awaitItem()) + + featureDiscoveredFlow.value = true + assertNull(awaitItem()) + } + } + // endregion present() + + // region UiEvent.Dismiss + @Test + fun `UiEvent - Dismiss - marks feature as discovered`() = runTest { + presenterFlow().test { + awaitItem()!!.eventSink(FavoriteToolsBannerPresenter.UiEvent.Dismiss) + verify { settings.setFeatureDiscovered(Settings.FEATURE_TOOL_FAVORITE) } + } + } + // endregion UiEvent.Dismiss +} diff --git a/app/src/test/kotlin/org/cru/godtools/ui/banner/tutorial/TutorialFeaturesBannerPresenterTest.kt b/app/src/test/kotlin/org/cru/godtools/ui/banner/tutorial/TutorialFeaturesBannerPresenterTest.kt new file mode 100644 index 0000000000..72fbe6ec5b --- /dev/null +++ b/app/src/test/kotlin/org/cru/godtools/ui/banner/tutorial/TutorialFeaturesBannerPresenterTest.kt @@ -0,0 +1,96 @@ +package org.cru.godtools.ui.banner.tutorial + +import android.app.Application +import android.content.Context +import androidx.compose.runtime.withCompositionLocal +import androidx.compose.ui.platform.LocalContext +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.cru.godtools.base.Settings +import org.cru.godtools.ui.banner.Banner +import org.greenrobot.eventbus.EventBus +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = Application::class) +class TutorialFeaturesBannerPresenterTest { + private val featureDiscoveredFlow = MutableStateFlow(false) + + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val eventBus: EventBus = mockk(relaxed = true) + private val settings: Settings = mockk { + every { isFeatureDiscoveredFlow(Settings.FEATURE_TUTORIAL_FEATURES) } returns featureDiscoveredFlow + every { setFeatureDiscovered(any()) } just Runs + } + + private val presenter = TutorialFeaturesBannerPresenter(eventBus, settings) + + // LocalContext is needed because the presenter captures it to start the tutorial activity on + // OpenTutorial events. This is temporary — once the dashboard is fully migrated to Circuit, + // navigation will go through the Circuit navigator instead of a raw context call. + private fun presenterFlow() = moleculeFlow(RecompositionMode.Immediate) { + withCompositionLocal(LocalContext provides context) { presenter.present() } + } + + // region present() + @Test + fun `present - returns UiState when feature not yet discovered`() = runTest { + presenterFlow().test { + assertNotNull(awaitItem()) { + assertEquals(Banner.Type.TUTORIAL_FEATURES, it.type) + } + } + } + + @Test + fun `present - returns null when feature is already discovered`() = runTest { + featureDiscoveredFlow.value = true + presenterFlow().test { + assertNull(expectMostRecentItem()) + } + } + + @Test + fun `present - transitions to null when feature becomes discovered`() = runTest { + presenterFlow().test { + assertNotNull(awaitItem()) + + featureDiscoveredFlow.value = true + assertNull(awaitItem()) + } + } + // endregion present() + + // region UiEvent.Dismiss + @Test + fun `UiEvent - Dismiss - marks feature as discovered`() = runTest { + presenterFlow().test { + awaitItem()!!.eventSink(TutorialFeaturesBannerPresenter.UiEvent.Dismiss) + verify { settings.setFeatureDiscovered(Settings.FEATURE_TUTORIAL_FEATURES) } + } + } + + @Test + fun `UiEvent - Dismiss - posts analytics event`() = runTest { + presenterFlow().test { + awaitItem()!!.eventSink(TutorialFeaturesBannerPresenter.UiEvent.Dismiss) + verify { eventBus.post(any()) } + } + } + // endregion UiEvent.Dismiss +} diff --git a/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Nexus_5,NIGHT,NO_ACCESSIBILITY].png b/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Nexus_5,NIGHT,NO_ACCESSIBILITY].png new file mode 100644 index 0000000000..e7d3c095f2 --- /dev/null +++ b/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Nexus_5,NIGHT,NO_ACCESSIBILITY].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe72508ae9e967a9807804df1437d3a893fa62792227322947f30b49d2002ddf +size 82679 diff --git a/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Nexus_5,NOTNIGHT,ACCESSIBILITY].png b/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Nexus_5,NOTNIGHT,ACCESSIBILITY].png new file mode 100644 index 0000000000..848814c7d0 --- /dev/null +++ b/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Nexus_5,NOTNIGHT,ACCESSIBILITY].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71e5a6d58151fa8131932bb7063089d7569800938c4adc29cc0309624c9cb267 +size 115727 diff --git a/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Nexus_5,NOTNIGHT,NO_ACCESSIBILITY].png b/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Nexus_5,NOTNIGHT,NO_ACCESSIBILITY].png new file mode 100644 index 0000000000..50d7ed4dd8 --- /dev/null +++ b/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Nexus_5,NOTNIGHT,NO_ACCESSIBILITY].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dcd4bed1a9a1ee5c21557ebeec7e66b2f197fba6cc380877201b3f9a3ccefefe +size 82098 diff --git a/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Pixel_6_Pro,NIGHT,NO_ACCESSIBILITY].png b/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Pixel_6_Pro,NIGHT,NO_ACCESSIBILITY].png new file mode 100644 index 0000000000..c8b73788fc --- /dev/null +++ b/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Pixel_6_Pro,NIGHT,NO_ACCESSIBILITY].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba0acd773ec0998c31df5619ea9cc6e6b30a833d37e5a48247f08431252802c4 +size 73497 diff --git a/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Pixel_6_Pro,NOTNIGHT,NO_ACCESSIBILITY].png b/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Pixel_6_Pro,NOTNIGHT,NO_ACCESSIBILITY].png new file mode 100644 index 0000000000..98dab6f247 --- /dev/null +++ b/app/src/test/snapshots/images/org.cru.godtools.ui.dashboard.home_HomeLayoutPaparazziTest_HomeLayout()_-_Banner_-_Tutorial[Pixel_6_Pro,NOTNIGHT,NO_ACCESSIBILITY].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4594bf01f365c579ee2f751a5e75b95bb5dedfeaf9339e020dc3660a0e5285b9 +size 73572 diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/banner/favoritetools/FavoriteToolsBannerLayoutTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/banner/favoritetools/FavoriteToolsBannerLayoutTest.kt new file mode 100644 index 0000000000..41ad76460e --- /dev/null +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/banner/favoritetools/FavoriteToolsBannerLayoutTest.kt @@ -0,0 +1,37 @@ +package org.cru.godtools.ui.banner.favoritetools + +import android.app.Application +import android.content.Context +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.slack.circuit.test.TestEventSink +import kotlin.test.Test +import org.cru.godtools.R +import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerPresenter.UiEvent +import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerPresenter.UiState +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = Application::class) +@OptIn(ExperimentalTestApi::class) +class FavoriteToolsBannerLayoutTest { + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val events = TestEventSink() + + // region UiEvent.Dismiss + @Test + fun `Action - Dismiss button - fires Dismiss event`() = runComposeUiTest { + setContent { FavoriteToolsBannerLayout(UiState(eventSink = events)) } + + onNodeWithText(context.getString(R.string.tools_list_favorites_banner_action_dismiss)) + .performClick() + + events.assertEvent(UiEvent.Dismiss) + } + // endregion UiEvent.Dismiss +} diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/banner/tutorial/TutorialFeaturesBannerLayoutTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/banner/tutorial/TutorialFeaturesBannerLayoutTest.kt new file mode 100644 index 0000000000..a930c64d68 --- /dev/null +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/banner/tutorial/TutorialFeaturesBannerLayoutTest.kt @@ -0,0 +1,49 @@ +package org.cru.godtools.ui.banner.tutorial + +import android.app.Application +import android.content.Context +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.slack.circuit.test.TestEventSink +import kotlin.test.Test +import org.cru.godtools.tutorial.R +import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerPresenter.UiEvent +import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerPresenter.UiState +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = Application::class) +@OptIn(ExperimentalTestApi::class) +class TutorialFeaturesBannerLayoutTest { + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val events = TestEventSink() + + // region UiEvent.OpenTutorial + @Test + fun `Action - Open button - fires OpenTutorial event`() = runComposeUiTest { + setContent { TutorialFeaturesBannerLayout(UiState(eventSink = events)) } + + onNodeWithText(context.getString(R.string.tutorial_features_banner_action_open)) + .performClick() + + events.assertEvent(UiEvent.OpenTutorial) + } + // endregion UiEvent.OpenTutorial + + // region UiEvent.Dismiss + @Test + fun `Action - Dismiss button - fires Dismiss event`() = runComposeUiTest { + setContent { TutorialFeaturesBannerLayout(UiState(eventSink = events)) } + + onNodeWithText(context.getString(R.string.tutorial_features_banner_action_dismiss)) + .performClick() + + events.assertEvent(UiEvent.Dismiss) + } + // endregion UiEvent.Dismiss +} diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/home/HomeLayoutPaparazziTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/home/HomeLayoutPaparazziTest.kt index f58340f5ac..07f4a70e69 100644 --- a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/home/HomeLayoutPaparazziTest.kt +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/home/HomeLayoutPaparazziTest.kt @@ -14,7 +14,6 @@ import com.google.testing.junit.testparameterinjector.TestParameter import com.google.testing.junit.testparameterinjector.TestParameterInjector import kotlin.test.AfterTest import kotlin.test.BeforeTest -import kotlin.test.Ignore import kotlin.test.Test import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -22,7 +21,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.cru.godtools.base.ui.BasePaparazziTest -import org.cru.godtools.ui.banner.BannerType +import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerPresenter import org.cru.godtools.ui.dashboard.home.HomePresenter.UiState import org.cru.godtools.ui.tools.ToolCardStateTestData import org.junit.Assume.assumeTrue @@ -78,9 +77,8 @@ class HomeLayoutPaparazziTest( } @Test - @Ignore("The Banner currently uses a ViewModel, which doesn't support Paparazzi") fun `HomeLayout() - Banner - Tutorial`() { - snapshotHomeLayout(state.copy(banner = BannerType.TUTORIAL_FEATURES)) + snapshotHomeLayout(state.copy(banner = TutorialFeaturesBannerPresenter.UiState())) } @Test diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/home/HomePresenterTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/home/HomePresenterTest.kt index e9735fa7ef..679f829b1a 100644 --- a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/home/HomePresenterTest.kt +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/home/HomePresenterTest.kt @@ -23,19 +23,16 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.ccci.gto.android.common.androidx.compose.ui.platform.AndroidUiDispatcherUtil import org.ccci.gto.android.common.util.content.equalsIntent import org.cru.godtools.base.CONFIG_UI_DASHBOARD_HOME_FAVORITE_TOOLS -import org.cru.godtools.base.Settings -import org.cru.godtools.base.Settings.Companion.FEATURE_TUTORIAL_FEATURES import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.model.Tool import org.cru.godtools.model.randomTool import org.cru.godtools.model.randomTranslation -import org.cru.godtools.ui.banner.BannerType +import org.cru.godtools.ui.banner.FakeBannerPresenter +import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerPresenter import org.cru.godtools.ui.dashboard.home.HomePresenter.UiEvent import org.cru.godtools.ui.dashboard.tools.ToolsScreen import org.cru.godtools.ui.tooldetails.ToolDetailsScreen @@ -49,7 +46,6 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config(application = Application::class) class HomePresenterTest { - private val appLanguageFlow = MutableStateFlow(Locale.ENGLISH) private val lessonsFlow = MutableSharedFlow>(replay = 1) private val toolsFlow = MutableSharedFlow>(replay = 1) @@ -58,10 +54,6 @@ class HomePresenterTest { private val remoteConfig: FirebaseRemoteConfig = mockk { every { getLong(CONFIG_UI_DASHBOARD_HOME_FAVORITE_TOOLS) } returns 5 } - private val settings: Settings = mockk { - every { appLanguageFlow } returns this@HomePresenterTest.appLanguageFlow - every { isFeatureDiscoveredFlow(any()) } returns flowOf(true) - } private val toolsRepository: ToolsRepository = mockk { every { getLessonsFlow() } returns lessonsFlow every { getFavoriteToolsFlow() } returns toolsFlow @@ -75,6 +67,7 @@ class HomePresenterTest { ) } } + private val tutorialFeaturesBannerPresenter = FakeBannerPresenter() private val navigator = FakeNavigator(HomeScreen) @@ -82,9 +75,9 @@ class HomePresenterTest { context = context, eventBus = eventBus, remoteConfig = remoteConfig, - settings = settings, toolCardPresenter = toolCardPresenter, toolsRepository = toolsRepository, + tutorialFeaturesBannerPresenter = tutorialFeaturesBannerPresenter, navigator = navigator, ) @@ -124,16 +117,15 @@ class HomePresenterTest { // region State.banner @Test fun `State - banner - Features Tutorial`() = runTest { - val featuresTutorialDiscovered = MutableStateFlow(true) - every { settings.isFeatureDiscoveredFlow(FEATURE_TUTORIAL_FEATURES) } returns featuresTutorialDiscovered + val bannerState = TutorialFeaturesBannerPresenter.UiState() presenter.test { assertNull(expectMostRecentItem().banner) - featuresTutorialDiscovered.value = false - assertEquals(BannerType.TUTORIAL_FEATURES, awaitItem().banner) + tutorialFeaturesBannerPresenter.updateState(bannerState) + assertEquals(bannerState, awaitItem().banner) - featuresTutorialDiscovered.value = true + tutorialFeaturesBannerPresenter.updateState(null) assertNull(awaitItem().banner) } } diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt index 6e34194395..1ca3efc18b 100644 --- a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt @@ -20,12 +20,12 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.ccci.gto.android.common.androidx.compose.ui.platform.AndroidUiDispatcherUtil -import org.cru.godtools.base.Settings import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.model.Language import org.cru.godtools.model.Tool import org.cru.godtools.model.randomTool -import org.cru.godtools.ui.banner.BannerType +import org.cru.godtools.ui.banner.FakeBannerPresenter +import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerPresenter import org.cru.godtools.ui.dashboard.filters.FilterMenu import org.cru.godtools.ui.tools.ToolCard import org.cru.godtools.ui.tools.ToolCardPresenter @@ -36,17 +36,14 @@ import org.robolectric.annotation.Config @Config(application = Application::class) @OptIn(ExperimentalCoroutinesApi::class) class ToolsPresenterTest { - private val isFavoritesFeatureDiscovered = MutableStateFlow(true) private val toolsFlow = MutableStateFlow(emptyList()) private val filteredToolsFlow = MutableStateFlow(emptyList()) + private val favoriteToolsBannerPresenter = FakeBannerPresenter(null) private val filteredToolsFlowProducer: FilteredToolsFlowProducer = mockk { every { getFlow(any(), any()) } returns filteredToolsFlow } private val navigator = FakeNavigator(ToolsScreen) - private val settings: Settings = mockk { - every { isFeatureDiscoveredFlow(Settings.FEATURE_TOOL_FAVORITE) } returns isFavoritesFeatureDiscovered - } private val toolsRepository: ToolsRepository = mockk { every { getNormalToolsFlow() } returns toolsFlow } @@ -59,9 +56,9 @@ class ToolsPresenterTest { private val presenter = ToolsPresenter( eventBus = mockk(), - settings = settings, toolCardPresenter = toolCardPresenter, toolsRepository = toolsRepository, + favoriteToolsBannerPresenter = favoriteToolsBannerPresenter, filteredToolsFlowProducer = filteredToolsFlowProducer, toolFiltersStateProducer = toolFiltersStateProducer, navigator = navigator, @@ -73,17 +70,18 @@ class ToolsPresenterTest { // region State.banner @Test fun `State - banner - none`() = runTest { + favoriteToolsBannerPresenter.updateState(null) presenter.test { - isFavoritesFeatureDiscovered.value = true assertNull(expectMostRecentItem().banner) } } @Test fun `State - banner - favorites`() = runTest { + val bannerState = FavoriteToolsBannerPresenter.UiState() + favoriteToolsBannerPresenter.updateState(bannerState) presenter.test { - isFavoritesFeatureDiscovered.value = false - assertEquals(BannerType.TOOL_LIST_FAVORITES, expectMostRecentItem().banner) + assertEquals(bannerState, expectMostRecentItem().banner) } } // endregion State.banner