Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/skills/pr-review/dismissed-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,11 @@ Issues listed here are suppressed in future PR reviews.
**Reason**: Circuit only requires `UiState` to be `@Stable`, not strictly immutable. Embedding `MutableState<T>` (which is stable) is valid and intentional — it allows the UI to mutate local state (e.g. search query) without routing every keystroke through `eventSink`.
**Dismissed**: 2026-03-30
**Dismissed by**: Daniel Frett

---

## Missing @AnyThread on sync task methods
**Pattern**: Flagging absence of `@AnyThread` annotation on `internal suspend fun` methods in `ToolSyncTasks` (or similar sync task classes).
**Reason**: `@AnyThread` is a vestigial remnant from older versions of the sync logic. It's unnecessary on suspend functions and not required going forward.
**Dismissed**: 2026-05-06
**Dismissed by**: Daniel Frett
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ The default branch for this repository is **`develop`**. Use `develop` as the ba

# Run tests for a specific module
./gradlew :library:model:test
./gradlew :app:testProductionDebugUnitTest
./gradlew :app:test

# Run a single test class
./gradlew :library:model:testProductionDebugUnitTest --tests "org.cru.godtools.model.ToolTest"
./gradlew :library:model:test --tests "org.cru.godtools.model.ToolTest"

# Verify Paparazzi snapshot tests (requires Git LFS)
./gradlew verifyPaparazzi
Expand Down Expand Up @@ -65,7 +65,7 @@ The default branch for this repository is **`develop`**. Use `develop` as the ba
- **Product flavors** (dimension `env`): `stage` (staging API), `production` (production API)
- **Build types**: `debug`, `qa` (inherits from debug), `release`
- **Stage flavor** is only enabled for debug and QA build types
- **Unit tests only run** on `productionDebug` variant
- **Unit test variants**: some modules have product flavors and expose `testProductionDebugUnitTest`; others only expose `testDebugUnitTest`. Use the `test` task to run all variants without needing to know which applies.

### Key Frameworks & Patterns

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package org.cru.godtools.ui.dashboard.tools

import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.ccci.gto.android.common.kotlin.coroutines.flow.combineTransformLatest
import org.cru.godtools.base.Settings
import org.cru.godtools.db.repository.ToolsRepository
import org.cru.godtools.model.Tool
import org.cru.godtools.ui.dashboard.tools.ToolsPresenter.UiState.Mode

internal class FeaturedToolsFlowProducer @Inject constructor(
private val settings: Settings,
private val toolsRepository: ToolsRepository,
) {
@OptIn(ExperimentalCoroutinesApi::class)
fun getFlow(mode: Mode, language: Locale? = null): Flow<List<Tool>> {
val baseFlow = when (mode) {
Mode.PERSONALIZATION -> {
val languageFlow = if (language != null) flowOf(language) else settings.appLanguageFlow
val fallbackFlow = languageFlow.flatMapLatest { toolsRepository.getFeaturedToolsFlow(it, null) }

languageFlow
.combineTransformLatest(settings.getPersonalizationCountryFlow()) { lang, country ->
emitAll(toolsRepository.getFeaturedToolsFlow(lang, country))
}
.combine(fallbackFlow) { featured, fallback -> featured.ifEmpty { fallback } }
.distinctUntilChanged()
}

Mode.ALL_TOOLS -> toolsRepository.getNormalToolsFlow()
.map { it.filter { it.isSpotlight }.sortedWith(Tool.COMPARATOR_DEFAULT_ORDER) }
}

return baseFlow.map { it.filterNot { it.isHidden } }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.components.SingletonComponent
import java.util.Locale
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.ccci.gto.android.common.sync.SyncTracker
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent
import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_TOOL_DETAILS
Expand All @@ -32,7 +33,6 @@ import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURC
import org.cru.godtools.base.CONFIG_UI_DASHBOARD_PERSONALIZATION_ENABLED
import org.cru.godtools.base.Settings
import org.cru.godtools.base.ui.circuit.screen.dashboard.page.ToolsScreen
import org.cru.godtools.db.repository.ToolsRepository
import org.cru.godtools.model.Language
import org.cru.godtools.model.Tool
import org.cru.godtools.sync.GodToolsSyncService
Expand All @@ -53,8 +53,8 @@ class ToolsPresenter @AssistedInject internal constructor(
private val remoteConfig: FirebaseRemoteConfig,
private val settings: Settings,
private val toolCardPresenter: ToolCardPresenter,
private val toolsRepository: ToolsRepository,
private val favoriteToolsBannerPresenter: BannerPresenter<FavoriteToolsBannerPresenter.UiState>,
private val featuredToolsFlowProducer: FeaturedToolsFlowProducer,
private val filteredToolsFlowProducer: FilteredToolsFlowProducer,
private val toolFiltersStateProducer: ToolFiltersStateProducer,
private val syncService: GodToolsSyncService,
Expand All @@ -79,7 +79,6 @@ class ToolsPresenter @AssistedInject internal constructor(

sealed interface UiEvent : CircuitUiEvent {
data class ChangeMode(val mode: Mode) : UiEvent
data class OpenToolDetails(val tool: String, val source: String? = null) : UiEvent
}
// endregion UiState / UiEvent

Expand All @@ -96,45 +95,33 @@ class ToolsPresenter @AssistedInject internal constructor(

RegisterSyncTask(selectedLocale)

val eventSink: (UiEvent) -> Unit = remember {
{
when (it) {
is UiEvent.ChangeMode -> mode = it.mode

is UiEvent.OpenToolDetails -> {
if (it.source != null) {
eventBus.post(OpenAnalyticsActionEvent(ACTION_OPEN_TOOL_DETAILS, it.tool, it.source))
}
navigator.goTo(ToolDetailsScreen(it.tool, selectedLocale))
}
}
}
}
val featuredTools = rememberFeaturedTools(
mode = mode,
language = filters.languageFilter.selectedItem
) { openToolDetails(it, selectedLocale, SOURCE_SPOTLIGHT) }

val spotlightTools = rememberSpotlightTools(
secondLanguage = filters.languageFilter.selectedItem,
eventSink = eventSink
)
val tools = rememberTools(
mode = mode,
category = filters.categoryFilter.selectedItem,
language = filters.languageFilter.selectedItem,
eventSink = eventSink,
)
) { openToolDetails(it, selectedLocale, SOURCE_ALL_TOOLS) }

return UiState(
mode = mode,
banner = favoriteToolsBannerPresenter.present(),
dataLoaded = spotlightTools != null && tools != null,
dataLoaded = featuredTools != null && tools != null,
spotlightTools = when {
isPersonalizationEnabled && mode == Mode.ALL_TOOLS -> emptyList()
else -> spotlightTools.orEmpty()
else -> featuredTools.orEmpty()
},
filters = filters,
tools = tools.orEmpty(),
isPersonalizationEnabled = isPersonalizationEnabled,
eventSink = eventSink,
)
) {
when (it) {
is UiEvent.ChangeMode -> mode = it.mode
}
}
}

@Composable
Expand All @@ -148,68 +135,62 @@ class ToolsPresenter @AssistedInject internal constructor(
}

@Composable
private fun rememberSpotlightTools(
secondLanguage: Language?,
eventSink: (UiEvent) -> Unit,
private fun rememberFeaturedTools(
mode: Mode,
language: Language?,
onOpenToolDetails: (String) -> Unit,
): List<ToolCardPresenter.UiState>? {
val tools by remember {
toolsRepository.getNormalToolsFlow()
.map { it.filter { !it.isHidden && it.isSpotlight }.sortedWith(Tool.COMPARATOR_DEFAULT_ORDER) }
val locale = language?.code
val tools by remember(mode, locale) {
featuredToolsFlowProducer.getFlow(mode, locale)
}.collectAsState(null)
val eventSink by rememberUpdatedState(eventSink)
return tools?.toToolCardState(language, onOpenToolDetails)
}

return tools?.map { tool ->
val toolCode by rememberUpdatedState(tool.code)
@Composable
private fun rememberTools(
mode: Mode,
category: String?,
language: Language?,
onOpenToolDetails: (String) -> Unit,
): List<ToolCardPresenter.UiState>? {
val locale = language?.code
val tools by remember(mode, category, locale) { filteredToolsFlowProducer.getFlow(mode, category, locale) }
.collectAsState(null)
return tools?.toToolCardState(language, onOpenToolDetails)
}

@Composable
private fun List<Tool>.toToolCardState(language: Language?, onOpenToolDetails: (String) -> Unit) = map { tool ->
key(tool.code) {
val toolCode by rememberUpdatedState(tool.code)
toolCardPresenter.present(
tool = tool,
secondLanguage = secondLanguage,
secondLanguage = language,
eventSink = {
when (it) {
ToolCardEvent.Click,
ToolCardEvent.OpenTool,
ToolCardEvent.OpenToolDetails ->
toolCode?.let { eventSink(UiEvent.OpenToolDetails(it, SOURCE_SPOTLIGHT)) }
ToolCardEvent.OpenToolDetails -> toolCode?.let { onOpenToolDetails(it) }
}
}
)
}
}

@Composable
private fun rememberTools(
mode: Mode,
category: String?,
language: Language?,
eventSink: (UiEvent) -> Unit,
): List<ToolCardPresenter.UiState>? {
val locale = language?.code
val tools by remember(mode, category, locale) { filteredToolsFlowProducer.getFlow(mode, category, locale) }
.collectAsState(null)
val eventSink by rememberUpdatedState(eventSink)

return tools?.map { tool ->
key(tool.code) {
val toolCode by rememberUpdatedState(tool.code)
toolCardPresenter.present(
tool = tool,
secondLanguage = language,
eventSink = {
when (it) {
ToolCardEvent.Click,
ToolCardEvent.OpenTool,
ToolCardEvent.OpenToolDetails ->
toolCode?.let { eventSink(UiEvent.OpenToolDetails(it, SOURCE_ALL_TOOLS)) }
}
}
)
}
private fun openToolDetails(tool: String, selectedLocale: Locale?, source: String?) {
if (source != null) {
eventBus.post(OpenAnalyticsActionEvent(ACTION_OPEN_TOOL_DETAILS, tool, source))
}
navigator.goTo(ToolDetailsScreen(tool, selectedLocale))
}

private fun SyncTracker.syncData(locale: Locale, force: Boolean = false) = launchSync {
val country = settings.getCountrySettingFlow().first()
syncService.syncToolOrder(locale, country, force)
coroutineScope {
launch { syncService.syncFeaturedTools(locale, country, force) }
launch { syncService.syncToolOrder(locale, country, force) }
}
}

@AssistedFactory
Expand Down
Loading