Skip to content

Commit b283b28

Browse files
authored
Merge pull request #4392 from CruGlobal/dashboardToolsPersonalization
Add dashboard tools personalization mode
2 parents 76c13ff + 6632a9d commit b283b28

72 files changed

Lines changed: 1114 additions & 455 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardLayout.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ import org.cru.godtools.base.ui.circuit.screen.dashboard.page.HomeScreen
5656
import org.cru.godtools.base.ui.circuit.screen.dashboard.page.LessonsScreen
5757
import org.cru.godtools.base.ui.circuit.screen.dashboard.page.ToolsScreen
5858
import org.cru.godtools.base.ui.compose.LocalEventBus
59-
import org.cru.godtools.base.ui.theme.GodToolsTheme
6059
import org.cru.godtools.shared.analytics.AnalyticsScreenNames
6160
import org.cru.godtools.ui.dashboard.DashboardPresenter.UiEvent
6261
import org.cru.godtools.ui.dashboard.DashboardPresenter.UiState
@@ -91,7 +90,6 @@ internal fun DashboardLayout(state: UiState, modifier: Modifier = Modifier) {
9190
}
9291
}
9392
},
94-
colors = GodToolsTheme.topAppBarColors,
9593
)
9694
},
9795
bottomBar = {

app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardPresenter.kt

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package org.cru.godtools.ui.dashboard
22

33
import androidx.compose.material3.SnackbarHostState
44
import androidx.compose.runtime.Composable
5+
import androidx.compose.runtime.DisposableEffect
56
import androidx.compose.runtime.collectAsState
67
import androidx.compose.runtime.remember
78
import com.slack.circuit.codegen.annotations.CircuitInject
89
import com.slack.circuit.foundation.NavEvent
910
import com.slack.circuit.foundation.onNavEvent
11+
import com.slack.circuit.runtime.CircuitContext
1012
import com.slack.circuit.runtime.CircuitUiEvent
1113
import com.slack.circuit.runtime.CircuitUiState
1214
import com.slack.circuit.runtime.Navigator
@@ -18,18 +20,20 @@ import dagger.hilt.components.SingletonComponent
1820
import kotlinx.coroutines.coroutineScope
1921
import kotlinx.coroutines.launch
2022
import org.ccci.gto.android.common.sync.SyncTracker
21-
import org.ccci.gto.android.common.sync.rememberSyncTracker
2223
import org.cru.godtools.base.ui.circuit.screen.dashboard.DashboardScreen
2324
import org.cru.godtools.base.ui.circuit.screen.dashboard.page.DashboardPage
2425
import org.cru.godtools.base.ui.circuit.screen.dashboard.page.HomeScreen
2526
import org.cru.godtools.sync.GodToolsSyncService
2627
import org.cru.godtools.ui.dashboard.DashboardPresenter.UiState
28+
import org.cru.godtools.ui.dashboard.SyncTaskRegistry.Companion.rememberSyncRegistry
29+
import org.cru.godtools.ui.dashboard.SyncTaskRegistry.Companion.syncTaskRegistry
2730
import org.cru.godtools.ui.drawer.DrawerMenuPresenter
2831
import org.cru.godtools.ui.drawer.DrawerMenuScreen
2932

3033
class DashboardPresenter @AssistedInject internal constructor(
3134
private val drawerMenuPresenter: DrawerMenuPresenter,
3235
private val syncService: GodToolsSyncService,
36+
@Assisted private val circuitContext: CircuitContext,
3337
@Assisted private val navigator: Navigator,
3438
@Assisted private val screen: DashboardScreen,
3539
) : Presenter<UiState> {
@@ -49,16 +53,25 @@ class DashboardPresenter @AssistedInject internal constructor(
4953

5054
@Composable
5155
override fun present(): UiState {
52-
val syncTracker = rememberSyncTracker { it.syncData() }
56+
val syncRegistry = rememberSyncRegistry()
57+
DisposableEffect(syncRegistry) {
58+
circuitContext.syncTaskRegistry = syncRegistry
59+
val id = syncRegistry.registerSyncTask { syncData(it) }
60+
61+
onDispose {
62+
circuitContext.syncTaskRegistry = null
63+
syncRegistry.unregisterSyncTask(id)
64+
}
65+
}
5366

5467
return UiState(
5568
drawerState = drawerMenuPresenter.present(),
56-
isSyncing = syncTracker.isSyncing.collectAsState().value,
69+
isSyncing = syncRegistry.syncTracker.isSyncing.collectAsState().value,
5770
initialPage = screen.initialPage,
5871
snackbarState = remember { SnackbarHostState() },
5972
) {
6073
when (it) {
61-
UiEvent.TriggerSync -> syncTracker.syncData(force = true)
74+
UiEvent.TriggerSync -> syncRegistry.triggerSyncTasks(force = true)
6275
is UiEvent.NestedNavEvent -> navigator.onNavEvent(it.event)
6376
}
6477
}
@@ -81,6 +94,6 @@ class DashboardPresenter @AssistedInject internal constructor(
8194
@AssistedFactory
8295
@CircuitInject(DashboardScreen::class, SingletonComponent::class)
8396
interface Factory {
84-
fun create(navigator: Navigator, screen: DashboardScreen): DashboardPresenter
97+
fun create(context: CircuitContext, navigator: Navigator, screen: DashboardScreen): DashboardPresenter
8598
}
8699
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package org.cru.godtools.ui.dashboard
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.remember
5+
import com.slack.circuit.runtime.CircuitContext
6+
import kotlin.uuid.ExperimentalUuidApi
7+
import kotlin.uuid.Uuid
8+
import org.ccci.gto.android.common.sync.SyncTracker
9+
import org.ccci.gto.android.common.sync.rememberSyncTracker
10+
11+
internal class SyncTaskRegistry(val syncTracker: SyncTracker) {
12+
private val tasks = mutableMapOf<String, SyncTracker.(force: Boolean) -> Unit>()
13+
14+
@OptIn(ExperimentalUuidApi::class)
15+
fun registerSyncTask(task: SyncTracker.(force: Boolean) -> Unit): String {
16+
val id = Uuid.generateV7().toString()
17+
synchronized(tasks) { tasks[id] = task }
18+
syncTracker.task(false)
19+
return id
20+
}
21+
22+
fun unregisterSyncTask(id: String) {
23+
synchronized(tasks) { tasks.remove(id) }
24+
}
25+
26+
fun triggerSyncTasks(force: Boolean = false) {
27+
synchronized(tasks) { tasks.values.toList() }.forEach { syncTracker.it(force) }
28+
}
29+
30+
companion object {
31+
internal var CircuitContext.syncTaskRegistry: SyncTaskRegistry?
32+
get() = tag() ?: parent?.syncTaskRegistry
33+
set(value) = putTag(value)
34+
35+
@Composable
36+
internal fun rememberSyncRegistry(): SyncTaskRegistry {
37+
val syncTracker = rememberSyncTracker()
38+
return remember(syncTracker) { SyncTaskRegistry(syncTracker) }
39+
}
40+
}
41+
}

app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/FilteredToolsFlowProducer.kt

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,49 @@ package org.cru.godtools.ui.dashboard.tools
22

33
import java.util.Locale
44
import javax.inject.Inject
5+
import kotlinx.coroutines.ExperimentalCoroutinesApi
56
import kotlinx.coroutines.flow.Flow
67
import kotlinx.coroutines.flow.combine
8+
import kotlinx.coroutines.flow.distinctUntilChanged
9+
import kotlinx.coroutines.flow.emitAll
10+
import kotlinx.coroutines.flow.flatMapLatest
11+
import kotlinx.coroutines.flow.flowOf
712
import kotlinx.coroutines.flow.map
13+
import org.ccci.gto.android.common.kotlin.coroutines.flow.combineTransformLatest
14+
import org.cru.godtools.base.Settings
815
import org.cru.godtools.db.repository.ToolsRepository
916
import org.cru.godtools.model.Tool
17+
import org.cru.godtools.ui.dashboard.tools.ToolsPresenter.UiState.Mode
1018

11-
internal class FilteredToolsFlowProducer @Inject constructor(private val toolsRepository: ToolsRepository) {
12-
fun getFlow(category: String? = null, language: Locale? = null): Flow<List<Tool>> {
13-
val baseFlow = when (language) {
14-
null -> toolsRepository.getNormalToolsFlow()
15-
else -> toolsRepository.getNormalToolsFlowByLanguage(language)
16-
}
17-
18-
val defaultVariantsFlow = toolsRepository.getMetaToolsFlow()
19-
.map { it.associateBy({ it.code }, { it.defaultVariantCode }) }
19+
internal class FilteredToolsFlowProducer @Inject constructor(
20+
private val settings: Settings,
21+
private val toolsRepository: ToolsRepository
22+
) {
23+
@OptIn(ExperimentalCoroutinesApi::class)
24+
fun getFlow(mode: Mode, category: String? = null, language: Locale? = null): Flow<List<Tool>> {
25+
val baseFlow = when {
26+
mode == Mode.PERSONALIZATION -> {
27+
val languageFlow = if (language != null) flowOf(language) else settings.appLanguageFlow
28+
val fallbackFlow = languageFlow.flatMapLatest { toolsRepository.getPersonalizedToolsFlow(it, null) }
2029

21-
return baseFlow
22-
.map { it.filterNot { it.isHidden }.sortedBy { it.defaultOrder } }
23-
.combine(defaultVariantsFlow) { tools, defaultVariants ->
24-
tools.filter { it.metatoolCode == null || it.code == defaultVariants[it.metatoolCode] }
30+
languageFlow
31+
.combineTransformLatest(settings.getPersonalizationCountryFlow()) { language, country ->
32+
emitAll(toolsRepository.getPersonalizedToolsFlow(language, country))
33+
}
34+
.combine(fallbackFlow) { personalized, fallback -> personalized.ifEmpty { fallback } }
35+
.distinctUntilChanged()
2536
}
26-
.map { tools -> if (category == null) tools else tools.filter { it.category == category } }
37+
38+
language != null -> toolsRepository.getNormalToolsFlowByLanguage(language)
39+
.map { it.sortedBy { it.defaultOrder } }
40+
41+
else -> toolsRepository.getNormalToolsFlow().map { it.sortedBy { it.defaultOrder } }
42+
}
43+
44+
return baseFlow.map {
45+
it
46+
.filterNot { it.isHidden }
47+
.filter { category == null || it.category == category }
48+
}
2749
}
2850
}

app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolFiltersStateProducer.kt

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import org.cru.godtools.model.Language
3434
import org.cru.godtools.model.Language.Companion.filterByDisplayAndNativeName
3535
import org.cru.godtools.ui.dashboard.filters.FilterMenu
3636
import org.cru.godtools.ui.dashboard.tools.ToolFiltersStateProducer.Filters
37+
import org.cru.godtools.ui.dashboard.tools.ToolsPresenter.UiState.Mode
3738

3839
interface ToolFiltersStateProducer {
3940
data class Filters(
@@ -42,7 +43,7 @@ interface ToolFiltersStateProducer {
4243
) : CircuitUiState
4344

4445
@Composable
45-
fun produce(): Filters
46+
fun produce(mode: Mode): Filters
4647
}
4748

4849
internal class DefaultToolFiltersStateProducer @Inject constructor(
@@ -54,7 +55,7 @@ internal class DefaultToolFiltersStateProducer @Inject constructor(
5455
@param:DispatcherType(IO) private val ioDispatcher: CoroutineDispatcher,
5556
) : ToolFiltersStateProducer {
5657
@Composable
57-
override fun produce(): Filters {
58+
override fun produce(mode: Mode): Filters {
5859
val scope = rememberCoroutineScope()
5960

6061
val selectedCategory by remember { settings.getDashboardFilterCategoryFlow() }.collectAsState(null)
@@ -69,7 +70,7 @@ internal class DefaultToolFiltersStateProducer @Inject constructor(
6970
return Filters(
7071
categoryFilter = FilterMenu.UiState(
7172
menuExpanded = rememberSaveable { mutableStateOf(false) },
72-
items = rememberFilterCategories(selectedLocale),
73+
items = rememberFilterCategories(mode, selectedLocale),
7374
query = remember { mutableStateOf("") },
7475
selectedItem = selectedCategory,
7576
eventSink = {
@@ -82,7 +83,7 @@ internal class DefaultToolFiltersStateProducer @Inject constructor(
8283
),
8384
languageFilter = FilterMenu.UiState(
8485
menuExpanded = languageMenuExpanded,
85-
items = rememberFilterLanguages(selectedCategory, languageQuery.value),
86+
items = rememberFilterLanguages(mode, selectedCategory, languageQuery.value),
8687
selectedItem = languagesRepository.rememberLanguage(selectedLocale),
8788
query = languageQuery,
8889
eventSink = {
@@ -97,16 +98,20 @@ internal class DefaultToolFiltersStateProducer @Inject constructor(
9798
}
9899

99100
@Composable
100-
private fun rememberFilterCategories(selectedLanguage: Locale?) = remember(selectedLanguage) {
101-
filteredToolsFlowProducer.getFlow(language = selectedLanguage).map {
101+
private fun rememberFilterCategories(mode: Mode, selectedLanguage: Locale?) = remember(mode, selectedLanguage) {
102+
filteredToolsFlowProducer.getFlow(mode, language = selectedLanguage).map {
102103
it.groupBy { it.category }
103104
.map { (category, tools) -> FilterMenu.UiState.Item(category, tools.size) }
104105
}
105106
}.collectAsState(emptyList()).value
106107

107108
@Composable
108109
@OptIn(ExperimentalCoroutinesApi::class)
109-
private fun rememberFilterLanguages(category: String?, query: String): List<FilterMenu.UiState.Item<Language?>> {
110+
private fun rememberFilterLanguages(
111+
mode: Mode,
112+
category: String?,
113+
query: String,
114+
): List<FilterMenu.UiState.Item<Language?>> {
110115
val scope = rememberCoroutineScope()
111116

112117
val categoryFlow = rememberStateFlow(category)
@@ -127,8 +132,8 @@ internal class DefaultToolFiltersStateProducer @Inject constructor(
127132
.shareIn(scope, started = SharingStarted.WhileSubscribed(5_000), replay = 1)
128133
}
129134

130-
return remember(category) {
131-
val toolCountsFlow = filteredToolsFlowProducer.getFlow(category = category)
135+
return remember(mode, category) {
136+
val toolCountsFlow = filteredToolsFlowProducer.getFlow(mode, category = category)
132137
.map { it.mapNotNullTo(mutableSetOf()) { it.code } }
133138
.distinctUntilChanged()
134139
.flatMapLatest { translationsRepository.getTranslationsFlowForTools(it) }

0 commit comments

Comments
 (0)