From 4f476e936a344a2e3cf4e718f8cff9e8142d5499 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 8 Dec 2023 17:25:38 -0700 Subject: [PATCH 01/37] introduce ToolCard.State and ToolCard.Event that will contain the state necessary for rendering a ToolCard --- .../cru/godtools/ui/tools/FavoriteAction.kt | 35 ++++++++-- .../org/cru/godtools/ui/tools/ToolCard.kt | 19 ++++++ .../godtools/ui/tools/FavoriteActionTest.kt | 66 ++++++++----------- 3 files changed, 76 insertions(+), 44 deletions(-) create mode 100644 app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt index 7d94bd7595..9373b48776 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -28,18 +29,40 @@ import org.cru.godtools.model.getName internal fun FavoriteAction( viewModel: ToolViewModels.ToolViewModel, modifier: Modifier = Modifier, - confirmRemoval: Boolean = true + confirmRemoval: Boolean = true, ) { val tool by viewModel.tool.collectAsState() + val translation by viewModel.firstTranslation.collectAsState() + val eventSink: (ToolCard.Event) -> Unit = remember(viewModel) { + { + when (it) { + ToolCard.Event.PinTool -> viewModel.pinTool() + ToolCard.Event.UnpinTool -> viewModel.unpinTool() + } + } + } + + FavoriteAction( + state = ToolCard.State(tool, translation.value, eventSink), + modifier = modifier, + confirmRemoval = confirmRemoval, + ) +} + +@Composable +internal fun FavoriteAction(state: ToolCard.State, modifier: Modifier = Modifier, confirmRemoval: Boolean = true) { + val tool by rememberUpdatedState(state.tool) val isFavorite by remember { derivedStateOf { tool?.isFavorite == true } } + val eventSink by rememberUpdatedState(state.eventSink) + var showRemovalConfirmation by rememberSaveable { mutableStateOf(false) } Surface( onClick = { when { - !isFavorite -> viewModel.pinTool() + !isFavorite -> eventSink(ToolCard.Event.PinTool) confirmRemoval -> showRemovalConfirmation = true - else -> viewModel.unpinTool() + else -> eventSink(ToolCard.Event.UnpinTool) } }, shape = CircleShape, @@ -59,7 +82,7 @@ internal fun FavoriteAction( } if (showRemovalConfirmation) { - val translation by viewModel.firstTranslation.collectAsState() + val translation by rememberUpdatedState(state.translation) AlertDialog( onDismissRequest = { showRemovalConfirmation = false }, @@ -67,14 +90,14 @@ internal fun FavoriteAction( Text( stringResource( R.string.tools_list_remove_favorite_dialog_title, - translation.value.getName(tool).orEmpty() + translation.getName(tool).orEmpty() ) ) }, confirmButton = { TextButton( onClick = { - viewModel.unpinTool() + eventSink(ToolCard.Event.UnpinTool) showRemovalConfirmation = false } ) { Text(stringResource(R.string.tools_list_remove_favorite_dialog_confirm)) } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt new file mode 100644 index 0000000000..c8fb651d80 --- /dev/null +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt @@ -0,0 +1,19 @@ +package org.cru.godtools.ui.tools + +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import org.cru.godtools.model.Tool +import org.cru.godtools.model.Translation + +object ToolCard { + data class State( + val tool: Tool? = null, + val translation: Translation? = null, + val eventSink: (Event) -> Unit = {}, + ) : CircuitUiState + + sealed interface Event : CircuitUiEvent { + data object PinTool : Event + data object UnpinTool : Event + } +} diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/FavoriteActionTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/FavoriteActionTest.kt index 914c92b4c4..eb95638f40 100644 --- a/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/FavoriteActionTest.kt +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/FavoriteActionTest.kt @@ -10,16 +10,7 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.mockk.Called -import io.mockk.every -import io.mockk.excludeRecords -import io.mockk.mockk -import io.mockk.verify -import io.mockk.verifyAll -import kotlinx.coroutines.flow.MutableStateFlow -import org.ccci.gto.android.common.kotlin.coroutines.flow.StateFlowValue -import org.cru.godtools.model.Tool -import org.cru.godtools.model.Translation +import com.slack.circuit.test.TestEventSink import org.cru.godtools.model.randomTool import org.junit.Rule import org.junit.Test @@ -32,67 +23,66 @@ class FavoriteActionTest { @get:Rule val composeTestRule = createComposeRule() - private val toolFlow = MutableStateFlow(null) - private val firstTranslationFlow = MutableStateFlow(StateFlowValue.Initial(null)) - - private val toolViewModel: ToolViewModels.ToolViewModel = mockk { - every { tool } returns toolFlow - every { firstTranslation } returns firstTranslationFlow - every { pinTool() } returns mockk() - every { unpinTool() } returns mockk() - - excludeRecords { - tool - firstTranslation - } - } + private val events = TestEventSink() // region FavoriteAction() @Test fun `FavoriteAction() - add to favorites`() { - composeTestRule.setContent { FavoriteAction(toolViewModel) } - toolFlow.value = randomTool(isFavorite = false) + val state = ToolCard.State( + tool = randomTool(isFavorite = false), + eventSink = events, + ) + composeTestRule.setContent { FavoriteAction(state) } composeTestRule.onRoot().performClick() - verifyAll { toolViewModel.pinTool() } + events.assertEvent(ToolCard.Event.PinTool) } @Test fun `FavoriteAction() - remove from favorites`() { - composeTestRule.setContent { FavoriteAction(toolViewModel, confirmRemoval = false) } - toolFlow.value = randomTool(isFavorite = true) + val state = ToolCard.State( + tool = randomTool(isFavorite = true), + eventSink = events, + ) + composeTestRule.setContent { FavoriteAction(state, confirmRemoval = false) } composeTestRule.onRoot().performClick() composeTestRule.onNode(isDialog()).assertDoesNotExist() - verifyAll { toolViewModel.unpinTool() } + events.assertEvent(ToolCard.Event.UnpinTool) } @Test fun `FavoriteAction() - remove from favorites - confirmRemoval - confirm`() { - composeTestRule.setContent { FavoriteAction(toolViewModel, confirmRemoval = true) } - toolFlow.value = randomTool(isFavorite = true) + val state = ToolCard.State( + tool = randomTool(isFavorite = true), + eventSink = events, + ) + composeTestRule.setContent { FavoriteAction(state, confirmRemoval = true) } composeTestRule.onRoot().performClick() composeTestRule.onNode(isDialog()).assertIsDisplayed() - verify { toolViewModel wasNot Called } + events.assertNoEvents() composeTestRule.onNode(hasAnyAncestor(isDialog()) and hasClickAction() and hasText("Remove")).performClick() composeTestRule.onNode(isDialog()).assertDoesNotExist() - verifyAll { toolViewModel.unpinTool() } + events.assertEvent(ToolCard.Event.UnpinTool) } @Test fun `FavoriteAction() - remove from favorites - confirmRemoval - cancel`() { - composeTestRule.setContent { FavoriteAction(toolViewModel, confirmRemoval = true) } - toolFlow.value = randomTool(isFavorite = true) + val state = ToolCard.State( + tool = randomTool(isFavorite = true), + eventSink = events, + ) + composeTestRule.setContent { FavoriteAction(state, confirmRemoval = true) } composeTestRule.onRoot().performClick() composeTestRule.onNode(isDialog()).assertIsDisplayed() - verify { toolViewModel wasNot Called } + events.assertNoEvents() composeTestRule.onNode(hasAnyAncestor(isDialog()) and hasClickAction() and hasText("Cancel")).performClick() composeTestRule.onNode(isDialog()).assertDoesNotExist() - verify { toolViewModel wasNot Called } + events.assertNoEvents() } // endregion FavoriteAction() } From 3b28b62e406362e3c9685c3ba22367413d7f0b18 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 11 Dec 2023 16:34:45 -0700 Subject: [PATCH 02/37] support rendering the ToolBanner based upon ToolCard.State --- .../kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt | 2 +- app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt | 2 ++ .../kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt | 8 ++++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt index 9373b48776..1dd2ed1c28 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt @@ -43,7 +43,7 @@ internal fun FavoriteAction( } FavoriteAction( - state = ToolCard.State(tool, translation.value, eventSink), + state = ToolCard.State(tool, translation = translation.value, eventSink = eventSink), modifier = modifier, confirmRemoval = confirmRemoval, ) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt index c8fb651d80..84067288b9 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt @@ -2,12 +2,14 @@ package org.cru.godtools.ui.tools import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState +import java.io.File import org.cru.godtools.model.Tool import org.cru.godtools.model.Translation object ToolCard { data class State( val tool: Tool? = null, + val banner: File? = null, val translation: Translation? = null, val eventSink: (Event) -> Unit = {}, ) : CircuitUiState diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt index 734c1c509f..0d455c6dd7 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt @@ -398,8 +398,12 @@ internal fun VariantToolCard( } @Composable -private fun ToolBanner(viewModel: ToolViewModels.ToolViewModel, modifier: Modifier = Modifier) = AsyncImage( - model = viewModel.bannerFile.collectAsState().value, +private fun ToolBanner(viewModel: ToolViewModels.ToolViewModel, modifier: Modifier = Modifier) = + ToolBanner(state = ToolCard.State(banner = viewModel.bannerFile.collectAsState().value), modifier = modifier) + +@Composable +private fun ToolBanner(state: ToolCard.State, modifier: Modifier = Modifier) = AsyncImage( + model = state.banner, contentDescription = null, contentScale = ContentScale.Crop, modifier = modifier.background(GodToolsTheme.GRAY_E6) From cd234e25cac03a651a6a9362c493f1b1a4047114 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Wed, 17 Aug 2022 16:50:39 -0400 Subject: [PATCH 03/37] introduce a withCompatFontFamilyFor extension function that will update the TextStyle with the compat fontfamily if necessary --- .../cru/godtools/ui/tools/ToolCardLayouts.kt | 10 ++-- .../base/ui/util/LocaleTypefaceUtils.kt | 5 ++ .../cru/godtools/base/ui/util/ModelUtils.kt | 4 ++ .../base/ui/util/LocaleTypefaceUtilsTest.kt | 51 +++++++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt index 0d455c6dd7..489436bc22 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt @@ -51,6 +51,7 @@ import org.cru.godtools.base.ui.theme.GodToolsTheme import org.cru.godtools.base.ui.util.ProvideLayoutDirectionFromLocale import org.cru.godtools.base.ui.util.getCategory import org.cru.godtools.base.ui.util.getFontFamilyOrNull +import org.cru.godtools.base.ui.util.withCompatFontFamilyFor import org.cru.godtools.model.Language import org.cru.godtools.model.Tool import org.cru.godtools.model.getName @@ -67,12 +68,9 @@ private fun toolNameStyle(viewModel: ToolViewModels.ToolViewModel): State Date: Mon, 11 Dec 2023 14:21:38 -0700 Subject: [PATCH 04/37] Make ToolName composable support rendering using ToolCard.State --- .../cru/godtools/ui/tools/ToolCardLayouts.kt | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt index 489436bc22..9b20faf175 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -61,6 +62,21 @@ private val toolViewModels: ToolViewModels @Composable get() = viewModel() private val toolCardElevation @Composable get() = elevatedCardElevation(defaultElevation = 4.dp) +private val ToolCard.State.toolNameStyle: TextStyle + @Composable + get() { + val baseStyle = MaterialTheme.typography.titleMedium + val translation by rememberUpdatedState(translation) + + return remember(baseStyle) { + derivedStateOf { + baseStyle + .withCompatFontFamilyFor(translation) + .merge(TextStyle(fontWeight = FontWeight.Bold)) + } + }.value + } + @Composable private fun toolNameStyle(viewModel: ToolViewModels.ToolViewModel): State { val translation by viewModel.firstTranslation.collectAsState() @@ -414,18 +430,34 @@ private fun ToolName( minLines: Int = 1, maxLines: Int = Int.MAX_VALUE, ) { - val tool by viewModel.tool.collectAsState() val translation by viewModel.firstTranslation.collectAsState() - val style by toolNameStyle(viewModel) + + ToolName( + state = ToolCard.State( + tool = viewModel.tool.collectAsState().value, + translation = translation.value + ), + modifier = modifier.invisibleIf { translation.isInitial }, + minLines = minLines, + maxLines = maxLines, + ) +} + +@Composable +private fun ToolName( + state: ToolCard.State, + modifier: Modifier = Modifier, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, +) { + val style = state.toolNameStyle Text( - translation.value.getName(tool).orEmpty(), + state.translation.getName(state.tool).orEmpty(), style = style, maxLines = maxLines, overflow = TextOverflow.Ellipsis, - modifier = modifier - .invisibleIf { translation.isInitial } - .minLinesHeight(minLines = minLines, textStyle = style) + modifier = modifier.minLinesHeight(minLines = minLines, textStyle = style) ) } From 8091e2ee53d551174c0e95c6d6ef25d5aeadef3e Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 11 Dec 2023 15:17:28 -0700 Subject: [PATCH 05/37] update the ToolCategory composable to support ToolCard.State --- .../cru/godtools/ui/tools/ToolCardLayouts.kt | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt index 9b20faf175..e5178e3712 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt @@ -463,16 +463,30 @@ private fun ToolName( @Composable private fun ToolCategory(viewModel: ToolViewModels.ToolViewModel, modifier: Modifier = Modifier) { - val context = LocalContext.current - val tool by viewModel.tool.collectAsState() val translation by viewModel.firstTranslation.collectAsState() - val locale by remember { derivedStateOf { translation.value?.languageCode } } + + ToolCategory( + ToolCard.State( + tool = viewModel.tool.collectAsState().value, + translation = translation.value, + ), + modifier = modifier.invisibleIf { translation.isInitial }, + ) +} + +@Composable +private fun ToolCategory(state: ToolCard.State, modifier: Modifier = Modifier) { + val context = LocalContext.current + val tool by rememberUpdatedState(state.tool) + val translation by rememberUpdatedState(state.translation) + val locale by remember { derivedStateOf { translation?.languageCode } } + val category by remember(context) { derivedStateOf { tool.getCategory(context, locale) } } Text( - tool.getCategory(context, locale), + category, style = toolCategoryStyle, maxLines = 1, - modifier = modifier.invisibleIf { translation.isInitial } + modifier = modifier ) } From 7fd159f9fd19e8d491ab6141574ae59d10af252b Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Tue, 12 Dec 2023 09:44:00 -0700 Subject: [PATCH 06/37] support rendering the ToolCardActions using ToolCard.State --- .../cru/godtools/ui/tools/FavoriteAction.kt | 2 + .../org/cru/godtools/ui/tools/ToolCard.kt | 2 + .../cru/godtools/ui/tools/ToolCardActions.kt | 44 ++++++++++++++++--- .../godtools/ui/tools/ToolCardActionsTest.kt | 39 ++++++++++++++++ 4 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 app/src/testDebug/kotlin/org/cru/godtools/ui/tools/ToolCardActionsTest.kt diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt index 1dd2ed1c28..096138b9d8 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt @@ -38,6 +38,8 @@ internal fun FavoriteAction( when (it) { ToolCard.Event.PinTool -> viewModel.pinTool() ToolCard.Event.UnpinTool -> viewModel.unpinTool() + ToolCard.Event.OpenTool -> TODO() + ToolCard.Event.OpenToolDetails -> TODO() } } } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt index 84067288b9..21ce203fe2 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt @@ -15,6 +15,8 @@ object ToolCard { ) : CircuitUiState sealed interface Event : CircuitUiEvent { + data object OpenTool : Event + data object OpenToolDetails : Event data object PinTool : Event data object UnpinTool : Event } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt index 3137bc0da1..a24703f738 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt @@ -15,6 +15,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -22,24 +24,56 @@ import org.cru.godtools.R import org.cru.godtools.ui.tools.ToolCardEvent.OpenTool as OpenToolEvent @Composable -@OptIn(ExperimentalMaterial3Api::class) internal fun ToolCardActions( viewModel: ToolViewModels.ToolViewModel, modifier: Modifier = Modifier, buttonModifier: Modifier = Modifier, buttonWeightFill: Boolean = true, onEvent: (ToolCardEvent) -> Unit = {}, -) = Row(modifier = modifier) { +) { val tool by viewModel.tool.collectAsState() val firstTranslation by viewModel.firstTranslation.collectAsState() val secondTranslation by viewModel.secondTranslation.collectAsState() + val onEvent by rememberUpdatedState(onEvent) + val state = remember(viewModel) { + ToolCard.State( + eventSink = { + when (it) { + ToolCard.Event.OpenTool -> onEvent( + OpenToolEvent(tool, firstTranslation.value?.languageCode, secondTranslation?.languageCode) + ) + ToolCard.Event.OpenToolDetails -> onEvent(ToolCardEvent.OpenToolDetails(tool)) + ToolCard.Event.PinTool -> TODO() + ToolCard.Event.UnpinTool -> TODO() + } + } + ) + } + + ToolCardActions( + state = state, + modifier = modifier, + buttonModifier = buttonModifier, + buttonWeightFill = buttonWeightFill, + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun ToolCardActions( + state: ToolCard.State, + modifier: Modifier = Modifier, + buttonModifier: Modifier = Modifier, + buttonWeightFill: Boolean = true, +) = Row(modifier = modifier) { + val eventSink by rememberUpdatedState(state.eventSink) val buttonContentPadding = PaddingValues(horizontal = 4.dp, vertical = 8.dp) val buttonMinHeight = 30.dp CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { OutlinedButton( - onClick = { onEvent(ToolCardEvent.OpenToolDetails(tool)) }, + onClick = { eventSink(ToolCard.Event.OpenToolDetails) }, contentPadding = buttonContentPadding, modifier = buttonModifier .alignByBaseline() @@ -53,9 +87,7 @@ internal fun ToolCardActions( } Spacer(Modifier.width(8.dp)) Button( - onClick = { - onEvent(OpenToolEvent(tool, firstTranslation.value?.languageCode, secondTranslation?.languageCode)) - }, + onClick = { eventSink(ToolCard.Event.OpenTool) }, contentPadding = buttonContentPadding, modifier = buttonModifier .alignByBaseline() diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/ToolCardActionsTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/ToolCardActionsTest.kt new file mode 100644 index 0000000000..6d4152a313 --- /dev/null +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/ToolCardActionsTest.kt @@ -0,0 +1,39 @@ +package org.cru.godtools.ui.tools + +import android.app.Application +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.slack.circuit.test.TestEventSink +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = Application::class) +class ToolCardActionsTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val events = TestEventSink() + + @Test + fun `ToolCardActions() - Open Tool`() { + val state = ToolCard.State(eventSink = events) + composeTestRule.setContent { ToolCardActions(state) } + + composeTestRule.onNodeWithText("Open", substring = true, ignoreCase = true).performClick() + events.assertEvent(ToolCard.Event.OpenTool) + } + + @Test + fun `ToolCardActions() - Tool Details`() { + val state = ToolCard.State(eventSink = events) + composeTestRule.setContent { ToolCardActions(state) } + + composeTestRule.onNodeWithText("Details", substring = true, ignoreCase = true).performClick() + events.assertEvent(ToolCard.Event.OpenToolDetails) + } +} From a95b2506778ce3f7f07669c3373ec2a0da37a47c Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Tue, 12 Dec 2023 11:33:58 -0700 Subject: [PATCH 07/37] update SquareToolCardSecondLanguage to take ToolCard.State --- .../org/cru/godtools/ui/tools/ToolCard.kt | 3 ++ .../cru/godtools/ui/tools/ToolCardLayouts.kt | 31 ++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt index 21ce203fe2..15d2ff4b2e 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt @@ -3,6 +3,7 @@ package org.cru.godtools.ui.tools import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import java.io.File +import org.cru.godtools.model.Language import org.cru.godtools.model.Tool import org.cru.godtools.model.Translation @@ -11,6 +12,8 @@ object ToolCard { val tool: Tool? = null, val banner: File? = null, val translation: Translation? = null, + val secondLanguage: Language? = null, + val secondTranslation: Translation? = null, val eventSink: (Event) -> Unit = {}, ) : CircuitUiState diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt index e5178e3712..023686522c 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt @@ -502,18 +502,25 @@ private fun SquareToolCardParallelLanguage(viewModel: ToolViewModels.ToolViewMod val parallelLanguage by viewModel.parallelLanguage.collectAsState() if (parallelLanguage != null) { - val secondTranslation by viewModel.secondTranslation.collectAsState() - val secondLanguage by viewModel.secondLanguage.collectAsState() - val available by remember { derivedStateOf { secondTranslation != null } } - - ToolCardInfoContent { - AvailableInLanguage( - secondLanguage, - horizontalArrangement = Arrangement.Start, - modifier = Modifier - .padding(top = 2.dp) - .invisibleIf { !available } + SquareToolCardSecondLanguage( + state = ToolCard.State( + secondLanguage = viewModel.secondLanguage.collectAsState().value, + secondTranslation = viewModel.secondTranslation.collectAsState().value, ) - } + ) } } + +@Composable +private fun SquareToolCardSecondLanguage(state: ToolCard.State) = ToolCardInfoContent { + val secondTranslation by rememberUpdatedState(state.secondTranslation) + val available by remember { derivedStateOf { secondTranslation != null } } + + AvailableInLanguage( + state.secondLanguage, + horizontalArrangement = Arrangement.Start, + modifier = Modifier + .padding(top = 2.dp) + .invisibleIf { !available } + ) +} From 89334900f8b1ceab6641e0598e25ce9c24d186b4 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Tue, 12 Dec 2023 14:35:04 -0700 Subject: [PATCH 08/37] utilize ToolCard.State when rendering a SquareToolCard --- .../cru/godtools/ui/tools/FavoriteAction.kt | 1 + .../org/cru/godtools/ui/tools/ToolCard.kt | 3 + .../cru/godtools/ui/tools/ToolCardActions.kt | 1 + .../cru/godtools/ui/tools/ToolCardLayouts.kt | 120 ++++++++++-------- 4 files changed, 71 insertions(+), 54 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt index 096138b9d8..57230d51ff 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt @@ -38,6 +38,7 @@ internal fun FavoriteAction( when (it) { ToolCard.Event.PinTool -> viewModel.pinTool() ToolCard.Event.UnpinTool -> viewModel.unpinTool() + ToolCard.Event.Click -> TODO() ToolCard.Event.OpenTool -> TODO() ToolCard.Event.OpenToolDetails -> TODO() } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt index 15d2ff4b2e..e1af8eb08c 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt @@ -3,6 +3,7 @@ package org.cru.godtools.ui.tools import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import java.io.File +import org.cru.godtools.downloadmanager.DownloadProgress import org.cru.godtools.model.Language import org.cru.godtools.model.Tool import org.cru.godtools.model.Translation @@ -14,10 +15,12 @@ object ToolCard { val translation: Translation? = null, val secondLanguage: Language? = null, val secondTranslation: Translation? = null, + val downloadProgress: DownloadProgress? = null, val eventSink: (Event) -> Unit = {}, ) : CircuitUiState sealed interface Event : CircuitUiEvent { + data object Click : Event data object OpenTool : Event data object OpenToolDetails : Event data object PinTool : Event diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt index a24703f738..2b0db1ec8e 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt @@ -43,6 +43,7 @@ internal fun ToolCardActions( OpenToolEvent(tool, firstTranslation.value?.languageCode, secondTranslation?.languageCode) ) ToolCard.Event.OpenToolDetails -> onEvent(ToolCardEvent.OpenToolDetails(tool)) + ToolCard.Event.Click -> TODO() ToolCard.Event.PinTool -> TODO() ToolCard.Event.UnpinTool -> TODO() } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt index 023686522c..5630ce681b 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt @@ -24,7 +24,6 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -77,26 +76,13 @@ private val ToolCard.State.toolNameStyle: TextStyle }.value } -@Composable -private fun toolNameStyle(viewModel: ToolViewModels.ToolViewModel): State { - val translation by viewModel.firstTranslation.collectAsState() - - val baseStyle = MaterialTheme.typography.titleMedium - return remember(baseStyle) { - derivedStateOf { - baseStyle - .withCompatFontFamilyFor(translation.value) - .merge(TextStyle(fontWeight = FontWeight.Bold)) - } - } -} private val toolDescriptionStyle @Composable get() = MaterialTheme.typography.bodyMedium private val toolCategoryStyle @Composable get() = MaterialTheme.typography.bodySmall private val toolCardInfoLabelColor: Color @Composable get() { val baseColor = LocalContentColor.current return remember(baseColor) { with(baseColor) { copy(alpha = alpha * 0.6f) } } } -internal val toolCardInfoLabelStyle @Composable get() = MaterialTheme.typography.labelSmall +private val toolCardInfoLabelStyle @Composable get() = MaterialTheme.typography.labelSmall sealed class ToolCardEvent(val tool: Tool?, val lang1: Locale?, val lang2: Locale?) { class Click( @@ -252,12 +238,12 @@ fun ToolCard( } @Composable -@OptIn(ExperimentalMaterial3Api::class) fun SquareToolCard( toolCode: String, modifier: Modifier = Modifier, viewModel: ToolViewModels.ToolViewModel = toolViewModels[toolCode], showCategory: Boolean = true, + showSecondLanguage: Boolean = false, showActions: Boolean = true, floatParallelLanguageUp: Boolean = true, confirmRemovalFromFavorites: Boolean = false, @@ -266,33 +252,74 @@ fun SquareToolCard( val tool by viewModel.tool.collectAsState() val firstTranslation by viewModel.firstTranslation.collectAsState() val secondTranslation by viewModel.secondTranslation.collectAsState() - val downloadProgress by viewModel.downloadProgress.collectAsState() - ProvideLayoutDirectionFromLocale(locale = { firstTranslation.value?.languageCode }) { + val eventSink: (ToolCard.Event) -> Unit = remember(viewModel) { + { + when (it) { + ToolCard.Event.Click -> onEvent( + ToolCardEvent.Click(tool, firstTranslation.value?.languageCode, secondTranslation?.languageCode) + ) + ToolCard.Event.OpenTool -> onEvent( + ToolCardEvent.OpenTool(tool, firstTranslation.value?.languageCode, secondTranslation?.languageCode) + ) + ToolCard.Event.OpenToolDetails -> onEvent(ToolCardEvent.OpenToolDetails(tool)) + ToolCard.Event.PinTool -> viewModel.pinTool() + ToolCard.Event.UnpinTool -> viewModel.unpinTool() + } + } + } + val state = ToolCard.State( + tool = tool, + banner = viewModel.bannerFile.collectAsState().value, + translation = firstTranslation.value, + secondLanguage = viewModel.secondLanguage.collectAsState().value, + secondTranslation = secondTranslation, + downloadProgress = viewModel.downloadProgress.collectAsState().value, + eventSink = eventSink, + ) + + SquareToolCard( + state = state, + modifier = modifier, + showCategory = showCategory, + showSecondLanguage = showSecondLanguage, + showActions = showActions, + floatParallelLanguageUp = floatParallelLanguageUp, + confirmRemovalFromFavorites = confirmRemovalFromFavorites, + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun SquareToolCard( + state: ToolCard.State, + modifier: Modifier = Modifier, + showCategory: Boolean = true, + showSecondLanguage: Boolean = false, + showActions: Boolean = true, + floatParallelLanguageUp: Boolean = true, + confirmRemovalFromFavorites: Boolean = false, +) { + val downloadProgress by rememberUpdatedState(state.downloadProgress) + val eventSink by rememberUpdatedState(state.eventSink) + + ProvideLayoutDirectionFromLocale(locale = { state.translation?.languageCode }) { ElevatedCard( elevation = toolCardElevation, - onClick = { - onEvent( - ToolCardEvent.Click( - tool, - lang1 = firstTranslation.value?.languageCode, - lang2 = secondTranslation?.languageCode, - ) - ) - }, + onClick = { eventSink(ToolCard.Event.Click) }, modifier = modifier.width(189.dp) ) { Box(modifier = Modifier.fillMaxWidth()) { ToolBanner( - viewModel, + state = state, modifier = Modifier .fillMaxWidth() .aspectRatio(189f / 128f) ) FavoriteAction( - viewModel, - confirmRemoval = confirmRemovalFromFavorites, - modifier = Modifier.align(Alignment.TopEnd) + state = state, + modifier = Modifier.align(Alignment.TopEnd), + confirmRemoval = confirmRemovalFromFavorites ) DownloadProgressIndicator( { downloadProgress }, @@ -304,21 +331,21 @@ fun SquareToolCard( Column(modifier = Modifier.padding(16.dp)) { Box { Column { - ToolName(viewModel, minLines = 1, maxLines = 2, modifier = Modifier.fillMaxWidth()) + ToolName(state, minLines = 1, maxLines = 2, modifier = Modifier.fillMaxWidth()) if (showCategory) { ToolCategory( - viewModel, + state, modifier = Modifier .padding(top = 2.dp) .fillMaxWidth() ) } - if (floatParallelLanguageUp) SquareToolCardParallelLanguage(viewModel) + if (showSecondLanguage && floatParallelLanguageUp) SquareToolCardSecondLanguage(state) } // Reserve the maximum height consistently across all cards Column(modifier = Modifier.invisibleIf(true)) { - Spacer(modifier = Modifier.minLinesHeight(2, toolNameStyle(viewModel).value)) + Spacer(modifier = Modifier.minLinesHeight(2, state.toolNameStyle)) if (showCategory) { Spacer( modifier = Modifier @@ -326,16 +353,15 @@ fun SquareToolCard( .minLinesHeight(1, toolCategoryStyle) ) } - if (floatParallelLanguageUp) SquareToolCardParallelLanguage(viewModel) + if (showSecondLanguage && floatParallelLanguageUp) SquareToolCardSecondLanguage(state) } } - if (!floatParallelLanguageUp) SquareToolCardParallelLanguage(viewModel) + if (showSecondLanguage && !floatParallelLanguageUp) SquareToolCardSecondLanguage(state) if (showActions) { ToolCardActions( - viewModel, - onEvent = onEvent, - modifier = Modifier.padding(top = 8.dp) + state, + modifier = Modifier.padding(top = 8.dp), ) } } @@ -497,20 +523,6 @@ private fun ToolCardInfoContent(content: @Composable () -> Unit) = CompositionLo content = content ) -@Composable -private fun SquareToolCardParallelLanguage(viewModel: ToolViewModels.ToolViewModel) { - val parallelLanguage by viewModel.parallelLanguage.collectAsState() - - if (parallelLanguage != null) { - SquareToolCardSecondLanguage( - state = ToolCard.State( - secondLanguage = viewModel.secondLanguage.collectAsState().value, - secondTranslation = viewModel.secondTranslation.collectAsState().value, - ) - ) - } -} - @Composable private fun SquareToolCardSecondLanguage(state: ToolCard.State) = ToolCardInfoContent { val secondTranslation by rememberUpdatedState(state.secondTranslation) From a0d8d671b51e8496539ce12d3fff9aa60f82753c Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Wed, 13 Dec 2023 08:20:18 -0700 Subject: [PATCH 09/37] update Dashboard events to only pass the tool code and type and not the entire tool object --- .../ui/dashboard/DashboardActivity.kt | 13 +++--- .../godtools/ui/dashboard/DashboardLayout.kt | 25 +++++++---- .../ui/dashboard/home/AllFavoritesLayout.kt | 4 +- .../godtools/ui/dashboard/home/HomeLayout.kt | 19 +++++---- .../ui/dashboard/lessons/LessonsLayout.kt | 5 +-- .../ui/dashboard/tools/ToolsLayout.kt | 11 +++-- .../cru/godtools/ui/tools/ToolCardActions.kt | 9 +++- .../cru/godtools/ui/tools/ToolCardLayouts.kt | 42 ++++++++++++------- 8 files changed, 83 insertions(+), 45 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardActivity.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardActivity.kt index 147d8219a9..8a50950b3b 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardActivity.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardActivity.kt @@ -38,9 +38,8 @@ class DashboardActivity : BaseActivity() { onEvent = { e -> when (e) { is DashboardEvent.OpenTool -> - openTool(e.tool, *listOfNotNull(e.lang1, e.lang2).toTypedArray()) - is DashboardEvent.OpenToolDetails -> - e.tool?.code?.let { startToolDetailsActivity(it, e.lang) } + openTool(e.tool, e.type, *listOfNotNull(e.lang1, e.lang2).toTypedArray()) + is DashboardEvent.OpenToolDetails -> e.tool?.let { startToolDetailsActivity(it, e.lang) } } }, ) @@ -88,12 +87,12 @@ class DashboardActivity : BaseActivity() { internal lateinit var lazyManifestManager: Lazy private val manifestManager get() = lazyManifestManager.get() - private fun openTool(tool: Tool?, vararg languages: Locale) { - val code = tool?.code ?: return + private fun openTool(tool: String?, type: Tool.Type?, vararg languages: Locale) { + if (tool == null || type == null) return if (languages.isEmpty()) return - languages.forEach { manifestManager.preloadLatestPublishedManifest(code, it) } - openToolActivity(code, tool.type, *languages) + languages.forEach { manifestManager.preloadLatestPublishedManifest(tool, it) } + openToolActivity(tool, type, *languages) } // endregion ToolsAdapterCallbacks // endregion UI diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardLayout.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardLayout.kt index d9dbf65db6..ae4bb1fcba 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardLayout.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardLayout.kt @@ -64,9 +64,14 @@ import org.cru.godtools.ui.drawer.DrawerMenuLayout import org.cru.godtools.ui.tools.ToolCardEvent internal sealed interface DashboardEvent { - open class OpenTool(val tool: Tool?, val lang1: Locale?, val lang2: Locale?) : DashboardEvent - class OpenLesson(tool: Tool?, lang: Locale?) : OpenTool(tool, lang, null) - class OpenToolDetails(val tool: Tool?, val lang: Locale? = null) : DashboardEvent + open class OpenTool( + val tool: String?, + val type: Tool.Type?, + val lang1: Locale?, + val lang2: Locale? = null, + ) : DashboardEvent + class OpenLesson(lesson: String?, lang: Locale?) : OpenTool(lesson, Tool.Type.LESSON, lang) + class OpenToolDetails(val tool: String?, val lang: Locale? = null) : DashboardEvent } @Composable @@ -121,7 +126,7 @@ internal fun DashboardLayout(onEvent: (DashboardEvent) -> Unit, viewModel: Dashb onEvent = { when (it) { is DashboardLessonsEvent.OpenLesson -> - onEvent(DashboardEvent.OpenLesson(it.tool, it.lang)) + onEvent(DashboardEvent.OpenLesson(it.lesson, it.lang)) } }, ) @@ -135,7 +140,7 @@ internal fun DashboardLayout(onEvent: (DashboardEvent) -> Unit, viewModel: Dashb } DashboardHomeEvent.ViewAllTools -> viewModel.updateCurrentPage(Page.ALL_TOOLS) is DashboardHomeEvent.OpenTool -> - onEvent(DashboardEvent.OpenTool(it.tool, it.lang1, it.lang2)) + onEvent(DashboardEvent.OpenTool(it.tool, it.type, it.lang1, it.lang2)) is DashboardHomeEvent.OpenToolDetails -> onEvent(DashboardEvent.OpenToolDetails(it.tool)) } @@ -146,8 +151,14 @@ internal fun DashboardLayout(onEvent: (DashboardEvent) -> Unit, viewModel: Dashb onEvent = { when (it) { is ToolCardEvent.Click, - is ToolCardEvent.OpenTool -> - onEvent(DashboardEvent.OpenTool(it.tool, it.lang1, it.lang2)) + is ToolCardEvent.OpenTool, -> onEvent( + DashboardEvent.OpenTool( + tool = it.tool, + type = it.toolType, + lang1 = it.lang1, + lang2 = it.lang2, + ) + ) is ToolCardEvent.OpenToolDetails -> onEvent(DashboardEvent.OpenToolDetails(it.tool)) } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/home/AllFavoritesLayout.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/home/AllFavoritesLayout.kt index d1befb8854..b5db75750e 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/home/AllFavoritesLayout.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/home/AllFavoritesLayout.kt @@ -82,12 +82,12 @@ internal fun AllFavoritesList( is ToolCardEvent.Click, is ToolCardEvent.OpenTool -> viewModel.recordOpenClickInAnalytics( ACTION_OPEN_TOOL, - it.tool?.code, + it.tool, SOURCE_FAVORITE ) is ToolCardEvent.OpenToolDetails -> viewModel.recordOpenClickInAnalytics( ACTION_OPEN_TOOL_DETAILS, - it.tool?.code, + it.tool, SOURCE_FAVORITE ) } 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 5190d14795..946ccdc006 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 @@ -52,13 +52,18 @@ import org.cru.godtools.ui.tools.ToolCardEvent private val PADDING_HORIZONTAL = 16.dp internal sealed interface DashboardHomeEvent { - open class OpenTool(val tool: Tool?, val lang1: Locale?, val lang2: Locale? = null) : DashboardHomeEvent { - constructor(event: ToolCardEvent) : this(event.tool, event.lang1, event.lang2) + open class OpenTool( + val tool: String?, + val type: Tool.Type?, + val lang1: Locale?, + val lang2: Locale? = null, + ) : DashboardHomeEvent { + constructor(event: ToolCardEvent) : this(event.tool, event.toolType, event.lang1, event.lang2) } - open class OpenToolDetails(val tool: Tool?) : DashboardHomeEvent { + open class OpenToolDetails(val tool: String?) : DashboardHomeEvent { constructor(event: ToolCardEvent.OpenToolDetails) : this(event.tool) } - class OpenLesson(event: ToolCardEvent) : OpenTool(event.tool, event.lang1, null) + class OpenLesson(event: ToolCardEvent) : OpenTool(event.tool, Tool.Type.LESSON, event.lang1) data object ViewAllFavorites : DashboardHomeEvent data object ViewAllTools : DashboardHomeEvent } @@ -111,7 +116,7 @@ internal fun HomeContent(onEvent: (DashboardHomeEvent) -> Unit, viewModel: HomeV onEvent = { when (it) { is ToolCardEvent.Click, is ToolCardEvent.OpenTool -> { - viewModel.recordOpenClickInAnalytics(ACTION_OPEN_LESSON, it.tool?.code, SOURCE_FEATURED) + viewModel.recordOpenClickInAnalytics(ACTION_OPEN_LESSON, it.tool, SOURCE_FEATURED) onEvent(DashboardHomeEvent.OpenLesson(it)) } is ToolCardEvent.OpenToolDetails -> { @@ -148,12 +153,12 @@ internal fun HomeContent(onEvent: (DashboardHomeEvent) -> Unit, viewModel: HomeV when { it is DashboardHomeEvent.OpenTool -> viewModel.recordOpenClickInAnalytics( ACTION_OPEN_TOOL, - it.tool?.code, + it.tool, SOURCE_FAVORITE ) it is DashboardHomeEvent.OpenToolDetails -> viewModel.recordOpenClickInAnalytics( ACTION_OPEN_TOOL_DETAILS, - it.tool?.code, + it.tool, SOURCE_FAVORITE ) } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsLayout.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsLayout.kt index 5c29b52a79..ec4befb066 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsLayout.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsLayout.kt @@ -19,12 +19,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel import java.util.Locale import org.cru.godtools.BuildConfig import org.cru.godtools.R -import org.cru.godtools.model.Tool import org.cru.godtools.ui.tools.LessonToolCard import org.cru.godtools.ui.tools.ToolCardEvent internal sealed interface DashboardLessonsEvent { - class OpenLesson(val tool: Tool?, val lang: Locale?) : DashboardLessonsEvent + class OpenLesson(val lesson: String?, val lang: Locale?) : DashboardLessonsEvent } @Composable @@ -40,7 +39,7 @@ internal fun LessonsLayout(viewModel: LessonsViewModel = viewModel(), onEvent: ( onEvent = { when (it) { is ToolCardEvent.OpenTool, is ToolCardEvent.Click -> { - viewModel.recordOpenLessonInAnalytics(it.tool?.code) + viewModel.recordOpenLessonInAnalytics(it.tool) onEvent(DashboardLessonsEvent.OpenLesson(it.tool, it.lang1)) } is ToolCardEvent.OpenToolDetails -> { 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 490968a040..80e4e0564a 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 @@ -96,8 +96,13 @@ internal fun ToolsLayout( is ToolCardEvent.Click, is ToolCardEvent.OpenTool, is ToolCardEvent.OpenToolDetails -> { - viewModel.recordOpenToolDetailsInAnalytics(it.tool?.code, SOURCE_ALL_TOOLS) - onEvent(ToolCardEvent.OpenToolDetails(it.tool, viewModel.selectedLocale.value)) + viewModel.recordOpenToolDetailsInAnalytics(it.tool, SOURCE_ALL_TOOLS) + onEvent( + ToolCardEvent.OpenToolDetails( + tool = it.tool, + additionalLocale = viewModel.selectedLocale.value, + ) + ) } } }, @@ -151,7 +156,7 @@ internal fun ToolSpotlight( is ToolCardEvent.Click, is ToolCardEvent.OpenTool, is ToolCardEvent.OpenToolDetails -> - viewModel.recordOpenToolDetailsInAnalytics(it.tool?.code, SOURCE_SPOTLIGHT) + viewModel.recordOpenToolDetailsInAnalytics(it.tool, SOURCE_SPOTLIGHT) } onEvent(it) }, diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt index 2b0db1ec8e..0174c91ae0 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt @@ -40,9 +40,14 @@ internal fun ToolCardActions( eventSink = { when (it) { ToolCard.Event.OpenTool -> onEvent( - OpenToolEvent(tool, firstTranslation.value?.languageCode, secondTranslation?.languageCode) + OpenToolEvent( + tool = tool?.code, + type = tool?.type, + lang1 = firstTranslation.value?.languageCode, + lang2 = secondTranslation?.languageCode + ) ) - ToolCard.Event.OpenToolDetails -> onEvent(ToolCardEvent.OpenToolDetails(tool)) + ToolCard.Event.OpenToolDetails -> onEvent(ToolCardEvent.OpenToolDetails(tool?.code)) ToolCard.Event.Click -> TODO() ToolCard.Event.PinTool -> TODO() ToolCard.Event.UnpinTool -> TODO() diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt index 5630ce681b..d3d57417d6 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt @@ -84,14 +84,17 @@ private val toolCardInfoLabelColor: Color @Composable get() { } private val toolCardInfoLabelStyle @Composable get() = MaterialTheme.typography.labelSmall -sealed class ToolCardEvent(val tool: Tool?, val lang1: Locale?, val lang2: Locale?) { - class Click( - tool: Tool?, - lang1: Locale? = null, - lang2: Locale? = null, - ) : ToolCardEvent(tool, lang1, lang2) - class OpenTool(tool: Tool?, lang1: Locale? = null, lang2: Locale? = null) : ToolCardEvent(tool, lang1, lang2) - class OpenToolDetails(tool: Tool?, val additionalLocale: Locale? = null) : ToolCardEvent(tool, null, null) +sealed class ToolCardEvent( + val tool: String?, + val toolType: Tool.Type?, + val lang1: Locale? = null, + val lang2: Locale? = null +) { + class Click(tool: String?, type: Tool.Type?, lang1: Locale? = null, lang2: Locale? = null) : + ToolCardEvent(tool, type, lang1, lang2) + class OpenTool(tool: String?, type: Tool.Type?, lang1: Locale?, lang2: Locale? = null) : + ToolCardEvent(tool, type, lang1, lang2) + class OpenToolDetails(tool: String?, val additionalLocale: Locale? = null) : ToolCardEvent(tool, null) } @Composable @@ -114,7 +117,7 @@ fun LessonToolCard( ProvideLayoutDirectionFromLocale(locale = { translation.value?.languageCode }) { ElevatedCard( elevation = toolCardElevation, - onClick = { onEvent(ToolCardEvent.Click(tool, translation.value?.languageCode)) }, + onClick = { onEvent(ToolCardEvent.Click(tool?.code, tool?.type, translation.value?.languageCode)) }, modifier = modifier.fillMaxWidth() ) { ToolBanner(viewModel, modifier = Modifier.aspectRatio(335f / 80f)) @@ -166,7 +169,8 @@ fun ToolCard( onClick = { onEvent( ToolCardEvent.Click( - tool, + tool = tool?.code, + type = tool?.type, lang1 = firstTranslation.value?.languageCode, lang2 = additionalLanguage?.code, ) @@ -257,12 +261,22 @@ fun SquareToolCard( { when (it) { ToolCard.Event.Click -> onEvent( - ToolCardEvent.Click(tool, firstTranslation.value?.languageCode, secondTranslation?.languageCode) + ToolCardEvent.Click( + tool = tool?.code, + type = tool?.type, + lang1 = firstTranslation.value?.languageCode, + lang2 = secondTranslation?.languageCode + ) ) ToolCard.Event.OpenTool -> onEvent( - ToolCardEvent.OpenTool(tool, firstTranslation.value?.languageCode, secondTranslation?.languageCode) + ToolCardEvent.OpenTool( + tool = tool?.code, + type = tool?.type, + lang1 = firstTranslation.value?.languageCode, + lang2 = secondTranslation?.languageCode + ) ) - ToolCard.Event.OpenToolDetails -> onEvent(ToolCardEvent.OpenToolDetails(tool)) + ToolCard.Event.OpenToolDetails -> onEvent(ToolCardEvent.OpenToolDetails(toolCode)) ToolCard.Event.PinTool -> viewModel.pinTool() ToolCard.Event.UnpinTool -> viewModel.unpinTool() } @@ -383,7 +397,7 @@ internal fun VariantToolCard( ProvideLayoutDirectionFromLocale(locale = { firstTranslation.value?.languageCode }) { ElevatedCard( elevation = toolCardElevation, - onClick = { onEvent(ToolCardEvent.Click(tool)) }, + onClick = { onEvent(ToolCardEvent.Click(tool?.code, tool?.type)) }, modifier = modifier ) { ToolBanner( From 8bd143d6172b9a5ebba47b662ff6c4bba1680660 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 14 Dec 2023 11:04:23 -0700 Subject: [PATCH 10/37] update LanguageFilters to utilize ToolsScreen.State --- .../ui/dashboard/tools/ToolFilters.kt | 56 +++++-- .../ui/dashboard/tools/ToolsScreen.kt | 27 ++++ .../ui/dashboard/tools/ToolsViewModel.kt | 2 +- .../ui/dashboard/tools/ToolFiltersTest.kt | 141 ++++++++++++++++++ 4 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt create mode 100644 app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFiltersTest.kt diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt index cd52098d5a..2efae94f5a 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt @@ -24,10 +24,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -37,10 +40,13 @@ import org.cru.godtools.base.LocalAppLanguage import org.cru.godtools.base.ui.theme.GodToolsTheme import org.cru.godtools.base.ui.util.getToolCategoryName import org.cru.godtools.ui.languages.LanguageName +import org.jetbrains.annotations.VisibleForTesting private val DROPDOWN_MAX_HEIGHT = 700.dp private val DROPDOWN_MAX_WIDTH = 400.dp +internal const val TEST_TAG_FILTER_DROPDOWN = "filter_dropdown" + @Composable internal fun ToolFilters(viewModel: ToolsViewModel, modifier: Modifier = Modifier) = Column(modifier.fillMaxWidth()) { Text( @@ -106,21 +112,49 @@ private fun CategoryFilter(viewModel: ToolsViewModel, modifier: Modifier = Modif } @Composable -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) private fun LanguageFilter(viewModel: ToolsViewModel, modifier: Modifier = Modifier) { + val filters = ToolsScreen.State.Filters( + languages = viewModel.languages.collectAsState().value, + languageQuery = viewModel.languageQuery.collectAsState().value, + selectedLanguage = viewModel.selectedLanguage.collectAsState().value, + ) + val eventSink: (ToolsScreen.Event) -> Unit = remember { + { + when (it) { + is ToolsScreen.Event.UpdateLanguageQuery -> viewModel.setLanguageQuery(it.query) + is ToolsScreen.Event.UpdateSelectedLanguage -> viewModel.setSelectedLocale(it.locale) + } + } + } + + LanguageFilter(filters = filters, eventSink = eventSink, modifier = modifier) +} + +@Composable +@VisibleForTesting +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +internal fun LanguageFilter( + filters: ToolsScreen.State.Filters, + modifier: Modifier = Modifier, + eventSink: (ToolsScreen.Event) -> Unit = {}, +) { val context = LocalContext.current + val languages by rememberUpdatedState(filters.languages) + val query by rememberUpdatedState(filters.languageQuery) + val selectedLanguage by rememberUpdatedState(filters.selectedLanguage) + val eventSink by rememberUpdatedState(eventSink) + var expanded by rememberSaveable { mutableStateOf(false) } ElevatedButton( onClick = { - if (!expanded) viewModel.setLanguageQuery("") + if (!expanded) eventSink(ToolsScreen.Event.UpdateLanguageQuery("")) expanded = !expanded }, modifier = modifier ) { - val language by viewModel.selectedLanguage.collectAsState() Text( - text = language?.getDisplayName(context, LocalAppLanguage.current) + text = selectedLanguage?.getDisplayName(context, LocalAppLanguage.current) ?: stringResource(R.string.dashboard_tools_section_filter_language_any), maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -128,18 +162,18 @@ private fun LanguageFilter(viewModel: ToolsViewModel, modifier: Modifier = Modif ) ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) - val query by viewModel.languageQuery.collectAsState() - val languages by viewModel.languages.collectAsState() LazyDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, - modifier = Modifier.sizeIn(maxHeight = DROPDOWN_MAX_HEIGHT, maxWidth = DROPDOWN_MAX_WIDTH), + modifier = Modifier + .sizeIn(maxHeight = DROPDOWN_MAX_HEIGHT, maxWidth = DROPDOWN_MAX_WIDTH) + .testTag(TEST_TAG_FILTER_DROPDOWN) ) { item { SearchBar( query, - onQueryChange = { viewModel.setLanguageQuery(it) }, - onSearch = { viewModel.setLanguageQuery(it) }, + onQueryChange = { eventSink(ToolsScreen.Event.UpdateLanguageQuery(it)) }, + onSearch = { eventSink(ToolsScreen.Event.UpdateLanguageQuery(it)) }, active = false, onActiveChange = {}, colors = GodToolsTheme.searchBarColors, @@ -151,7 +185,7 @@ private fun LanguageFilter(viewModel: ToolsViewModel, modifier: Modifier = Modif DropdownMenuItem( text = { Text(stringResource(R.string.dashboard_tools_section_filter_language_any)) }, onClick = { - viewModel.setSelectedLanguage(null) + eventSink(ToolsScreen.Event.UpdateSelectedLanguage(null)) expanded = false } ) @@ -161,7 +195,7 @@ private fun LanguageFilter(viewModel: ToolsViewModel, modifier: Modifier = Modif DropdownMenuItem( text = { LanguageName(it) }, onClick = { - viewModel.setSelectedLanguage(it) + eventSink(ToolsScreen.Event.UpdateSelectedLanguage(it.code)) expanded = false }, modifier = Modifier.animateItemPlacement() diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt new file mode 100644 index 0000000000..587c18ba7f --- /dev/null +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt @@ -0,0 +1,27 @@ +package org.cru.godtools.ui.dashboard.tools + +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.screen.Screen +import java.util.Locale +import kotlinx.parcelize.Parcelize +import org.cru.godtools.model.Language + +@Parcelize +data object ToolsScreen : Screen { + data class State( + val filters: Filters = Filters(), + val eventSink: (Event) -> Unit, + ) : CircuitUiState { + data class Filters( + val languages: List = emptyList(), + val languageQuery: String = "", + val selectedLanguage: Language? = null, + ) + } + + sealed interface Event : CircuitUiEvent { + data class UpdateLanguageQuery(val query: String) : Event + data class UpdateSelectedLanguage(val locale: Locale?) : Event + } +} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt index e7e77538f6..4eb2ac6085 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt @@ -59,7 +59,7 @@ class ToolsViewModel @Inject constructor( val selectedLanguage = selectedLocale .flatMapLatest { it?.let { languagesRepository.findLanguageFlow(it) } ?: flowOf(null) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - fun setSelectedLanguage(language: Language?) = savedState.set(KEY_SELECTED_LANGUAGE, language?.code) + fun setSelectedLocale(locale: Locale?) = savedState.set(KEY_SELECTED_LANGUAGE, locale) val languageQuery = savedState.getStateFlow(KEY_LANGUAGE_QUERY, "") fun setLanguageQuery(query: String) = savedState.set(KEY_LANGUAGE_QUERY, query) diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFiltersTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFiltersTest.kt new file mode 100644 index 0000000000..9e84155691 --- /dev/null +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFiltersTest.kt @@ -0,0 +1,141 @@ +package org.cru.godtools.ui.dashboard.tools + +import android.app.Application +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.slack.circuit.test.TestEventSink +import java.util.Locale +import org.cru.godtools.model.Language +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = Application::class) +class ToolFiltersTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val events = TestEventSink() + + // region: LanguagesFilter + @Test + fun `LanguagesFilter() - Shows selectedLanguage`() { + composeTestRule.setContent { + LanguageFilter( + filters = ToolsScreen.State.Filters( + selectedLanguage = Language(Locale.ENGLISH) + ), + eventSink = events, + ) + } + + composeTestRule.onNodeWithText("English", substring = true, ignoreCase = true).assertExists() + events.assertNoEvents() + } + + @Test + fun `LanguagesFilter() - Shows Any Language when no language is specified`() { + composeTestRule.setContent { + LanguageFilter( + filters = ToolsScreen.State.Filters(selectedLanguage = null), + eventSink = events, + ) + } + + composeTestRule.onNodeWithText("Any language", substring = true, ignoreCase = true).assertExists() + events.assertNoEvents() + } + + @Test + fun `LanguagesFilter() - Dropdown Menu - Show when button is clicked`() { + composeTestRule.setContent { + LanguageFilter( + filters = ToolsScreen.State.Filters(), + eventSink = events, + ) + } + + // dropdown menu not shown + composeTestRule.onNodeWithTag(TEST_TAG_FILTER_DROPDOWN).assertDoesNotExist() + + // click button to show dropdown + composeTestRule.onNode(hasClickAction()).performClick() + composeTestRule.onNodeWithTag(TEST_TAG_FILTER_DROPDOWN).assertExists() + events.assertEvent(ToolsScreen.Event.UpdateLanguageQuery("")) + } + + @Test + fun `LanguagesFilter() - Dropdown Menu - Show languages`() { + composeTestRule.setContent { + LanguageFilter( + filters = ToolsScreen.State.Filters( + languages = listOf( + Language(Locale.FRENCH), + Language(Locale.GERMAN) + ) + ), + eventSink = events, + ) + } + composeTestRule.onNode(hasClickAction()).performClick() + + composeTestRule.onNodeWithText("English", substring = true, ignoreCase = true).assertDoesNotExist() + composeTestRule.onNodeWithText("French", substring = true, ignoreCase = true).assertExists() + composeTestRule.onNodeWithText("German", substring = true, ignoreCase = true).assertExists() + events.assertEvent(ToolsScreen.Event.UpdateLanguageQuery("")) + } + + @Test + fun `LanguagesFilter() - Dropdown Menu - Select "Any language" option`() { + composeTestRule.setContent { + LanguageFilter( + filters = ToolsScreen.State.Filters( + selectedLanguage = Language(Locale.FRENCH), + languages = listOf( + Language(Locale.FRENCH), + Language(Locale.GERMAN) + ) + ), + eventSink = events, + ) + } + composeTestRule.onNode(hasClickAction()).performClick() + + composeTestRule.onNodeWithText("Any language", substring = true, ignoreCase = true).performClick() + composeTestRule.onNodeWithTag(TEST_TAG_FILTER_DROPDOWN).assertDoesNotExist() + events.assertEvents( + ToolsScreen.Event.UpdateLanguageQuery(""), + ToolsScreen.Event.UpdateSelectedLanguage(null) + ) + } + + @Test + fun `LanguagesFilter() - Dropdown Menu - Select a language`() { + composeTestRule.setContent { + LanguageFilter( + filters = ToolsScreen.State.Filters( + languages = listOf( + Language(Locale.FRENCH), + Language(Locale.GERMAN) + ) + ), + eventSink = events, + ) + } + composeTestRule.onNode(hasClickAction()).performClick() + + composeTestRule.onNodeWithText("French", substring = true, ignoreCase = true).performClick() + composeTestRule.onNodeWithTag(TEST_TAG_FILTER_DROPDOWN).assertDoesNotExist() + events.assertEvents( + ToolsScreen.Event.UpdateLanguageQuery(""), + ToolsScreen.Event.UpdateSelectedLanguage(Locale.FRENCH) + ) + } + // endregion: LanguagesFilter +} From 32b414257a73325e5483698fcdf0aebab083cb2a Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 14 Dec 2023 14:44:58 -0700 Subject: [PATCH 11/37] update Categories Filter to utilize ToolsScreen.State --- .../ui/dashboard/tools/ToolFilters.kt | 38 ++++++++++++++++--- .../ui/dashboard/tools/ToolsScreen.kt | 3 ++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt index 2efae94f5a..e34756082b 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt @@ -67,18 +67,43 @@ internal fun ToolFilters(viewModel: ToolsViewModel, modifier: Modifier = Modifie } @Composable -@OptIn(ExperimentalMaterial3Api::class) private fun CategoryFilter(viewModel: ToolsViewModel, modifier: Modifier = Modifier) { - val categories by viewModel.categories.collectAsState() + val filters = ToolsScreen.State.Filters( + categories = viewModel.categories.collectAsState().value, + selectedCategory = viewModel.selectedCategory.collectAsState().value + ) + val eventSink: (ToolsScreen.Event) -> Unit = remember { + { + when (it) { + is ToolsScreen.Event.UpdateSelectedCategory -> viewModel.setSelectedCategory(it.category) + is ToolsScreen.Event.UpdateLanguageQuery -> TODO() + is ToolsScreen.Event.UpdateSelectedLanguage -> TODO() + } + } + } + + CategoryFilter(filters = filters, eventSink = eventSink, modifier = modifier) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun CategoryFilter( + filters: ToolsScreen.State.Filters, + modifier: Modifier = Modifier, + eventSink: (ToolsScreen.Event) -> Unit = {}, +) { + val categories by rememberUpdatedState(filters.categories) + val selectedCategory by rememberUpdatedState(filters.selectedCategory) + val eventSink by rememberUpdatedState(eventSink) + var expanded by rememberSaveable { mutableStateOf(false) } ElevatedButton( onClick = { expanded = !expanded }, modifier = modifier ) { - val category by viewModel.selectedCategory.collectAsState() Text( - category?.let { getToolCategoryName(it, LocalContext.current) } + selectedCategory?.let { getToolCategoryName(it, LocalContext.current) } ?: stringResource(R.string.dashboard_tools_section_filter_category_any), maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -94,7 +119,7 @@ private fun CategoryFilter(viewModel: ToolsViewModel, modifier: Modifier = Modif DropdownMenuItem( text = { Text(stringResource(R.string.dashboard_tools_section_filter_category_any)) }, onClick = { - viewModel.setSelectedCategory(null) + eventSink(ToolsScreen.Event.UpdateSelectedCategory(null)) expanded = false } ) @@ -102,7 +127,7 @@ private fun CategoryFilter(viewModel: ToolsViewModel, modifier: Modifier = Modif DropdownMenuItem( text = { Text(getToolCategoryName(it, LocalContext.current)) }, onClick = { - viewModel.setSelectedCategory(it) + eventSink(ToolsScreen.Event.UpdateSelectedCategory(it)) expanded = false } ) @@ -123,6 +148,7 @@ private fun LanguageFilter(viewModel: ToolsViewModel, modifier: Modifier = Modif when (it) { is ToolsScreen.Event.UpdateLanguageQuery -> viewModel.setLanguageQuery(it.query) is ToolsScreen.Event.UpdateSelectedLanguage -> viewModel.setSelectedLocale(it.locale) + is ToolsScreen.Event.UpdateSelectedCategory -> TODO() } } } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt index 587c18ba7f..8ac1b6d45c 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt @@ -14,6 +14,8 @@ data object ToolsScreen : Screen { val eventSink: (Event) -> Unit, ) : CircuitUiState { data class Filters( + val categories: List = emptyList(), + val selectedCategory: String? = null, val languages: List = emptyList(), val languageQuery: String = "", val selectedLanguage: Language? = null, @@ -21,6 +23,7 @@ data object ToolsScreen : Screen { } sealed interface Event : CircuitUiEvent { + data class UpdateSelectedCategory(val category: String?) : Event data class UpdateLanguageQuery(val query: String) : Event data class UpdateSelectedLanguage(val locale: Locale?) : Event } From b146e09a3fbfb3e5fb51260819f068af9d5084b3 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 14 Dec 2023 14:56:27 -0700 Subject: [PATCH 12/37] update ToolFilters to take ToolsScreen.State --- .../ui/dashboard/tools/ToolFilters.kt | 71 ++++++++----------- 1 file changed, 29 insertions(+), 42 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt index e34756082b..63b38d14f4 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt @@ -48,7 +48,33 @@ private val DROPDOWN_MAX_WIDTH = 400.dp internal const val TEST_TAG_FILTER_DROPDOWN = "filter_dropdown" @Composable -internal fun ToolFilters(viewModel: ToolsViewModel, modifier: Modifier = Modifier) = Column(modifier.fillMaxWidth()) { +internal fun ToolFilters(viewModel: ToolsViewModel, modifier: Modifier = Modifier) { + val filters = ToolsScreen.State.Filters( + categories = viewModel.categories.collectAsState().value, + selectedCategory = viewModel.selectedCategory.collectAsState().value, + languages = viewModel.languages.collectAsState().value, + languageQuery = viewModel.languageQuery.collectAsState().value, + selectedLanguage = viewModel.selectedLanguage.collectAsState().value, + ) + val eventSink: (ToolsScreen.Event) -> Unit = remember { + { + when (it) { + is ToolsScreen.Event.UpdateSelectedCategory -> viewModel.setSelectedCategory(it.category) + is ToolsScreen.Event.UpdateLanguageQuery -> viewModel.setLanguageQuery(it.query) + is ToolsScreen.Event.UpdateSelectedLanguage -> viewModel.setSelectedLocale(it.locale) + } + } + } + + ToolFilters(filters, modifier = modifier, eventSink = eventSink) +} + +@Composable +internal fun ToolFilters( + filters: ToolsScreen.State.Filters, + modifier: Modifier = Modifier, + eventSink: (ToolsScreen.Event) -> Unit = {}, +) = Column(modifier.fillMaxWidth()) { Text( stringResource(R.string.dashboard_tools_section_filter_label), style = MaterialTheme.typography.titleLarge, @@ -61,30 +87,11 @@ internal fun ToolFilters(viewModel: ToolsViewModel, modifier: Modifier = Modifie horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 16.dp) ) { - CategoryFilter(viewModel, modifier = Modifier.weight(1f)) - LanguageFilter(viewModel, modifier = Modifier.weight(1f)) + CategoryFilter(filters, modifier = Modifier.weight(1f), eventSink = eventSink) + LanguageFilter(filters, modifier = Modifier.weight(1f), eventSink = eventSink) } } -@Composable -private fun CategoryFilter(viewModel: ToolsViewModel, modifier: Modifier = Modifier) { - val filters = ToolsScreen.State.Filters( - categories = viewModel.categories.collectAsState().value, - selectedCategory = viewModel.selectedCategory.collectAsState().value - ) - val eventSink: (ToolsScreen.Event) -> Unit = remember { - { - when (it) { - is ToolsScreen.Event.UpdateSelectedCategory -> viewModel.setSelectedCategory(it.category) - is ToolsScreen.Event.UpdateLanguageQuery -> TODO() - is ToolsScreen.Event.UpdateSelectedLanguage -> TODO() - } - } - } - - CategoryFilter(filters = filters, eventSink = eventSink, modifier = modifier) -} - @Composable @OptIn(ExperimentalMaterial3Api::class) private fun CategoryFilter( @@ -136,26 +143,6 @@ private fun CategoryFilter( } } -@Composable -private fun LanguageFilter(viewModel: ToolsViewModel, modifier: Modifier = Modifier) { - val filters = ToolsScreen.State.Filters( - languages = viewModel.languages.collectAsState().value, - languageQuery = viewModel.languageQuery.collectAsState().value, - selectedLanguage = viewModel.selectedLanguage.collectAsState().value, - ) - val eventSink: (ToolsScreen.Event) -> Unit = remember { - { - when (it) { - is ToolsScreen.Event.UpdateLanguageQuery -> viewModel.setLanguageQuery(it.query) - is ToolsScreen.Event.UpdateSelectedLanguage -> viewModel.setSelectedLocale(it.locale) - is ToolsScreen.Event.UpdateSelectedCategory -> TODO() - } - } - } - - LanguageFilter(filters = filters, eventSink = eventSink, modifier = modifier) -} - @Composable @VisibleForTesting @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) From 68df4a74ff11af5b6983a12486fc321d204a5da6 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 14 Dec 2023 18:14:25 -0700 Subject: [PATCH 13/37] create a ToolViewModel.toState() composable to simplify migration --- .../cru/godtools/ui/tools/ToolCardLayouts.kt | 70 ++++--------------- .../cru/godtools/ui/tools/ToolViewModels.kt | 13 ++++ 2 files changed, 25 insertions(+), 58 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt index d3d57417d6..c8c1229e6c 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt @@ -111,6 +111,7 @@ fun LessonToolCard( viewModel: ToolViewModels.ToolViewModel = toolViewModels[toolCode], onEvent: (ToolCardEvent) -> Unit = {}, ) { + val state = viewModel.toState() val tool by viewModel.tool.collectAsState() val translation by viewModel.firstTranslation.collectAsState() @@ -120,14 +121,14 @@ fun LessonToolCard( onClick = { onEvent(ToolCardEvent.Click(tool?.code, tool?.type, translation.value?.languageCode)) }, modifier = modifier.fillMaxWidth() ) { - ToolBanner(viewModel, modifier = Modifier.aspectRatio(335f / 80f)) + ToolBanner(state, modifier = Modifier.aspectRatio(335f / 80f)) Column( modifier = Modifier .fillMaxWidth() .padding(16.dp) ) { - ToolName(viewModel, minLines = 2, modifier = Modifier.fillMaxWidth()) + ToolName(state, minLines = 2, modifier = Modifier.fillMaxWidth()) val appLanguage by viewModel.appLanguage.collectAsState() val appTranslation by viewModel.appTranslation.collectAsState() @@ -158,9 +159,10 @@ fun ToolCard( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, onEvent: (ToolCardEvent) -> Unit = {}, ) { + val state = viewModel.toState() val tool by viewModel.tool.collectAsState() val firstTranslation by viewModel.firstTranslation.collectAsState() - val downloadProgress by viewModel.downloadProgress.collectAsState() + val downloadProgress by rememberUpdatedState(state.downloadProgress) ProvideLayoutDirectionFromLocale(locale = { firstTranslation.value?.languageCode }) { ElevatedCard( @@ -180,7 +182,7 @@ fun ToolCard( ) { Box(modifier = Modifier.fillMaxWidth()) { ToolBanner( - viewModel, + state, modifier = Modifier .fillMaxWidth() .aspectRatio(335f / 87f) @@ -203,7 +205,7 @@ fun ToolCard( modifier = Modifier.fillMaxWidth() ) { ToolName( - viewModel, + state, modifier = Modifier .run { if (additionalLanguage != null) widthIn(max = { it - 70.dp }) else this } .alignByBaseline() @@ -220,10 +222,7 @@ fun ToolCard( } } } - ToolCategory( - viewModel, - modifier = Modifier.fillMaxWidth() - ) + ToolCategory(state, modifier = Modifier.fillMaxWidth()) if (showActions) { ToolCardActions( @@ -282,18 +281,9 @@ fun SquareToolCard( } } } - val state = ToolCard.State( - tool = tool, - banner = viewModel.bannerFile.collectAsState().value, - translation = firstTranslation.value, - secondLanguage = viewModel.secondLanguage.collectAsState().value, - secondTranslation = secondTranslation, - downloadProgress = viewModel.downloadProgress.collectAsState().value, - eventSink = eventSink, - ) SquareToolCard( - state = state, + state = viewModel.toState(eventSink), modifier = modifier, showCategory = showCategory, showSecondLanguage = showSecondLanguage, @@ -391,6 +381,7 @@ internal fun VariantToolCard( modifier: Modifier = Modifier, onEvent: (ToolCardEvent) -> Unit = {}, ) { + val state = viewModel.toState() val tool by viewModel.tool.collectAsState() val firstTranslation by viewModel.firstTranslation.collectAsState() @@ -401,7 +392,7 @@ internal fun VariantToolCard( modifier = modifier ) { ToolBanner( - viewModel, + state, modifier = Modifier .fillMaxWidth() .aspectRatio(335f / 87f) @@ -410,7 +401,7 @@ internal fun VariantToolCard( RadioButton(selected = isSelected, onClick = null) Column(modifier = Modifier.padding(start = 16.dp)) { - ToolName(viewModel) + ToolName(state) Text( firstTranslation.value.getTagline(tool).orEmpty(), fontFamily = firstTranslation.value?.getFontFamilyOrNull(), @@ -451,10 +442,6 @@ internal fun VariantToolCard( } } -@Composable -private fun ToolBanner(viewModel: ToolViewModels.ToolViewModel, modifier: Modifier = Modifier) = - ToolBanner(state = ToolCard.State(banner = viewModel.bannerFile.collectAsState().value), modifier = modifier) - @Composable private fun ToolBanner(state: ToolCard.State, modifier: Modifier = Modifier) = AsyncImage( model = state.banner, @@ -463,26 +450,6 @@ private fun ToolBanner(state: ToolCard.State, modifier: Modifier = Modifier) = A modifier = modifier.background(GodToolsTheme.GRAY_E6) ) -@Composable -private fun ToolName( - viewModel: ToolViewModels.ToolViewModel, - modifier: Modifier = Modifier, - minLines: Int = 1, - maxLines: Int = Int.MAX_VALUE, -) { - val translation by viewModel.firstTranslation.collectAsState() - - ToolName( - state = ToolCard.State( - tool = viewModel.tool.collectAsState().value, - translation = translation.value - ), - modifier = modifier.invisibleIf { translation.isInitial }, - minLines = minLines, - maxLines = maxLines, - ) -} - @Composable private fun ToolName( state: ToolCard.State, @@ -501,19 +468,6 @@ private fun ToolName( ) } -@Composable -private fun ToolCategory(viewModel: ToolViewModels.ToolViewModel, modifier: Modifier = Modifier) { - val translation by viewModel.firstTranslation.collectAsState() - - ToolCategory( - ToolCard.State( - tool = viewModel.tool.collectAsState().value, - translation = translation.value, - ), - modifier = modifier.invisibleIf { translation.isInitial }, - ) -} - @Composable private fun ToolCategory(state: ToolCard.State, modifier: Modifier = Modifier) { val context = LocalContext.current diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolViewModels.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolViewModels.kt index f2049c1d3f..0ff6c51874 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolViewModels.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolViewModels.kt @@ -1,5 +1,7 @@ package org.cru.godtools.ui.tools +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -135,6 +137,17 @@ class ToolViewModels @Inject constructor( toolsRepository.unpinTool(code) syncService.syncDirtyFavoriteTools() } + + @Composable + fun toState(eventSink: (ToolCard.Event) -> Unit = {}) = ToolCard.State( + tool = tool.collectAsState().value, + banner = bannerFile.collectAsState().value, + translation = firstTranslation.collectAsState().value.value, + secondLanguage = secondLanguage.collectAsState().value, + secondTranslation = secondTranslation.collectAsState().value, + downloadProgress = downloadProgress.collectAsState().value, + eventSink = eventSink, + ) } private fun Flow.attachmentFileFlow(transform: (value: Tool?) -> Long?) = this From 537cbe573540aa8a2a1215229052d54889b0a4b5 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 15 Dec 2023 10:58:56 -0700 Subject: [PATCH 14/37] Build the filters object in ToolsLayout --- .../ui/dashboard/tools/ToolFilters.kt | 24 ------------------- .../ui/dashboard/tools/ToolsLayout.kt | 21 +++++++++++++++- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt index 63b38d14f4..bfb79d20fd 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt @@ -21,10 +21,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -47,28 +45,6 @@ private val DROPDOWN_MAX_WIDTH = 400.dp internal const val TEST_TAG_FILTER_DROPDOWN = "filter_dropdown" -@Composable -internal fun ToolFilters(viewModel: ToolsViewModel, modifier: Modifier = Modifier) { - val filters = ToolsScreen.State.Filters( - categories = viewModel.categories.collectAsState().value, - selectedCategory = viewModel.selectedCategory.collectAsState().value, - languages = viewModel.languages.collectAsState().value, - languageQuery = viewModel.languageQuery.collectAsState().value, - selectedLanguage = viewModel.selectedLanguage.collectAsState().value, - ) - val eventSink: (ToolsScreen.Event) -> Unit = remember { - { - when (it) { - is ToolsScreen.Event.UpdateSelectedCategory -> viewModel.setSelectedCategory(it.category) - is ToolsScreen.Event.UpdateLanguageQuery -> viewModel.setLanguageQuery(it.query) - is ToolsScreen.Event.UpdateSelectedLanguage -> viewModel.setSelectedLocale(it.locale) - } - } - } - - ToolFilters(filters, modifier = modifier, eventSink = eventSink) -} - @Composable internal fun ToolFilters( filters: ToolsScreen.State.Filters, 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 80e4e0564a..6edf3ee493 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 @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -46,6 +47,23 @@ internal fun ToolsLayout( val tools by viewModel.tools.collectAsState() val selectedLanguage by viewModel.selectedLanguage.collectAsState() + val filters = ToolsScreen.State.Filters( + categories = viewModel.categories.collectAsState().value, + selectedCategory = viewModel.selectedCategory.collectAsState().value, + languages = viewModel.languages.collectAsState().value, + languageQuery = viewModel.languageQuery.collectAsState().value, + selectedLanguage = viewModel.selectedLanguage.collectAsState().value, + ) + val eventSink: (ToolsScreen.Event) -> Unit = remember(onEvent) { + { + when (it) { + is ToolsScreen.Event.UpdateSelectedCategory -> viewModel.setSelectedCategory(it.category) + is ToolsScreen.Event.UpdateLanguageQuery -> viewModel.setLanguageQuery(it.query) + is ToolsScreen.Event.UpdateSelectedLanguage -> viewModel.setSelectedLocale(it.locale) + } + } + } + val columnState = rememberLazyListState() LaunchedEffect(banner) { if (banner != null) columnState.animateScrollToItem(0) } @@ -79,7 +97,8 @@ internal fun ToolsLayout( item("tool-filters", "tool-filters") { ToolFilters( - viewModel = viewModel, + filters = filters, + eventSink = eventSink, modifier = Modifier .animateItemPlacement() .padding(vertical = 16.dp) From 282842b34b60994b5452a3c1ef34bb3539ec6cc6 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 15 Dec 2023 11:23:41 -0700 Subject: [PATCH 15/37] track the ToolsLayout banner in ToolsState --- .../ui/dashboard/tools/ToolsLayout.kt | 19 +++++++++++++------ .../ui/dashboard/tools/ToolsScreen.kt | 2 ++ 2 files changed, 15 insertions(+), 6 deletions(-) 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 6edf3ee493..6ca020bd5b 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 @@ -18,6 +18,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -37,12 +38,10 @@ internal val MARGIN_TOOLS_LAYOUT_HORIZONTAL = 16.dp @Composable @OptIn(ExperimentalFoundationApi::class) -internal fun ToolsLayout( - onEvent: (ToolCardEvent) -> Unit, - viewModel: ToolsViewModel = viewModel(), - toolViewModels: ToolViewModels = viewModel(), -) { - val banner by viewModel.banner.collectAsState() +internal fun ToolsLayout(onEvent: (ToolCardEvent) -> Unit) { + val viewModel: ToolsViewModel = viewModel() + val toolViewModels: ToolViewModels = viewModel() + val spotlightTools by viewModel.spotlightTools.collectAsState() val tools by viewModel.tools.collectAsState() val selectedLanguage by viewModel.selectedLanguage.collectAsState() @@ -64,6 +63,14 @@ internal fun ToolsLayout( } } + val state = ToolsScreen.State( + banner = viewModel.banner.collectAsState().value, + filters = filters, + eventSink = eventSink, + ) + + val banner by rememberUpdatedState(state.banner) + val columnState = rememberLazyListState() LaunchedEffect(banner) { if (banner != null) columnState.animateScrollToItem(0) } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt index 8ac1b6d45c..d24671b921 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt @@ -6,10 +6,12 @@ import com.slack.circuit.runtime.screen.Screen import java.util.Locale import kotlinx.parcelize.Parcelize import org.cru.godtools.model.Language +import org.cru.godtools.ui.banner.BannerType @Parcelize data object ToolsScreen : Screen { data class State( + val banner: BannerType? = null, val filters: Filters = Filters(), val eventSink: (Event) -> Unit, ) : CircuitUiState { From 40a218e05f7f1874e73ed7cbe485a4d6ccb76d6e Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 15 Dec 2023 16:43:50 -0700 Subject: [PATCH 16/37] update ToolCard UI to use ToolCard.State --- .../cru/godtools/ui/tools/FavoriteAction.kt | 28 ------- .../cru/godtools/ui/tools/ToolCardActions.kt | 44 ----------- .../cru/godtools/ui/tools/ToolCardLayouts.kt | 74 ++++++++++++++----- .../cru/godtools/ui/tools/ToolViewModels.kt | 7 +- 4 files changed, 59 insertions(+), 94 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt index 57230d51ff..22de101206 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt @@ -9,7 +9,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,33 +24,6 @@ import org.ccci.gto.android.common.androidx.compose.foundation.layout.padding import org.cru.godtools.R import org.cru.godtools.model.getName -@Composable -internal fun FavoriteAction( - viewModel: ToolViewModels.ToolViewModel, - modifier: Modifier = Modifier, - confirmRemoval: Boolean = true, -) { - val tool by viewModel.tool.collectAsState() - val translation by viewModel.firstTranslation.collectAsState() - val eventSink: (ToolCard.Event) -> Unit = remember(viewModel) { - { - when (it) { - ToolCard.Event.PinTool -> viewModel.pinTool() - ToolCard.Event.UnpinTool -> viewModel.unpinTool() - ToolCard.Event.Click -> TODO() - ToolCard.Event.OpenTool -> TODO() - ToolCard.Event.OpenToolDetails -> TODO() - } - } - } - - FavoriteAction( - state = ToolCard.State(tool, translation = translation.value, eventSink = eventSink), - modifier = modifier, - confirmRemoval = confirmRemoval, - ) -} - @Composable internal fun FavoriteAction(state: ToolCard.State, modifier: Modifier = Modifier, confirmRemoval: Boolean = true) { val tool by rememberUpdatedState(state.tool) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt index 0174c91ae0..40d17daffc 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardActions.kt @@ -13,56 +13,12 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.cru.godtools.R -import org.cru.godtools.ui.tools.ToolCardEvent.OpenTool as OpenToolEvent - -@Composable -internal fun ToolCardActions( - viewModel: ToolViewModels.ToolViewModel, - modifier: Modifier = Modifier, - buttonModifier: Modifier = Modifier, - buttonWeightFill: Boolean = true, - onEvent: (ToolCardEvent) -> Unit = {}, -) { - val tool by viewModel.tool.collectAsState() - val firstTranslation by viewModel.firstTranslation.collectAsState() - val secondTranslation by viewModel.secondTranslation.collectAsState() - val onEvent by rememberUpdatedState(onEvent) - val state = remember(viewModel) { - ToolCard.State( - eventSink = { - when (it) { - ToolCard.Event.OpenTool -> onEvent( - OpenToolEvent( - tool = tool?.code, - type = tool?.type, - lang1 = firstTranslation.value?.languageCode, - lang2 = secondTranslation?.languageCode - ) - ) - ToolCard.Event.OpenToolDetails -> onEvent(ToolCardEvent.OpenToolDetails(tool?.code)) - ToolCard.Event.Click -> TODO() - ToolCard.Event.PinTool -> TODO() - ToolCard.Event.UnpinTool -> TODO() - } - } - ) - } - - ToolCardActions( - state = state, - modifier = modifier, - buttonModifier = buttonModifier, - buttonWeightFill = buttonWeightFill, - ) -} @Composable @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt index c8c1229e6c..326df64f25 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt @@ -149,7 +149,6 @@ fun LessonToolCard( } @Composable -@OptIn(ExperimentalMaterial3Api::class) fun ToolCard( viewModel: ToolViewModels.ToolViewModel, modifier: Modifier = Modifier, @@ -159,25 +158,61 @@ fun ToolCard( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, onEvent: (ToolCardEvent) -> Unit = {}, ) { - val state = viewModel.toState() val tool by viewModel.tool.collectAsState() val firstTranslation by viewModel.firstTranslation.collectAsState() + + val state = viewModel.toState(secondLanguage = additionalLanguage) { + when (it) { + ToolCard.Event.Click -> onEvent( + ToolCardEvent.Click( + tool = tool?.code, + type = tool?.type, + lang1 = firstTranslation.value?.languageCode, + lang2 = additionalLanguage?.code, + ) + ) + ToolCard.Event.OpenTool -> onEvent( + ToolCardEvent.OpenTool( + tool = tool?.code, + type = tool?.type, + lang1 = firstTranslation.value?.languageCode, + lang2 = additionalLanguage?.code, + ) + ) + ToolCard.Event.OpenToolDetails -> onEvent(ToolCardEvent.OpenToolDetails(tool?.code)) + ToolCard.Event.PinTool -> viewModel.pinTool() + ToolCard.Event.UnpinTool -> viewModel.unpinTool() + } + } + + ToolCard( + state = state, + modifier = modifier, + confirmRemovalFromFavorites = confirmRemovalFromFavorites, + showActions = showActions, + interactionSource = interactionSource, + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun ToolCard( + state: ToolCard.State, + modifier: Modifier = Modifier, + confirmRemovalFromFavorites: Boolean = false, + showActions: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val translation by rememberUpdatedState(state.translation) + val secondLanguage by rememberUpdatedState(state.secondLanguage) val downloadProgress by rememberUpdatedState(state.downloadProgress) + val eventSink by rememberUpdatedState(state.eventSink) - ProvideLayoutDirectionFromLocale(locale = { firstTranslation.value?.languageCode }) { + ProvideLayoutDirectionFromLocale(locale = { translation?.languageCode }) { ElevatedCard( elevation = toolCardElevation, interactionSource = interactionSource, - onClick = { - onEvent( - ToolCardEvent.Click( - tool = tool?.code, - type = tool?.type, - lang1 = firstTranslation.value?.languageCode, - lang2 = additionalLanguage?.code, - ) - ) - }, + onClick = { eventSink(ToolCard.Event.Click) }, modifier = modifier ) { Box(modifier = Modifier.fillMaxWidth()) { @@ -188,7 +223,7 @@ fun ToolCard( .aspectRatio(335f / 87f) ) FavoriteAction( - viewModel, + state, confirmRemoval = confirmRemovalFromFavorites, modifier = Modifier.align(Alignment.TopEnd) ) @@ -207,13 +242,13 @@ fun ToolCard( ToolName( state, modifier = Modifier - .run { if (additionalLanguage != null) widthIn(max = { it - 70.dp }) else this } + .run { if (secondLanguage != null) widthIn(max = { it - 70.dp }) else this } .alignByBaseline() ) - if (additionalLanguage != null) { + if (secondLanguage != null) { ToolCardInfoContent { AvailableInLanguage( - additionalLanguage, + secondLanguage, horizontalArrangement = Arrangement.End, modifier = Modifier .padding(start = 8.dp) @@ -226,10 +261,9 @@ fun ToolCard( if (showActions) { ToolCardActions( - viewModel, + state, buttonWeightFill = false, buttonModifier = Modifier.widthIn(min = 92.dp), - onEvent = onEvent, modifier = Modifier .padding(top = 4.dp) .align(Alignment.End) @@ -283,7 +317,7 @@ fun SquareToolCard( } SquareToolCard( - state = viewModel.toState(eventSink), + state = viewModel.toState(eventSink = eventSink), modifier = modifier, showCategory = showCategory, showSecondLanguage = showSecondLanguage, diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolViewModels.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolViewModels.kt index 0ff6c51874..313a3799dc 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolViewModels.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolViewModels.kt @@ -139,11 +139,14 @@ class ToolViewModels @Inject constructor( } @Composable - fun toState(eventSink: (ToolCard.Event) -> Unit = {}) = ToolCard.State( + fun toState( + secondLanguage: Language? = this.secondLanguage.collectAsState().value, + eventSink: (ToolCard.Event) -> Unit = {} + ) = ToolCard.State( tool = tool.collectAsState().value, banner = bannerFile.collectAsState().value, translation = firstTranslation.collectAsState().value.value, - secondLanguage = secondLanguage.collectAsState().value, + secondLanguage = secondLanguage, secondTranslation = secondTranslation.collectAsState().value, downloadProgress = downloadProgress.collectAsState().value, eventSink = eventSink, From bce8f78e97cd743cee48e348a26b0f08a5b3cb17 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 18 Dec 2023 09:51:06 -0700 Subject: [PATCH 17/37] update tool cards to utilize ToolState --- .../ui/dashboard/tools/ToolsLayout.kt | 35 +++++++++---------- .../ui/dashboard/tools/ToolsScreen.kt | 3 ++ 2 files changed, 20 insertions(+), 18 deletions(-) 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 6ca020bd5b..9080cbaf37 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 @@ -43,7 +43,6 @@ internal fun ToolsLayout(onEvent: (ToolCardEvent) -> Unit) { val toolViewModels: ToolViewModels = viewModel() val spotlightTools by viewModel.spotlightTools.collectAsState() - val tools by viewModel.tools.collectAsState() val selectedLanguage by viewModel.selectedLanguage.collectAsState() val filters = ToolsScreen.State.Filters( @@ -56,6 +55,10 @@ internal fun ToolsLayout(onEvent: (ToolCardEvent) -> Unit) { val eventSink: (ToolsScreen.Event) -> Unit = remember(onEvent) { { when (it) { + is ToolsScreen.Event.OpenToolDetails -> { + if (it.source != null) viewModel.recordOpenToolDetailsInAnalytics(it.tool, it.source) + onEvent(ToolCardEvent.OpenToolDetails(it.tool, additionalLocale = selectedLanguage?.code)) + } is ToolsScreen.Event.UpdateSelectedCategory -> viewModel.setSelectedCategory(it.category) is ToolsScreen.Event.UpdateLanguageQuery -> viewModel.setLanguageQuery(it.query) is ToolsScreen.Event.UpdateSelectedLanguage -> viewModel.setSelectedLocale(it.locale) @@ -66,10 +69,12 @@ internal fun ToolsLayout(onEvent: (ToolCardEvent) -> Unit) { val state = ToolsScreen.State( banner = viewModel.banner.collectAsState().value, filters = filters, + tools = viewModel.tools.collectAsState().value, eventSink = eventSink, ) val banner by rememberUpdatedState(state.banner) + val tools by rememberUpdatedState(state.tools) val columnState = rememberLazyListState() LaunchedEffect(banner) { if (banner != null) columnState.animateScrollToItem(0) } @@ -113,25 +118,19 @@ internal fun ToolsLayout(onEvent: (ToolCardEvent) -> Unit) { } items(tools, { "tool:${it.code.orEmpty()}" }, { "tool" }) { tool -> + val toolViewModel = toolViewModels[tool.code.orEmpty(), tool] + val toolState = toolViewModel.toState(secondLanguage = selectedLanguage) { + when (it) { + ToolCard.Event.Click, ToolCard.Event.OpenTool, ToolCard.Event.OpenToolDetails -> + tool.code?.let { eventSink(ToolsScreen.Event.OpenToolDetails(it, SOURCE_ALL_TOOLS)) } + ToolCard.Event.PinTool -> toolViewModel.pinTool() + ToolCard.Event.UnpinTool -> toolViewModel.unpinTool() + } + } + ToolCard( - toolViewModels[tool.code.orEmpty(), tool], - additionalLanguage = selectedLanguage, + state = toolState, showActions = false, - onEvent = { - when (it) { - is ToolCardEvent.Click, - is ToolCardEvent.OpenTool, - is ToolCardEvent.OpenToolDetails -> { - viewModel.recordOpenToolDetailsInAnalytics(it.tool, SOURCE_ALL_TOOLS) - onEvent( - ToolCardEvent.OpenToolDetails( - tool = it.tool, - additionalLocale = viewModel.selectedLocale.value, - ) - ) - } - } - }, modifier = Modifier .animateItemPlacement() .padding(bottom = 16.dp, horizontal = 16.dp) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt index d24671b921..88169439c8 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt @@ -6,6 +6,7 @@ import com.slack.circuit.runtime.screen.Screen import java.util.Locale import kotlinx.parcelize.Parcelize import org.cru.godtools.model.Language +import org.cru.godtools.model.Tool import org.cru.godtools.ui.banner.BannerType @Parcelize @@ -13,6 +14,7 @@ data object ToolsScreen : Screen { data class State( val banner: BannerType? = null, val filters: Filters = Filters(), + val tools: List = emptyList(), val eventSink: (Event) -> Unit, ) : CircuitUiState { data class Filters( @@ -25,6 +27,7 @@ data object ToolsScreen : Screen { } sealed interface Event : CircuitUiEvent { + data class OpenToolDetails(val tool: String, val source: String? = null) : Event data class UpdateSelectedCategory(val category: String?) : Event data class UpdateLanguageQuery(val query: String) : Event data class UpdateSelectedLanguage(val locale: Locale?) : Event From 362704e151370e93be3a7f6dc82993f0df589123 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 18 Dec 2023 12:32:09 -0700 Subject: [PATCH 18/37] update spotlight tools to render using ToolsState --- .../ui/dashboard/tools/ToolsLayout.kt | 94 +++++++++---------- .../ui/dashboard/tools/ToolsScreen.kt | 1 + 2 files changed, 47 insertions(+), 48 deletions(-) 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 9080cbaf37..e45abb9c8f 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 @@ -28,7 +28,6 @@ import org.cru.godtools.R 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.ui.banner.Banners -import org.cru.godtools.ui.tools.PreloadTool import org.cru.godtools.ui.tools.SquareToolCard import org.cru.godtools.ui.tools.ToolCard import org.cru.godtools.ui.tools.ToolCardEvent @@ -42,7 +41,6 @@ internal fun ToolsLayout(onEvent: (ToolCardEvent) -> Unit) { val viewModel: ToolsViewModel = viewModel() val toolViewModels: ToolViewModels = viewModel() - val spotlightTools by viewModel.spotlightTools.collectAsState() val selectedLanguage by viewModel.selectedLanguage.collectAsState() val filters = ToolsScreen.State.Filters( @@ -68,12 +66,14 @@ internal fun ToolsLayout(onEvent: (ToolCardEvent) -> Unit) { val state = ToolsScreen.State( banner = viewModel.banner.collectAsState().value, + spotlightTools = viewModel.spotlightTools.collectAsState().value, filters = filters, tools = viewModel.tools.collectAsState().value, eventSink = eventSink, ) val banner by rememberUpdatedState(state.banner) + val spotlightTools by rememberUpdatedState(state.spotlightTools) val tools by rememberUpdatedState(state.tools) val columnState = rememberLazyListState() @@ -92,8 +92,8 @@ internal fun ToolsLayout(onEvent: (ToolCardEvent) -> Unit) { if (spotlightTools.isNotEmpty()) { item("tool-spotlight", "tool-spotlight") { ToolSpotlight( - viewModel, - onEvent = onEvent, + state, + toolViewModels, modifier = Modifier .animateItemPlacement() .padding(top = 16.dp) @@ -140,52 +140,50 @@ internal fun ToolsLayout(onEvent: (ToolCardEvent) -> Unit) { } @Composable -internal fun ToolSpotlight( - viewModel: ToolsViewModel, - onEvent: (ToolCardEvent) -> Unit, - modifier: Modifier = Modifier, -) = Column(modifier = modifier.fillMaxWidth()) { - val spotlightTools by viewModel.spotlightTools.collectAsState() - - Text( - stringResource(R.string.dashboard_tools_section_spotlight_label), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) - Text( - stringResource(R.string.dashboard_tools_section_spotlight_description), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .padding(top = 4.dp, horizontal = 16.dp) - .fillMaxWidth() - ) - LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .padding(vertical = 8.dp) - ) { - items(spotlightTools, key = { it.code.orEmpty() }) { - PreloadTool(it) - - SquareToolCard( - toolCode = it.code.orEmpty(), - showCategory = false, - showActions = false, - floatParallelLanguageUp = false, - confirmRemovalFromFavorites = false, - onEvent = { +private fun ToolSpotlight(state: ToolsScreen.State, toolViewModels: ToolViewModels, modifier: Modifier = Modifier) { + val spotlightTools by rememberUpdatedState(state.spotlightTools) + val selectedLanguage by rememberUpdatedState(state.filters.selectedLanguage) + val eventSink by rememberUpdatedState(state.eventSink) + + Column(modifier = modifier.fillMaxWidth()) { + Text( + stringResource(R.string.dashboard_tools_section_spotlight_label), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + ) + Text( + stringResource(R.string.dashboard_tools_section_spotlight_description), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(top = 4.dp, horizontal = 16.dp) + .fillMaxWidth() + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(vertical = 8.dp) + ) { + items(spotlightTools, key = { it.code.orEmpty() }) { tool -> + val toolViewModel = toolViewModels[tool.code.orEmpty()] + val toolState = toolViewModel.toState(secondLanguage = selectedLanguage) { when (it) { - is ToolCardEvent.Click, - is ToolCardEvent.OpenTool, - is ToolCardEvent.OpenToolDetails -> - viewModel.recordOpenToolDetailsInAnalytics(it.tool, SOURCE_SPOTLIGHT) + ToolCard.Event.Click, ToolCard.Event.OpenTool, ToolCard.Event.OpenToolDetails -> + tool.code?.let { eventSink(ToolsScreen.Event.OpenToolDetails(it, SOURCE_SPOTLIGHT)) } + ToolCard.Event.PinTool -> toolViewModel.pinTool() + ToolCard.Event.UnpinTool -> toolViewModel.unpinTool() } - onEvent(it) - }, - ) + } + + SquareToolCard( + state = toolState, + showCategory = false, + showActions = false, + floatParallelLanguageUp = false, + confirmRemovalFromFavorites = false, + ) + } } } } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt index 88169439c8..6e91ef7f0f 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt @@ -13,6 +13,7 @@ import org.cru.godtools.ui.banner.BannerType data object ToolsScreen : Screen { data class State( val banner: BannerType? = null, + val spotlightTools: List = emptyList(), val filters: Filters = Filters(), val tools: List = emptyList(), val eventSink: (Event) -> Unit, From e2d0f1140ddebbab0841a1f4f555eef249bf0662 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 18 Dec 2023 12:52:13 -0700 Subject: [PATCH 19/37] make a standalone ToolsLayout that just takes ToolsScreen.State --- .../ui/dashboard/tools/ToolsLayout.kt | 59 ++++++++++--------- 1 file changed, 32 insertions(+), 27 deletions(-) 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 e45abb9c8f..ccc0de62df 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 @@ -36,50 +36,55 @@ import org.cru.godtools.ui.tools.ToolViewModels internal val MARGIN_TOOLS_LAYOUT_HORIZONTAL = 16.dp @Composable -@OptIn(ExperimentalFoundationApi::class) internal fun ToolsLayout(onEvent: (ToolCardEvent) -> Unit) { val viewModel: ToolsViewModel = viewModel() - val toolViewModels: ToolViewModels = viewModel() - val selectedLanguage by viewModel.selectedLanguage.collectAsState() - val filters = ToolsScreen.State.Filters( - categories = viewModel.categories.collectAsState().value, - selectedCategory = viewModel.selectedCategory.collectAsState().value, - languages = viewModel.languages.collectAsState().value, - languageQuery = viewModel.languageQuery.collectAsState().value, - selectedLanguage = viewModel.selectedLanguage.collectAsState().value, - ) - val eventSink: (ToolsScreen.Event) -> Unit = remember(onEvent) { - { - when (it) { - is ToolsScreen.Event.OpenToolDetails -> { - if (it.source != null) viewModel.recordOpenToolDetailsInAnalytics(it.tool, it.source) - onEvent(ToolCardEvent.OpenToolDetails(it.tool, additionalLocale = selectedLanguage?.code)) - } - is ToolsScreen.Event.UpdateSelectedCategory -> viewModel.setSelectedCategory(it.category) - is ToolsScreen.Event.UpdateLanguageQuery -> viewModel.setLanguageQuery(it.query) - is ToolsScreen.Event.UpdateSelectedLanguage -> viewModel.setSelectedLocale(it.locale) - } - } - } - val state = ToolsScreen.State( banner = viewModel.banner.collectAsState().value, spotlightTools = viewModel.spotlightTools.collectAsState().value, - filters = filters, + filters = ToolsScreen.State.Filters( + categories = viewModel.categories.collectAsState().value, + selectedCategory = viewModel.selectedCategory.collectAsState().value, + languages = viewModel.languages.collectAsState().value, + languageQuery = viewModel.languageQuery.collectAsState().value, + selectedLanguage = viewModel.selectedLanguage.collectAsState().value, + ), tools = viewModel.tools.collectAsState().value, - eventSink = eventSink, + eventSink = remember(onEvent) { + { + when (it) { + is ToolsScreen.Event.OpenToolDetails -> { + if (it.source != null) viewModel.recordOpenToolDetailsInAnalytics(it.tool, it.source) + onEvent(ToolCardEvent.OpenToolDetails(it.tool, additionalLocale = selectedLanguage?.code)) + } + is ToolsScreen.Event.UpdateSelectedCategory -> viewModel.setSelectedCategory(it.category) + is ToolsScreen.Event.UpdateLanguageQuery -> viewModel.setLanguageQuery(it.query) + is ToolsScreen.Event.UpdateSelectedLanguage -> viewModel.setSelectedLocale(it.locale) + } + } + }, ) + ToolsLayout(state) +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +internal fun ToolsLayout(state: ToolsScreen.State, modifier: Modifier = Modifier) { + val toolViewModels: ToolViewModels = viewModel() + 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 selectedLanguage by rememberUpdatedState(state.filters.selectedLanguage) + val eventSink by rememberUpdatedState(state.eventSink) val columnState = rememberLazyListState() LaunchedEffect(banner) { if (banner != null) columnState.animateScrollToItem(0) } - LazyColumn(state = columnState) { + LazyColumn(state = columnState, modifier = modifier) { item("banners", "banners") { Banners( { banner }, From 9503c2210f7f09248acdcf9be18b925be09e65cb Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 18 Dec 2023 17:39:04 -0700 Subject: [PATCH 20/37] use Circuit to manage the ToolsLayout --- .../ui/dashboard/DashboardActivity.kt | 28 +++++---- .../godtools/ui/dashboard/DashboardLayout.kt | 37 ++++++++---- .../ui/dashboard/tools/ToolsLayout.kt | 40 +------------ .../ui/dashboard/tools/ToolsPresenter.kt | 60 +++++++++++++++++++ .../ui/tooldetails/ToolDetailsScreen.kt | 8 +++ 5 files changed, 115 insertions(+), 58 deletions(-) create mode 100644 app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt create mode 100644 app/src/main/kotlin/org/cru/godtools/ui/tooldetails/ToolDetailsScreen.kt diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardActivity.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardActivity.kt index 8a50950b3b..082c8fe2ab 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardActivity.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardActivity.kt @@ -4,6 +4,8 @@ import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels +import com.slack.circuit.foundation.Circuit +import com.slack.circuit.foundation.CircuitCompositionLocals import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint import java.util.Locale @@ -27,22 +29,28 @@ class DashboardActivity : BaseActivity() { private val viewModel: DashboardViewModel by viewModels() private val launchTrackingViewModel: LaunchTrackingViewModel by viewModels() + @Inject + lateinit var circuit: Circuit + // region Lifecycle override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState == null) intent?.let { processIntent(it) } triggerOnboardingIfNecessary() setContent { - GodToolsTheme { - DashboardLayout( - onEvent = { e -> - when (e) { - is DashboardEvent.OpenTool -> - openTool(e.tool, e.type, *listOfNotNull(e.lang1, e.lang2).toTypedArray()) - is DashboardEvent.OpenToolDetails -> e.tool?.let { startToolDetailsActivity(it, e.lang) } - } - }, - ) + CircuitCompositionLocals(circuit) { + GodToolsTheme { + DashboardLayout( + onEvent = { e -> + when (e) { + is DashboardEvent.OpenTool -> + openTool(e.tool, e.type, *listOfNotNull(e.lang1, e.lang2).toTypedArray()) + is DashboardEvent.OpenToolDetails -> + e.tool?.let { startToolDetailsActivity(it, e.lang) } + } + }, + ) + } } } } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardLayout.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardLayout.kt index ae4bb1fcba..5e71968337 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardLayout.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardLayout.kt @@ -35,13 +35,15 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.slack.circuit.foundation.CircuitContent +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.screen.Screen import java.util.Locale import kotlinx.coroutines.launch import org.ccci.gto.android.common.androidx.compose.material3.ui.navigationdrawer.toggle import org.ccci.gto.android.common.androidx.compose.material3.ui.pullrefresh.PullRefreshIndicator import org.ccci.gto.android.common.androidx.compose.ui.draw.autoMirror import org.ccci.gto.android.common.androidx.lifecycle.compose.OnResume -import org.cru.godtools.BuildConfig import org.cru.godtools.R import org.cru.godtools.analytics.compose.RecordAnalyticsScreen import org.cru.godtools.analytics.firebase.model.ACTION_IAM_ALL_TOOLS @@ -59,8 +61,9 @@ import org.cru.godtools.ui.dashboard.home.DashboardHomeEvent import org.cru.godtools.ui.dashboard.home.HomeContent import org.cru.godtools.ui.dashboard.lessons.DashboardLessonsEvent import org.cru.godtools.ui.dashboard.lessons.LessonsLayout -import org.cru.godtools.ui.dashboard.tools.ToolsLayout +import org.cru.godtools.ui.dashboard.tools.ToolsScreen import org.cru.godtools.ui.drawer.DrawerMenuLayout +import org.cru.godtools.ui.tooldetails.ToolDetailsScreen import org.cru.godtools.ui.tools.ToolCardEvent internal sealed interface DashboardEvent { @@ -165,17 +168,29 @@ internal fun DashboardLayout(onEvent: (DashboardEvent) -> Unit, viewModel: Dashb }, ) - Page.ALL_TOOLS -> ToolsLayout( - onEvent = { e -> - when (e) { - is ToolCardEvent.Click -> onEvent(DashboardEvent.OpenToolDetails(e.tool)) - is ToolCardEvent.OpenToolDetails -> - onEvent(DashboardEvent.OpenToolDetails(e.tool, e.additionalLocale)) - is ToolCardEvent.OpenTool -> - if (BuildConfig.DEBUG) error("opening a tool from All Tools is unsupported") + Page.ALL_TOOLS -> { + val navigator = remember(onEvent) { + object : Navigator { + override fun goTo(screen: Screen) { + when (screen) { + is ToolDetailsScreen -> onEvent( + DashboardEvent.OpenToolDetails( + screen.initialTool, + screen.secondLanguage, + ) + ) + } + } + + override fun pop() = TODO("Not yet implemented") + override fun resetRoot(newRoot: Screen) = TODO("Not yet implemented") } } - ) + CircuitContent( + screen = ToolsScreen, + navigator = navigator, + ) + } } } } 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 ccc0de62df..fa46ec5071 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 @@ -15,14 +15,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.slack.circuit.codegen.annotations.CircuitInject +import dagger.hilt.components.SingletonComponent import org.ccci.gto.android.common.androidx.compose.foundation.layout.padding import org.cru.godtools.R import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURCE_ALL_TOOLS @@ -30,47 +30,13 @@ import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURC import org.cru.godtools.ui.banner.Banners import org.cru.godtools.ui.tools.SquareToolCard import org.cru.godtools.ui.tools.ToolCard -import org.cru.godtools.ui.tools.ToolCardEvent import org.cru.godtools.ui.tools.ToolViewModels internal val MARGIN_TOOLS_LAYOUT_HORIZONTAL = 16.dp -@Composable -internal fun ToolsLayout(onEvent: (ToolCardEvent) -> Unit) { - val viewModel: ToolsViewModel = viewModel() - val selectedLanguage by viewModel.selectedLanguage.collectAsState() - - val state = ToolsScreen.State( - banner = viewModel.banner.collectAsState().value, - spotlightTools = viewModel.spotlightTools.collectAsState().value, - filters = ToolsScreen.State.Filters( - categories = viewModel.categories.collectAsState().value, - selectedCategory = viewModel.selectedCategory.collectAsState().value, - languages = viewModel.languages.collectAsState().value, - languageQuery = viewModel.languageQuery.collectAsState().value, - selectedLanguage = viewModel.selectedLanguage.collectAsState().value, - ), - tools = viewModel.tools.collectAsState().value, - eventSink = remember(onEvent) { - { - when (it) { - is ToolsScreen.Event.OpenToolDetails -> { - if (it.source != null) viewModel.recordOpenToolDetailsInAnalytics(it.tool, it.source) - onEvent(ToolCardEvent.OpenToolDetails(it.tool, additionalLocale = selectedLanguage?.code)) - } - is ToolsScreen.Event.UpdateSelectedCategory -> viewModel.setSelectedCategory(it.category) - is ToolsScreen.Event.UpdateLanguageQuery -> viewModel.setLanguageQuery(it.query) - is ToolsScreen.Event.UpdateSelectedLanguage -> viewModel.setSelectedLocale(it.locale) - } - } - }, - ) - - ToolsLayout(state) -} - @Composable @OptIn(ExperimentalFoundationApi::class) +@CircuitInject(ToolsScreen::class, SingletonComponent::class) internal fun ToolsLayout(state: ToolsScreen.State, modifier: Modifier = Modifier) { val toolViewModels: ToolViewModels = viewModel() 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 new file mode 100644 index 0000000000..50998648a3 --- /dev/null +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt @@ -0,0 +1,60 @@ +package org.cru.godtools.ui.dashboard.tools + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.viewmodel.compose.viewModel +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.components.SingletonComponent +import org.cru.godtools.ui.tooldetails.ToolDetailsScreen + +class ToolsPresenter @AssistedInject constructor( + @Assisted private val navigator: Navigator, +) : Presenter { + @Composable + override fun present(): ToolsScreen.State { + val viewModel: ToolsViewModel = viewModel() + + val selectedLanguage by viewModel.selectedLanguage.collectAsState() + + val eventSink: (ToolsScreen.Event) -> Unit = remember { + { + when (it) { + is ToolsScreen.Event.OpenToolDetails -> { + if (it.source != null) viewModel.recordOpenToolDetailsInAnalytics(it.tool, it.source) + navigator.goTo(ToolDetailsScreen(it.tool, selectedLanguage?.code)) + } + is ToolsScreen.Event.UpdateSelectedCategory -> viewModel.setSelectedCategory(it.category) + is ToolsScreen.Event.UpdateLanguageQuery -> viewModel.setLanguageQuery(it.query) + is ToolsScreen.Event.UpdateSelectedLanguage -> viewModel.setSelectedLocale(it.locale) + } + } + } + + return ToolsScreen.State( + banner = viewModel.banner.collectAsState().value, + spotlightTools = viewModel.spotlightTools.collectAsState().value, + filters = ToolsScreen.State.Filters( + categories = viewModel.categories.collectAsState().value, + selectedCategory = viewModel.selectedCategory.collectAsState().value, + languages = viewModel.languages.collectAsState().value, + languageQuery = viewModel.languageQuery.collectAsState().value, + selectedLanguage = viewModel.selectedLanguage.collectAsState().value, + ), + tools = viewModel.tools.collectAsState().value, + eventSink = eventSink, + ) + } + + @AssistedFactory + @CircuitInject(ToolsScreen::class, SingletonComponent::class) + interface Factory { + fun create(navigator: Navigator): ToolsPresenter + } +} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tooldetails/ToolDetailsScreen.kt b/app/src/main/kotlin/org/cru/godtools/ui/tooldetails/ToolDetailsScreen.kt new file mode 100644 index 0000000000..59b5e2238f --- /dev/null +++ b/app/src/main/kotlin/org/cru/godtools/ui/tooldetails/ToolDetailsScreen.kt @@ -0,0 +1,8 @@ +package org.cru.godtools.ui.tooldetails + +import com.slack.circuit.runtime.screen.Screen +import java.util.Locale +import kotlinx.parcelize.Parcelize + +@Parcelize +class ToolDetailsScreen(val initialTool: String, val secondLanguage: Locale? = null) : Screen From 037929d72e614f205710b21b37cb5a13cd5e1db4 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Tue, 19 Dec 2023 12:53:47 -0700 Subject: [PATCH 21/37] load the active banner for the ToolsLayout in the ToolsPresenter --- .../ui/dashboard/tools/ToolsPresenter.kt | 14 +++- .../ui/dashboard/tools/ToolsViewModel.kt | 5 -- .../ui/dashboard/tools/ToolsPresenterTest.kt | 64 +++++++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt 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 50998648a3..2d437d3068 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 @@ -1,5 +1,6 @@ package org.cru.godtools.ui.dashboard.tools +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -12,9 +13,13 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.map +import org.cru.godtools.base.Settings +import org.cru.godtools.ui.banner.BannerType import org.cru.godtools.ui.tooldetails.ToolDetailsScreen class ToolsPresenter @AssistedInject constructor( + private val settings: Settings, @Assisted private val navigator: Navigator, ) : Presenter { @Composable @@ -38,7 +43,7 @@ class ToolsPresenter @AssistedInject constructor( } return ToolsScreen.State( - banner = viewModel.banner.collectAsState().value, + banner = rememberBanner(), spotlightTools = viewModel.spotlightTools.collectAsState().value, filters = ToolsScreen.State.Filters( categories = viewModel.categories.collectAsState().value, @@ -52,6 +57,13 @@ class ToolsPresenter @AssistedInject constructor( ) } + @Composable + @VisibleForTesting + internal fun rememberBanner() = remember { + settings.isFeatureDiscoveredFlow(Settings.FEATURE_TOOL_FAVORITE) + .map { if (!it) BannerType.TOOL_LIST_FAVORITES else null } + }.collectAsState(null).value + @AssistedFactory @CircuitInject(ToolsScreen::class, SingletonComponent::class) interface Factory { diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt index 4eb2ac6085..81fb76f0f7 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt @@ -26,7 +26,6 @@ import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.model.Language import org.cru.godtools.model.Language.Companion.filterByDisplayAndNativeName import org.cru.godtools.model.Tool -import org.cru.godtools.ui.banner.BannerType import org.greenrobot.eventbus.EventBus private const val KEY_SELECTED_CATEGORY = "selectedCategory" @@ -43,10 +42,6 @@ class ToolsViewModel @Inject constructor( languagesRepository: LanguagesRepository, private val savedState: SavedStateHandle, ) : ViewModel() { - val banner = settings.isFeatureDiscoveredFlow(Settings.FEATURE_TOOL_FAVORITE) - .map { if (!it) BannerType.TOOL_LIST_FAVORITES else null } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - val spotlightTools = toolsRepository.getNormalToolsFlow() .map { it.filter { !it.isHidden && it.isSpotlight }.sortedWith(Tool.COMPARATOR_DEFAULT_ORDER) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) 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 new file mode 100644 index 0000000000..d66d2de7de --- /dev/null +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt @@ -0,0 +1,64 @@ +package org.cru.godtools.ui.dashboard.tools + +import android.app.Application +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.slack.circuit.test.FakeNavigator +import com.slack.circuit.test.presenterTestOf +import io.mockk.every +import io.mockk.mockk +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.cru.godtools.TestUtils.clearAndroidUiDispatcher +import org.cru.godtools.base.Settings +import org.cru.godtools.ui.banner.BannerType +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = Application::class) +class ToolsPresenterTest { + private val isFavoritesFeatureDiscovered = MutableStateFlow(true) + + private val navigator = FakeNavigator() + private val settings: Settings = mockk { + every { isFeatureDiscoveredFlow(Settings.FEATURE_TOOL_FAVORITE) } returns isFavoritesFeatureDiscovered + } + + private val presenter = ToolsPresenter( + settings = settings, + navigator = navigator, + ) + + @AfterTest + fun cleanup() = clearAndroidUiDispatcher() + + // region State.banner + @Test + fun `State - banner - none`() = runTest { + presenterTestOf( + presentFunction = { + ToolsScreen.State(banner = presenter.rememberBanner(), eventSink = {}) + } + ) { + isFavoritesFeatureDiscovered.value = true + assertNull(expectMostRecentItem().banner) + } + } + + @Test + fun `State - banner - favorites`() = runTest { + presenterTestOf( + presentFunction = { + ToolsScreen.State(banner = presenter.rememberBanner(), eventSink = {}) + } + ) { + isFavoritesFeatureDiscovered.value = false + assertEquals(BannerType.TOOL_LIST_FAVORITES, expectMostRecentItem().banner) + } + } + // endregion State.banner +} From bda5fc1f63226ccaf288540db47a1cfbb642ed77 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Tue, 19 Dec 2023 13:45:09 -0700 Subject: [PATCH 22/37] load the selectedLanguage in the ToolsPresenter --- .../ui/dashboard/tools/ToolsPresenter.kt | 17 ++++- .../ui/dashboard/tools/ToolsViewModel.kt | 4 -- .../ui/dashboard/tools/ToolsPresenterTest.kt | 69 +++++++++++++++++++ 3 files changed, 83 insertions(+), 7 deletions(-) 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 2d437d3068..1e578b8b1a 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 @@ -13,27 +13,32 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.components.SingletonComponent +import java.util.Locale +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import org.cru.godtools.base.Settings +import org.cru.godtools.db.repository.LanguagesRepository import org.cru.godtools.ui.banner.BannerType import org.cru.godtools.ui.tooldetails.ToolDetailsScreen class ToolsPresenter @AssistedInject constructor( private val settings: Settings, + private val languagesRepository: LanguagesRepository, @Assisted private val navigator: Navigator, ) : Presenter { @Composable override fun present(): ToolsScreen.State { val viewModel: ToolsViewModel = viewModel() - val selectedLanguage by viewModel.selectedLanguage.collectAsState() + val selectedLocale by viewModel.selectedLocale.collectAsState() + val selectedLanguage = rememberLanguage(selectedLocale) val eventSink: (ToolsScreen.Event) -> Unit = remember { { when (it) { is ToolsScreen.Event.OpenToolDetails -> { if (it.source != null) viewModel.recordOpenToolDetailsInAnalytics(it.tool, it.source) - navigator.goTo(ToolDetailsScreen(it.tool, selectedLanguage?.code)) + navigator.goTo(ToolDetailsScreen(it.tool, selectedLocale)) } is ToolsScreen.Event.UpdateSelectedCategory -> viewModel.setSelectedCategory(it.category) is ToolsScreen.Event.UpdateLanguageQuery -> viewModel.setLanguageQuery(it.query) @@ -50,7 +55,7 @@ class ToolsPresenter @AssistedInject constructor( selectedCategory = viewModel.selectedCategory.collectAsState().value, languages = viewModel.languages.collectAsState().value, languageQuery = viewModel.languageQuery.collectAsState().value, - selectedLanguage = viewModel.selectedLanguage.collectAsState().value, + selectedLanguage = selectedLanguage, ), tools = viewModel.tools.collectAsState().value, eventSink = eventSink, @@ -64,6 +69,12 @@ class ToolsPresenter @AssistedInject constructor( .map { if (!it) BannerType.TOOL_LIST_FAVORITES else null } }.collectAsState(null).value + @Composable + @VisibleForTesting + internal fun rememberLanguage(locale: Locale?) = remember(locale) { + locale?.let { languagesRepository.findLanguageFlow(it) } ?: flowOf(null) + }.collectAsState(null).value + @AssistedFactory @CircuitInject(ToolsScreen::class, SingletonComponent::class) interface Factory { diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt index 81fb76f0f7..9fc2cf4410 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest @@ -51,9 +50,6 @@ class ToolsViewModel @Inject constructor( fun setSelectedCategory(category: String?) = savedState.set(KEY_SELECTED_CATEGORY, category) internal val selectedLocale = savedState.getStateFlow(KEY_SELECTED_LANGUAGE, null) - val selectedLanguage = selectedLocale - .flatMapLatest { it?.let { languagesRepository.findLanguageFlow(it) } ?: flowOf(null) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) fun setSelectedLocale(locale: Locale?) = savedState.set(KEY_SELECTED_LANGUAGE, locale) val languageQuery = savedState.getStateFlow(KEY_LANGUAGE_QUERY, "") 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 d66d2de7de..73110ae8df 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 @@ -4,16 +4,22 @@ import android.app.Application import androidx.test.ext.junit.runners.AndroidJUnit4 import com.slack.circuit.test.FakeNavigator import com.slack.circuit.test.presenterTestOf +import io.mockk.Called import io.mockk.every import io.mockk.mockk +import io.mockk.verifyAll +import java.util.Locale import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.cru.godtools.TestUtils.clearAndroidUiDispatcher import org.cru.godtools.base.Settings +import org.cru.godtools.db.repository.LanguagesRepository +import org.cru.godtools.model.Language import org.cru.godtools.ui.banner.BannerType import org.junit.runner.RunWith import org.robolectric.annotation.Config @@ -23,6 +29,9 @@ import org.robolectric.annotation.Config class ToolsPresenterTest { private val isFavoritesFeatureDiscovered = MutableStateFlow(true) + private val languagesRepository: LanguagesRepository = mockk { + every { findLanguageFlow(any()) } returns flowOf(null) + } private val navigator = FakeNavigator() private val settings: Settings = mockk { every { isFeatureDiscoveredFlow(Settings.FEATURE_TOOL_FAVORITE) } returns isFavoritesFeatureDiscovered @@ -30,6 +39,7 @@ class ToolsPresenterTest { private val presenter = ToolsPresenter( settings = settings, + languagesRepository = languagesRepository, navigator = navigator, ) @@ -61,4 +71,63 @@ class ToolsPresenterTest { } } // endregion State.banner + + // region State.filters.selectedLanguage + @Test + fun `State - filters - selectedLanguage - no language selected`() = runTest { + presenterTestOf( + presentFunction = { + ToolsScreen.State( + filters = ToolsScreen.State.Filters( + selectedLanguage = presenter.rememberLanguage(null) + ), + eventSink = {} + ) + } + ) { + assertNull(expectMostRecentItem().filters.selectedLanguage) + } + + verifyAll { languagesRepository wasNot Called } + } + + @Test + fun `State - filters - selectedLanguage - language not found`() = runTest { + presenterTestOf( + presentFunction = { + ToolsScreen.State( + filters = ToolsScreen.State.Filters( + selectedLanguage = presenter.rememberLanguage(Locale.ENGLISH) + ), + eventSink = {} + ) + } + ) { + assertNull(expectMostRecentItem().filters.selectedLanguage) + } + + verifyAll { languagesRepository.findLanguageFlow(Locale.ENGLISH) } + } + + @Test + fun `State - filters - selectedLanguage - language selected`() = runTest { + val language = Language(Locale.ENGLISH) + every { languagesRepository.findLanguageFlow(Locale.ENGLISH) } returns flowOf(language) + + presenterTestOf( + presentFunction = { + ToolsScreen.State( + filters = ToolsScreen.State.Filters( + selectedLanguage = presenter.rememberLanguage(Locale.ENGLISH) + ), + eventSink = {} + ) + } + ) { + assertEquals(language, expectMostRecentItem().filters.selectedLanguage) + } + + verifyAll { languagesRepository.findLanguageFlow(Locale.ENGLISH) } + } + // endregion State.filters.selectedLanguage } From 4ad60d88086fced3941467e045f15f8a98613e2d Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Tue, 19 Dec 2023 14:53:13 -0700 Subject: [PATCH 23/37] move tool details analytics events into the Presenter --- .../cru/godtools/ui/dashboard/tools/ToolsPresenter.kt | 8 +++++++- .../cru/godtools/ui/dashboard/tools/ToolsViewModel.kt | 10 ---------- .../godtools/ui/dashboard/tools/ToolsViewModelTest.kt | 1 - .../godtools/ui/dashboard/tools/ToolsPresenterTest.kt | 1 + 4 files changed, 8 insertions(+), 12 deletions(-) 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 1e578b8b1a..fe06d0659e 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 @@ -16,12 +16,16 @@ import dagger.hilt.components.SingletonComponent import java.util.Locale import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent +import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_TOOL_DETAILS import org.cru.godtools.base.Settings import org.cru.godtools.db.repository.LanguagesRepository import org.cru.godtools.ui.banner.BannerType import org.cru.godtools.ui.tooldetails.ToolDetailsScreen +import org.greenrobot.eventbus.EventBus class ToolsPresenter @AssistedInject constructor( + private val eventBus: EventBus, private val settings: Settings, private val languagesRepository: LanguagesRepository, @Assisted private val navigator: Navigator, @@ -37,7 +41,9 @@ class ToolsPresenter @AssistedInject constructor( { when (it) { is ToolsScreen.Event.OpenToolDetails -> { - if (it.source != null) viewModel.recordOpenToolDetailsInAnalytics(it.tool, it.source) + if (it.source != null) { + eventBus.post(OpenAnalyticsActionEvent(ACTION_OPEN_TOOL_DETAILS, it.tool, it.source)) + } navigator.goTo(ToolDetailsScreen(it.tool, selectedLocale)) } is ToolsScreen.Event.UpdateSelectedCategory -> viewModel.setSelectedCategory(it.category) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt index 9fc2cf4410..0c9585fcc2 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt @@ -17,15 +17,12 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn -import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent -import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_TOOL_DETAILS import org.cru.godtools.base.Settings import org.cru.godtools.db.repository.LanguagesRepository import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.model.Language import org.cru.godtools.model.Language.Companion.filterByDisplayAndNativeName import org.cru.godtools.model.Tool -import org.greenrobot.eventbus.EventBus private const val KEY_SELECTED_CATEGORY = "selectedCategory" private const val KEY_SELECTED_LANGUAGE = "selectedLanguage" @@ -35,7 +32,6 @@ private const val KEY_LANGUAGE_QUERY = "languageQuery" @OptIn(ExperimentalCoroutinesApi::class) class ToolsViewModel @Inject constructor( @ApplicationContext context: Context, - private val eventBus: EventBus, settings: Settings, toolsRepository: ToolsRepository, languagesRepository: LanguagesRepository, @@ -88,10 +84,4 @@ class ToolsViewModel @Inject constructor( .combine(selectedCategory) { tools, category -> tools.filter { category == null || it.category == category } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) // endregion Tools - - // region Analytics - fun recordOpenToolDetailsInAnalytics(tool: String?, source: String) { - eventBus.post(OpenAnalyticsActionEvent(ACTION_OPEN_TOOL_DETAILS, tool, source)) - } - // endregion Analytics } diff --git a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt index eea3df3156..da49e2f7b4 100644 --- a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt +++ b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt @@ -47,7 +47,6 @@ class ToolsViewModelTest { Dispatchers.setMain(UnconfinedTestDispatcher(testScope.testScheduler)) viewModel = ToolsViewModel( context = mockk(), - eventBus = mockk(), settings = settings, languagesRepository = mockk(), toolsRepository = toolsRepository, 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 73110ae8df..013aedf616 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 @@ -38,6 +38,7 @@ class ToolsPresenterTest { } private val presenter = ToolsPresenter( + eventBus = mockk(), settings = settings, languagesRepository = languagesRepository, navigator = navigator, From 5bf4795788af860acc8e0ce7d6508184e88a17dc Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 21 Dec 2023 14:08:20 -0700 Subject: [PATCH 24/37] create a ToolCardPresenter that will generate ToolCard.State for a tool --- .../org/cru/godtools/ui/tools/ToolCard.kt | 1 + .../godtools/ui/tools/ToolCardPresenter.kt | 77 ++++++ .../ui/tools/ToolCardPresenterTest.kt | 259 ++++++++++++++++++ .../kotlin/org/cru/godtools/model/Tool.kt | 3 +- 4 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardPresenter.kt create mode 100644 app/src/testDebug/kotlin/org/cru/godtools/ui/tools/ToolCardPresenterTest.kt diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt index e1af8eb08c..06a0a0c60e 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCard.kt @@ -11,6 +11,7 @@ import org.cru.godtools.model.Translation object ToolCard { data class State( val tool: Tool? = null, + val isLoaded: Boolean = true, val banner: File? = null, val translation: Translation? = null, val secondLanguage: Language? = null, diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardPresenter.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardPresenter.kt new file mode 100644 index 0000000000..10eed4a0c1 --- /dev/null +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardPresenter.kt @@ -0,0 +1,77 @@ +package org.cru.godtools.ui.tools + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import org.ccci.gto.android.common.kotlin.coroutines.flow.StateFlowValue +import org.cru.godtools.base.Settings +import org.cru.godtools.base.ToolFileSystem +import org.cru.godtools.db.repository.AttachmentsRepository +import org.cru.godtools.db.repository.TranslationsRepository +import org.cru.godtools.model.Language +import org.cru.godtools.model.Tool + +@Singleton +class ToolCardPresenter @Inject constructor( + private val fileSystem: ToolFileSystem, + private val settings: Settings, + private val attachmentsRepository: AttachmentsRepository, + private val translationsRepository: TranslationsRepository, +) { + @Composable + fun present( + tool: Tool, + secondLanguage: Language? = null, + eventSink: (ToolCard.Event) -> Unit = {}, + ): ToolCard.State { + val toolCode = tool.code + + // Tool Card Banner + val bannerId = tool.bannerId + val banner by remember(bannerId) { + when { + bannerId != null -> attachmentsRepository.findAttachmentFlow(bannerId) + .map { it?.takeIf { it.isDownloaded }?.getFile(fileSystem) } + else -> flowOf(null) + } + }.collectAsState(null) + + // Translation + val appLanguage by remember { settings.appLanguageFlow }.collectAsState(settings.appLanguage) + val primaryTranslationFlow = remember(toolCode, appLanguage) { + translationsRepository.findLatestTranslationFlow(toolCode, appLanguage) + } + val defaultTranslationFlow = remember(toolCode) { + translationsRepository.findLatestTranslationFlow(toolCode, Settings.defaultLanguage).onStart { emit(null) } + } + val translation by remember(primaryTranslationFlow, defaultTranslationFlow) { + combine(primaryTranslationFlow, defaultTranslationFlow) { t1, t2 -> StateFlowValue(t1 ?: t2) } + }.collectAsState(StateFlowValue.Initial(null)) + + // Second Translation + val secondLocale = secondLanguage?.code + val secondTranslation by remember(toolCode, secondLocale) { + translationsRepository.findLatestTranslationFlow(toolCode, secondLocale) + }.collectAsState(null) + + return ToolCard.State( + tool = tool, + isLoaded = !translation.isInitial, + banner = banner, + translation = translation.value, + secondLanguage = secondLanguage, + secondTranslation = when (secondLocale) { + translation.value?.languageCode -> null + else -> secondTranslation + }, + eventSink = eventSink, + ) + } +} diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/ToolCardPresenterTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/ToolCardPresenterTest.kt new file mode 100644 index 0000000000..78d4d09c9e --- /dev/null +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/ToolCardPresenterTest.kt @@ -0,0 +1,259 @@ +package org.cru.godtools.ui.tools + +import android.app.Application +import androidx.compose.runtime.collectAsState +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.slack.circuit.test.presenterTestOf +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verifyAll +import java.io.File +import java.util.Locale +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +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.cru.godtools.base.Settings +import org.cru.godtools.base.ToolFileSystem +import org.cru.godtools.db.repository.AttachmentsRepository +import org.cru.godtools.db.repository.TranslationsRepository +import org.cru.godtools.model.Attachment +import org.cru.godtools.model.Language +import org.cru.godtools.model.Translation +import org.cru.godtools.model.randomTool +import org.cru.godtools.model.randomTranslation +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +private const val TOOL = "tool" +private const val BANNER_ID = 1L + +@RunWith(AndroidJUnit4::class) +@Config(application = Application::class) +class ToolCardPresenterTest { + private val toolFlow = MutableStateFlow(randomTool(TOOL, bannerId = BANNER_ID)) + private val bannerFlow = MutableSharedFlow(extraBufferCapacity = 1) + private val appLanguageFlow = MutableStateFlow(Locale.ENGLISH) + private val enTranslationFlow = MutableSharedFlow(extraBufferCapacity = 1) + private val frTranslationFlow = MutableSharedFlow(extraBufferCapacity = 1) + + private val attachmentsRepository: AttachmentsRepository = mockk { + every { findAttachmentFlow(any()) } returns flowOf(null) + every { findAttachmentFlow(BANNER_ID) } returns bannerFlow + } + private val fileSystem: ToolFileSystem = mockk() + private val settings: Settings = mockk { + every { appLanguageFlow } returns this@ToolCardPresenterTest.appLanguageFlow + every { appLanguage } returns this@ToolCardPresenterTest.appLanguageFlow.value + } + private val translationsRepository: TranslationsRepository = mockk { + every { findLatestTranslationFlow(TOOL, any()) } returns flowOf(null) + every { findLatestTranslationFlow(TOOL, Locale.ENGLISH) } returns enTranslationFlow + every { findLatestTranslationFlow(TOOL, Locale.FRENCH) } returns frTranslationFlow + } + + private val presenter = ToolCardPresenter( + fileSystem = fileSystem, + settings = settings, + attachmentsRepository = attachmentsRepository, + translationsRepository = translationsRepository, + ) + + // region ToolCard.State.tool + @Test + fun `ToolCardState - tool`() = runTest { + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value) } + ) { + assertEquals(toolFlow.value, expectMostRecentItem().tool) + } + } + + @Test + fun `ToolCardState - tool - emit new state on update`() = runTest { + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value) } + ) { + assertEquals(toolFlow.value, expectMostRecentItem().tool) + + toolFlow.value = randomTool(TOOL) + assertEquals(toolFlow.value, expectMostRecentItem().tool) + } + } + // endregion ToolCard.State.tool + + // region ToolCard.State.banner + @Test + fun `ToolCardState - banner`() = runTest { + val banner = Attachment(BANNER_ID) { + sha256 = "0123456789abcdef" + isDownloaded = true + } + + val file = File.createTempFile("tmp", null) + coEvery { banner.getFile(fileSystem) } returns file + + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value) } + ) { + bannerFlow.emit(banner) + assertEquals(file, expectMostRecentItem().banner) + } + } + + @Test + fun `ToolCardState - banner - don't return banners not downloaded yet`() = runTest { + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value) } + ) { + bannerFlow.emit( + Attachment(BANNER_ID) { + sha256 = "0123456789abcdef" + isDownloaded = false + } + ) + assertNull(expectMostRecentItem().banner) + } + + verifyAll { + attachmentsRepository.findAttachmentFlow(BANNER_ID) + fileSystem wasNot Called + } + } + + @Test + fun `ToolCardState - banner - emit new state on Attachment update`() = runTest { + val banner = Attachment(BANNER_ID) { + sha256 = "0123456789abcdef" + isDownloaded = true + } + + val file = File.createTempFile("tmp", null) + coEvery { banner.getFile(fileSystem) } returns file + + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value) } + ) { + bannerFlow.emit(Attachment(BANNER_ID) { isDownloaded = false }) + assertNull(expectMostRecentItem().banner) + + bannerFlow.emit(banner) + assertEquals(file, expectMostRecentItem().banner) + } + } + // endregion ToolCard.State.banner + + // region ToolCard.State.translation + @Test + fun `ToolCardState - translation`() = runTest { + toolFlow.value = randomTool(TOOL) + appLanguageFlow.value = Locale.FRENCH + val translation = randomTranslation(TOOL, Locale.FRENCH) + + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value) } + ) { + frTranslationFlow.emit(translation) + + val state = expectMostRecentItem() + assertTrue(state.isLoaded) + assertEquals(translation, state.translation) + } + } + + @Test + fun `ToolCardState - translation - fallback to default language`() = runTest { + toolFlow.value = randomTool(TOOL) + appLanguageFlow.value = Locale.FRENCH + val translation = randomTranslation(TOOL, Locale.ENGLISH) + + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value) } + ) { + frTranslationFlow.emit(null) + enTranslationFlow.emit(translation) + + val state = expectMostRecentItem() + assertTrue(state.isLoaded) + assertEquals(translation, state.translation) + } + } + + @Test + fun `ToolCardState - translation - don't emit fallback if primary hasn't loaded yet`() = runTest { + toolFlow.value = randomTool(TOOL) + appLanguageFlow.value = Locale.FRENCH + val translation = randomTranslation(TOOL, Locale.ENGLISH) + + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value) } + ) { + enTranslationFlow.emit(translation) + + val state = expectMostRecentItem() + assertFalse(state.isLoaded, "isLoaded should only be true once the translation flow emits a value") + assertNull(state.translation) + } + } + // endregion ToolCard.State.translation + + // region ToolCard.State.secondLanguage + @Test + fun `ToolCardState - secondLanguage`() = runTest { + toolFlow.value = randomTool(TOOL) + val language = Language(Locale.FRENCH) + + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value, secondLanguage = language) } + ) { + assertEquals(language, expectMostRecentItem().secondLanguage) + } + } + // endregion ToolCard.State.secondLanguage + + // region ToolCard.State.secondTranslation + @Test + fun `ToolCardState - secondTranslation`() = runTest { + toolFlow.value = randomTool(TOOL) + val language = Language(Locale.FRENCH) + val translation = randomTranslation(TOOL, Locale.FRENCH) + + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value, secondLanguage = language) } + ) { + frTranslationFlow.emit(translation) + assertEquals(translation, expectMostRecentItem().secondTranslation) + } + } + + @Test + fun `ToolCardState - secondTranslation - Doesn't match the language for the main translation`() = runTest { + toolFlow.value = randomTool(TOOL) + val translation = randomTranslation(TOOL, Locale.ENGLISH) + + presenterTestOf( + presentFunction = { + presenter.present( + tool = toolFlow.collectAsState().value, + secondLanguage = Language(Locale.ENGLISH), + ) + } + ) { + enTranslationFlow.emit(translation) + + assertNotNull(expectMostRecentItem()) { state -> + assertNull(state.secondTranslation) + assertEquals(translation, state.translation) + } + } + } + // endregion ToolCard.State.secondTranslation +} diff --git a/library/model/src/main/kotlin/org/cru/godtools/model/Tool.kt b/library/model/src/main/kotlin/org/cru/godtools/model/Tool.kt index 4c5dedf7ec..057b594e74 100644 --- a/library/model/src/main/kotlin/org/cru/godtools/model/Tool.kt +++ b/library/model/src/main/kotlin/org/cru/godtools/model/Tool.kt @@ -237,6 +237,7 @@ fun randomTool( CATEGORY_TRAINING, ).random(), description: String? = UUID.randomUUID().toString().takeIf { Random.nextBoolean() }, + bannerId: Long? = Random.nextLong().takeIf { Random.nextBoolean() }, isFavorite: Boolean = Random.nextBoolean(), isHidden: Boolean = Random.nextBoolean(), isSpotlight: Boolean = Random.nextBoolean(), @@ -250,7 +251,7 @@ fun randomTool( name = name, category = category, description = description, - bannerId = Random.nextLong().takeIf { Random.nextBoolean() }, + bannerId = bannerId, detailsBannerId = Random.nextLong().takeIf { Random.nextBoolean() }, detailsBannerAnimationId = Random.nextLong().takeIf { Random.nextBoolean() }, detailsBannerYoutubeVideoId = UUID.randomUUID().toString().takeIf { Random.nextBoolean() }, From 1ecb62b7cd6dc6d6b8e639bc03ac280b0e482801 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 21 Dec 2023 16:39:33 -0700 Subject: [PATCH 25/37] update ToolCardPresenter to handle PinTool and UnpinTool events directly --- .../godtools/ui/tools/ToolCardPresenter.kt | 21 +++++- .../ui/tools/ToolCardPresenterTest.kt | 73 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardPresenter.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardPresenter.kt index 10eed4a0c1..a4775fea5f 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardPresenter.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardPresenter.kt @@ -4,16 +4,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch import org.ccci.gto.android.common.kotlin.coroutines.flow.StateFlowValue import org.cru.godtools.base.Settings import org.cru.godtools.base.ToolFileSystem import org.cru.godtools.db.repository.AttachmentsRepository +import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.db.repository.TranslationsRepository import org.cru.godtools.model.Language import org.cru.godtools.model.Tool @@ -23,6 +27,7 @@ class ToolCardPresenter @Inject constructor( private val fileSystem: ToolFileSystem, private val settings: Settings, private val attachmentsRepository: AttachmentsRepository, + private val toolsRepository: ToolsRepository, private val translationsRepository: TranslationsRepository, ) { @Composable @@ -32,6 +37,7 @@ class ToolCardPresenter @Inject constructor( eventSink: (ToolCard.Event) -> Unit = {}, ): ToolCard.State { val toolCode = tool.code + val coroutineScope = rememberCoroutineScope() // Tool Card Banner val bannerId = tool.bannerId @@ -61,6 +67,19 @@ class ToolCardPresenter @Inject constructor( translationsRepository.findLatestTranslationFlow(toolCode, secondLocale) }.collectAsState(null) + // eventSink + val interceptingEventSink: (ToolCard.Event) -> Unit = remember(eventSink) { + { + when (it) { + ToolCard.Event.PinTool -> + coroutineScope.launch(NonCancellable) { toolCode?.let { toolsRepository.pinTool(toolCode) } } + ToolCard.Event.UnpinTool -> + coroutineScope.launch(NonCancellable) { toolCode?.let { toolsRepository.unpinTool(toolCode) } } + else -> eventSink(it) + } + } + } + return ToolCard.State( tool = tool, isLoaded = !translation.isInitial, @@ -71,7 +90,7 @@ class ToolCardPresenter @Inject constructor( translation.value?.languageCode -> null else -> secondTranslation }, - eventSink = eventSink, + eventSink = interceptingEventSink, ) } } diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/ToolCardPresenterTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/ToolCardPresenterTest.kt index 78d4d09c9e..e2c74768f4 100644 --- a/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/ToolCardPresenterTest.kt +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/ToolCardPresenterTest.kt @@ -3,9 +3,11 @@ package org.cru.godtools.ui.tools import android.app.Application import androidx.compose.runtime.collectAsState import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.slack.circuit.test.TestEventSink import com.slack.circuit.test.presenterTestOf import io.mockk.Called import io.mockk.coEvery +import io.mockk.coVerifyAll import io.mockk.every import io.mockk.mockk import io.mockk.verifyAll @@ -24,6 +26,7 @@ import kotlinx.coroutines.test.runTest import org.cru.godtools.base.Settings import org.cru.godtools.base.ToolFileSystem import org.cru.godtools.db.repository.AttachmentsRepository +import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.db.repository.TranslationsRepository import org.cru.godtools.model.Attachment import org.cru.godtools.model.Language @@ -54,16 +57,19 @@ class ToolCardPresenterTest { every { appLanguageFlow } returns this@ToolCardPresenterTest.appLanguageFlow every { appLanguage } returns this@ToolCardPresenterTest.appLanguageFlow.value } + private val toolsRepository: ToolsRepository = mockk(relaxUnitFun = true) private val translationsRepository: TranslationsRepository = mockk { every { findLatestTranslationFlow(TOOL, any()) } returns flowOf(null) every { findLatestTranslationFlow(TOOL, Locale.ENGLISH) } returns enTranslationFlow every { findLatestTranslationFlow(TOOL, Locale.FRENCH) } returns frTranslationFlow } + private val events = TestEventSink() private val presenter = ToolCardPresenter( fileSystem = fileSystem, settings = settings, attachmentsRepository = attachmentsRepository, + toolsRepository = toolsRepository, translationsRepository = translationsRepository, ) @@ -256,4 +262,71 @@ class ToolCardPresenterTest { } } // endregion ToolCard.State.secondTranslation + + // region ToolCard.Event.Click + @Test + fun `ToolCardEvent - Click`() = runTest { + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value, eventSink = events) } + ) { + expectMostRecentItem().eventSink(ToolCard.Event.Click) + } + + events.assertEvent(ToolCard.Event.Click) + } + // endregion ToolCard.Event.Click + + // region ToolCard.Event.OpenTool + @Test + fun `ToolCardEvent - OpenTool`() = runTest { + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value, eventSink = events) } + ) { + expectMostRecentItem().eventSink(ToolCard.Event.OpenTool) + } + + events.assertEvent(ToolCard.Event.OpenTool) + } + // endregion ToolCard.Event.OpenTool + + // region ToolCard.Event.OpenToolDetails + @Test + fun `ToolCardEvent - OpenToolDetails`() = runTest { + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value, eventSink = events) } + ) { + expectMostRecentItem().eventSink(ToolCard.Event.OpenToolDetails) + } + + events.assertEvent(ToolCard.Event.OpenToolDetails) + } + // endregion ToolCard.Event.OpenToolDetails + + // region ToolCard.Event.PinTool + @Test + fun `ToolCardEvent - PinTool`() = runTest { + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value, eventSink = events) } + ) { + expectMostRecentItem().eventSink(ToolCard.Event.PinTool) + } + + coVerifyAll { toolsRepository.pinTool(TOOL) } + events.assertNoEvents() + } + // endregion ToolCard.Event.PinTool + + // region ToolCard.Event.UnpinTool + @Test + fun `ToolCardEvent - UnpinTool`() = runTest { + presenterTestOf( + presentFunction = { presenter.present(tool = toolFlow.collectAsState().value, eventSink = events) } + ) { + expectMostRecentItem().eventSink(ToolCard.Event.UnpinTool) + } + + coVerifyAll { toolsRepository.unpinTool(TOOL) } + events.assertNoEvents() + } + // endregion ToolCard.Event.UnpinTool } From 5fa9d50cd162704f1e322be22881fb87d4bd3157 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 21 Dec 2023 16:54:55 -0700 Subject: [PATCH 26/37] generate the spotlight tools in the ToolsPresenter --- .../ui/dashboard/tools/ToolsLayout.kt | 25 ++----- .../ui/dashboard/tools/ToolsPresenter.kt | 47 +++++++++++- .../ui/dashboard/tools/ToolsScreen.kt | 3 +- .../ui/dashboard/tools/ToolsViewModel.kt | 5 -- .../ui/dashboard/tools/ToolsViewModelTest.kt | 37 ---------- .../ui/dashboard/tools/ToolsPresenterTest.kt | 71 +++++++++++++++++++ .../kotlin/org/cru/godtools/model/Tool.kt | 3 +- 7 files changed, 126 insertions(+), 65 deletions(-) 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 fa46ec5071..3032a0b9d0 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 @@ -26,7 +26,6 @@ import dagger.hilt.components.SingletonComponent import org.ccci.gto.android.common.androidx.compose.foundation.layout.padding import org.cru.godtools.R 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.ui.banner.Banners import org.cru.godtools.ui.tools.SquareToolCard import org.cru.godtools.ui.tools.ToolCard @@ -63,8 +62,7 @@ internal fun ToolsLayout(state: ToolsScreen.State, modifier: Modifier = Modifier if (spotlightTools.isNotEmpty()) { item("tool-spotlight", "tool-spotlight") { ToolSpotlight( - state, - toolViewModels, + spotlightTools, modifier = Modifier .animateItemPlacement() .padding(top = 16.dp) @@ -111,11 +109,7 @@ internal fun ToolsLayout(state: ToolsScreen.State, modifier: Modifier = Modifier } @Composable -private fun ToolSpotlight(state: ToolsScreen.State, toolViewModels: ToolViewModels, modifier: Modifier = Modifier) { - val spotlightTools by rememberUpdatedState(state.spotlightTools) - val selectedLanguage by rememberUpdatedState(state.filters.selectedLanguage) - val eventSink by rememberUpdatedState(state.eventSink) - +private fun ToolSpotlight(tools: List, modifier: Modifier = Modifier) { Column(modifier = modifier.fillMaxWidth()) { Text( stringResource(R.string.dashboard_tools_section_spotlight_label), @@ -136,20 +130,11 @@ private fun ToolSpotlight(state: ToolsScreen.State, toolViewModels: ToolViewMode horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(vertical = 8.dp) ) { - items(spotlightTools, key = { it.code.orEmpty() }) { tool -> - val toolViewModel = toolViewModels[tool.code.orEmpty()] - val toolState = toolViewModel.toState(secondLanguage = selectedLanguage) { - when (it) { - ToolCard.Event.Click, ToolCard.Event.OpenTool, ToolCard.Event.OpenToolDetails -> - tool.code?.let { eventSink(ToolsScreen.Event.OpenToolDetails(it, SOURCE_SPOTLIGHT)) } - ToolCard.Event.PinTool -> toolViewModel.pinTool() - ToolCard.Event.UnpinTool -> toolViewModel.unpinTool() - } - } - + items(tools, key = { it.tool?.code.orEmpty() }) { tool -> SquareToolCard( - state = toolState, + state = tool, showCategory = false, + showSecondLanguage = true, showActions = false, floatParallelLanguageUp = false, confirmRemovalFromFavorites = false, 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 fe06d0659e..81e7cb2cbb 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 @@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.lifecycle.viewmodel.compose.viewModel import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.runtime.Navigator @@ -18,22 +19,31 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map 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_SPOTLIGHT import org.cru.godtools.base.Settings import org.cru.godtools.db.repository.LanguagesRepository +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.tooldetails.ToolDetailsScreen +import org.cru.godtools.ui.tools.ToolCard +import org.cru.godtools.ui.tools.ToolCardPresenter import org.greenrobot.eventbus.EventBus class ToolsPresenter @AssistedInject constructor( private val eventBus: EventBus, private val settings: Settings, + private val toolCardPresenter: ToolCardPresenter, private val languagesRepository: LanguagesRepository, + private val toolsRepository: ToolsRepository, @Assisted private val navigator: Navigator, ) : Presenter { @Composable override fun present(): ToolsScreen.State { val viewModel: ToolsViewModel = viewModel() + // selected language val selectedLocale by viewModel.selectedLocale.collectAsState() val selectedLanguage = rememberLanguage(selectedLocale) @@ -55,7 +65,7 @@ class ToolsPresenter @AssistedInject constructor( return ToolsScreen.State( banner = rememberBanner(), - spotlightTools = viewModel.spotlightTools.collectAsState().value, + spotlightTools = rememberSpotlightTools(secondLanguage = selectedLanguage, eventSink = eventSink), filters = ToolsScreen.State.Filters( categories = viewModel.categories.collectAsState().value, selectedCategory = viewModel.selectedCategory.collectAsState().value, @@ -81,6 +91,41 @@ class ToolsPresenter @AssistedInject constructor( locale?.let { languagesRepository.findLanguageFlow(it) } ?: flowOf(null) }.collectAsState(null).value + @Composable + @VisibleForTesting + internal fun rememberSpotlightTools( + secondLanguage: Language?, + eventSink: (ToolsScreen.Event) -> Unit, + ): List { + val tools by remember { + toolsRepository.getNormalToolsFlow() + .map { it.filter { !it.isHidden && it.isSpotlight }.sortedWith(Tool.COMPARATOR_DEFAULT_ORDER) } + }.collectAsState(emptyList()) + val eventSink by rememberUpdatedState(eventSink) + + return tools.map { tool -> + val toolCode by rememberUpdatedState(tool.code) + val toolEventSink: (ToolCard.Event) -> Unit = remember { + { + when (it) { + ToolCard.Event.Click, + ToolCard.Event.OpenTool, + ToolCard.Event.OpenToolDetails -> + toolCode?.let { eventSink(ToolsScreen.Event.OpenToolDetails(it, SOURCE_SPOTLIGHT)) } + ToolCard.Event.PinTool, + ToolCard.Event.UnpinTool -> error("$it should be handled by the ToolCardPresenter") + } + } + } + + toolCardPresenter.present( + tool = tool, + secondLanguage = secondLanguage, + eventSink = toolEventSink, + ) + } + } + @AssistedFactory @CircuitInject(ToolsScreen::class, SingletonComponent::class) interface Factory { diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt index 6e91ef7f0f..76782bea1f 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt @@ -8,12 +8,13 @@ import kotlinx.parcelize.Parcelize import org.cru.godtools.model.Language import org.cru.godtools.model.Tool import org.cru.godtools.ui.banner.BannerType +import org.cru.godtools.ui.tools.ToolCard @Parcelize data object ToolsScreen : Screen { data class State( val banner: BannerType? = null, - val spotlightTools: List = emptyList(), + val spotlightTools: List = emptyList(), val filters: Filters = Filters(), val tools: List = emptyList(), val eventSink: (Event) -> Unit, diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt index 0c9585fcc2..35e13f3550 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt @@ -22,7 +22,6 @@ import org.cru.godtools.db.repository.LanguagesRepository import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.model.Language import org.cru.godtools.model.Language.Companion.filterByDisplayAndNativeName -import org.cru.godtools.model.Tool private const val KEY_SELECTED_CATEGORY = "selectedCategory" private const val KEY_SELECTED_LANGUAGE = "selectedLanguage" @@ -37,10 +36,6 @@ class ToolsViewModel @Inject constructor( languagesRepository: LanguagesRepository, private val savedState: SavedStateHandle, ) : ViewModel() { - val spotlightTools = toolsRepository.getNormalToolsFlow() - .map { it.filter { !it.isHidden && it.isSpotlight }.sortedWith(Tool.COMPARATOR_DEFAULT_ORDER) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - // region Tools val selectedCategory = savedState.getStateFlow(KEY_SELECTED_CATEGORY, null) fun setSelectedCategory(category: String?) = savedState.set(KEY_SELECTED_CATEGORY, category) diff --git a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt index da49e2f7b4..873a65b28a 100644 --- a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt +++ b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt @@ -59,43 +59,6 @@ class ToolsViewModelTest { Dispatchers.resetMain() } - // region Property spotlightTools - @Test - fun `Property spotlightTools`() = testScope.runTest { - viewModel.spotlightTools.test { - assertThat(awaitItem(), empty()) - - val normal = randomTool("normal", isHidden = false, isSpotlight = false) - val spotlight = randomTool("spotlight", isHidden = false, isSpotlight = true) - toolsFlow.value = listOf(normal, spotlight) - assertEquals(listOf(spotlight), awaitItem()) - } - } - - @Test - fun `Property spotlightTools - Don't show hidden tools`() = testScope.runTest { - viewModel.spotlightTools.test { - assertThat(awaitItem(), empty()) - - val hidden = randomTool("normal", isHidden = true, isSpotlight = true) - val spotlight = randomTool("spotlight", isHidden = false, isSpotlight = true) - toolsFlow.value = listOf(hidden, spotlight) - assertEquals(listOf(spotlight), awaitItem()) - } - } - - @Test - fun `Property spotlightTools - Sorted by default order`() = testScope.runTest { - viewModel.spotlightTools.test { - assertThat(awaitItem(), empty()) - - val tools = List(10) { randomTool("tool$it", Tool.Type.TRACT, isHidden = false, isSpotlight = true) } - toolsFlow.value = tools - assertEquals(tools.sortedWith(Tool.COMPARATOR_DEFAULT_ORDER), awaitItem()) - } - } - // endregion Property spotlightTools - // region Property filteredTools @Test fun `Property filteredTools - return only default variants`() = testScope.runTest { 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 013aedf616..5f2fb54419 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 @@ -13,14 +13,19 @@ import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.cru.godtools.TestUtils.clearAndroidUiDispatcher import org.cru.godtools.base.Settings import org.cru.godtools.db.repository.LanguagesRepository +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.tools.ToolCardPresenter import org.junit.runner.RunWith import org.robolectric.annotation.Config @@ -28,6 +33,7 @@ import org.robolectric.annotation.Config @Config(application = Application::class) class ToolsPresenterTest { private val isFavoritesFeatureDiscovered = MutableStateFlow(true) + private val toolsFlow = MutableSharedFlow>(extraBufferCapacity = 1) private val languagesRepository: LanguagesRepository = mockk { every { findLanguageFlow(any()) } returns flowOf(null) @@ -36,11 +42,25 @@ class ToolsPresenterTest { private val settings: Settings = mockk { every { isFeatureDiscoveredFlow(Settings.FEATURE_TOOL_FAVORITE) } returns isFavoritesFeatureDiscovered } + private val toolsRepository: ToolsRepository = mockk { + every { getNormalToolsFlow() } returns toolsFlow + } + + // TODO: figure out how to mock ToolCardPresenter + private val toolCardPresenter = ToolCardPresenter( + fileSystem = mockk(), + settings = mockk(relaxed = true), + attachmentsRepository = mockk(relaxed = true), + toolsRepository = mockk(), + translationsRepository = mockk(relaxed = true), + ) private val presenter = ToolsPresenter( eventBus = mockk(), settings = settings, + toolCardPresenter = toolCardPresenter, languagesRepository = languagesRepository, + toolsRepository = toolsRepository, navigator = navigator, ) @@ -73,6 +93,57 @@ class ToolsPresenterTest { } // endregion State.banner + // region State.spotlightTools + @Test + fun `Property spotlightTools`() = runTest { + val language = Language(Locale.ENGLISH) + val normalTool = randomTool("normal", isHidden = false, isSpotlight = false) + val spotlightTool = randomTool("spotlight", isHidden = false, isSpotlight = true) + + presenterTestOf( + presentFunction = { + ToolsScreen.State(spotlightTools = presenter.rememberSpotlightTools(language, {}), eventSink = {}) + } + ) { + toolsFlow.emit(listOf(normalTool, spotlightTool)) + assertEquals(listOf(spotlightTool), expectMostRecentItem().spotlightTools.map { it.tool }) + } + } + + @Test + fun `Property spotlightTools - Don't show hidden tools`() = runTest { + val language = Language(Locale.ENGLISH) + val hiddenTool = randomTool("normal", isHidden = true, isSpotlight = true) + val spotlightTool = randomTool("spotlight", isHidden = false, isSpotlight = true) + + presenterTestOf( + presentFunction = { + ToolsScreen.State(spotlightTools = presenter.rememberSpotlightTools(language, {}), eventSink = {}) + } + ) { + toolsFlow.emit(listOf(hiddenTool, spotlightTool)) + assertEquals(listOf(spotlightTool), expectMostRecentItem().spotlightTools.map { it.tool }) + } + } + + @Test + fun `Property spotlightTools - Sorted by default order`() = runTest { + val language = Language(Locale.ENGLISH) + val tools = List(10) { + randomTool("tool$it", Tool.Type.TRACT, defaultOrder = it, isHidden = false, isSpotlight = true) + } + + presenterTestOf( + presentFunction = { + ToolsScreen.State(spotlightTools = presenter.rememberSpotlightTools(language, {}), eventSink = {}) + } + ) { + toolsFlow.emit(tools.shuffled()) + assertEquals(tools, expectMostRecentItem().spotlightTools.map { it.tool }) + } + } + // endregion State.spotlightTools + // region State.filters.selectedLanguage @Test fun `State - filters - selectedLanguage - no language selected`() = runTest { diff --git a/library/model/src/main/kotlin/org/cru/godtools/model/Tool.kt b/library/model/src/main/kotlin/org/cru/godtools/model/Tool.kt index 057b594e74..4a4fd5c492 100644 --- a/library/model/src/main/kotlin/org/cru/godtools/model/Tool.kt +++ b/library/model/src/main/kotlin/org/cru/godtools/model/Tool.kt @@ -238,6 +238,7 @@ fun randomTool( ).random(), description: String? = UUID.randomUUID().toString().takeIf { Random.nextBoolean() }, bannerId: Long? = Random.nextLong().takeIf { Random.nextBoolean() }, + defaultOrder: Int = Random.nextInt(), isFavorite: Boolean = Random.nextBoolean(), isHidden: Boolean = Random.nextBoolean(), isSpotlight: Boolean = Random.nextBoolean(), @@ -255,7 +256,7 @@ fun randomTool( detailsBannerId = Random.nextLong().takeIf { Random.nextBoolean() }, detailsBannerAnimationId = Random.nextLong().takeIf { Random.nextBoolean() }, detailsBannerYoutubeVideoId = UUID.randomUUID().toString().takeIf { Random.nextBoolean() }, - defaultOrder = Random.nextInt(), + defaultOrder = defaultOrder, order = Random.nextInt(), isFavorite = isFavorite, isHidden = isHidden, From bcd22703a121545a74b07c7e4ca4107c4011918b Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Wed, 3 Jan 2024 09:46:40 -0700 Subject: [PATCH 27/37] generate the list of filter languages in the ToolsPresenter --- .../ui/dashboard/tools/ToolsPresenter.kt | 40 ++++++++- .../ui/dashboard/tools/ToolsViewModel.kt | 25 ------ .../ui/dashboard/tools/ToolsViewModelTest.kt | 10 --- .../ui/dashboard/tools/ToolsPresenterTest.kt | 82 +++++++++++++++++-- 4 files changed, 111 insertions(+), 46 deletions(-) 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 81e7cb2cbb..d1e657f1ee 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 @@ -1,8 +1,10 @@ package org.cru.godtools.ui.dashboard.tools +import android.content.Context import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -13,9 +15,13 @@ import com.slack.circuit.runtime.presenter.Presenter import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_TOOL_DETAILS @@ -24,6 +30,7 @@ import org.cru.godtools.base.Settings import org.cru.godtools.db.repository.LanguagesRepository import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.model.Language +import org.cru.godtools.model.Language.Companion.filterByDisplayAndNativeName import org.cru.godtools.model.Tool import org.cru.godtools.ui.banner.BannerType import org.cru.godtools.ui.tooldetails.ToolDetailsScreen @@ -32,6 +39,8 @@ import org.cru.godtools.ui.tools.ToolCardPresenter import org.greenrobot.eventbus.EventBus class ToolsPresenter @AssistedInject constructor( + @ApplicationContext + private val context: Context, private val eventBus: EventBus, private val settings: Settings, private val toolCardPresenter: ToolCardPresenter, @@ -43,9 +52,13 @@ class ToolsPresenter @AssistedInject constructor( override fun present(): ToolsScreen.State { val viewModel: ToolsViewModel = viewModel() + // selected category + val selectedCategory by viewModel.selectedCategory.collectAsState() + // selected language val selectedLocale by viewModel.selectedLocale.collectAsState() val selectedLanguage = rememberLanguage(selectedLocale) + val languageQuery by viewModel.languageQuery.collectAsState() val eventSink: (ToolsScreen.Event) -> Unit = remember { { @@ -68,9 +81,9 @@ class ToolsPresenter @AssistedInject constructor( spotlightTools = rememberSpotlightTools(secondLanguage = selectedLanguage, eventSink = eventSink), filters = ToolsScreen.State.Filters( categories = viewModel.categories.collectAsState().value, - selectedCategory = viewModel.selectedCategory.collectAsState().value, - languages = viewModel.languages.collectAsState().value, - languageQuery = viewModel.languageQuery.collectAsState().value, + selectedCategory = selectedCategory, + languages = rememberFilterLanguages(selectedCategory, languageQuery), + languageQuery = languageQuery, selectedLanguage = selectedLanguage, ), tools = viewModel.tools.collectAsState().value, @@ -85,6 +98,27 @@ class ToolsPresenter @AssistedInject constructor( .map { if (!it) BannerType.TOOL_LIST_FAVORITES else null } }.collectAsState(null).value + @Composable + @VisibleForTesting + internal fun rememberFilterLanguages(selectedCategory: String?, query: String): List { + val appLanguage by settings.appLanguageFlow.collectAsState(settings.appLanguage) + + val rawLanguages by remember(context, selectedCategory) { + combine( + when (selectedCategory) { + null -> languagesRepository.getLanguagesFlow() + else -> languagesRepository.getLanguagesFlowForToolCategory(selectedCategory) + }, + settings.appLanguageFlow, + ) { langs, appLang -> langs.sortedWith(Language.displayNameComparator(context, appLang)) } + .flowOn(Dispatchers.Default) + }.collectAsState(emptyList()) + + return remember(context, query) { + derivedStateOf { rawLanguages.filterByDisplayAndNativeName(query, context, appLanguage) } + }.value + } + @Composable @VisibleForTesting internal fun rememberLanguage(locale: Locale?) = remember(locale) { diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt index 35e13f3550..2eb3b18524 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt @@ -1,27 +1,19 @@ package org.cru.godtools.ui.dashboard.tools -import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Locale import javax.inject.Inject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn -import org.cru.godtools.base.Settings -import org.cru.godtools.db.repository.LanguagesRepository import org.cru.godtools.db.repository.ToolsRepository -import org.cru.godtools.model.Language -import org.cru.godtools.model.Language.Companion.filterByDisplayAndNativeName private const val KEY_SELECTED_CATEGORY = "selectedCategory" private const val KEY_SELECTED_LANGUAGE = "selectedLanguage" @@ -30,10 +22,7 @@ private const val KEY_LANGUAGE_QUERY = "languageQuery" @HiltViewModel @OptIn(ExperimentalCoroutinesApi::class) class ToolsViewModel @Inject constructor( - @ApplicationContext context: Context, - settings: Settings, toolsRepository: ToolsRepository, - languagesRepository: LanguagesRepository, private val savedState: SavedStateHandle, ) : ViewModel() { // region Tools @@ -61,20 +50,6 @@ class ToolsViewModel @Inject constructor( val categories = toolsForLocale.mapLatest { it.mapNotNull { it.category }.distinct() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - val languages = selectedCategory - .flatMapLatest { - when { - it != null -> languagesRepository.getLanguagesFlowForToolCategory(it) - else -> languagesRepository.getLanguagesFlow() - } - } - .combine(settings.appLanguageFlow) { langs, appLang -> - langs.sortedWith(Language.displayNameComparator(context, appLang)) to appLang - } - .combine(languageQuery) { (langs, appLang), q -> langs.filterByDisplayAndNativeName(q, context, appLang) } - .flowOn(Dispatchers.Default) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - val tools = toolsForLocale .combine(selectedCategory) { tools, category -> tools.filter { category == null || it.category == category } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) diff --git a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt index 873a65b28a..e3e150f22f 100644 --- a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt +++ b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt @@ -4,18 +4,15 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import io.mockk.every import io.mockk.mockk -import java.util.Locale import kotlin.test.assertEquals import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import org.cru.godtools.base.Settings import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.model.Tool import org.cru.godtools.model.randomTool @@ -30,10 +27,6 @@ class ToolsViewModelTest { private val toolsFlow = MutableStateFlow(emptyList()) private val metaToolsFlow = MutableStateFlow(emptyList()) - private val settings: Settings = mockk { - every { appLanguageFlow } returns flowOf(Locale.ENGLISH) - every { isFeatureDiscoveredFlow(any()) } returns flowOf(true) - } private val testScope = TestScope() private val toolsRepository: ToolsRepository = mockk { every { getNormalToolsFlow() } returns toolsFlow @@ -46,9 +39,6 @@ class ToolsViewModelTest { fun setup() { Dispatchers.setMain(UnconfinedTestDispatcher(testScope.testScheduler)) viewModel = ToolsViewModel( - context = mockk(), - settings = settings, - languagesRepository = mockk(), toolsRepository = toolsRepository, savedState = SavedStateHandle(), ) 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 5f2fb54419..dca76e9a88 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 @@ -1,6 +1,7 @@ package org.cru.godtools.ui.dashboard.tools import android.app.Application +import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.slack.circuit.test.FakeNavigator import com.slack.circuit.test.presenterTestOf @@ -10,6 +11,7 @@ import io.mockk.mockk import io.mockk.verifyAll import java.util.Locale import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -32,14 +34,20 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config(application = Application::class) class ToolsPresenterTest { + private val appLanguage = MutableStateFlow(Locale.ENGLISH) private val isFavoritesFeatureDiscovered = MutableStateFlow(true) private val toolsFlow = MutableSharedFlow>(extraBufferCapacity = 1) + private val languagesFlow = MutableSharedFlow>(extraBufferCapacity = 1) private val languagesRepository: LanguagesRepository = mockk { every { findLanguageFlow(any()) } returns flowOf(null) + every { getLanguagesFlow() } returns languagesFlow + every { getLanguagesFlowForToolCategory(any()) } returns languagesFlow } private val navigator = FakeNavigator() private val settings: Settings = mockk { + every { appLanguage } returns this@ToolsPresenterTest.appLanguage.value + every { appLanguageFlow } returns this@ToolsPresenterTest.appLanguage every { isFeatureDiscoveredFlow(Settings.FEATURE_TOOL_FAVORITE) } returns isFavoritesFeatureDiscovered } private val toolsRepository: ToolsRepository = mockk { @@ -55,14 +63,20 @@ class ToolsPresenterTest { translationsRepository = mockk(relaxed = true), ) - private val presenter = ToolsPresenter( - eventBus = mockk(), - settings = settings, - toolCardPresenter = toolCardPresenter, - languagesRepository = languagesRepository, - toolsRepository = toolsRepository, - navigator = navigator, - ) + private lateinit var presenter: ToolsPresenter + + @BeforeTest + fun setup() { + presenter = ToolsPresenter( + context = ApplicationProvider.getApplicationContext(), + eventBus = mockk(), + settings = settings, + toolCardPresenter = toolCardPresenter, + languagesRepository = languagesRepository, + toolsRepository = toolsRepository, + navigator = navigator, + ) + } @AfterTest fun cleanup() = clearAndroidUiDispatcher() @@ -144,6 +158,58 @@ class ToolsPresenterTest { } // endregion State.spotlightTools + // region State.filters.languages + @Test + fun `State - filters - languages - no category`() = runTest { + val languages = listOf(Language(Locale.ENGLISH), Language(Locale.FRENCH)) + + presenterTestOf( + presentFunction = { + ToolsScreen.State( + filters = ToolsScreen.State.Filters( + languages = presenter.rememberFilterLanguages(null, ""), + ), + eventSink = {} + ) + } + ) { + awaitItem() + + languagesFlow.emit(languages) + assertEquals(languages, awaitItem().filters.languages) + } + + verifyAll { + languagesRepository.getLanguagesFlow() + } + } + + @Test + fun `State - filters - languages - for category`() = runTest { + val languages = listOf(Language(Locale.ENGLISH), Language(Locale.FRENCH)) + + presenterTestOf( + presentFunction = { + ToolsScreen.State( + filters = ToolsScreen.State.Filters( + languages = presenter.rememberFilterLanguages("gospel", ""), + ), + eventSink = {} + ) + } + ) { + awaitItem() + + languagesFlow.emit(languages) + assertEquals(languages, awaitItem().filters.languages) + } + + verifyAll { + languagesRepository.getLanguagesFlowForToolCategory("gospel") + } + } + // endregion State.filters.languages + // region State.filters.selectedLanguage @Test fun `State - filters - selectedLanguage - no language selected`() = runTest { From ed6e1c34868d377af5199408761c075de0b94f5b Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Wed, 3 Jan 2024 14:18:38 -0700 Subject: [PATCH 28/37] add some tests for SquareToolCards --- .../cru/godtools/ui/tools/FavoriteAction.kt | 5 +- .../cru/godtools/ui/tools/ToolCardLayouts.kt | 5 +- .../godtools/ui/tools/SquareToolCardTest.kt | 67 +++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 app/src/testDebug/kotlin/org/cru/godtools/ui/tools/SquareToolCardTest.kt diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt index 22de101206..0ae6572745 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/FavoriteAction.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -24,6 +25,8 @@ import org.ccci.gto.android.common.androidx.compose.foundation.layout.padding import org.cru.godtools.R import org.cru.godtools.model.getName +internal const val TEST_TAG_FAVORITE_ACTION = "favorite_action" + @Composable internal fun FavoriteAction(state: ToolCard.State, modifier: Modifier = Modifier, confirmRemoval: Boolean = true) { val tool by rememberUpdatedState(state.tool) @@ -42,7 +45,7 @@ internal fun FavoriteAction(state: ToolCard.State, modifier: Modifier = Modifier }, shape = CircleShape, shadowElevation = 6.dp, - modifier = modifier + modifier = modifier.testTag(TEST_TAG_FAVORITE_ACTION) ) { Icon( painter = painterResource( diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt index 326df64f25..33479b1bb3 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolCardLayouts.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -57,6 +58,8 @@ import org.cru.godtools.model.Tool import org.cru.godtools.model.getName import org.cru.godtools.model.getTagline +internal const val TEST_TAG_TOOL_CATEGORY = "tool_category" + private val toolViewModels: ToolViewModels @Composable get() = viewModel() private val toolCardElevation @Composable get() = elevatedCardElevation(defaultElevation = 4.dp) @@ -514,7 +517,7 @@ private fun ToolCategory(state: ToolCard.State, modifier: Modifier = Modifier) { category, style = toolCategoryStyle, maxLines = 1, - modifier = modifier + modifier = modifier.testTag(TEST_TAG_TOOL_CATEGORY) ) } diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/SquareToolCardTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/SquareToolCardTest.kt new file mode 100644 index 0000000000..82a6264383 --- /dev/null +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/tools/SquareToolCardTest.kt @@ -0,0 +1,67 @@ +package org.cru.godtools.ui.tools + +import android.app.Application +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.slack.circuit.test.TestEventSink +import java.util.UUID +import kotlin.test.Test +import org.cru.godtools.model.randomTool +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = Application::class) +class SquareToolCardTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val events = TestEventSink() + + @Test + fun `SquareToolCard()`() { + val tool = randomTool(name = UUID.randomUUID().toString()) + + composeTestRule.setContent { SquareToolCard(ToolCard.State(tool)) } + + composeTestRule.onNodeWithText(tool.name!!).assertExists() + composeTestRule.onNodeWithTag(TEST_TAG_FAVORITE_ACTION).assertExists() + } + + @Test + fun `SquareToolCard() - Event - Click`() { + composeTestRule.setContent { SquareToolCard(ToolCard.State(eventSink = events)) } + + composeTestRule.onRoot().performClick() + events.assertEvent(ToolCard.Event.Click) + } + + @Test + fun `SquareToolCard(showCategory=true)`() { + composeTestRule.setContent { + SquareToolCard( + state = ToolCard.State(randomTool(category = "gospel")), + showCategory = true + ) + } + + composeTestRule.onNodeWithTag(TEST_TAG_TOOL_CATEGORY).assertDoesNotExist() + } + + @Test + fun `SquareToolCard(showCategory=false)`() { + composeTestRule.setContent { + SquareToolCard( + state = ToolCard.State(randomTool(category = "gospel")), + showCategory = false + ) + } + + composeTestRule.onNodeWithTag(TEST_TAG_TOOL_CATEGORY).assertDoesNotExist() + } +} From d91f4df4324485e67cfca18730d4f6522aa1d4a1 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 4 Jan 2024 14:01:50 -0700 Subject: [PATCH 29/37] generate filter categories in the ToolsPresenter --- .../ui/dashboard/tools/ToolsPresenter.kt | 33 ++++- .../ui/dashboard/tools/ToolsViewModel.kt | 4 - .../ui/dashboard/tools/ToolsPresenterTest.kt | 130 ++++++++++++++++++ 3 files changed, 162 insertions(+), 5 deletions(-) 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 d1e657f1ee..170753db5d 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 @@ -19,6 +19,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import java.util.Locale import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn @@ -80,7 +81,7 @@ class ToolsPresenter @AssistedInject constructor( banner = rememberBanner(), spotlightTools = rememberSpotlightTools(secondLanguage = selectedLanguage, eventSink = eventSink), filters = ToolsScreen.State.Filters( - categories = viewModel.categories.collectAsState().value, + categories = rememberFilterCategories(selectedLocale), selectedCategory = selectedCategory, languages = rememberFilterLanguages(selectedCategory, languageQuery), languageQuery = languageQuery, @@ -98,6 +99,16 @@ class ToolsPresenter @AssistedInject constructor( .map { if (!it) BannerType.TOOL_LIST_FAVORITES else null } }.collectAsState(null).value + @Composable + @VisibleForTesting + internal fun rememberFilterCategories(selectedLanguage: Locale?): List { + val filteredToolsFlow = rememberFilteredToolsFlow(language = selectedLanguage) + + return remember(filteredToolsFlow) { + filteredToolsFlow.map { it.mapNotNull { it.category }.distinct() } + }.collectAsState(emptyList()).value + } + @Composable @VisibleForTesting internal fun rememberFilterLanguages(selectedCategory: String?, query: String): List { @@ -160,6 +171,26 @@ class ToolsPresenter @AssistedInject constructor( } } + @Composable + private fun rememberFilteredToolsFlow(language: Locale? = null): Flow> { + val defaultVariantsFlow = remember { + toolsRepository.getMetaToolsFlow() + .map { it.associateBy({ it.code }, { it.defaultVariantCode }) } + } + + return remember(language) { + when (language) { + null -> toolsRepository.getNormalToolsFlow() + else -> toolsRepository.getNormalToolsFlowByLanguage(language) + }.combine(defaultVariantsFlow) { tools, defaultVariants -> + tools + .filter { it.metatoolCode == null || it.code == defaultVariants[it.metatoolCode] } + .filterNot { it.isHidden } + .sortedBy { it.defaultOrder } + } + } + } + @AssistedFactory @CircuitInject(ToolsScreen::class, SingletonComponent::class) interface Factory { diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt index 2eb3b18524..45107ae4f1 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import org.cru.godtools.db.repository.ToolsRepository @@ -47,9 +46,6 @@ class ToolsViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - val categories = toolsForLocale.mapLatest { it.mapNotNull { it.category }.distinct() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - val tools = toolsForLocale .combine(selectedCategory) { tools, category -> tools.filter { category == null || it.category == category } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) 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 dca76e9a88..2ed82eab34 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 @@ -36,6 +36,7 @@ import org.robolectric.annotation.Config class ToolsPresenterTest { private val appLanguage = MutableStateFlow(Locale.ENGLISH) private val isFavoritesFeatureDiscovered = MutableStateFlow(true) + private val metatoolsFlow = MutableSharedFlow>(extraBufferCapacity = 1) private val toolsFlow = MutableSharedFlow>(extraBufferCapacity = 1) private val languagesFlow = MutableSharedFlow>(extraBufferCapacity = 1) @@ -52,6 +53,7 @@ class ToolsPresenterTest { } private val toolsRepository: ToolsRepository = mockk { every { getNormalToolsFlow() } returns toolsFlow + every { getMetaToolsFlow() } returns metatoolsFlow } // TODO: figure out how to mock ToolCardPresenter @@ -158,6 +160,134 @@ class ToolsPresenterTest { } // endregion State.spotlightTools + // region State.filters.categories + @Test + fun `State - filters - categories - no language`() = runTest { + val tools = listOf( + randomTool(category = Tool.CATEGORY_GOSPEL, metatoolCode = null, isHidden = false, defaultOrder = 0), + randomTool(category = Tool.CATEGORY_ARTICLES, metatoolCode = null, isHidden = false, defaultOrder = 1), + ) + + presenterTestOf( + presentFunction = { + ToolsScreen.State( + filters = ToolsScreen.State.Filters( + categories = presenter.rememberFilterCategories(null) + ), + eventSink = {} + ) + } + ) { + assertEquals(emptyList(), awaitItem().filters.categories) + + metatoolsFlow.emit(emptyList()) + toolsFlow.emit(tools) + assertEquals(listOf(Tool.CATEGORY_GOSPEL, Tool.CATEGORY_ARTICLES), awaitItem().filters.categories) + } + } + + @Test + fun `State - filters - categories - distinct categories`() = runTest { + val tools = listOf( + randomTool(category = Tool.CATEGORY_GOSPEL, metatoolCode = null, isHidden = false), + randomTool(category = Tool.CATEGORY_GOSPEL, metatoolCode = null, isHidden = false), + ) + + presenterTestOf( + presentFunction = { + ToolsScreen.State( + filters = ToolsScreen.State.Filters( + categories = presenter.rememberFilterCategories(null) + ), + eventSink = {} + ) + } + ) { + assertEquals(emptyList(), awaitItem().filters.categories) + + metatoolsFlow.emit(emptyList()) + toolsFlow.emit(tools) + assertEquals(listOf(Tool.CATEGORY_GOSPEL), awaitItem().filters.categories) + } + } + + @Test + fun `State - filters - categories - ordered by tool default order`() = runTest { + val tools = listOf( + randomTool(category = Tool.CATEGORY_GOSPEL, metatoolCode = null, isHidden = false, defaultOrder = 1), + randomTool(category = Tool.CATEGORY_ARTICLES, metatoolCode = null, isHidden = false, defaultOrder = 0), + ) + + presenterTestOf( + presentFunction = { + ToolsScreen.State( + filters = ToolsScreen.State.Filters( + categories = presenter.rememberFilterCategories(null) + ), + eventSink = {} + ) + } + ) { + assertEquals(emptyList(), awaitItem().filters.categories) + + metatoolsFlow.emit(emptyList()) + toolsFlow.emit(tools) + assertEquals(listOf(Tool.CATEGORY_ARTICLES, Tool.CATEGORY_GOSPEL), awaitItem().filters.categories) + } + } + + @Test + fun `State - filters - categories - exclude non-default variants`() = runTest { + val meta = randomTool("meta", defaultVariantCode = "tool") + val tools = listOf( + randomTool("tool", category = Tool.CATEGORY_GOSPEL, metatoolCode = "meta", isHidden = false), + randomTool("other", category = Tool.CATEGORY_ARTICLES, metatoolCode = "meta", isHidden = false), + ) + + presenterTestOf( + presentFunction = { + ToolsScreen.State( + filters = ToolsScreen.State.Filters( + categories = presenter.rememberFilterCategories(null) + ), + eventSink = {} + ) + } + ) { + assertEquals(emptyList(), awaitItem().filters.categories) + + metatoolsFlow.emit(listOf(meta)) + toolsFlow.emit(tools) + assertEquals(listOf(Tool.CATEGORY_GOSPEL), awaitItem().filters.categories) + } + } + + @Test + fun `State - filters - categories - exclude hidden tools`() = runTest { + val tools = listOf( + randomTool(category = Tool.CATEGORY_GOSPEL, metatoolCode = null, isHidden = false), + randomTool(category = Tool.CATEGORY_ARTICLES, metatoolCode = null, isHidden = true), + ) + + presenterTestOf( + presentFunction = { + ToolsScreen.State( + filters = ToolsScreen.State.Filters( + categories = presenter.rememberFilterCategories(null) + ), + eventSink = {} + ) + } + ) { + assertEquals(emptyList(), awaitItem().filters.categories) + + metatoolsFlow.emit(emptyList()) + toolsFlow.emit(tools) + assertEquals(listOf(Tool.CATEGORY_GOSPEL), awaitItem().filters.categories) + } + } + // endregion State.filters.categories + // region State.filters.languages @Test fun `State - filters - languages - no category`() = runTest { From ef6b70ffb4c49b02439ceb4e064a6a82dbd43c8d Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 4 Jan 2024 15:00:31 -0700 Subject: [PATCH 30/37] generate the filtered list of tools in the ToolsPresenter --- .../ui/dashboard/tools/ToolsPresenter.kt | 25 +++--- .../ui/dashboard/tools/ToolsViewModel.kt | 33 +------- .../ui/dashboard/tools/ToolsViewModelTest.kt | 80 ------------------- .../ui/dashboard/tools/ToolsPresenterTest.kt | 46 +++++++++++ 4 files changed, 62 insertions(+), 122 deletions(-) delete mode 100644 app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt 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 170753db5d..1a41e10be6 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 @@ -77,17 +77,20 @@ class ToolsPresenter @AssistedInject constructor( } } + val filters = ToolsScreen.State.Filters( + categories = rememberFilterCategories(selectedLocale), + selectedCategory = selectedCategory, + languages = rememberFilterLanguages(selectedCategory, languageQuery), + languageQuery = languageQuery, + selectedLanguage = selectedLanguage, + ) + return ToolsScreen.State( banner = rememberBanner(), spotlightTools = rememberSpotlightTools(secondLanguage = selectedLanguage, eventSink = eventSink), - filters = ToolsScreen.State.Filters( - categories = rememberFilterCategories(selectedLocale), - selectedCategory = selectedCategory, - languages = rememberFilterLanguages(selectedCategory, languageQuery), - languageQuery = languageQuery, - selectedLanguage = selectedLanguage, - ), - tools = viewModel.tools.collectAsState().value, + filters = filters, + tools = rememberFilteredToolsFlow(filters.selectedCategory, filters.selectedLanguage?.code) + .collectAsState(emptyList()).value, eventSink = eventSink, ) } @@ -172,19 +175,21 @@ class ToolsPresenter @AssistedInject constructor( } @Composable - private fun rememberFilteredToolsFlow(language: Locale? = null): Flow> { + @VisibleForTesting + internal fun rememberFilteredToolsFlow(category: String? = null, language: Locale? = null): Flow> { val defaultVariantsFlow = remember { toolsRepository.getMetaToolsFlow() .map { it.associateBy({ it.code }, { it.defaultVariantCode }) } } - return remember(language) { + return remember(category, language) { when (language) { null -> toolsRepository.getNormalToolsFlow() else -> toolsRepository.getNormalToolsFlowByLanguage(language) }.combine(defaultVariantsFlow) { tools, defaultVariants -> tools .filter { it.metatoolCode == null || it.code == defaultVariants[it.metatoolCode] } + .filter { category == null || it.category == category } .filterNot { it.isHidden } .sortedBy { it.defaultOrder } } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt index 45107ae4f1..f666f5ec43 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt @@ -2,28 +2,13 @@ package org.cru.godtools.ui.dashboard.tools import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel import java.util.Locale -import javax.inject.Inject -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import org.cru.godtools.db.repository.ToolsRepository private const val KEY_SELECTED_CATEGORY = "selectedCategory" private const val KEY_SELECTED_LANGUAGE = "selectedLanguage" private const val KEY_LANGUAGE_QUERY = "languageQuery" -@HiltViewModel -@OptIn(ExperimentalCoroutinesApi::class) -class ToolsViewModel @Inject constructor( - toolsRepository: ToolsRepository, - private val savedState: SavedStateHandle, -) : ViewModel() { +class ToolsViewModel(private val savedState: SavedStateHandle) : ViewModel() { // region Tools val selectedCategory = savedState.getStateFlow(KEY_SELECTED_CATEGORY, null) fun setSelectedCategory(category: String?) = savedState.set(KEY_SELECTED_CATEGORY, category) @@ -33,21 +18,5 @@ class ToolsViewModel @Inject constructor( val languageQuery = savedState.getStateFlow(KEY_LANGUAGE_QUERY, "") fun setLanguageQuery(query: String) = savedState.set(KEY_LANGUAGE_QUERY, query) - - private val toolsForLocale = selectedLocale - .flatMapLatest { - if (it != null) toolsRepository.getNormalToolsFlowByLanguage(it) else toolsRepository.getNormalToolsFlow() - } - .map { it.filterNot { it.isHidden }.sortedBy { it.defaultOrder } } - .combine( - toolsRepository.getMetaToolsFlow().map { it.associateBy({ it.code }, { it.defaultVariantCode }) } - ) { tools, defaultVariants -> - tools.filter { it.metatoolCode == null || it.code == defaultVariants[it.metatoolCode] } - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - - val tools = toolsForLocale - .combine(selectedCategory) { tools, category -> tools.filter { category == null || it.category == category } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) // endregion Tools } diff --git a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt deleted file mode 100644 index e3e150f22f..0000000000 --- a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModelTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package org.cru.godtools.ui.dashboard.tools - -import androidx.lifecycle.SavedStateHandle -import app.cash.turbine.test -import io.mockk.every -import io.mockk.mockk -import kotlin.test.assertEquals -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.cru.godtools.db.repository.ToolsRepository -import org.cru.godtools.model.Tool -import org.cru.godtools.model.randomTool -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.empty -import org.junit.After -import org.junit.Before -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class ToolsViewModelTest { - private val toolsFlow = MutableStateFlow(emptyList()) - private val metaToolsFlow = MutableStateFlow(emptyList()) - - private val testScope = TestScope() - private val toolsRepository: ToolsRepository = mockk { - every { getNormalToolsFlow() } returns toolsFlow - every { getMetaToolsFlow() } returns metaToolsFlow - } - - private lateinit var viewModel: ToolsViewModel - - @Before - fun setup() { - Dispatchers.setMain(UnconfinedTestDispatcher(testScope.testScheduler)) - viewModel = ToolsViewModel( - toolsRepository = toolsRepository, - savedState = SavedStateHandle(), - ) - } - - @After - fun cleanup() { - Dispatchers.resetMain() - } - - // region Property filteredTools - @Test - fun `Property filteredTools - return only default variants`() = testScope.runTest { - val meta = Tool("meta", Tool.Type.META, defaultVariantCode = "variant2") - val variant1 = Tool("variant1", metatoolCode = "meta") - val variant2 = Tool("variant2", metatoolCode = "meta") - - viewModel.tools.test { - assertThat(awaitItem(), empty()) - - toolsFlow.value = listOf(variant1, variant2) - metaToolsFlow.value = listOf(meta) - assertEquals(listOf(variant2), expectMostRecentItem()) - } - } - - @Test - fun `Property filteredTools - Don't show hidden tools`() = testScope.runTest { - viewModel.tools.test { - assertThat(awaitItem(), empty()) - - val hidden = randomTool("hidden", isHidden = true, metatoolCode = null) - val visible = randomTool("visible", isHidden = false, metatoolCode = null) - toolsFlow.value = listOf(hidden, visible) - assertEquals(listOf(visible), awaitItem()) - } - } - // endregion Property filteredTools -} 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 2ed82eab34..389c8b8503 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 @@ -1,6 +1,7 @@ package org.cru.godtools.ui.dashboard.tools import android.app.Application +import androidx.compose.runtime.collectAsState import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.slack.circuit.test.FakeNavigator @@ -398,4 +399,49 @@ class ToolsPresenterTest { verifyAll { languagesRepository.findLanguageFlow(Locale.ENGLISH) } } // endregion State.filters.selectedLanguage + + // region State.tools + @Test + fun `State - tools - return only default variants`() = runTest { + val meta = Tool("meta", Tool.Type.META, defaultVariantCode = "variant2") + val variant1 = Tool("variant1", metatoolCode = "meta") + val variant2 = Tool("variant2", metatoolCode = "meta") + + presenterTestOf( + presentFunction = { + ToolsScreen.State( + tools = presenter.rememberFilteredToolsFlow().collectAsState(emptyList()).value, + eventSink = {}, + ) + } + ) { + assertEquals(emptyList(), awaitItem().tools) + + metatoolsFlow.emit(listOf(meta)) + toolsFlow.emit(listOf(variant1, variant2)) + assertEquals(listOf(variant2), awaitItem().tools) + } + } + + @Test + fun `State - tools - Don't return hidden tools`() = runTest { + val hidden = randomTool("hidden", isHidden = true, metatoolCode = null) + val visible = randomTool("visible", isHidden = false, metatoolCode = null) + + presenterTestOf( + presentFunction = { + ToolsScreen.State( + tools = presenter.rememberFilteredToolsFlow().collectAsState(emptyList()).value, + eventSink = {}, + ) + } + ) { + assertEquals(emptyList(), awaitItem().tools) + + metatoolsFlow.emit(emptyList()) + toolsFlow.emit(listOf(hidden, visible)) + assertEquals(listOf(visible), awaitItem().tools) + } + } + // endregion State.tools } From 889e5ce82ef2e95ef62592d581d31eedc9e534af Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 4 Jan 2024 15:13:57 -0700 Subject: [PATCH 31/37] don't recreate the tools flow every time the category or language filter changes --- .../ui/dashboard/tools/ToolsPresenter.kt | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) 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 1a41e10be6..c52dd84aa7 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 @@ -19,8 +19,11 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import java.util.Locale import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -176,23 +179,29 @@ class ToolsPresenter @AssistedInject constructor( @Composable @VisibleForTesting + @OptIn(ExperimentalCoroutinesApi::class) internal fun rememberFilteredToolsFlow(category: String? = null, language: Locale? = null): Flow> { - val defaultVariantsFlow = remember { - toolsRepository.getMetaToolsFlow() + val categoryFlow = remember { MutableStateFlow(category) }.apply { value = category } + val languageFlow = remember { MutableStateFlow(language) }.apply { value = language } + + return remember { + val defaultVariantsFlow = toolsRepository.getMetaToolsFlow() .map { it.associateBy({ it.code }, { it.defaultVariantCode }) } - } - return remember(category, language) { - when (language) { - null -> toolsRepository.getNormalToolsFlow() - else -> toolsRepository.getNormalToolsFlowByLanguage(language) - }.combine(defaultVariantsFlow) { tools, defaultVariants -> - tools - .filter { it.metatoolCode == null || it.code == defaultVariants[it.metatoolCode] } - .filter { category == null || it.category == category } - .filterNot { it.isHidden } - .sortedBy { it.defaultOrder } - } + languageFlow + .flatMapLatest { + when (it) { + null -> toolsRepository.getNormalToolsFlow() + else -> toolsRepository.getNormalToolsFlowByLanguage(it) + } + } + .map { it.filterNot { it.isHidden }.sortedBy { it.defaultOrder } } + .combine(defaultVariantsFlow) { tools, defaultVariants -> + tools.filter { it.metatoolCode == null || it.code == defaultVariants[it.metatoolCode] } + } + .combine(categoryFlow) { tools, category -> + if (category == null) tools else tools.filter { it.category == category } + } } } From 6a9b985bdc5df176b695f04a762c36058619f1e8 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 4 Jan 2024 15:35:49 -0700 Subject: [PATCH 32/37] get rid of the ToolsViewModel --- .../ui/dashboard/tools/ToolsPresenter.kt | 35 ++-- .../ui/dashboard/tools/ToolsViewModel.kt | 22 --- .../ui/dashboard/tools/ToolsPresenterTest.kt | 177 ++++-------------- 3 files changed, 47 insertions(+), 187 deletions(-) delete mode 100644 app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt 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 c52dd84aa7..9be9521364 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 @@ -6,9 +6,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.runtime.setValue import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter @@ -18,14 +19,12 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import java.util.Locale -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_TOOL_DETAILS @@ -54,15 +53,13 @@ class ToolsPresenter @AssistedInject constructor( ) : Presenter { @Composable override fun present(): ToolsScreen.State { - val viewModel: ToolsViewModel = viewModel() - // selected category - val selectedCategory by viewModel.selectedCategory.collectAsState() + var selectedCategory: String? by remember { mutableStateOf(null) } // selected language - val selectedLocale by viewModel.selectedLocale.collectAsState() + var selectedLocale: Locale? by remember { mutableStateOf(null) } val selectedLanguage = rememberLanguage(selectedLocale) - val languageQuery by viewModel.languageQuery.collectAsState() + var languageQuery by remember { mutableStateOf("") } val eventSink: (ToolsScreen.Event) -> Unit = remember { { @@ -73,9 +70,9 @@ class ToolsPresenter @AssistedInject constructor( } navigator.goTo(ToolDetailsScreen(it.tool, selectedLocale)) } - is ToolsScreen.Event.UpdateSelectedCategory -> viewModel.setSelectedCategory(it.category) - is ToolsScreen.Event.UpdateLanguageQuery -> viewModel.setLanguageQuery(it.query) - is ToolsScreen.Event.UpdateSelectedLanguage -> viewModel.setSelectedLocale(it.locale) + is ToolsScreen.Event.UpdateSelectedCategory -> selectedCategory = it.category + is ToolsScreen.Event.UpdateLanguageQuery -> languageQuery = it.query + is ToolsScreen.Event.UpdateSelectedLanguage -> selectedLocale = it.locale } } } @@ -99,15 +96,13 @@ class ToolsPresenter @AssistedInject constructor( } @Composable - @VisibleForTesting - internal fun rememberBanner() = remember { + private fun rememberBanner() = remember { settings.isFeatureDiscoveredFlow(Settings.FEATURE_TOOL_FAVORITE) .map { if (!it) BannerType.TOOL_LIST_FAVORITES else null } }.collectAsState(null).value @Composable - @VisibleForTesting - internal fun rememberFilterCategories(selectedLanguage: Locale?): List { + private fun rememberFilterCategories(selectedLanguage: Locale?): List { val filteredToolsFlow = rememberFilteredToolsFlow(language = selectedLanguage) return remember(filteredToolsFlow) { @@ -116,8 +111,7 @@ class ToolsPresenter @AssistedInject constructor( } @Composable - @VisibleForTesting - internal fun rememberFilterLanguages(selectedCategory: String?, query: String): List { + private fun rememberFilterLanguages(selectedCategory: String?, query: String): List { val appLanguage by settings.appLanguageFlow.collectAsState(settings.appLanguage) val rawLanguages by remember(context, selectedCategory) { @@ -128,7 +122,6 @@ class ToolsPresenter @AssistedInject constructor( }, settings.appLanguageFlow, ) { langs, appLang -> langs.sortedWith(Language.displayNameComparator(context, appLang)) } - .flowOn(Dispatchers.Default) }.collectAsState(emptyList()) return remember(context, query) { @@ -143,8 +136,7 @@ class ToolsPresenter @AssistedInject constructor( }.collectAsState(null).value @Composable - @VisibleForTesting - internal fun rememberSpotlightTools( + private fun rememberSpotlightTools( secondLanguage: Language?, eventSink: (ToolsScreen.Event) -> Unit, ): List { @@ -178,9 +170,8 @@ class ToolsPresenter @AssistedInject constructor( } @Composable - @VisibleForTesting @OptIn(ExperimentalCoroutinesApi::class) - internal fun rememberFilteredToolsFlow(category: String? = null, language: Locale? = null): Flow> { + private fun rememberFilteredToolsFlow(category: String? = null, language: Locale? = null): Flow> { val categoryFlow = remember { MutableStateFlow(category) }.apply { value = category } val languageFlow = remember { MutableStateFlow(language) }.apply { value = language } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt deleted file mode 100644 index f666f5ec43..0000000000 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsViewModel.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.cru.godtools.ui.dashboard.tools - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import java.util.Locale - -private const val KEY_SELECTED_CATEGORY = "selectedCategory" -private const val KEY_SELECTED_LANGUAGE = "selectedLanguage" -private const val KEY_LANGUAGE_QUERY = "languageQuery" - -class ToolsViewModel(private val savedState: SavedStateHandle) : ViewModel() { - // region Tools - val selectedCategory = savedState.getStateFlow(KEY_SELECTED_CATEGORY, null) - fun setSelectedCategory(category: String?) = savedState.set(KEY_SELECTED_CATEGORY, category) - - internal val selectedLocale = savedState.getStateFlow(KEY_SELECTED_LANGUAGE, null) - fun setSelectedLocale(locale: Locale?) = savedState.set(KEY_SELECTED_LANGUAGE, locale) - - val languageQuery = savedState.getStateFlow(KEY_LANGUAGE_QUERY, "") - fun setLanguageQuery(query: String) = savedState.set(KEY_LANGUAGE_QUERY, query) - // endregion Tools -} 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 389c8b8503..efb14fa2ee 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 @@ -1,11 +1,11 @@ package org.cru.godtools.ui.dashboard.tools import android.app.Application -import androidx.compose.runtime.collectAsState import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.slack.circuit.test.FakeNavigator import com.slack.circuit.test.presenterTestOf +import com.slack.circuit.test.test import io.mockk.Called import io.mockk.every import io.mockk.mockk @@ -40,11 +40,12 @@ class ToolsPresenterTest { private val metatoolsFlow = MutableSharedFlow>(extraBufferCapacity = 1) private val toolsFlow = MutableSharedFlow>(extraBufferCapacity = 1) private val languagesFlow = MutableSharedFlow>(extraBufferCapacity = 1) + private val gospelLanguagesFlow = MutableSharedFlow>(extraBufferCapacity = 1) private val languagesRepository: LanguagesRepository = mockk { every { findLanguageFlow(any()) } returns flowOf(null) every { getLanguagesFlow() } returns languagesFlow - every { getLanguagesFlowForToolCategory(any()) } returns languagesFlow + every { getLanguagesFlowForToolCategory(Tool.CATEGORY_GOSPEL) } returns gospelLanguagesFlow } private val navigator = FakeNavigator() private val settings: Settings = mockk { @@ -87,11 +88,7 @@ class ToolsPresenterTest { // region State.banner @Test fun `State - banner - none`() = runTest { - presenterTestOf( - presentFunction = { - ToolsScreen.State(banner = presenter.rememberBanner(), eventSink = {}) - } - ) { + presenter.test { isFavoritesFeatureDiscovered.value = true assertNull(expectMostRecentItem().banner) } @@ -99,11 +96,7 @@ class ToolsPresenterTest { @Test fun `State - banner - favorites`() = runTest { - presenterTestOf( - presentFunction = { - ToolsScreen.State(banner = presenter.rememberBanner(), eventSink = {}) - } - ) { + presenter.test { isFavoritesFeatureDiscovered.value = false assertEquals(BannerType.TOOL_LIST_FAVORITES, expectMostRecentItem().banner) } @@ -113,15 +106,10 @@ class ToolsPresenterTest { // region State.spotlightTools @Test fun `Property spotlightTools`() = runTest { - val language = Language(Locale.ENGLISH) val normalTool = randomTool("normal", isHidden = false, isSpotlight = false) val spotlightTool = randomTool("spotlight", isHidden = false, isSpotlight = true) - presenterTestOf( - presentFunction = { - ToolsScreen.State(spotlightTools = presenter.rememberSpotlightTools(language, {}), eventSink = {}) - } - ) { + presenter.test { toolsFlow.emit(listOf(normalTool, spotlightTool)) assertEquals(listOf(spotlightTool), expectMostRecentItem().spotlightTools.map { it.tool }) } @@ -129,15 +117,10 @@ class ToolsPresenterTest { @Test fun `Property spotlightTools - Don't show hidden tools`() = runTest { - val language = Language(Locale.ENGLISH) val hiddenTool = randomTool("normal", isHidden = true, isSpotlight = true) val spotlightTool = randomTool("spotlight", isHidden = false, isSpotlight = true) - presenterTestOf( - presentFunction = { - ToolsScreen.State(spotlightTools = presenter.rememberSpotlightTools(language, {}), eventSink = {}) - } - ) { + presenter.test { toolsFlow.emit(listOf(hiddenTool, spotlightTool)) assertEquals(listOf(spotlightTool), expectMostRecentItem().spotlightTools.map { it.tool }) } @@ -145,16 +128,11 @@ class ToolsPresenterTest { @Test fun `Property spotlightTools - Sorted by default order`() = runTest { - val language = Language(Locale.ENGLISH) val tools = List(10) { randomTool("tool$it", Tool.Type.TRACT, defaultOrder = it, isHidden = false, isSpotlight = true) } - presenterTestOf( - presentFunction = { - ToolsScreen.State(spotlightTools = presenter.rememberSpotlightTools(language, {}), eventSink = {}) - } - ) { + presenter.test { toolsFlow.emit(tools.shuffled()) assertEquals(tools, expectMostRecentItem().spotlightTools.map { it.tool }) } @@ -169,21 +147,13 @@ class ToolsPresenterTest { randomTool(category = Tool.CATEGORY_ARTICLES, metatoolCode = null, isHidden = false, defaultOrder = 1), ) - presenterTestOf( - presentFunction = { - ToolsScreen.State( - filters = ToolsScreen.State.Filters( - categories = presenter.rememberFilterCategories(null) - ), - eventSink = {} - ) - } - ) { - assertEquals(emptyList(), awaitItem().filters.categories) - + presenter.test { metatoolsFlow.emit(emptyList()) toolsFlow.emit(tools) - assertEquals(listOf(Tool.CATEGORY_GOSPEL, Tool.CATEGORY_ARTICLES), awaitItem().filters.categories) + assertEquals( + listOf(Tool.CATEGORY_GOSPEL, Tool.CATEGORY_ARTICLES), + expectMostRecentItem().filters.categories + ) } } @@ -194,21 +164,10 @@ class ToolsPresenterTest { randomTool(category = Tool.CATEGORY_GOSPEL, metatoolCode = null, isHidden = false), ) - presenterTestOf( - presentFunction = { - ToolsScreen.State( - filters = ToolsScreen.State.Filters( - categories = presenter.rememberFilterCategories(null) - ), - eventSink = {} - ) - } - ) { - assertEquals(emptyList(), awaitItem().filters.categories) - + presenter.test { metatoolsFlow.emit(emptyList()) toolsFlow.emit(tools) - assertEquals(listOf(Tool.CATEGORY_GOSPEL), awaitItem().filters.categories) + assertEquals(listOf(Tool.CATEGORY_GOSPEL), expectMostRecentItem().filters.categories) } } @@ -219,21 +178,13 @@ class ToolsPresenterTest { randomTool(category = Tool.CATEGORY_ARTICLES, metatoolCode = null, isHidden = false, defaultOrder = 0), ) - presenterTestOf( - presentFunction = { - ToolsScreen.State( - filters = ToolsScreen.State.Filters( - categories = presenter.rememberFilterCategories(null) - ), - eventSink = {} - ) - } - ) { - assertEquals(emptyList(), awaitItem().filters.categories) - + presenter.test { metatoolsFlow.emit(emptyList()) toolsFlow.emit(tools) - assertEquals(listOf(Tool.CATEGORY_ARTICLES, Tool.CATEGORY_GOSPEL), awaitItem().filters.categories) + assertEquals( + listOf(Tool.CATEGORY_ARTICLES, Tool.CATEGORY_GOSPEL), + expectMostRecentItem().filters.categories + ) } } @@ -245,21 +196,10 @@ class ToolsPresenterTest { randomTool("other", category = Tool.CATEGORY_ARTICLES, metatoolCode = "meta", isHidden = false), ) - presenterTestOf( - presentFunction = { - ToolsScreen.State( - filters = ToolsScreen.State.Filters( - categories = presenter.rememberFilterCategories(null) - ), - eventSink = {} - ) - } - ) { - assertEquals(emptyList(), awaitItem().filters.categories) - + presenter.test { metatoolsFlow.emit(listOf(meta)) toolsFlow.emit(tools) - assertEquals(listOf(Tool.CATEGORY_GOSPEL), awaitItem().filters.categories) + assertEquals(listOf(Tool.CATEGORY_GOSPEL), expectMostRecentItem().filters.categories) } } @@ -270,21 +210,10 @@ class ToolsPresenterTest { randomTool(category = Tool.CATEGORY_ARTICLES, metatoolCode = null, isHidden = true), ) - presenterTestOf( - presentFunction = { - ToolsScreen.State( - filters = ToolsScreen.State.Filters( - categories = presenter.rememberFilterCategories(null) - ), - eventSink = {} - ) - } - ) { - assertEquals(emptyList(), awaitItem().filters.categories) - + presenter.test { metatoolsFlow.emit(emptyList()) toolsFlow.emit(tools) - assertEquals(listOf(Tool.CATEGORY_GOSPEL), awaitItem().filters.categories) + assertEquals(listOf(Tool.CATEGORY_GOSPEL), expectMostRecentItem().filters.categories) } } // endregion State.filters.categories @@ -294,20 +223,9 @@ class ToolsPresenterTest { fun `State - filters - languages - no category`() = runTest { val languages = listOf(Language(Locale.ENGLISH), Language(Locale.FRENCH)) - presenterTestOf( - presentFunction = { - ToolsScreen.State( - filters = ToolsScreen.State.Filters( - languages = presenter.rememberFilterLanguages(null, ""), - ), - eventSink = {} - ) - } - ) { - awaitItem() - + presenter.test { languagesFlow.emit(languages) - assertEquals(languages, awaitItem().filters.languages) + assertEquals(languages, expectMostRecentItem().filters.languages) } verifyAll { @@ -319,24 +237,11 @@ class ToolsPresenterTest { fun `State - filters - languages - for category`() = runTest { val languages = listOf(Language(Locale.ENGLISH), Language(Locale.FRENCH)) - presenterTestOf( - presentFunction = { - ToolsScreen.State( - filters = ToolsScreen.State.Filters( - languages = presenter.rememberFilterLanguages("gospel", ""), - ), - eventSink = {} - ) - } - ) { - awaitItem() + presenter.test { + awaitItem().eventSink(ToolsScreen.Event.UpdateSelectedCategory(Tool.CATEGORY_GOSPEL)) - languagesFlow.emit(languages) - assertEquals(languages, awaitItem().filters.languages) - } - - verifyAll { - languagesRepository.getLanguagesFlowForToolCategory("gospel") + gospelLanguagesFlow.emit(languages) + assertEquals(languages, expectMostRecentItem().filters.languages) } } // endregion State.filters.languages @@ -407,19 +312,12 @@ class ToolsPresenterTest { val variant1 = Tool("variant1", metatoolCode = "meta") val variant2 = Tool("variant2", metatoolCode = "meta") - presenterTestOf( - presentFunction = { - ToolsScreen.State( - tools = presenter.rememberFilteredToolsFlow().collectAsState(emptyList()).value, - eventSink = {}, - ) - } - ) { + presenter.test { assertEquals(emptyList(), awaitItem().tools) metatoolsFlow.emit(listOf(meta)) toolsFlow.emit(listOf(variant1, variant2)) - assertEquals(listOf(variant2), awaitItem().tools) + assertEquals(listOf(variant2), expectMostRecentItem().tools) } } @@ -428,19 +326,12 @@ class ToolsPresenterTest { val hidden = randomTool("hidden", isHidden = true, metatoolCode = null) val visible = randomTool("visible", isHidden = false, metatoolCode = null) - presenterTestOf( - presentFunction = { - ToolsScreen.State( - tools = presenter.rememberFilteredToolsFlow().collectAsState(emptyList()).value, - eventSink = {}, - ) - } - ) { + presenter.test { assertEquals(emptyList(), awaitItem().tools) metatoolsFlow.emit(emptyList()) toolsFlow.emit(listOf(hidden, visible)) - assertEquals(listOf(visible), awaitItem().tools) + assertEquals(listOf(visible), expectMostRecentItem().tools) } } // endregion State.tools From d64d32e69dc2823ef3a2de5f1ffda2f00f4de758 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 5 Jan 2024 11:31:01 -0700 Subject: [PATCH 33/37] don't recreate the languages flow every time the query changes --- .../ui/dashboard/tools/ToolsPresenter.kt | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) 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 9be9521364..abdc4873ec 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 @@ -4,7 +4,6 @@ import android.content.Context import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -111,22 +110,27 @@ class ToolsPresenter @AssistedInject constructor( } @Composable - private fun rememberFilterLanguages(selectedCategory: String?, query: String): List { - val appLanguage by settings.appLanguageFlow.collectAsState(settings.appLanguage) - - val rawLanguages by remember(context, selectedCategory) { - combine( - when (selectedCategory) { - null -> languagesRepository.getLanguagesFlow() - else -> languagesRepository.getLanguagesFlowForToolCategory(selectedCategory) - }, - settings.appLanguageFlow, - ) { langs, appLang -> langs.sortedWith(Language.displayNameComparator(context, appLang)) } - }.collectAsState(emptyList()) + @OptIn(ExperimentalCoroutinesApi::class) + private fun rememberFilterLanguages(category: String?, query: String): List { + val categoryFlow = remember { MutableStateFlow(category) }.apply { value = category } + val queryFlow = remember { MutableStateFlow(query) }.apply { value = query } + + return remember { + val languagesFlow = categoryFlow + .flatMapLatest { + when (it) { + null -> languagesRepository.getLanguagesFlow() + else -> languagesRepository.getLanguagesFlowForToolCategory(it) + } + } + .combine(settings.appLanguageFlow) { languages, appLang -> + languages.sortedWith(Language.displayNameComparator(context, appLang)) + } - return remember(context, query) { - derivedStateOf { rawLanguages.filterByDisplayAndNativeName(query, context, appLanguage) } - }.value + combine(languagesFlow, settings.appLanguageFlow, queryFlow) { languages, appLang, query -> + languages.filterByDisplayAndNativeName(query, context, appLang) + } + }.collectAsState(emptyList()).value } @Composable From 5fa7fee4f640b94d62c84e1474846eab2021ae79 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 5 Jan 2024 11:41:00 -0700 Subject: [PATCH 34/37] update the ToolsScreen.Filters state class name --- .../godtools/ui/dashboard/tools/ToolFilters.kt | 6 +++--- .../ui/dashboard/tools/ToolsPresenter.kt | 2 +- .../godtools/ui/dashboard/tools/ToolsScreen.kt | 18 +++++++++--------- .../ui/dashboard/tools/ToolFiltersTest.kt | 12 ++++++------ .../ui/dashboard/tools/ToolsPresenterTest.kt | 6 +++--- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt index bfb79d20fd..4834a606f7 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt @@ -47,7 +47,7 @@ internal const val TEST_TAG_FILTER_DROPDOWN = "filter_dropdown" @Composable internal fun ToolFilters( - filters: ToolsScreen.State.Filters, + filters: ToolsScreen.Filters, modifier: Modifier = Modifier, eventSink: (ToolsScreen.Event) -> Unit = {}, ) = Column(modifier.fillMaxWidth()) { @@ -71,7 +71,7 @@ internal fun ToolFilters( @Composable @OptIn(ExperimentalMaterial3Api::class) private fun CategoryFilter( - filters: ToolsScreen.State.Filters, + filters: ToolsScreen.Filters, modifier: Modifier = Modifier, eventSink: (ToolsScreen.Event) -> Unit = {}, ) { @@ -123,7 +123,7 @@ private fun CategoryFilter( @VisibleForTesting @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) internal fun LanguageFilter( - filters: ToolsScreen.State.Filters, + filters: ToolsScreen.Filters, modifier: Modifier = Modifier, eventSink: (ToolsScreen.Event) -> Unit = {}, ) { 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 abdc4873ec..f26cde9c3f 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 @@ -76,7 +76,7 @@ class ToolsPresenter @AssistedInject constructor( } } - val filters = ToolsScreen.State.Filters( + val filters = ToolsScreen.Filters( categories = rememberFilterCategories(selectedLocale), selectedCategory = selectedCategory, languages = rememberFilterLanguages(selectedCategory, languageQuery), diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt index 76782bea1f..00353c0de9 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt @@ -18,15 +18,15 @@ data object ToolsScreen : Screen { val filters: Filters = Filters(), val tools: List = emptyList(), val eventSink: (Event) -> Unit, - ) : CircuitUiState { - data class Filters( - val categories: List = emptyList(), - val selectedCategory: String? = null, - val languages: List = emptyList(), - val languageQuery: String = "", - val selectedLanguage: Language? = null, - ) - } + ) : CircuitUiState + + data class Filters( + val categories: List = emptyList(), + val selectedCategory: String? = null, + val languages: List = emptyList(), + val languageQuery: String = "", + val selectedLanguage: Language? = null, + ) : CircuitUiState sealed interface Event : CircuitUiEvent { data class OpenToolDetails(val tool: String, val source: String? = null) : Event diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFiltersTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFiltersTest.kt index 9e84155691..f042cdadca 100644 --- a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFiltersTest.kt +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFiltersTest.kt @@ -28,7 +28,7 @@ class ToolFiltersTest { fun `LanguagesFilter() - Shows selectedLanguage`() { composeTestRule.setContent { LanguageFilter( - filters = ToolsScreen.State.Filters( + filters = ToolsScreen.Filters( selectedLanguage = Language(Locale.ENGLISH) ), eventSink = events, @@ -43,7 +43,7 @@ class ToolFiltersTest { fun `LanguagesFilter() - Shows Any Language when no language is specified`() { composeTestRule.setContent { LanguageFilter( - filters = ToolsScreen.State.Filters(selectedLanguage = null), + filters = ToolsScreen.Filters(selectedLanguage = null), eventSink = events, ) } @@ -56,7 +56,7 @@ class ToolFiltersTest { fun `LanguagesFilter() - Dropdown Menu - Show when button is clicked`() { composeTestRule.setContent { LanguageFilter( - filters = ToolsScreen.State.Filters(), + filters = ToolsScreen.Filters(), eventSink = events, ) } @@ -74,7 +74,7 @@ class ToolFiltersTest { fun `LanguagesFilter() - Dropdown Menu - Show languages`() { composeTestRule.setContent { LanguageFilter( - filters = ToolsScreen.State.Filters( + filters = ToolsScreen.Filters( languages = listOf( Language(Locale.FRENCH), Language(Locale.GERMAN) @@ -95,7 +95,7 @@ class ToolFiltersTest { fun `LanguagesFilter() - Dropdown Menu - Select "Any language" option`() { composeTestRule.setContent { LanguageFilter( - filters = ToolsScreen.State.Filters( + filters = ToolsScreen.Filters( selectedLanguage = Language(Locale.FRENCH), languages = listOf( Language(Locale.FRENCH), @@ -119,7 +119,7 @@ class ToolFiltersTest { fun `LanguagesFilter() - Dropdown Menu - Select a language`() { composeTestRule.setContent { LanguageFilter( - filters = ToolsScreen.State.Filters( + filters = ToolsScreen.Filters( languages = listOf( Language(Locale.FRENCH), Language(Locale.GERMAN) 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 efb14fa2ee..cbfb2236ef 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 @@ -252,7 +252,7 @@ class ToolsPresenterTest { presenterTestOf( presentFunction = { ToolsScreen.State( - filters = ToolsScreen.State.Filters( + filters = ToolsScreen.Filters( selectedLanguage = presenter.rememberLanguage(null) ), eventSink = {} @@ -270,7 +270,7 @@ class ToolsPresenterTest { presenterTestOf( presentFunction = { ToolsScreen.State( - filters = ToolsScreen.State.Filters( + filters = ToolsScreen.Filters( selectedLanguage = presenter.rememberLanguage(Locale.ENGLISH) ), eventSink = {} @@ -291,7 +291,7 @@ class ToolsPresenterTest { presenterTestOf( presentFunction = { ToolsScreen.State( - filters = ToolsScreen.State.Filters( + filters = ToolsScreen.Filters( selectedLanguage = presenter.rememberLanguage(Locale.ENGLISH) ), eventSink = {} From dee00accc32e00faf2880e650b27e0f4ea95275d Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 5 Jan 2024 12:34:35 -0700 Subject: [PATCH 35/37] create a separate event sink for tool filtering changes --- .../ui/dashboard/tools/ToolFilters.kt | 34 ++++++-------- .../ui/dashboard/tools/ToolsPresenter.kt | 14 ++++-- .../ui/dashboard/tools/ToolsScreen.kt | 10 +++-- .../ui/dashboard/tools/ToolFiltersTest.kt | 45 +++++++++---------- .../ui/dashboard/tools/ToolsPresenterTest.kt | 2 +- 5 files changed, 54 insertions(+), 51 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt index 4834a606f7..32e0061872 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFilters.kt @@ -63,21 +63,17 @@ internal fun ToolFilters( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 16.dp) ) { - CategoryFilter(filters, modifier = Modifier.weight(1f), eventSink = eventSink) - LanguageFilter(filters, modifier = Modifier.weight(1f), eventSink = eventSink) + CategoryFilter(filters, modifier = Modifier.weight(1f)) + LanguageFilter(filters, modifier = Modifier.weight(1f)) } } @Composable @OptIn(ExperimentalMaterial3Api::class) -private fun CategoryFilter( - filters: ToolsScreen.Filters, - modifier: Modifier = Modifier, - eventSink: (ToolsScreen.Event) -> Unit = {}, -) { +private fun CategoryFilter(filters: ToolsScreen.Filters, modifier: Modifier = Modifier) { val categories by rememberUpdatedState(filters.categories) val selectedCategory by rememberUpdatedState(filters.selectedCategory) - val eventSink by rememberUpdatedState(eventSink) + val eventSink by rememberUpdatedState(filters.eventSink) var expanded by rememberSaveable { mutableStateOf(false) } @@ -102,7 +98,7 @@ private fun CategoryFilter( DropdownMenuItem( text = { Text(stringResource(R.string.dashboard_tools_section_filter_category_any)) }, onClick = { - eventSink(ToolsScreen.Event.UpdateSelectedCategory(null)) + eventSink(ToolsScreen.FiltersEvent.SelectCategory(null)) expanded = false } ) @@ -110,7 +106,7 @@ private fun CategoryFilter( DropdownMenuItem( text = { Text(getToolCategoryName(it, LocalContext.current)) }, onClick = { - eventSink(ToolsScreen.Event.UpdateSelectedCategory(it)) + eventSink(ToolsScreen.FiltersEvent.SelectCategory(it)) expanded = false } ) @@ -122,22 +118,18 @@ private fun CategoryFilter( @Composable @VisibleForTesting @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) -internal fun LanguageFilter( - filters: ToolsScreen.Filters, - modifier: Modifier = Modifier, - eventSink: (ToolsScreen.Event) -> Unit = {}, -) { +internal fun LanguageFilter(filters: ToolsScreen.Filters, modifier: Modifier = Modifier) { val context = LocalContext.current val languages by rememberUpdatedState(filters.languages) val query by rememberUpdatedState(filters.languageQuery) val selectedLanguage by rememberUpdatedState(filters.selectedLanguage) - val eventSink by rememberUpdatedState(eventSink) + val eventSink by rememberUpdatedState(filters.eventSink) var expanded by rememberSaveable { mutableStateOf(false) } ElevatedButton( onClick = { - if (!expanded) eventSink(ToolsScreen.Event.UpdateLanguageQuery("")) + if (!expanded) eventSink(ToolsScreen.FiltersEvent.UpdateLanguageQuery("")) expanded = !expanded }, modifier = modifier @@ -161,8 +153,8 @@ internal fun LanguageFilter( item { SearchBar( query, - onQueryChange = { eventSink(ToolsScreen.Event.UpdateLanguageQuery(it)) }, - onSearch = { eventSink(ToolsScreen.Event.UpdateLanguageQuery(it)) }, + onQueryChange = { eventSink(ToolsScreen.FiltersEvent.UpdateLanguageQuery(it)) }, + onSearch = { eventSink(ToolsScreen.FiltersEvent.UpdateLanguageQuery(it)) }, active = false, onActiveChange = {}, colors = GodToolsTheme.searchBarColors, @@ -174,7 +166,7 @@ internal fun LanguageFilter( DropdownMenuItem( text = { Text(stringResource(R.string.dashboard_tools_section_filter_language_any)) }, onClick = { - eventSink(ToolsScreen.Event.UpdateSelectedLanguage(null)) + eventSink(ToolsScreen.FiltersEvent.SelectLanguage(null)) expanded = false } ) @@ -184,7 +176,7 @@ internal fun LanguageFilter( DropdownMenuItem( text = { LanguageName(it) }, onClick = { - eventSink(ToolsScreen.Event.UpdateSelectedLanguage(it.code)) + eventSink(ToolsScreen.FiltersEvent.SelectLanguage(it.code)) expanded = false }, modifier = Modifier.animateItemPlacement() 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 f26cde9c3f..b34a932c23 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 @@ -69,9 +69,16 @@ class ToolsPresenter @AssistedInject constructor( } navigator.goTo(ToolDetailsScreen(it.tool, selectedLocale)) } - is ToolsScreen.Event.UpdateSelectedCategory -> selectedCategory = it.category - is ToolsScreen.Event.UpdateLanguageQuery -> languageQuery = it.query - is ToolsScreen.Event.UpdateSelectedLanguage -> selectedLocale = it.locale + } + } + } + + val filtersEventSink: (ToolsScreen.FiltersEvent) -> Unit = remember { + { + when (it) { + is ToolsScreen.FiltersEvent.SelectCategory -> selectedCategory = it.category + is ToolsScreen.FiltersEvent.SelectLanguage -> selectedLocale = it.locale + is ToolsScreen.FiltersEvent.UpdateLanguageQuery -> languageQuery = it.query } } } @@ -82,6 +89,7 @@ class ToolsPresenter @AssistedInject constructor( languages = rememberFilterLanguages(selectedCategory, languageQuery), languageQuery = languageQuery, selectedLanguage = selectedLanguage, + eventSink = filtersEventSink, ) return ToolsScreen.State( diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt index 00353c0de9..9c497489e5 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsScreen.kt @@ -26,12 +26,16 @@ data object ToolsScreen : Screen { val languages: List = emptyList(), val languageQuery: String = "", val selectedLanguage: Language? = null, + val eventSink: (FiltersEvent) -> Unit = {}, ) : CircuitUiState sealed interface Event : CircuitUiEvent { data class OpenToolDetails(val tool: String, val source: String? = null) : Event - data class UpdateSelectedCategory(val category: String?) : Event - data class UpdateLanguageQuery(val query: String) : Event - data class UpdateSelectedLanguage(val locale: Locale?) : Event + } + + sealed interface FiltersEvent : CircuitUiEvent { + data class UpdateLanguageQuery(val query: String) : FiltersEvent + data class SelectCategory(val category: String?) : FiltersEvent + data class SelectLanguage(val locale: Locale?) : FiltersEvent } } diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFiltersTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFiltersTest.kt index f042cdadca..2a5989f44f 100644 --- a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFiltersTest.kt +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFiltersTest.kt @@ -21,17 +21,17 @@ class ToolFiltersTest { @get:Rule val composeTestRule = createComposeRule() - private val events = TestEventSink() + private val events = TestEventSink() // region: LanguagesFilter @Test fun `LanguagesFilter() - Shows selectedLanguage`() { composeTestRule.setContent { LanguageFilter( - filters = ToolsScreen.Filters( - selectedLanguage = Language(Locale.ENGLISH) + ToolsScreen.Filters( + selectedLanguage = Language(Locale.ENGLISH), + eventSink = events, ), - eventSink = events, ) } @@ -43,8 +43,10 @@ class ToolFiltersTest { fun `LanguagesFilter() - Shows Any Language when no language is specified`() { composeTestRule.setContent { LanguageFilter( - filters = ToolsScreen.Filters(selectedLanguage = null), - eventSink = events, + ToolsScreen.Filters( + selectedLanguage = null, + eventSink = events, + ), ) } @@ -55,10 +57,7 @@ class ToolFiltersTest { @Test fun `LanguagesFilter() - Dropdown Menu - Show when button is clicked`() { composeTestRule.setContent { - LanguageFilter( - filters = ToolsScreen.Filters(), - eventSink = events, - ) + LanguageFilter(ToolsScreen.Filters(eventSink = events)) } // dropdown menu not shown @@ -67,7 +66,7 @@ class ToolFiltersTest { // click button to show dropdown composeTestRule.onNode(hasClickAction()).performClick() composeTestRule.onNodeWithTag(TEST_TAG_FILTER_DROPDOWN).assertExists() - events.assertEvent(ToolsScreen.Event.UpdateLanguageQuery("")) + events.assertEvent(ToolsScreen.FiltersEvent.UpdateLanguageQuery("")) } @Test @@ -77,10 +76,10 @@ class ToolFiltersTest { filters = ToolsScreen.Filters( languages = listOf( Language(Locale.FRENCH), - Language(Locale.GERMAN) - ) + Language(Locale.GERMAN), + ), + eventSink = events, ), - eventSink = events, ) } composeTestRule.onNode(hasClickAction()).performClick() @@ -88,7 +87,7 @@ class ToolFiltersTest { composeTestRule.onNodeWithText("English", substring = true, ignoreCase = true).assertDoesNotExist() composeTestRule.onNodeWithText("French", substring = true, ignoreCase = true).assertExists() composeTestRule.onNodeWithText("German", substring = true, ignoreCase = true).assertExists() - events.assertEvent(ToolsScreen.Event.UpdateLanguageQuery("")) + events.assertEvent(ToolsScreen.FiltersEvent.UpdateLanguageQuery("")) } @Test @@ -100,9 +99,9 @@ class ToolFiltersTest { languages = listOf( Language(Locale.FRENCH), Language(Locale.GERMAN) - ) + ), + eventSink = events, ), - eventSink = events, ) } composeTestRule.onNode(hasClickAction()).performClick() @@ -110,8 +109,8 @@ class ToolFiltersTest { composeTestRule.onNodeWithText("Any language", substring = true, ignoreCase = true).performClick() composeTestRule.onNodeWithTag(TEST_TAG_FILTER_DROPDOWN).assertDoesNotExist() events.assertEvents( - ToolsScreen.Event.UpdateLanguageQuery(""), - ToolsScreen.Event.UpdateSelectedLanguage(null) + ToolsScreen.FiltersEvent.UpdateLanguageQuery(""), + ToolsScreen.FiltersEvent.SelectLanguage(null) ) } @@ -123,9 +122,9 @@ class ToolFiltersTest { languages = listOf( Language(Locale.FRENCH), Language(Locale.GERMAN) - ) + ), + eventSink = events, ), - eventSink = events, ) } composeTestRule.onNode(hasClickAction()).performClick() @@ -133,8 +132,8 @@ class ToolFiltersTest { composeTestRule.onNodeWithText("French", substring = true, ignoreCase = true).performClick() composeTestRule.onNodeWithTag(TEST_TAG_FILTER_DROPDOWN).assertDoesNotExist() events.assertEvents( - ToolsScreen.Event.UpdateLanguageQuery(""), - ToolsScreen.Event.UpdateSelectedLanguage(Locale.FRENCH) + ToolsScreen.FiltersEvent.UpdateLanguageQuery(""), + ToolsScreen.FiltersEvent.SelectLanguage(Locale.FRENCH) ) } // endregion: LanguagesFilter 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 cbfb2236ef..dea478f451 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 @@ -238,7 +238,7 @@ class ToolsPresenterTest { val languages = listOf(Language(Locale.ENGLISH), Language(Locale.FRENCH)) presenter.test { - awaitItem().eventSink(ToolsScreen.Event.UpdateSelectedCategory(Tool.CATEGORY_GOSPEL)) + awaitItem().filters.eventSink(ToolsScreen.FiltersEvent.SelectCategory(Tool.CATEGORY_GOSPEL)) gospelLanguagesFlow.emit(languages) assertEquals(languages, expectMostRecentItem().filters.languages) From 7a02de5c77730607516ce9627d9a29e54481bb04 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 5 Jan 2024 13:00:24 -0700 Subject: [PATCH 36/37] move all the filter logic into a separate method --- .../ui/dashboard/tools/ToolsPresenter.kt | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) 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 b34a932c23..910ddd6903 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 @@ -52,13 +52,8 @@ class ToolsPresenter @AssistedInject constructor( ) : Presenter { @Composable override fun present(): ToolsScreen.State { - // selected category - var selectedCategory: String? by remember { mutableStateOf(null) } - - // selected language - var selectedLocale: Locale? by remember { mutableStateOf(null) } - val selectedLanguage = rememberLanguage(selectedLocale) - var languageQuery by remember { mutableStateOf("") } + val filters = rememberFilters() + val selectedLocale by rememberUpdatedState(filters.selectedLanguage?.code) val eventSink: (ToolsScreen.Event) -> Unit = remember { { @@ -73,6 +68,32 @@ class ToolsPresenter @AssistedInject constructor( } } + return ToolsScreen.State( + banner = rememberBanner(), + spotlightTools = rememberSpotlightTools(secondLanguage = filters.selectedLanguage, eventSink = eventSink), + filters = filters, + tools = rememberFilteredToolsFlow(filters.selectedCategory, filters.selectedLanguage?.code) + .collectAsState(emptyList()).value, + eventSink = eventSink, + ) + } + + @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 rememberFilters(): ToolsScreen.Filters { + // selected category + var selectedCategory: String? by remember { mutableStateOf(null) } + + // selected language + var selectedLocale: Locale? by remember { mutableStateOf(null) } + val selectedLanguage = rememberLanguage(selectedLocale) + var languageQuery by remember { mutableStateOf("") } + val filtersEventSink: (ToolsScreen.FiltersEvent) -> Unit = remember { { when (it) { @@ -83,7 +104,7 @@ class ToolsPresenter @AssistedInject constructor( } } - val filters = ToolsScreen.Filters( + return ToolsScreen.Filters( categories = rememberFilterCategories(selectedLocale), selectedCategory = selectedCategory, languages = rememberFilterLanguages(selectedCategory, languageQuery), @@ -91,23 +112,8 @@ class ToolsPresenter @AssistedInject constructor( selectedLanguage = selectedLanguage, eventSink = filtersEventSink, ) - - return ToolsScreen.State( - banner = rememberBanner(), - spotlightTools = rememberSpotlightTools(secondLanguage = selectedLanguage, eventSink = eventSink), - filters = filters, - tools = rememberFilteredToolsFlow(filters.selectedCategory, filters.selectedLanguage?.code) - .collectAsState(emptyList()).value, - eventSink = eventSink, - ) } - @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 rememberFilterCategories(selectedLanguage: Locale?): List { val filteredToolsFlow = rememberFilteredToolsFlow(language = selectedLanguage) From efe6d27a91a908c08139579c8cc7ae8f684ed0d4 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 5 Jan 2024 13:20:27 -0700 Subject: [PATCH 37/37] clean up the selectedLanguage tests --- .../ui/dashboard/tools/ToolsPresenter.kt | 4 +- .../ui/dashboard/tools/ToolsPresenterTest.kt | 47 +++++-------------- 2 files changed, 13 insertions(+), 38 deletions(-) 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 910ddd6903..4b3e527d1a 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 @@ -1,7 +1,6 @@ package org.cru.godtools.ui.dashboard.tools import android.content.Context -import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -148,8 +147,7 @@ class ToolsPresenter @AssistedInject constructor( } @Composable - @VisibleForTesting - internal fun rememberLanguage(locale: Locale?) = remember(locale) { + private fun rememberLanguage(locale: Locale?) = remember(locale) { locale?.let { languagesRepository.findLanguageFlow(it) } ?: flowOf(null) }.collectAsState(null).value 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 dea478f451..1271fb77b7 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 @@ -4,11 +4,10 @@ import android.app.Application import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.slack.circuit.test.FakeNavigator -import com.slack.circuit.test.presenterTestOf import com.slack.circuit.test.test -import io.mockk.Called import io.mockk.every import io.mockk.mockk +import io.mockk.verify import io.mockk.verifyAll import java.util.Locale import kotlin.test.AfterTest @@ -55,6 +54,7 @@ class ToolsPresenterTest { } private val toolsRepository: ToolsRepository = mockk { every { getNormalToolsFlow() } returns toolsFlow + every { getNormalToolsFlowByLanguage(any()) } returns flowOf(emptyList()) every { getMetaToolsFlow() } returns metatoolsFlow } @@ -249,38 +249,22 @@ class ToolsPresenterTest { // region State.filters.selectedLanguage @Test fun `State - filters - selectedLanguage - no language selected`() = runTest { - presenterTestOf( - presentFunction = { - ToolsScreen.State( - filters = ToolsScreen.Filters( - selectedLanguage = presenter.rememberLanguage(null) - ), - eventSink = {} - ) - } - ) { + presenter.test { assertNull(expectMostRecentItem().filters.selectedLanguage) } - verifyAll { languagesRepository wasNot Called } + verify(inverse = true) { languagesRepository.findLanguageFlow(any()) } } @Test fun `State - filters - selectedLanguage - language not found`() = runTest { - presenterTestOf( - presentFunction = { - ToolsScreen.State( - filters = ToolsScreen.Filters( - selectedLanguage = presenter.rememberLanguage(Locale.ENGLISH) - ), - eventSink = {} - ) - } - ) { + presenter.test { + awaitItem().filters.eventSink(ToolsScreen.FiltersEvent.SelectLanguage(Locale.ENGLISH)) + assertNull(expectMostRecentItem().filters.selectedLanguage) } - verifyAll { languagesRepository.findLanguageFlow(Locale.ENGLISH) } + verify { languagesRepository.findLanguageFlow(Locale.ENGLISH) } } @Test @@ -288,20 +272,13 @@ class ToolsPresenterTest { val language = Language(Locale.ENGLISH) every { languagesRepository.findLanguageFlow(Locale.ENGLISH) } returns flowOf(language) - presenterTestOf( - presentFunction = { - ToolsScreen.State( - filters = ToolsScreen.Filters( - selectedLanguage = presenter.rememberLanguage(Locale.ENGLISH) - ), - eventSink = {} - ) - } - ) { + presenter.test { + awaitItem().filters.eventSink(ToolsScreen.FiltersEvent.SelectLanguage(Locale.ENGLISH)) + assertEquals(language, expectMostRecentItem().filters.selectedLanguage) } - verifyAll { languagesRepository.findLanguageFlow(Locale.ENGLISH) } + verify { languagesRepository.findLanguageFlow(Locale.ENGLISH) } } // endregion State.filters.selectedLanguage