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
38 changes: 34 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ Nested component modules:
Current screen hierarchy:
- `RootComponentDefault` starts with `Onboarding` when onboarding has not been completed, otherwise `HomeScreen`.
- `RootComponentDefault` owns the app-level stack and pushes `Preferences`, `Workout`, `Achievements`, `Garden`, and `AddNewReminder` from `ComponentOutput` values emitted by child components.
- `RootComponentDefault` keeps `BlinklyAchievementsWatcher` active app-wide and maps exact `BlinklyNotifier.unlockedAchievements()` events to `BlinklyNotification.AchievementUnlocked` values exposed through `RootComponent.notifications`.
- `HomeScreenComponentDefault` owns only the four-tab stack: main, trainings, progress, and reminders. It handles `Main.OpenProgressTab` locally with `bringToFront`; other tab outputs go up to root.
- `PreferencesComponentDefault` owns the nested `BlinklySyncComponent`; Google sign-in UI remains in Compose, and the sync Store only receives domain-level `BlinklyUser` results.
- `OnboardingComponentDefault` owns the onboarding step stack from step 1 through step 5. Steps 1, 2, and 3 are thin output-forwarding components; step 4 and step 5 are Store-backed.
Expand Down Expand Up @@ -134,6 +135,20 @@ Keep UI-specific code out of component and store layers. Compose should render s

Apply these rules by default when changing Blinkly code:

- Run every Gradle task in quiet mode by passing `-q` immediately after the
wrapper command: `.\gradlew.bat -q <task>`. Treat a zero exit code as the
complete success result; routine task progress and successful build output
are not needed.
- Redirect both stdout and stderr from every Gradle invocation to a temporary
log file. On success, report only the zero exit code and do not read the log.
On failure, inspect only the last 200 lines first, then use targeted searches
such as `Select-String` with context to retrieve any additional relevant
sections. Do not print or read the complete log unless these bounded views
are insufficient to diagnose the failure.
- On Gradle failure, use the error output produced by quiet mode first. Rerun
with additional diagnostics such as `--stacktrace`, `--info`, or `--debug`
only when the quiet error is insufficient to identify the cause, and keep
`-q` enabled whenever the selected diagnostic option supports it.
- Prefer a thin Decompose component without MVIKotlin when the feature only forwards user actions to `ComponentOutput`.
- Add MVIKotlin only when the feature needs reducer-owned state, async work, startup subscriptions, or one-off labels.
- Retain Stores with `instanceKeeper.getStore { ... }` and expose UI state through `store.asValue().map(stateToModel)`.
Expand Down Expand Up @@ -171,6 +186,7 @@ Reference modules:

`RootComponentFactory` is the composition root on both platforms. It builds dispatchers, utils, platform modules (`alarm`, `database`, `notifier`, `settings`, `beeper`), then `SyncModule`, then `DomainModule`, then `RootComponentDefault`.
`SyncModule` receives the raw database/settings implementations, exposes tracking decorators for app use, and passes `BlinklySyncManager` into `RootComponentDefault` so Preferences can create the nested sync component.
Sync metadata is split by data type: database writes update `lastLocalDatabaseChangeAt`, settings writes update `lastLocalSettingsChangeAt`, and `lastRemoteUpdatedAt` is the baseline for detecting remote changes. `BlinklySyncManagerImpl` merges database snapshots when both local and remote changed after the baseline, and resolves settings snapshots by their settings-specific timestamp.

Important local rule: configuration objects in Decompose navigation carry only persistent arguments, never dependencies. Dependencies are supplied in child factories.

Expand Down Expand Up @@ -290,7 +306,7 @@ Current store references:

`main` is the reference for a tab Store with bootstrapper subscriptions, manager-based business logic, domain-derived model data, and labels mapped to parent output.
`progress`, `reminders`, and `trainings` are references for Store-backed tabs that emit navigation outputs. `preferences`, `achievements`, `garden`, `newreminder`, and `workout` are references for Store-backed feature screens opened by the root stack.
`sync` is the reference for a Store that observes a domain manager state flow, emits a UI-owned sign-in request label, and performs post-auth synchronization from a domain `BlinklyUser`.
`sync` is the reference for a Store that observes a domain manager state flow and performs post-auth synchronization from a domain `BlinklyUser`; Google Sign-In is initiated in Compose through the KMPAuth Google button container, and the Store only receives completed or failed sign-in results.

### Store structure

Expand Down Expand Up @@ -331,7 +347,7 @@ Local pattern:
Utility reference:
- `shared/utils/src/commonMain/kotlin/com/sedsoftware/blinkly/utils/StoreExt.kt`

### Labels and errors
### Labels, errors, and notifications

The project models one-off errors as `ComponentOutput.Common.ErrorCaught`.
When a Store label must be visible outside the feature, collect `store.labels` in the component layer, map the label to a typed `ComponentOutput`, and let the parent decide whether to handle it locally or forward it upward.
Expand All @@ -345,6 +361,11 @@ Wrap low-level failures at the point where the user-facing operation is known, s
`RootContent` collects `RootComponent.errors`, maps `BlinklyError` to localized strings, and shows the app-wide top error snackbar.
When adding a new user-visible error context, add the `BlinklyError` type and matching English/Russian strings in `shared/compose/src/commonMain/composeResources`; unknown or too-narrow failures should fall back to `BlinklyError.Unknown`.

App-wide informational events use `BlinklyNotification` from `shared/domain/src/commonMain/kotlin/com/sedsoftware/blinkly/domain/model/BlinklyNotification.kt`.
Components may route them upward as `ComponentOutput.Common.NotificationReceived`; `RootComponentDefault` emits them through `RootComponent.notifications`.
Achievement unlock events originate when `BlinklyAchievementsWatcherImpl` calls `BlinklyNotifier.achievementUnlocked` after saving, so restored or initially loaded achievements do not produce stale success messages.
`RootContent` queues errors and notifications in one top snackbar host, using error colors for failures and `secondaryContainer` colors for neutral success notifications.

## Compose Conventions

Compose lives in `shared/compose` and depends on components, not the reverse.
Expand All @@ -354,6 +375,15 @@ Rules:
- obtain Decompose `Value` state with `subscribeAsState()` in Compose
- root rendering uses `ChildStack` from Decompose Compose extensions
- keep business logic and navigation decisions out of composables
- Whenever a change introduces new user-visible text, add it as a Compose
string resource in both
`shared/compose/src/commonMain/composeResources/values/strings.xml` for
English and
`shared/compose/src/commonMain/composeResources/values-ru/strings.xml` for
Russian in the same change. Do not hardcode new user-visible strings in
Kotlin or Compose code. Keep resource names, placeholders, and formatting
arguments consistent between the two locales, and provide the translation
immediately rather than relying on the default-locale fallback.

References:
- `shared/compose/src/commonMain/kotlin/com/sedsoftware/blinkly/compose/ui/RootContent.kt`
Expand Down Expand Up @@ -393,8 +423,8 @@ Rules for UI changes:
realistic single-state phone preview when that helps catch real viewport
layout issues.
- After intentional visual changes, run
`.\gradlew.bat :shared:compose:cleanRecordPaparazziDebug` to regenerate
goldens, then run `.\gradlew.bat :shared:compose:verifyPaparazziDebug`.
`.\gradlew.bat -q :shared:compose:cleanRecordPaparazziDebug` to regenerate
goldens, then run `.\gradlew.bat -q :shared:compose:verifyPaparazziDebug`.
- Inspect new or materially changed PNGs in
`shared/compose/src/test/snapshots/images` before finishing the change.
- Commit the updated snapshot PNGs together with the UI code that caused them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,7 @@ fun RootComponentFactory(
return RootComponentDefault(
componentContext = componentContext,
storeFactory = DefaultStoreFactory(),
alarmManager = alarmManager,
beeper = beeper,
database = database,
dispatchers = dispatchers,
notifier = notifier,
settings = settings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.sedsoftware.blinkly.component.newreminder.AddNewReminderComponent
import com.sedsoftware.blinkly.component.onboarding.OnboardingComponent
import com.sedsoftware.blinkly.component.preferences.PreferencesComponent
import com.sedsoftware.blinkly.domain.model.BlinklyError
import com.sedsoftware.blinkly.domain.model.BlinklyNotification
import com.sedsoftware.blinkly.domain.model.ThemeState
import kotlinx.coroutines.flow.SharedFlow

Expand All @@ -19,6 +20,7 @@ interface RootComponent : BackHandlerOwner {
val childStack: Value<ChildStack<*, Child>>
val themeState: Value<ThemeState>
val errors: SharedFlow<BlinklyError>
val notifications: SharedFlow<BlinklyNotification>

fun onBack()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,28 @@ import com.sedsoftware.blinkly.domain.BlinklyExerciseManager
import com.sedsoftware.blinkly.domain.BlinklyHighlightsProvider
import com.sedsoftware.blinkly.domain.BlinklyReminderManager
import com.sedsoftware.blinkly.domain.BlinklyTreeProgressWatcher
import com.sedsoftware.blinkly.domain.external.BlinklyAlarmManager
import com.sedsoftware.blinkly.domain.external.BlinklyBeeper
import com.sedsoftware.blinkly.domain.external.BlinklyDatabase
import com.sedsoftware.blinkly.domain.external.BlinklyDispatchers
import com.sedsoftware.blinkly.domain.external.BlinklyNotifier
import com.sedsoftware.blinkly.domain.external.BlinklySettings
import com.sedsoftware.blinkly.domain.external.BlinklySyncManager
import com.sedsoftware.blinkly.domain.external.BlinklyTimeUtils
import com.sedsoftware.blinkly.domain.model.Achievement
import com.sedsoftware.blinkly.domain.model.AchievementType
import com.sedsoftware.blinkly.domain.model.BlinklyError
import com.sedsoftware.blinkly.domain.model.BlinklyNotification
import com.sedsoftware.blinkly.domain.model.ComponentOutput
import com.sedsoftware.blinkly.domain.model.ExerciseBlock
import com.sedsoftware.blinkly.domain.model.ThemeState
import com.sedsoftware.blinkly.domain.model.asBlinklyError
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable

@Suppress("LongParameterList")
Expand All @@ -61,16 +68,16 @@ class RootComponentDefault private constructor(
private val achievementsComponent: (ComponentContext, (ComponentOutput) -> Unit) -> AchievementsComponent,
private val gardenComponent: (ComponentContext, (ComponentOutput) -> Unit) -> GardenComponent,
private val addNewReminderComponent: (ComponentContext, (ComponentOutput) -> Unit) -> AddNewReminderComponent,
private val achievements: Flow<List<Achievement>>,
private val achievementUnlockEvents: Flow<AchievementType>,
mainDispatcher: CoroutineDispatcher,
private val releaseBeeper: () -> Unit,
) : RootComponent, ComponentContext by componentContext {

@Suppress("UnusedPrivateProperty")
constructor(
componentContext: ComponentContext,
storeFactory: StoreFactory,
alarmManager: BlinklyAlarmManager,
beeper: BlinklyBeeper,
database: BlinklyDatabase,
dispatchers: BlinklyDispatchers,
notifier: BlinklyNotifier,
settings: BlinklySettings,
Expand Down Expand Up @@ -132,15 +139,34 @@ class RootComponentDefault private constructor(
addNewReminderOutput = output,
)
},
achievements = achievementsWatcher.achievements,
achievementUnlockEvents = notifier.unlockedAchievements(),
mainDispatcher = dispatchers.main,
releaseBeeper = beeper::release,
)

private val navigation: StackNavigation<Config> = StackNavigation()
private val themeStateValue: MutableValue<ThemeState> = MutableValue(settings.themeState)
private val errorEvents: MutableSharedFlow<BlinklyError> = MutableSharedFlow(extraBufferCapacity = ERROR_BUFFER_CAPACITY)
private val notificationEvents: MutableSharedFlow<BlinklyNotification> =
MutableSharedFlow(extraBufferCapacity = NOTIFICATION_BUFFER_CAPACITY)
private val scope = CoroutineScope(mainDispatcher + SupervisorJob())

init {
scope.launch {
achievements.collect {}
}
scope.launch {
achievementUnlockEvents.collect { type ->
onChildOutput(
ComponentOutput.Common.NotificationReceived(
BlinklyNotification.AchievementUnlocked(type),
)
)
}
}
lifecycle.doOnDestroy {
scope.cancel()
releaseBeeper()
}
}
Expand All @@ -157,6 +183,7 @@ class RootComponentDefault private constructor(
override val childStack: Value<ChildStack<*, RootComponent.Child>> = stack
override val themeState: Value<ThemeState> = themeStateValue
override val errors = errorEvents.asSharedFlow()
override val notifications = notificationEvents.asSharedFlow()

override fun onBack() {
navigation.pop()
Expand Down Expand Up @@ -231,6 +258,10 @@ class RootComponentDefault private constructor(
errorEvents.tryEmit(error)
}

is ComponentOutput.Common.NotificationReceived -> {
notificationEvents.tryEmit(output.notification)
}

else -> Unit
}
}
Expand Down Expand Up @@ -269,5 +300,6 @@ class RootComponentDefault private constructor(

private companion object {
const val ERROR_BUFFER_CAPACITY = 16
const val NOTIFICATION_BUFFER_CAPACITY = 16
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,8 @@ class MainTabComponentTest : ComponentTest<MainTabComponent>() {
override var displayedHighlights: List<Int> = emptyList()
override var currentHighlightDate: LocalDate? = null
override var onboardingDisplayed: Boolean = false
override var lastLocalChangeAt: Instant? = null
override var lastLocalDatabaseChangeAt: Instant? = null
override var lastLocalSettingsChangeAt: Instant? = null
override var lastSyncedAt: Instant? = null
override var lastRemoteUpdatedAt: Instant? = null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ class PreferencesComponentTest : ComponentTest<PreferencesComponent>() {
override var displayedHighlights: List<Int> = emptyList()
override var currentHighlightDate: LocalDate? = null
override var onboardingDisplayed: Boolean = false
override var lastLocalChangeAt: Instant? = null
override var lastLocalDatabaseChangeAt: Instant? = null
override var lastLocalSettingsChangeAt: Instant? = null
override var lastSyncedAt: Instant? = null
override var lastRemoteUpdatedAt: Instant? = null
}
Expand All @@ -145,7 +146,6 @@ class PreferencesComponentTest : ComponentTest<PreferencesComponent>() {
)
)

override suspend fun signInOrSync() = Unit
override suspend fun completeGoogleSignIn(user: BlinklyUser) = Unit
override suspend fun syncNow() = Unit
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ import com.sedsoftware.blinkly.domain.BlinklyExerciseManager
import com.sedsoftware.blinkly.domain.BlinklyHighlightsProvider
import com.sedsoftware.blinkly.domain.BlinklyReminderManager
import com.sedsoftware.blinkly.domain.BlinklyTreeProgressWatcher
import com.sedsoftware.blinkly.domain.external.BlinklyAlarmManager
import com.sedsoftware.blinkly.domain.external.BlinklyBeeper
import com.sedsoftware.blinkly.domain.external.BlinklyDatabase
import com.sedsoftware.blinkly.domain.external.BlinklyNotifier
import com.sedsoftware.blinkly.domain.external.BlinklySettings
import com.sedsoftware.blinkly.domain.external.BlinklySyncManager
import com.sedsoftware.blinkly.domain.external.BlinklyTimeUtils
import com.sedsoftware.blinkly.domain.model.AchievementType
import com.sedsoftware.blinkly.domain.model.BlinklyError
import com.sedsoftware.blinkly.domain.model.BlinklyNotification
import com.sedsoftware.blinkly.domain.model.BlinklySyncState
import com.sedsoftware.blinkly.domain.model.BlinklyUser
import com.sedsoftware.blinkly.domain.model.ThemeState
Expand All @@ -34,6 +34,7 @@ import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow
Expand All @@ -47,13 +48,14 @@ import kotlin.time.Instant

class RootComponentTest : ComponentTest<RootComponent>() {

private val alarmManagerMock: BlinklyAlarmManager = mock()
private val beeperMock: BlinklyBeeper = mock {
every { beep() } returns Unit
every { release() } returns Unit
}
private val databaseMock: BlinklyDatabase = mock()
private val notifierMock: BlinklyNotifier = mock()
private val unlockedAchievementsFlow = MutableSharedFlow<AchievementType>()
private val notifierMock: BlinklyNotifier = mock {
every { unlockedAchievements() } returns unlockedAchievementsFlow
}
private val fakeSettings = FakeSettings()
private val syncManager: FakeBlinklySyncManager = FakeBlinklySyncManager()
private val timeUtilsMock: BlinklyTimeUtils = mock {
Expand Down Expand Up @@ -286,6 +288,24 @@ class RootComponentTest : ComponentTest<RootComponent>() {
collectJob.cancel()
}

@Test
fun `when achievement unlocked then root publishes notification`() = runTest(testScheduler) {
// given
val notifications = mutableListOf<BlinklyNotification>()
val collectJob = launch { component.notifications.collect { notifications.add(it) } }
testScheduler.advanceUntilIdle()

// when
unlockedAchievementsFlow.emit(AchievementType.FIRST_SPARK)
testScheduler.advanceUntilIdle()

// then
assertThat(notifications).isEqualTo(
listOf(BlinklyNotification.AchievementUnlocked(AchievementType.FIRST_SPARK))
)
collectJob.cancel()
}

private fun completeOnboardingFlow(currentComponent: RootComponent) {
val onboardingComponent = currentComponent.childStack.active.instance as? RootComponent.Child.Onboarding ?: return
val step1 = onboardingComponent.component.childStack.active.instance as OnboardingComponent.Child.Step1
Expand Down Expand Up @@ -328,9 +348,7 @@ class RootComponentTest : ComponentTest<RootComponent>() {
RootComponentDefault(
componentContext = DefaultComponentContext(lifecycle),
storeFactory = DefaultStoreFactory(),
alarmManager = alarmManagerMock,
beeper = beeperMock,
database = databaseMock,
dispatchers = testDispatchers,
notifier = notifierMock,
settings = fakeSettings,
Expand Down Expand Up @@ -360,7 +378,8 @@ class RootComponentTest : ComponentTest<RootComponent>() {
override var displayedHighlights: List<Int> = emptyList()
override var currentHighlightDate: LocalDate? = null
override var onboardingDisplayed: Boolean = false
override var lastLocalChangeAt: Instant? = null
override var lastLocalDatabaseChangeAt: Instant? = null
override var lastLocalSettingsChangeAt: Instant? = null
override var lastSyncedAt: Instant? = null
override var lastRemoteUpdatedAt: Instant? = null
}
Expand All @@ -375,7 +394,6 @@ class RootComponentTest : ComponentTest<RootComponent>() {
)
)

override suspend fun signInOrSync() = Unit
override suspend fun completeGoogleSignIn(user: BlinklyUser) = Unit
override suspend fun syncNow() = Unit
}
Expand Down
Loading
Loading