Blinkly is a Kotlin Multiplatform application for preventing eye strain and building healthy screen-time habits.
Targets:
- Android app in
androidApp - iOS app in
iosApp - Shared business logic, navigation, and UI in
shared
Product areas:
- 20-20-20 break reminders
- exercise blocks A, B, and C
- onboarding flow
- progress tracking, streaks, and garden growth
- achievements and gamification
- reminder management and notifications
- educational eye-health content
Core stack:
- Kotlin Multiplatform
- Compose Multiplatform
- Yandex Mobile Ads Compose Multiplatform SDK for contextual inline banners
- Decompose for component architecture and navigation
- MVIKotlin for stateful feature logic
- SQLDelight for persistence
- Multiplatform Settings for preferences
- MOKO Permissions for notification permissions
- Alarmee for cross-platform reminder scheduling
Blinkly-specific skills are project-local. Treat the copies in .ai/skills
as the source of truth:
mvikotlin- .ai/skills/mvikotlin/SKILL.mddecompose- .ai/skills/decompose/SKILL.mddecompose-component-tests- .ai/skills/decompose-component-tests/SKILL.md
Read local skills lazily:
- Before reading a skill body, match the current task against the skill's
nameanddescription. - Read only the matching
SKILL.mdfiles, and only afterAGENTS.mdgives enough project context to decide that the skill applies. - Do not preload all local skills at the start of a task.
- If multiple skills could apply, read the minimal set needed for the current change and explain the order briefly.
Use decompose when work touches Decompose component contracts, default
implementations, factories, navigation stacks, child outputs, tab navigation,
or Compose bindings to component state.
Use mvikotlin when a Blinkly component needs or already has an MVIKotlin
Store, StoreProvider, reducer, executor, bootstrapper, label, integration
mapper, or Store-backed model.
Use decompose-component-tests when writing or reviewing commonTest coverage
for component navigation, parent-child output propagation, Store-backed
component state, lifecycle behaviour, coroutine side effects, or Mokkery
collaborator verification.
These skills should not be duplicated in the global Codex skills directory for normal Blinkly work. If a global copy exists temporarily, update the repository copy first and remove or refresh the global copy immediately after.
androidApp- Android entry point and Android resourcesiosApp- iOS host app and Xcode projectshared/alarm- alarm scheduling through Alarmeeshared/beeper- platform beep/audio feedback abstraction used by workout executionshared/component- Decompose component modules, usually one module per screen or nested flowshared/compose- shared Compose UI, platform-provided ads configuration, and Yandex adaptive inline banner renderingshared/database- SQLDelight database implementation and mappersshared/domain- business interfaces, models, and core logicshared/notifier- notification permissions and notification API implementationshared/settings- settings storage through Multiplatform Settingsshared/component/sync- Google/Firebase-backed account sync, remote snapshot DTOs, conflict handling, and sync metadata tracking wrappersshared/utils- utility code, preview helpers, and Store helpers
Use this map as the current shared component structure. The core component modules have baseline contracts, default implementations, Compose bindings, preview implementations where useful, and common component tests.
Top-level component modules:
root- app root, dependency composition, top-level stack navigation, and root-level feature screen routingonboarding- one-time onboarding flow with five child stepshome- shell with four tabs; owns tab stack navigation and forwards root-level tab outputs upwardmain- implemented main dashboard tab with MVIKotlin Store, feature-local manager, Compose UI, preview component, and common testsprogress- progress tab with MVIKotlin Store, progress manager, calendar/garden/achievement summary state, outputs for achievements and garden screens, Compose UI, preview component, and common testsreminders- reminders tab with MVIKotlin Store, logical schedule list/delete/undo state, notification-permission gating before the add-new-reminder output, Compose UI, preview component, and common tests; one Workday Period schedule owns many physical weekly alarms but renders as one list itemsync- settings-embedded sync component with MVIKotlin Store, Google sign-in bridge, Firestore snapshot data source, sync manager, DTO mappers, preview component, and common teststrainings- trainings tab with MVIKotlin Store, trainings manager, exercise block cards, outputs for workout execution, Compose UI, preview component, and common tests
Nested component modules:
main/child/preferences- preferences screen opened on the root stack from the main tab; has MVIKotlin Store, manager, nestedsynccomponent, Compose UI, preview component, and common testsonboarding/child/step1throughstep5- onboarding steps;step4andstep5are Store-backedprogress/child/achievements- achievements screen opened on the root stack from the progress tab; has MVIKotlin Store, Compose UI, preview component, and common testsprogress/child/garden- garden screen opened on the root stack from the progress tab; has MVIKotlin Store, Compose UI, preview component, and common testsreminders/child/newreminder- add-new-reminder screen opened on the root stack from the reminders tab; has MVIKotlin Store, manager, Compose UI, preview component, and common teststrainings/child/workout- workout execution screen opened on the root stack from the trainings tab or main CTA; has MVIKotlin Store, beeper feedback, Compose UI, preview component, and common tests
Current screen hierarchy:
RootComponentDefaultstarts withOnboardingwhen onboarding has not been completed, otherwiseHomeScreen.RootComponentDefaultowns the app-level stack and pushesPreferences,Workout,Achievements,Garden, andAddNewReminderfromComponentOutputvalues emitted by child components.RootComponentDefaultkeepsBlinklyAchievementsWatcheractive app-wide and maps exactBlinklyNotifier.unlockedAchievements()events toBlinklyNotification.AchievementUnlockedvalues exposed throughRootComponent.notifications.HomeScreenComponentDefaultowns only the four-tab stack: main, trainings, progress, and reminders. It handlesMain.OpenProgressTablocally withbringToFront; other tab outputs go up to root.PreferencesComponentDefaultowns the nestedBlinklySyncComponent; Google sign-in UI remains in Compose, and the sync Store only receives domain-levelBlinklyUserresults.OnboardingComponentDefaultowns 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.- Root-level feature screens use
ComponentOutput.Common.BackPressedto return to the previous root stack entry.
Current implementation notes:
- Component work is materially implemented across root navigation, onboarding, home tabs, root-pushed feature screens, Compose bindings, previews, and common component tests.
mainis the current reference for a Store-backed tab component with domain-derived dashboard state, one-off error labels, a Compose screen, preview-only component implementation, and common component tests.- The main CTA keeps actionable and informational states distinct. After blocks A and B and one daily 20-20-20 break, it shows a non-actionable cooldown until 20 minutes have elapsed, then offers a repeat of block C until the 22:30 day-closing boundary; non-actionable states render without an action label.
progress,reminders, andtrainingsare the current references for Store-backed tabs that emit root-handled navigation outputs.preferences,achievements,garden,newreminder, andworkoutare the current references for Store-backed feature screens that are physically grouped under their owning product area but opened on the root stack.syncis the current reference for a reusable Store-backed settings child component that observes an external manager and bridges a Compose-owned Google sign-in result back into component state.- Reminder persistence separates logical
ReminderScheduleparents from physicalReminderalarm rows. The logical schedule is the list/delete/undo/sync identity; physical reminders carryscheduleIdand remain the Alarmee/reschedule unit. Workday Period creates one parent with many weekly children, while onboarding intentionally counts the physical children. - Reminder rows keep their original scheduling anchor for repeating platform alarms. The reminders tab derives each displayed next occurrence from the current time and time zone when database data arrives and whenever the component resumes; never render the persisted anchor as an upcoming occurrence without this projection.
- Android reminder scheduling uses Alarmee exact scheduling. On Android 12 and newer, both the reminders add flow and onboarding step 5 must obtain the separate
SCHEDULE_EXACT_ALARMspecial access before creating physical alarms. Each Store retains an awaiting-permission state, rechecks access after its component resumes, and either creates the pending reminders or reschedules persisted physical alarms after access is granted. Rescheduling must derive each alarm's next future local occurrence from the currentBlinklyTimeUtils.timeZone()instead of passing an expired stored start date to the platform scheduler. - Android
AlarmManagerregistrations do not survive device reboot or app replacement.BlinklyReminderRescheduleReceiverhandlesBOOT_COMPLETED,MY_PACKAGE_REPLACED, and exact-alarm permission grants, usesgoAsync()for the database-backed work, and calls the shared reminder rescheduler only when exact-alarm access is currently available. - Never use Alarmee
cancelAll()to cancel scheduled Blinkly reminders: on Android it only clears displayed notifications. Load the persisted physical reminders and cancel every alarm by its UUID before deleting or rescheduling their database rows. - Exercise movement events carry their DSL cadence through
ExerciseEvent.Movement.durationMs, the workout Store/model, andBlinklyEyePanel. Keep movement timing single-sourced from the script and finish repeating UI animations shortly before the next movement event so the final frame is rendered without cancellation. - Yandex advertising is configured at the Android/iOS entry points and exposed only through a safe-disabled Compose
CompositionLocal; it is not component or Store state. Both Achievements and Garden use contextual adaptive inline banners as regular lazy-list items. Android removesAD_ID, iOS does not request ATT/IDFA, and ad requests must contain only the platform-specificadUnitId, never Blinkly health, workout, reminder, account, achievement, garden, or sync data. The iOSPodfilemust link YandexMobileAds together with the native FirebaseAuth, FirebaseFirestore, and GoogleSignIn SDKs required by GitLive/KMPAuth, usinguse_frameworks! :linkage => :static; do not replace this with dynamic linkage or globaluse_modular_headers!. Yandex documents Xcode 16.4 as the minimum for YandexMobileAds 8.1.0, but the Intel Simulatorx86_64slice requires Xcode 26.1.1 or newer in practice because Xcode 16.4 lacks_swift_coroFrameAlloc; Intel Macs that cannot install that toolchain must use a supported Mac or macOS CI runner for iOS builds. - Workout execution uses the same manual
READYgate before every exercise, including the first one. Starting a block initializes the exercise manager and shows the first exercise instructions; only the explicit Start exercise action may callstartNextExercise()and enterRUNNING. step4,step5,main,preferences,progress,achievements,garden,reminders,newreminder,trainings,workout, andsyncare the current reference implementations for MVIKotlin stores.
Use this dependency direction:
- platform app -> root factory -> shared component modules
- components -> domain abstractions and external interfaces
- domain -> external interfaces from
shared/domain/src/commonMain/kotlin/com/sedsoftware/blinkly/domain/external - database, notifier, alarm, settings, beeper, utils -> implementations of those external interfaces
- compose -> components only, no business logic
Keep UI-specific code out of component and store layers. Compose should render state and call component methods. Components should translate UI actions into navigation, Store intents, or domain calls.
Apply these rules by default when changing Blinkly code:
- Run every Gradle task in quiet mode by passing
-qimmediately 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-Stringwith 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--debugonly when the quiet error is insufficient to identify the cause, and keep-qenabled 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 throughstore.asValue().map(stateToModel). - Keep Store contracts independent from component UI contracts. Do not put
Component.Modelor other component-facing UI models into StoreState; keep Store state as raw feature fields or feature/domain state and build the componentModelonly inintegration/Mappers.kt. - Put feature-specific business calculations and external reads behind a small manager class in the component module's
domainpackage when the Store would otherwise mix orchestration and calculations.MainTabManageris the reference: it exposes watcher flows, wraps suspend calls inResult, and derivesMainTabDatafrom workouts, settings, and time utilities. - Keep Store reducers pure. Put side effects, subscriptions, manager calls, and IO switching in the executor.
- Route cross-component events upward through
ComponentOutput; do not let child components manipulate parent navigation directly. - Keep dependencies in constructors and root or parent factories; never place dependencies into Decompose navigation configs.
- When adding or materially changing a shared module, component module, feature screen, or dependency wiring, update this guide in the same change. Keep
Repository Layout,Module Map,Shared layering, andManual dependency injectionaccurate, including new DI modules, external interfaces, root factory wiring, and navigation ownership. - When diagnosing a bug or technical problem, proactively propose adding the resulting guidance to this file if the solution reveals a reusable project rule, design principle, or implementation pattern. Do not propose documenting one-off fixes or narrowly specific edge cases.
- Keep Compose as a rendering layer only. Do not move business logic, navigation decisions, or mutable feature state into composables.
- Cover time-driven CTA state machines with boundary and completion-combination tests, and keep displayed action labels, enabled semantics, and component outputs consistent for every state.
- Cover Decompose behaviour in
shared/component/root/src/commonTest, extendingComponentTest<T>withDefaultComponentContext(lifecycle),testDispatchers, navigation assertions onchildStack, andmodel.valueassertions for Store-backed components. - Keep
*Defaultcomponent implementations as the real runtime wiring for Stores, dependencies, labels, lifecycle, and output routing. Use separate*Previewimplementations only for Compose previews; they should implement the same public component interface with staticMutableValuemodel data and no production dependencies.
When unsure whether to follow a generic library pattern or the project pattern, follow the project pattern demonstrated in main, step4, step5, onboarding, home, progress, reminders, trainings, and their feature components.
Blinkly uses manual DI with small module factories, not a DI framework.
Pattern:
- each implementation module exposes an interface such as
DomainModule - a companion dependencies interface declares required inputs
- a top-level factory function returns an anonymous implementation
- exposed dependencies are usually
by lazy
Reference modules:
shared/domain/src/commonMain/kotlin/com/sedsoftware/blinkly/domain/di/DomainModule.ktshared/database/src/commonMain/kotlin/com/sedsoftware/blinkly/database/di/DatabaseModule.ktshared/settings/src/commonMain/kotlin/com/sedsoftware/blinkly/settings/di/SettingsModule.ktshared/notifier/src/commonMain/kotlin/com/sedsoftware/blinkly/notifier/di/NotifierModule.ktshared/alarm/src/commonMain/kotlin/com/sedsoftware/blinkly/alarm/di/AlarmModule.ktshared/beeper/src/commonMain/kotlin/com/sedsoftware/blinkly/beeper/di/BeeperModule.ktshared/component/sync/src/commonMain/kotlin/com/sedsoftware/blinkly/component/sync/di/SyncModule.ktshared/utils/src/commonMain/kotlin/com/sedsoftware/blinkly/utils/di/UtilsModule.kt
RootComponentFactory is the composition root on both platforms. It builds dispatchers, utils, platform modules (alarm, database, notifier, settings, beeper), then SyncModule tracking wrappers, then DomainModule, then creates BlinklySyncManager with the domain reminder reschedule callback before RootComponentDefault.
On iOS, the Swift @main app must call FirebaseApp.configure() before creating MainKt.MainViewController(). The Compose controller lazily creates RootComponentFactory, whose sync graph constructs FirebaseBlinklyAuthService and accesses the default Firebase.auth; reversing this order causes an immediate FirebaseAuth fatal error at launch. Keep GoogleService-Info.plist in the iOS app target resources.
On Android, RootComponentFactory also supplies AlarmModule with the platform BlinklyExactAlarmPermissionController, which checks AlarmManager.canScheduleExactAlarms() and opens the app-specific exact-alarm settings screen when access is missing.
The Android boot/package receiver does not create a RootComponent. It calls rescheduleBlinklyReminders, which builds a focused one-shot graph from the raw database, alarm module, time utilities, dispatchers, and createBlinklyReminderManager, then closes its SQLDelight driver after rescheduling.
RootComponentDefault passes BlinklyNotifier through HomeScreenComponentDefault to the reminders tab so adding a reminder checks or requests notification permission before root navigation opens the add-new-reminder screen.
SyncModule receives the raw database/settings implementations, exposes tracking decorators for app use, and creates BlinklySyncManager after DomainModule is available so applying changed remote reminder rows can reschedule their physical alarms.
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, resolves settings snapshots by their settings-specific timestamp, deduplicates reminder schedules by schedule ID and physical reminders by alarm UUID, and reschedules alarms when restored reminder data differs.
Important local rule: configuration objects in Decompose navigation carry only persistent arguments, never dependencies. Dependencies are supplied in child factories.
The standard component shape in Blinkly is:
- a public interface in the module root package
- a
integration/*Default.ktimplementation class - optional
store/,domain/, andintegration/Mappers.kt - optional parent component that owns child navigation
Reference files:
shared/component/main/src/commonMain/kotlin/com/sedsoftware/blinkly/component/main/MainTabComponent.ktshared/component/main/src/commonMain/kotlin/com/sedsoftware/blinkly/component/main/integration/MainTabComponentDefault.ktshared/component/main/src/commonMain/kotlin/com/sedsoftware/blinkly/component/main/integration/MainTabComponentPreview.ktshared/component/main/src/commonMain/kotlin/com/sedsoftware/blinkly/component/main/integration/Mappers.ktshared/component/main/child/preferences/src/commonMain/kotlin/com/sedsoftware/blinkly/component/preferences/PreferencesComponent.ktshared/component/main/child/preferences/src/commonMain/kotlin/com/sedsoftware/blinkly/component/preferences/integration/PreferencesComponentDefault.ktshared/component/progress/src/commonMain/kotlin/com/sedsoftware/blinkly/component/progress/ProgressTabComponent.ktshared/component/progress/src/commonMain/kotlin/com/sedsoftware/blinkly/component/progress/integration/ProgressTabComponentDefault.ktshared/component/progress/child/achievements/src/commonMain/kotlin/com/sedsoftware/blinkly/component/achievements/AchievementsComponent.ktshared/component/progress/child/achievements/src/commonMain/kotlin/com/sedsoftware/blinkly/component/achievements/integration/AchievementsComponentDefault.ktshared/component/progress/child/garden/src/commonMain/kotlin/com/sedsoftware/blinkly/component/garden/GardenComponent.ktshared/component/progress/child/garden/src/commonMain/kotlin/com/sedsoftware/blinkly/component/garden/integration/GardenComponentDefault.ktshared/component/reminders/src/commonMain/kotlin/com/sedsoftware/blinkly/component/reminders/RemindersTabComponent.ktshared/component/reminders/src/commonMain/kotlin/com/sedsoftware/blinkly/component/reminders/integration/RemindersTabComponentDefault.ktshared/component/reminders/child/newreminder/src/commonMain/kotlin/com/sedsoftware/blinkly/component/newreminder/AddNewReminderComponent.ktshared/component/reminders/child/newreminder/src/commonMain/kotlin/com/sedsoftware/blinkly/component/newreminder/integration/AddNewReminderComponentDefault.ktshared/component/trainings/src/commonMain/kotlin/com/sedsoftware/blinkly/component/trainings/TrainingsTabComponent.ktshared/component/trainings/src/commonMain/kotlin/com/sedsoftware/blinkly/component/trainings/integration/TrainingsTabComponentDefault.ktshared/component/trainings/child/workout/src/commonMain/kotlin/com/sedsoftware/blinkly/component/workout/WorkoutComponent.ktshared/component/trainings/child/workout/src/commonMain/kotlin/com/sedsoftware/blinkly/component/workout/integration/WorkoutComponentDefault.ktshared/component/sync/src/commonMain/kotlin/com/sedsoftware/blinkly/component/sync/BlinklySyncComponent.ktshared/component/sync/src/commonMain/kotlin/com/sedsoftware/blinkly/component/sync/integration/BlinklySyncComponentDefault.ktshared/component/onboarding/child/step4/src/commonMain/kotlin/com/sedsoftware/blinkly/component/step4/OnboardingStep4Component.ktshared/component/onboarding/child/step4/src/commonMain/kotlin/com/sedsoftware/blinkly/component/step4/integration/OnboardingStep4ComponentDefault.ktshared/component/onboarding/child/step5/src/commonMain/kotlin/com/sedsoftware/blinkly/component/step5/OnboardingStep5Component.ktshared/component/onboarding/child/step5/src/commonMain/kotlin/com/sedsoftware/blinkly/component/step5/integration/OnboardingStep5ComponentDefault.ktshared/component/onboarding/src/commonMain/kotlin/com/sedsoftware/blinkly/component/onboarding/integration/OnboardingComponentDefault.ktshared/component/home/src/commonMain/kotlin/com/sedsoftware/blinkly/component/home/integration/HomeScreenComponentDefault.ktshared/component/root/src/commonMain/kotlin/com/sedsoftware/blinkly/component/root/integration/RootComponentDefault.kt
Use ComponentContext delegation in implementations:
class FeatureComponentDefault(...) : FeatureComponent, ComponentContext by componentContext
Parent components use:
StackNavigation<Config>childStack(...)- a private
@SerializablesealedConfig - a
createChild(config, componentContext)factory - a child output handler that maps
ComponentOutputto navigation operations
Local reference patterns:
OnboardingComponentDefaultfor nested flow navigationRootComponentDefaultfor app-level stack-navigation shape, root-level feature screens, and root-level interpretation of child outputsHomeScreenComponentDefaultfor tab-switching shape withbringToFront, creation of real tab components, and local handling of tab-switch outputsProgressTabComponentDefault,RemindersTabComponentDefault, andTrainingsTabComponentDefaultfor Store-backed tab state and root-handled navigation outputs
Keep navigation on the main thread. This matches Decompose guidance.
When a ChildStack must use the same transition for UI back actions and the
system back button, use handleBackButton = true with the regular
stackAnimation. Do not add PredictiveBackParams unless gesture-driven
predictive back animation is explicitly required, because it introduces a
separate animation path that can differ from the configured stack animator.
Cross-component communication uses ComponentOutput from shared/domain/src/commonMain/kotlin/com/sedsoftware/blinkly/domain/model/ComponentOutput.kt.
Rules:
- child components do not mutate parent navigation directly
- leaf components emit typed
ComponentOutput - parent components interpret outputs and navigate
- use
ComponentOutput.Common.BackPressedfor standard back propagation when needed
When a component owns a retained Store, create it with instanceKeeper.getStore { ... }.
This is the local equivalent of retaining stateful logic across recreation.
If a component owns a custom coroutine scope, cancel it in lifecycle.doOnDestroy { scope.cancel() }.
Only create a scope when the component actually runs background work.
Use MVIKotlin only when the component has meaningful state transitions or asynchronous business logic.
Current store references:
shared/component/main/src/commonMain/kotlin/com/sedsoftware/blinkly/component/main/store/MainTabStore.ktshared/component/main/src/commonMain/kotlin/com/sedsoftware/blinkly/component/main/store/MainTabStoreProvider.ktshared/component/main/src/commonMain/kotlin/com/sedsoftware/blinkly/component/main/domain/MainTabManager.ktshared/component/main/src/commonMain/kotlin/com/sedsoftware/blinkly/component/main/integration/MainTabComponentDefault.ktshared/component/main/src/commonMain/kotlin/com/sedsoftware/blinkly/component/main/integration/Mappers.ktshared/component/main/child/preferences/src/commonMain/kotlin/com/sedsoftware/blinkly/component/preferences/store/PreferencesStore.ktshared/component/main/child/preferences/src/commonMain/kotlin/com/sedsoftware/blinkly/component/preferences/store/PreferencesStoreProvider.ktshared/component/progress/src/commonMain/kotlin/com/sedsoftware/blinkly/component/progress/store/ProgressTabStore.ktshared/component/progress/src/commonMain/kotlin/com/sedsoftware/blinkly/component/progress/store/ProgressTabStoreProvider.ktshared/component/progress/child/achievements/src/commonMain/kotlin/com/sedsoftware/blinkly/component/achievements/store/AchievementsStore.ktshared/component/progress/child/achievements/src/commonMain/kotlin/com/sedsoftware/blinkly/component/achievements/store/AchievementsStoreProvider.ktshared/component/progress/child/garden/src/commonMain/kotlin/com/sedsoftware/blinkly/component/garden/store/GardenStore.ktshared/component/progress/child/garden/src/commonMain/kotlin/com/sedsoftware/blinkly/component/garden/store/GardenStoreProvider.ktshared/component/reminders/src/commonMain/kotlin/com/sedsoftware/blinkly/component/reminders/store/RemindersStore.ktshared/component/reminders/src/commonMain/kotlin/com/sedsoftware/blinkly/component/reminders/store/RemindersStoreProvider.ktshared/component/reminders/child/newreminder/src/commonMain/kotlin/com/sedsoftware/blinkly/component/newreminder/store/AddNewReminderStore.ktshared/component/reminders/child/newreminder/src/commonMain/kotlin/com/sedsoftware/blinkly/component/newreminder/store/AddNewReminderStoreProvider.ktshared/component/trainings/src/commonMain/kotlin/com/sedsoftware/blinkly/component/trainings/store/TrainingsTabStore.ktshared/component/trainings/src/commonMain/kotlin/com/sedsoftware/blinkly/component/trainings/store/TrainingsTabStoreProvider.ktshared/component/trainings/child/workout/src/commonMain/kotlin/com/sedsoftware/blinkly/component/workout/store/WorkoutStore.ktshared/component/trainings/child/workout/src/commonMain/kotlin/com/sedsoftware/blinkly/component/workout/store/WorkoutStoreProvider.ktshared/component/sync/src/commonMain/kotlin/com/sedsoftware/blinkly/component/sync/store/BlinklySyncStore.ktshared/component/sync/src/commonMain/kotlin/com/sedsoftware/blinkly/component/sync/store/BlinklySyncStoreProvider.ktshared/component/onboarding/child/step4/.../store/DisclaimerStore.ktshared/component/onboarding/child/step4/.../store/DisclaimerStoreProvider.ktshared/component/onboarding/child/step5/.../store/InitialRemindersStore.ktshared/component/onboarding/child/step5/.../store/InitialRemindersStoreProvider.kt
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 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.
Preferred local structure:
internal interface FeatureStore : Store<Intent, State, Label>- nested
Intent,State, and optionalLabel FeatureStoreProviderthat receivesStoreFactory, coroutine contexts, and feature dependenciescreate(autoInit: Boolean = true)factory annotated with@StoreProvider- reducer stays pure and only transforms
State
Use coroutineExecutorFactory(mainContext) for executors.
Use coroutineBootstrapper(mainContext) only when the feature needs startup actions, subscriptions, or initial async checks.
These match both local code and official MVIKotlin guidance:
store.accept(...)must be triggered on the main thread- reducer work stays on the main thread
- switch to
dispatchers.ioinside executor coroutines for IO or long-running operations - publish one-off failures through
Labelwhen they should not live in state - feature-local managers in component
domainpackages should wrap suspend/business operations inResult<T>viarunCatching; Store executors handle those results withunwrap(...) - managers may expose watcher flows directly when the Store only needs to subscribe and map stream values; protect those subscriptions with
.catch { publish(Label.ErrorCaught(it.asBlinklyError(...))) }, using the matchingBlinklyErrorsubclass for that feature or operation - flow subscriptions may remain as
Flow<T>from managers and should be protected with.catch { publish(Label.ErrorCaught(it.asBlinklyError(...))) }in the executor, using the matchingBlinklyErrorsubclass rather than publishing raw exceptions
main is the reference for bootstrapper plus subscriptions that also derive aggregate dashboard state through a manager.
step5 is the reference for bootstrapper plus subscription-driven setup logic.
step4 is the reference for a minimal synchronous Store.
Expose component state as Decompose Value<Model>.
Local pattern:
store.asValue().map(stateToModel)- keep
stateToModelinintegration/Mappers.kt - component
Modelshould be UI-oriented and decoupled from raw Store state - Store
Stateshould not reference component interfaces or componentModelclasses; map raw Store fields to componentModelinstateToModel
Utility reference:
shared/utils/src/commonMain/kotlin/com/sedsoftware/blinkly/utils/StoreExt.kt
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.
OnboardingStep5ComponentDefault is the current reference: it maps InitialRemindersStore.Label.ErrorCaught to ComponentOutput.Common.ErrorCaught; OnboardingComponentDefault handles onboarding navigation outputs locally and forwards common outputs to the root.
Only route labels upward when a parent actually needs the event. Otherwise keep the event as a Store Label for local handling.
App-wide user-visible errors use BlinklyError from shared/domain/src/commonMain/kotlin/com/sedsoftware/blinkly/domain/model/BlinklyError.kt.
Wrap low-level failures at the point where the user-facing operation is known, such as loading progress data, saving preferences, creating a reminder, or saving a workout. Keep the original throwable as cause for logging.
RootComponentDefault maps incoming ComponentOutput.Common.ErrorCaught values to BlinklyError, logs the original cause, and emits them through RootComponent.errors.
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 lives in shared/compose and depends on components, not the reverse.
Rules:
- UI renders component models and child stacks only
- obtain Decompose
Valuestate withsubscribeAsState()in Compose - root rendering uses
ChildStackfrom 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.xmlfor English andshared/compose/src/commonMain/composeResources/values-ru/strings.xmlfor 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.ktshared/compose/src/commonMain/kotlin/com/sedsoftware/blinkly/compose/ui/home/HomeScreenContent.ktshared/compose/src/commonMain/kotlin/com/sedsoftware/blinkly/compose/ui/home/tabs/MainTabContent.ktshared/compose/src/commonMain/kotlin/com/sedsoftware/blinkly/compose/ui/home/tabs/ProgressTabContent.ktshared/compose/src/commonMain/kotlin/com/sedsoftware/blinkly/compose/ui/home/tabs/RemindersTabContent.ktshared/compose/src/commonMain/kotlin/com/sedsoftware/blinkly/compose/ui/home/tabs/TrainingsTabContent.ktshared/compose/src/commonMain/kotlin/com/sedsoftware/blinkly/compose/ui/preferences/PreferencesContent.ktshared/compose/src/commonMain/kotlin/com/sedsoftware/blinkly/compose/ui/achievements/AchievementsContent.ktshared/compose/src/commonMain/kotlin/com/sedsoftware/blinkly/compose/ui/garden/GardenContent.ktshared/compose/src/commonMain/kotlin/com/sedsoftware/blinkly/compose/ui/newreminder/AddNewReminderContent.ktshared/compose/src/commonMain/kotlin/com/sedsoftware/blinkly/compose/ui/exercises/WorkoutContent.ktshared/compose/src/commonMain/kotlin/com/sedsoftware/blinkly/compose/ui/onboarding/*
MainTabContent is the reference for connecting a component to Compose:
- subscribe to
component.modelwithsubscribeAsState() - render only the public
MainTabComponent.Model - call public component methods from UI events
- use
MainTabComponentPreviewto supply stable fake data for@Previewvariants without creating aComponentContext, Store, manager, or platform dependencies
Platform note:
- Android creates the root
ComponentContextonce inAppActivity.onCreate()viadefaultComponentContext(). - This follows Decompose guidance to create the root component outside Compose.
shared/compose uses Paparazzi golden screenshots generated from Compose
@Preview functions.
Rules for UI changes:
- Any change to shared Compose UI, theme, typography, spacing, preview data, or visual assets must include the corresponding Paparazzi golden updates.
- Keep previews deterministic: use stable preview components/data, fixed preview dimensions, and avoid runtime-only dependencies in preview code.
- For screens represented by a large variant board, also keep at least one realistic single-state phone preview when that helps catch real viewport layout issues.
- After intentional visual changes, run
.\gradlew.bat -q :shared:compose:cleanRecordPaparazziDebugto regenerate goldens, then run.\gradlew.bat -q :shared:compose:verifyPaparazziDebug. - Inspect new or materially changed PNGs in
shared/compose/src/test/snapshots/imagesbefore finishing the change. - Commit the updated snapshot PNGs together with the UI code that caused them.
- Do not bless unrelated Paparazzi diffs. If
verifyPaparazziDebugreports unexpected changes, inspect the diff and either fix the UI or update only the goldens that match the intended visual change.
Component tests are common tests, not instrumentation tests.
Local convention: component tests live in shared/component/root/src/commonTest, even when the component under test belongs to another component module. This keeps shared component test utilities and cross-component navigation coverage in one module.
Base test utility:
shared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/ComponentTest.kt
Use this pattern:
- extend
ComponentTest<T> - create component with
DefaultComponentContext(lifecycle) - use
testDispatchersfrom the base class - collect parent outputs in
componentOutput - use
runTest(testScheduler)andadvanceUntilIdle()when async work is involved
Current references:
shared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/root/RootComponentTest.ktshared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/home/HomeScreenComponentTest.ktshared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/onboarding/OnboardingComponentTest.ktshared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/main/MainTabComponentTest.ktshared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/preferences/PreferencesComponentTest.ktshared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/progress/ProgressTabComponentTest.ktshared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/achievements/AchievementsComponentTest.ktshared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/garden/GardenComponentTest.ktshared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/reminders/RemindersTabComponentTest.ktshared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/newreminder/AddNewReminderComponentTest.ktshared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/trainings/TrainingsTabComponentTest.ktshared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/workout/WorkoutComponentTest.ktshared/component/root/src/commonTest/kotlin/com/sedsoftware/blinkly/component/sync/BlinklySyncComponentTest.kt
Preferred assertions:
- inspect
childStack.active.instancefor navigation state - inspect
childStack.items.sizewhen stack depth matters - inspect
component.model.valuefor Store-driven UI state - verify collaborators with Mokkery
- fake settings by wrapping
BlinklySettingsand overriding the properties under test
Testing defaults for Blinkly components:
- drive behaviour through the public component API, not internal navigation objects or Store internals
- use
advanceUntilIdle()after component creation, flow emission, or callbacks that launch coroutines - use
MutableStateFlowfor subscription-driven scenarios - for Store-backed business state, fake external interfaces and assert
component.model.value;MainTabComponentTestis the reference for calendar flow, tree flow, fake time, fake settings, highlight failure labels, and CTA output mapping - prefer component tests over Compose tests for navigation and business-state validation
When adding a new Blinkly feature:
- decide whether it is a thin component or needs MVIKotlin state
- keep navigation inside the nearest parent component
- keep domain logic in
shared/domainor a feature-local domain helper, not in Compose - inject dependencies from the root or parent factory, never through navigation configs
- add common tests for component behavior and state transitions
- update
AGENTS.mdin the same change when the feature adds or changes a module, component hierarchy, dependency direction, external interface, DI module, or root factory wiring
When modifying existing code:
- preserve current package naming even when file path and package names differ in nesting depth
- prefer local patterns over generic library examples
- keep new modules symmetric with existing
component/*modules - avoid introducing Android-only logic into common component or domain code
Official references used for this guide:
- MVIKotlin overview: https://arkivanov.github.io/MVIKotlin/
- MVIKotlin Store: https://arkivanov.github.io/MVIKotlin/store/
- MVIKotlin Binding and Lifecycle: https://arkivanov.github.io/MVIKotlin/binding_and_lifecycle/
- Decompose overview: https://arkivanov.github.io/Decompose/
- Decompose component overview: https://arkivanov.github.io/Decompose/component/overview/
- Decompose navigation overview: https://arkivanov.github.io/Decompose/navigation/overview/
- Decompose Compose extensions: https://arkivanov.github.io/Decompose/extensions/compose/