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 7b2c0ce52e..6ad938a83f 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 {