From f2314e6cf5a8475281ede8be1fa92c8c86080344 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 18 Jun 2026 20:04:18 -0300 Subject: [PATCH 1/3] docs: align CLAUDE.md and Architecture.md with KMP best practices Add a prescriptive "Coding conventions" section to CLAUDE.md ported from the sibling Bitkit AGENTS.md (shared/Android/iOS): androidx ViewModel + viewModelScope, _state.update{} over .value=, koinViewModel routes, navigateTo, immutable-collection guidance. Fix the eco -> echo package typo. Reconcile Architecture.md with the v1 reality: repositories are Pubky-only with an in-memory session cache (no SQLDelight, no multiplatform-settings), secrets via SecureSessionStore/KVault, SKIE not yet wired. Mark SQLDelight/SKIE sections as not-adopted/future rather than current design, and point conventions back to CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 56 ++++++++++++++++++++++++++- docs/Architecture.md | 91 ++++++++++++++++++++++++++++---------------- 2 files changed, 113 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ebcce2..7499910 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,14 +24,14 @@ Kotlin lint is detekt (`config/detekt/detekt.yml`, with `detekt-formatting` + `d **Business logic is shared; UI is native per platform.** This is the core rule — internalize it before making changes. -- `shared/src/commonMain/kotlin/com/github/jvsena42/eco/` holds all cross-platform code: +- `shared/src/commonMain/kotlin/com/github/jvsena42/echo/` holds all cross-platform code: - `domain/model/` — pure Kotlin data classes (`Deck`, `Card`, `ImportDraft`, `SrsState`, `AppError`, etc.). No framework imports. - `data/repository/` — repository interfaces (all 8 in `Repositories.kt`: Identity, Deck, Card, Import, Media, Tag, Discovery, Srs), implementations under `data/repository/impl/`. **Repositories own the business logic** — parsing, triage, publishing, SRS grading, follow/unfollow, sign-in/out all live as methods on the relevant repo rather than in a separate use-case layer. **All 8 are implemented** (`IdentityRepositoryImpl`, `DeckRepositoryImpl`, `CardRepositoryImpl`, `ImportRepositoryImpl` — the paste parser, spec §6 rules + §9 edge cases —, `MediaRepositoryImpl`, `SrsRepositoryImpl`, `DiscoveryRepositoryImpl`, `TagRepositoryImpl`), plus `SessionRevalidatorImpl`. `TagRepositoryImpl` writes pubky-app-specs tag records to the homeserver and reads trending from the Nexus indexer (`data/nexus/NexusClient`, see Architecture.md §7.6). The impls are Pubky-only: they write/read through `PubkyClient` and hold an in-memory per-session cache. No SQLDelight yet — the app is not offline-first, Pubky is the single source of truth. - `data/pubky/` — `PubkyClient` interface + DTOs (`ManifestDto`, `CardDto`, `MediaRefDto` in `DeckDtos.kt`, `ProfileDto`) and path helpers (`PubkyPaths`, `Hashing`) that map between domain models and the on-homeserver JSON layout defined in `docs/Architecture.md §8.0`. `SessionProvider`/`MutableSessionProvider` is the tiny read-only abstraction repos use to author writes without depending on `IdentityRepository`. `SessionRevalidator` + `SessionRetry` + `SessionPayloadParser` handle expired-session retry. - `data/pubky/PubkyClient.kt` — the single interface that wraps `pubky-core-ffi-fork`. All Pubky calls must route through this. It is a **thin** 1:1 mirror of the FFI surface (keys, mnemonics, recovery, auth, records, DHT). Do not add deck/card concepts here — those belong in repositories. The `actual` impl is `AndroidPubkyClient` (androidMain); the iOS impl (`IosPubkyClient.swift`) is still a stub awaiting framework binding. - `data/storage/` — `SecureSessionStore` interface for persisting the signed-in `Session`, backed by the platform keystore via Liftric KVault (`AndroidSecureSessionStore` wraps EncryptedSharedPreferences; `IosSecureSessionStore` wraps Keychain). This resolves the secret-storage open question — see "Non-obvious rules" below. - `di/SharedModule.kt` — Koin graph binding repos, ViewModels, and `SessionProvider`; platforms override `PubkyClient` + `SecureSessionStore` via `PlatformModule.{android,ios}.kt`. - - `presentation/` — KMP ViewModels, one per screen (`StateFlow` + `SharedFlow`). **Implemented** across `onboarding/` (`OnboardingViewModel` + UiState/Effect), `home/` (`HomeViewModel`), `decks/` (`DecksLibraryViewModel`, `DeckDetailViewModel`, `DeckEditorViewModel`, `EditCardViewModel`), `import/` (`PasteImportViewModel`, `PublishDeckViewModel`), and `profile/` (`ProfileViewModel`). Coroutines + Koin are wired (no longer blocked). + - `presentation/` — KMP ViewModels, one per screen, each extending the multiplatform `androidx.lifecycle.ViewModel` (`viewModelScope`) and exposing `StateFlow` + `SharedFlow` (see "Coding conventions" below). **Implemented** across `onboarding/` (`OnboardingViewModel` + UiState/Effect), `home/` (`HomeViewModel`), `decks/` (`DecksLibraryViewModel`, `DeckDetailViewModel`, `DeckEditorViewModel`, `EditCardViewModel`), `import/` (`PasteImportViewModel`, `PublishDeckViewModel`), and `profile/` (`ProfileViewModel`). Coroutines + Koin are wired (no longer blocked). - `shared/src/{android,ios}Main/` — `expect`/`actual` platform glue only (Pubky FFI, TTS, haptics, file I/O). Nothing else lives here. - `composeApp/src/androidMain/` — Android app. Compose screens in `ui/`, Koin in `di/`, `MainActivity` as entry point. Uses Jetpack Navigation Compose. - `iosApp/iosApp/` — iOS app. SwiftUI screens in `Views/`, `NavigationStack` in `Navigation/`, Koin bootstrap in `DI/`. Compose Multiplatform UI is **not** used for iOS screens. @@ -55,6 +55,58 @@ Kotlin lint is detekt (`config/detekt/detekt.yml`, with `detekt-formatting` + `d Root package is `com.github.jvsena42.echo`. Android namespace is `com.github.jvsena42.echo` (app) and `com.github.jvsena42.echo.shared` (library). +## Coding conventions + +Prescriptive rules, adapted from the sibling Bitkit apps' `AGENTS.md` to Echo's +shared-logic / native-UI split. These are the canonical conventions — `docs/Architecture.md` +points here rather than restating them. + +### Shared (Kotlin · `shared/commonMain`) + +- **ViewModels extend `androidx.lifecycle.ViewModel`** (the multiplatform JetBrains build) and + launch work in `viewModelScope`. Do **not** hand-roll a `CoroutineScope`/`SupervisorJob` or an + `onDispose()` — `viewModelScope` cancels in `onCleared()`. Never use `GlobalScope`; never + `runBlocking` in suspend code. +- **State:** expose `val state: StateFlow = _state.asStateFlow()`. **ALWAYS mutate with + `_state.update { … }`; NEVER `_state.value = …`** (atomic read-modify-write). Reading + `_state.value` is fine. +- **Effects:** one-shot effects (navigation, haptics, toasts, clipboard) go through a + `MutableSharedFlow(extraBufferCapacity = 4)` exposed as `SharedFlow`, separate from state. +- **UiState shape:** `sealed interface` for screens with distinct modes (Loading/Empty/Content/Error); + a single `data class` with nullable fields otherwise. Keep `UiState`/`Effect`/small helper data + classes in the same file, after the ViewModel. (Annotate with `@Immutable` only in the *Android* + layer — shared `commonMain` has no Compose dependency.) +- **Errors:** prefer `runCatching { … }.onSuccess { }.onFailure { }` / `Result` over try/catch; map + domain `AppError` into the UI state. Prefer `requireNotNull(x) { "…" }` over `!!`. +- **DI:** bind ViewModels with Koin's `viewModel { }` DSL (`org.koin.core.module.dsl.viewModel`) in + `SharedModule.kt`; repositories stay `single { }`. +- **Imports:** always import; never inline fully-qualified names (Kotlin and Swift). + +### Android (Compose · `composeApp`) + +- **Stateful/stateless split:** a `…Route` composable resolves the VM via `koinViewModel()` (NOT + `koinInject`), collects state with `collectAsStateWithLifecycle()`, and consumes effects in a + `LaunchedEffect`; it delegates to a stateless `…Screen(state, callbacks)`. Pass `viewModel::method` + references down — never the ViewModel itself. +- **No manual VM disposal.** With `koinViewModel()` + `viewModelScope`, drop the old + `DisposableEffect { onDispose { viewModel.onDispose() } }` blocks. +- **`modifier: Modifier = Modifier`** is the first optional parameter and is passed **last** at call sites. +- **Navigation goes through `NavController.navigateTo()`** (`ui/nav/NavExt.kt`), which dedups the + current destination — never raw `navController.navigate(...)`. +- **Immutable collections (recommended, not yet adopted):** prefer `ImmutableList`/`persistentListOf()` + for `UiState` list fields and Compose params, and annotate `UiState`/token data classes `@Immutable`. + `kotlinx.collections.immutable` is not yet a dependency — treat this as the target when touching state. +- No hardcoded user-facing strings — use string resources. + +### iOS (SwiftUI · `iosApp`) + +- **Consume the shared KMP ViewModels.** Do **not** introduce iOS-side `@Observable` business-logic + objects (unlike bitkit-ios) — Echo shares its VMs. Bridge `StateFlow`/`SharedFlow` → SwiftUI per the + Architecture §9.2 decision; call the VM's generated `clear()` on disappear (there is no `onDispose()`). +- Reuse the project's text/components instead of raw `Text().font().foregroundColor()` chains; use + `.task` (not `.onAppear`) for async tied to a view's lifetime; mutate state on `@MainActor`; use + self-documenting names (`isLoadingDecks`, not `loading`); comment only non-obvious "why". + ## Where to read before starting work - `docs/Architecture.md` — always. §4 (shared layering), §6 (Paste-to-Import state flow), §7 (Pubky open question), §12 (open questions blocking feature work). diff --git a/docs/Architecture.md b/docs/Architecture.md index 58f2cc0..d64513a 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -74,7 +74,9 @@ echo/ pubky-core-ffi-fork bindings ``` -Platform UI modules depend on `shared`. `shared` depends only on Kotlin stdlib, Coroutines, SQLDelight, Koin, multiplatform-settings, and (via expect/actual) the Pubky FFI. +Platform UI modules depend on `shared`. `shared` depends only on Kotlin stdlib, Coroutines, kotlinx-serialization, Koin (+ the Koin ViewModel DSL), the multiplatform `androidx.lifecycle` ViewModel, Liftric KVault, and (via expect/actual) the Pubky FFI. SQLDelight and multiplatform-settings are **not** dependencies in v1 — see §8. + +> **Note (v1 reality vs. earlier design).** This doc originally sketched a SQLDelight cache, multiplatform-settings, and SKIE. None are wired today: repositories are Pubky-only with an in-memory per-session cache, secrets persist via `SecureSessionStore` (KVault), and the Swift↔Flow bridge is still an open question. Sections below are annotated where they describe a *possible future* rather than the current build. > **Open question — UI strategy.** The working assumption is fully native UI per platform. Compose Multiplatform UI is **not** used for screens. This is not yet final; revisit before the first screen ships. See §12. @@ -92,43 +94,51 @@ Business logic (parse, triage, publish, review, follow, sign-in/out) lives on re ### 4.2 Data (Repositories) -Repositories are the only layer that talks to SQLDelight and Pubky, and they also **own the business logic**: parsing, triage, publishing, SRS grading, follow/unfollow, and session handling are all methods on the relevant repo. They expose **`Flow`s** for reads and suspend functions for writes. No UI state lives here. +Repositories are the only layer that talks to Pubky, and they also **own the business logic**: parsing, triage, publishing, SRS grading, follow/unfollow, and session handling are all methods on the relevant repo. They expose **`Flow`s** for reads and suspend functions for writes. No UI state lives here. There is **no SQLDelight in v1** — each repo keeps an in-memory per-session cache fronting `PubkyClient`. | Repository | Responsibilities | Backing | |---|---|---| -| `IdentityRepository` | Current session, pubky, capabilities, `signInWithRing()` / `signOut()` (brief §9.1) | Pubky FFI + multiplatform-settings | -| `DeckRepository` | CRUD + `publishDeck(deck, cards)` / fetch decks; enforces the "each side has at least one populated field" rule | SQLDelight + Pubky FFI | -| `CardRepository` | CRUD cards within a deck | SQLDelight | +| `IdentityRepository` | Current session, pubky, capabilities, `signInWithRing()` / `signOut()` (brief §9.1) | Pubky FFI + `SecureSessionStore` (KVault) | +| `DeckRepository` | CRUD + `publishDeck(deck, cards)` / fetch decks; enforces the "each side has at least one populated field" rule | Pubky FFI + in-memory cache | +| `CardRepository` | CRUD cards within a deck | Pubky FFI + in-memory cache | | `ImportRepository` | `parsePaste(rawText, separator, mapping)` per spec §6/§7, `applyTriageDecisions(draft, decisions)`, in-memory drafts, dedupe | In-memory | -| `TagRepository` | Read/write Pubky tags on decks (brief §9.3) | Pubky FFI | +| `TagRepository` | Read/write Pubky tags on decks (brief §9.3); trending via Nexus | Pubky FFI + Nexus REST | | `DiscoveryRepository` | Trending/followed tags, decks by followed users, `followUser()` / `unfollowUser()` (brief §9.4) | Pubky FFI | -| `SrsRepository` | Per-card SRS state, today's due queue, `reviewCard(cardId, grade)` | SQLDelight | -| `MediaRepository` | Image + audio blob storage for cards | Platform file I/O via expect/actual | +| `SrsRepository` | Per-card SRS state, today's due queue, `reviewCard(cardId, grade)` | In-memory (v1) | +| `MediaRepository` | Image + audio blob storage for cards | Pubky FFI (blobs) + platform file I/O | -All repositories are interfaces in `commonMain`. Implementations are also in `commonMain` where possible; only the FFI- and file-touching parts drop into `androidMain`/`iosMain` actuals. +All repositories are interfaces in `commonMain` with implementations in `commonMain` (`data/repository/impl/`); only the FFI- and file-touching parts drop into `androidMain`/`iosMain` actuals. ### 4.3 Presentation (ViewModels) -KMP ViewModels built on Coroutines. One per screen / sheet in brief §6 and spec §5. +KMP ViewModels extend the multiplatform `androidx.lifecycle.ViewModel` and launch work in +`viewModelScope`. One per screen / sheet in brief §6 and spec §5. ```kotlin class PasteImportViewModel( private val importRepo: ImportRepository, -) { +) : ViewModel() { private val _state = MutableStateFlow(PasteImportUiState.Empty) - val state: StateFlow = _state + val state: StateFlow = _state.asStateFlow() - fun onTextChanged(text: String) { /* debounce + parse */ } + fun onTextChanged(text: String) { + viewModelScope.launch { _state.update { /* debounce + parse */ } } + } fun onSeparatorOverride(sep: Separator) { /* re-parse */ } fun onColumnMappingChanged(mapping: ColumnMapping) { /* re-parse */ } - fun onNextClicked() { /* emit nav event */ } + fun onNextClicked() { /* emit nav effect on _effects */ } } ``` Rules: -- `UiState` is a sealed class or a single data class with nullable fields — never leak domain models raw. +- Extend `androidx.lifecycle.ViewModel`; use `viewModelScope` (cancels in `onCleared()`). Do not + hand-roll a `CoroutineScope`/`onDispose()`. +- Mutate state with `_state.update { }`, never `_state.value = …`. +- `UiState` is a `sealed interface` (modes) or a single data class with nullable fields — never leak domain models raw. - Events the UI fires are plain method calls. One-shot effects (navigation, haptics, toasts) are a separate `SharedFlow`. -- No Android or iOS imports. No `@Composable`, no `ObservableObject`. +- No UI-framework imports beyond the multiplatform `androidx.lifecycle.ViewModel`. No `@Composable`, no `ObservableObject`. + +See the **Coding conventions** section in `CLAUDE.md` for the full prescriptive ruleset (this doc points there to avoid re-drift). ViewModels that back brief §6 screens: `OnboardingVM`, `StudyQueueVM`, `StudySessionVM`, `DeckDetailVM`, `DeckEditorVM`, `DiscoverVM`, `ProfileVM`, `SettingsVM`. ViewModels that back spec §5 flows: `PasteImportVM`, `TriageVM`, `CommitDeckVM`. @@ -184,7 +194,7 @@ Ties spec §5 (UX flow) to code. Each arrow is an actual function call. │ │ │ │ │ │ │ │ │ Completes triage │ → │ nav → CommitDeckScreen │ → │ CommitDeckVM │ │ │ │ Fills metadata, Publish │ → │ onPublish(meta) │ → │ DeckRepository.publishDeck() │ → │ Pubky homeserver│ -│ │ │ │ │ → local SQLDelight cache │ → │ SQLDelight │ +│ │ │ │ │ → in-memory session cache │ │ │ │ │ │ success screen + haptic │ ← │ _state = Success(deck) │ │ │ │ │ │ │ │ │ │ │ │ Undo (within 10 s) │ → │ onUndo() │ → │ DeckRepository.delete(deck) │ → │ Pubky homeserver│ @@ -209,7 +219,7 @@ Every state listed in spec §10 maps to a single `PasteImportUiState` / `TriageU - UniFFI-generated `pubkycore.kt` is checked in at `shared/src/androidMain/kotlin/uniffi/pubkycore/pubkycore.kt` (package `uniffi.pubkycore`). - Native libraries live at `shared/src/androidMain/jniLibs/{arm64-v8a,armeabi-v7a,x86,x86_64}/libpubkycore.so`. AGP picks them up automatically and merges them into the APK. - JNA is required by the generated bindings and declared as an `@aar` dependency on `androidMain` (see `libs.versions.toml` → `jna`). -- `AndroidPubkyClient` (`shared/src/androidMain/kotlin/com/github/jvsena42/eco/data/pubky/AndroidPubkyClient.kt`) is the `PubkyClient` implementation. Blocking FFI calls are dispatched to `Dispatchers.IO`. +- `AndroidPubkyClient` (`shared/src/androidMain/kotlin/com/github/jvsena42/echo/data/pubky/AndroidPubkyClient.kt`) is the `PubkyClient` implementation. Blocking FFI calls are dispatched to `Dispatchers.IO`. ### 7.3 iOS wiring @@ -262,7 +272,7 @@ pubky-app-specs tag records (`/pub/pubky.app/tags/{id}`, id derived via the FFI ### 8.0 Homeserver layout (canonical) -Published decks live under the author's pubky, one record per card plus a manifest plus media blobs. SQLDelight is a read cache of this layout — the homeserver is the source of truth (see §8.3). +Published decks live under the author's pubky, one record per card plus a manifest plus media blobs. The homeserver is the source of truth; an in-memory per-session cache fronts it (see §8.3). A persistent SQLDelight cache is a possible future addition (§8.1). **Path layout:** @@ -343,7 +353,12 @@ On local edit: No cross-record transactions. A momentarily stale manifest vs a newer card record is tolerated — the next sync reconciles. Last-write-wins; no tombstones, no conflict resolution in v1. -### 8.1 SQLDelight schema (sketch) +### 8.1 SQLDelight schema (NOT adopted in v1 — future sketch) + +> **Status:** not in the build. v1 has no SQLDelight dependency and no local relational store — +> repos cache in memory for the session and re-fetch from Pubky. The schema below is kept only as a +> sketch for if/when a persistent offline cache is added (see §12 #3). Until then it is aspirational, +> not a description of the running app. ``` Deck( @@ -401,16 +416,21 @@ Session( ) ``` -### 8.2 multiplatform-settings +### 8.2 Preferences & secrets -Non-relational prefs: theme override, TTS voice per language, onboarding progress, last-seen snackbar timestamps. Session secret is stored here only if the platform Keychain/Keystore is not accessible via FFI — otherwise use the secure store. +multiplatform-settings is **not** wired in v1. Secrets — the signed-in `Session` — persist only +through `SecureSessionStore` (Liftric KVault → Android Keystore-backed EncryptedSharedPreferences / +iOS Keychain; see §7.5). Non-secret prefs (theme override, TTS voice, onboarding progress) are not +yet persisted; add multiplatform-settings only if/when one is needed, and never for secrets. ### 8.3 Source of truth -- **Published decks:** Pubky homeserver is canonical. SQLDelight caches the last fetched copy for offline reads. -- **Study progress (SRS):** SQLDelight is canonical; not yet synced to Pubky in v1. +- **Published decks:** Pubky homeserver is canonical. An in-memory per-session cache holds the last + fetched copy; nothing is persisted to disk in v1. +- **Study progress (SRS):** in-memory in v1; not synced to Pubky (see §12 #6). - **Import drafts:** in-memory only — each paste is a fresh canvas (spec §4 story 5). -- **Private decks:** out of scope for v1 (spec §11). If spec §13 Q1 flips, local-only decks become a first-class SQLDelight row with `pubky_uri = NULL`. +- **Private decks:** out of scope for v1 (spec §11). If spec §13 Q1 flips, local-only decks would need + a persistent store (the §8.1 SQLDelight sketch) with `pubky_uri = NULL`. --- @@ -433,11 +453,18 @@ shared/iosMain: actual platformModule() { PubkyClient, TtsEngine, Haptics, FileStore } ``` -Android bootstraps Koin in `MainActivity.onCreate`. iOS bootstraps in the `@main` `App` initializer and hands VMs to SwiftUI views via initializers. +ViewModels are bound with Koin's `viewModel { }` DSL (`org.koin.core.module.dsl.viewModel`, from +`koin-core-viewmodel`) in `SharedModule.kt`; repositories stay `single { }`. Android resolves VMs in +composables via `koinViewModel()` (`koin-compose-viewmodel`), which scopes them to the nav/backstack +lifecycle. Android bootstraps Koin in `MainActivity.onCreate`; iOS bootstraps in the `@main` `App` +initializer and hands VMs to SwiftUI views via initializers. ### 9.2 Async -Kotlin Coroutines + Flow everywhere. All public repository methods are `suspend` or return `Flow`. Swift consumes these via **SKIE** (working assumption — see §12); `@Published` wrappers are generated per VM. +Kotlin Coroutines + Flow everywhere. ViewModels launch in `viewModelScope`; all public repository +methods are `suspend` or return `Flow`. The Swift↔Flow bridge is **not yet wired** — SKIE is the +working assumption (see §12 #2) but no bridge dependency is in the build today, which is part of why +the iOS app is still inert. ### 9.3 Error handling @@ -467,8 +494,8 @@ Reserve a `Logger` interface in `commonMain` with no-op default. Platform actual - **`commonTest`** — the important tier. - `ImportRepository.parsePaste()`: one test per rule in spec §6, plus every edge case in spec §9. - - Repositories against a `FakePubkyClient` and an in-memory SQLDelight driver. - - ViewModels with [Turbine](https://github.com/cashapp/turbine) asserting state sequences for every spec §10 state. + - Repositories against a `FakePubkyClient` (no SQLDelight to fake in v1 — the cache is in-memory). + - ViewModels with [Turbine](https://github.com/cashapp/turbine) asserting state sequences for every spec §10 state. Drive the `viewModelScope` with `Dispatchers.setMain(testDispatcher)` (kotlinx-coroutines-test) rather than injecting a scope. - **Android UI** — Compose UI tests (`composeApp/androidUnitTest` or `androidInstrumentedTest`) for Paste → Triage → Commit and Study session. - **iOS UI** — XCTest snapshot tests for the same flows. - **Integration** — a minimal smoke target that exercises the real `pubky-core-ffi-fork` against a test homeserver; kept separate from the unit suite. @@ -478,9 +505,9 @@ Reserve a `Logger` interface in `commonMain` with no-op default. Platform actual ## 11. Build & tooling - **Gradle** with version catalog (`gradle/libs.versions.toml`). Kotlin, AGP, and Compose versions already pinned in the scaffold. -- **Plugins:** `org.jetbrains.kotlin.multiplatform`, `com.android.application`, `app.cash.sqldelight`, `io.insert-koin` (runtime only), Compose Multiplatform plugin for the Android-only Compose dependency. -- **iOS framework packaging:** `shared` publishes an XCFramework via the KMP `XCFramework` Gradle task; `iosApp` consumes it via SPM or direct embedding. -- **SKIE** (pending §12 decision) plugs into the `shared` Gradle build. +- **Plugins (actual):** `org.jetbrains.kotlin.multiplatform`, `com.android.library`/`com.android.application`, `org.jetbrains.kotlin.plugin.serialization`, the Compose Multiplatform + Compose-compiler plugins (Android-only Compose), and `io.gitlab.arturbosch.detekt`. Koin is a runtime dependency (no plugin). **No `app.cash.sqldelight` plugin** — SQLDelight is not adopted (§8.1). +- **iOS framework packaging:** `shared` is consumed as a static framework (`baseName = "Shared"`, `isStatic = true`) per `shared/build.gradle.kts`; an XCFramework / SPM packaging step can come later. +- **SKIE** is **not** in the build yet (pending §12 #2); it would plug into the `shared` Gradle build once the Swift↔Flow bridge is chosen. - **CI:** run `commonTest`, Android unit + Compose tests, iOS unit + snapshot tests per PR. --- From 7b3d9337766e2a62f2bd806423616e33060308c1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 18 Jun 2026 20:04:26 -0300 Subject: [PATCH 2/3] feat(nav): add navigateTo helper to dedup destinations Introduce NavController.navigateTo(route, builder) which skips navigation when the route is already the current destination, guarding against duplicate destinations from rapid taps or re-emitted navigation effects. Route all EchoNavigation call sites through it instead of raw navController.navigate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jvsena42/echo/ui/nav/EchoNavigation.kt | 40 +++++++++---------- .../com/github/jvsena42/echo/ui/nav/NavExt.kt | 15 +++++++ 2 files changed, 35 insertions(+), 20 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/NavExt.kt diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/EchoNavigation.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/EchoNavigation.kt index 6683b6d..f1f40d7 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/EchoNavigation.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/EchoNavigation.kt @@ -25,7 +25,7 @@ fun EchoNavHost() { composable(Routes.ONBOARDING) { OnboardingRoute( onNavigateHome = { - navController.navigate(Routes.MAIN) { + navController.navigateTo(Routes.MAIN) { popUpTo(Routes.ONBOARDING) { inclusive = true } } }, @@ -34,26 +34,26 @@ fun EchoNavHost() { composable(Routes.MAIN) { MainScreen( onNavigateDeckDetail = { deckId, author -> - navController.navigate(Routes.deckDetail(deckId, author)) + navController.navigateTo(Routes.deckDetail(deckId, author)) }, onNavigateCreateDeck = { // Deck creation always starts at the Paste import flow (design node h9wya). - navController.navigate(Routes.IMPORT_PASTE) + navController.navigateTo(Routes.IMPORT_PASTE) }, onNavigateImport = { - navController.navigate(Routes.IMPORT_PASTE) + navController.navigateTo(Routes.IMPORT_PASTE) }, onNavigateStudy = { deckId -> - navController.navigate(Routes.study(deckId)) + navController.navigateTo(Routes.study(deckId)) }, onNavigateProfile = { pubky -> - navController.navigate(Routes.friendProfile(pubky)) + navController.navigateTo(Routes.friendProfile(pubky)) }, onNavigateSettings = { - navController.navigate(Routes.SETTINGS) + navController.navigateTo(Routes.SETTINGS) }, onSignOut = { - navController.navigate(Routes.ONBOARDING) { + navController.navigateTo(Routes.ONBOARDING) { popUpTo(Routes.MAIN) { inclusive = true } } }, @@ -63,7 +63,7 @@ fun EchoNavHost() { SettingsRoute( onBack = { navController.popBackStack() }, onSignedOut = { - navController.navigate(Routes.ONBOARDING) { + navController.navigateTo(Routes.ONBOARDING) { popUpTo(Routes.MAIN) { inclusive = true } } }, @@ -86,8 +86,8 @@ fun EchoNavHost() { deckId = deckId, authorPubky = author, onBack = { navController.popBackStack() }, - onEditDeck = { id -> navController.navigate(Routes.deckEditor(id)) }, - onStudy = { id -> navController.navigate(Routes.study(id)) }, + onEditDeck = { id -> navController.navigateTo(Routes.deckEditor(id)) }, + onStudy = { id -> navController.navigateTo(Routes.study(id)) }, ) } composable( @@ -98,10 +98,10 @@ fun EchoNavHost() { DeckEditorRoute( deckId = deckId, onBack = { navController.popBackStack() }, - onEditCard = { dId, cId -> navController.navigate(Routes.editCard(dId, cId)) }, + onEditCard = { dId, cId -> navController.navigateTo(Routes.editCard(dId, cId)) }, onSaved = { savedDeckId -> navController.popBackStack() - navController.navigate(Routes.deckDetail(savedDeckId)) + navController.navigateTo(Routes.deckDetail(savedDeckId)) }, ) } @@ -109,24 +109,24 @@ fun EchoNavHost() { DeckEditorRoute( deckId = null, onBack = { navController.popBackStack() }, - onEditCard = { dId, cId -> navController.navigate(Routes.editCard(dId, cId)) }, + onEditCard = { dId, cId -> navController.navigateTo(Routes.editCard(dId, cId)) }, onSaved = { savedDeckId -> navController.popBackStack() - navController.navigate(Routes.deckDetail(savedDeckId)) + navController.navigateTo(Routes.deckDetail(savedDeckId)) }, ) } composable(Routes.IMPORT_PASTE) { PasteRoute( onCancel = { navController.popBackStack() }, - onNext = { navController.navigate(Routes.IMPORT_TRIAGE) }, + onNext = { navController.navigateTo(Routes.IMPORT_TRIAGE) }, ) } composable(Routes.IMPORT_TRIAGE) { TriageRoute( onBack = { navController.popBackStack() }, - onEditCard = { rowIndex -> navController.navigate(Routes.triageEditCard(rowIndex)) }, - onNext = { navController.navigate(Routes.IMPORT_PUBLISH) }, + onEditCard = { rowIndex -> navController.navigateTo(Routes.triageEditCard(rowIndex)) }, + onNext = { navController.navigateTo(Routes.IMPORT_PUBLISH) }, ) } composable( @@ -145,7 +145,7 @@ fun EchoNavHost() { onPublished = { deckId -> // Pop both import screens and navigate to deck detail navController.popBackStack(Routes.MAIN, inclusive = false) - navController.navigate(Routes.deckDetail(deckId)) + navController.navigateTo(Routes.deckDetail(deckId)) }, ) } @@ -188,7 +188,7 @@ fun EchoNavHost() { FriendProfileRoute( pubky = pubky, onBack = { navController.popBackStack() }, - onOpenDeck = { deckId -> navController.navigate(Routes.deckDetail(deckId, author = pubky)) }, + onOpenDeck = { deckId -> navController.navigateTo(Routes.deckDetail(deckId, author = pubky)) }, ) } } diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/NavExt.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/NavExt.kt new file mode 100644 index 0000000..160c503 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/nav/NavExt.kt @@ -0,0 +1,15 @@ +package com.github.jvsena42.echo.ui.nav + +import androidx.navigation.NavController +import androidx.navigation.NavOptionsBuilder + +/** + * Navigates to [route] unless it is already the current destination, guarding against + * duplicate destinations caused by rapid taps or re-emitted navigation effects. Prefer this + * over [NavController.navigate] for all in-app navigation. [builder] forwards `NavOptions` + * (e.g. `popUpTo`) for the cases that need them. + */ +fun NavController.navigateTo(route: String, builder: NavOptionsBuilder.() -> Unit = {}) { + if (currentDestination?.route == route) return + navigate(route, builder) +} From d1c408a93600e1a9340c31c3c07f2b1e36bccc66 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 18 Jun 2026 20:04:40 -0300 Subject: [PATCH 3/3] refactor: adopt androidx KMP ViewModel and standardize state updates Migrate all 15 shared ViewModels off the hand-rolled CoroutineScope/onDispose() pattern to the multiplatform androidx.lifecycle.ViewModel + viewModelScope, and standardize every state write on _state.update{} (removing the remaining _state.value= sites). viewModelScope cancels in onCleared(), so the manual disposal is gone. - deps: add androidx-lifecycle-viewmodel and koin-core-viewmodel to commonMain - DI: bind ViewModels with Koin's viewModel{} DSL in SharedModule - Android routes: resolve via koinViewModel() and drop the DisposableEffect onDispose blocks; navigation lifecycle now owns the VM - tests: drive viewModelScope via Dispatchers.setMain(StandardTestDispatcher) instead of injecting a scope Verified: detektAll clean, :shared:allTests (commonTest + iOS sim + Android unit) green, composeApp builds, and an on-emulator smoke run (onboarding -> home -> decks -> discover -> profile -> paste import) resolves every VM with no DI or lifecycle crashes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../echo/ui/components/ImagePickerSheet.kt | 5 +- .../echo/ui/decks/DeckDetailScreen.kt | 8 +-- .../echo/ui/decks/DeckEditorScreen.kt | 8 +-- .../jvsena42/echo/ui/decks/DecksScreen.kt | 8 +-- .../jvsena42/echo/ui/decks/EditCardScreen.kt | 7 +-- .../echo/ui/discover/DiscoverScreen.kt | 6 +-- .../jvsena42/echo/ui/home/HomeScreen.kt | 8 +-- .../echo/ui/importflow/PasteScreen.kt | 6 +-- .../echo/ui/importflow/PublishDeckScreen.kt | 6 +-- .../echo/ui/importflow/TriageScreen.kt | 6 +-- .../echo/ui/onboarding/OnboardingScreen.kt | 8 +-- .../echo/ui/profile/FriendProfileScreen.kt | 6 +-- .../jvsena42/echo/ui/profile/ProfileScreen.kt | 6 +-- .../echo/ui/settings/SettingsScreen.kt | 6 +-- .../echo/ui/study/StudySessionScreen.kt | 8 +-- gradle/libs.versions.toml | 2 + shared/build.gradle.kts | 4 ++ .../github/jvsena42/echo/di/SharedModule.kt | 31 +++++------ .../presentation/decks/DeckDetailViewModel.kt | 50 +++++++---------- .../presentation/decks/DeckEditorViewModel.kt | 30 ++++------- .../decks/DecksLibraryViewModel.kt | 38 +++++-------- .../presentation/decks/EditCardViewModel.kt | 34 ++++-------- .../discover/DiscoverViewModel.kt | 42 ++++++--------- .../echo/presentation/home/HomeViewModel.kt | 42 ++++++--------- .../importflow/PasteImportViewModel.kt | 23 +++----- .../importflow/PublishDeckViewModel.kt | 28 +++------- .../importflow/TriageViewModel.kt | 24 +++------ .../presentation/media/ImageSheetViewModel.kt | 21 ++------ .../onboarding/OnboardingViewModel.kt | 54 ++++++++----------- .../profile/FriendProfileViewModel.kt | 26 +++------ .../presentation/profile/ProfileViewModel.kt | 26 +++------ .../settings/SettingsViewModel.kt | 24 +++------ .../study/StudySessionViewModel.kt | 46 +++++++--------- .../discover/DiscoverViewModelTest.kt | 22 ++++++-- .../presentation/home/HomeViewModelTest.kt | 21 +++++++- .../importflow/PublishDeckViewModelTest.kt | 22 ++++++-- .../study/StudySessionViewModelTest.kt | 22 ++++++-- 37 files changed, 300 insertions(+), 434 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt index 42513f7..2f1e63e 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/components/ImagePickerSheet.kt @@ -37,7 +37,6 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -65,6 +64,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel /** * Reusable bottom sheet for choosing an image — web search (Unsplash) + a 3-column grid, plus a @@ -81,9 +81,8 @@ fun ImagePickerSheet( onSelected: (ImageSelection) -> Unit, ) { val colors = EchoTheme.colors - val viewModel = koinInject() + val viewModel = koinViewModel() val mediaProcessor = koinInject() - DisposableEffect(viewModel) { onDispose { viewModel.onDispose() } } val state by viewModel.state.collectAsStateWithLifecycle() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt index f93a1cb..6ef9b32 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckDetailScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState @@ -61,7 +60,7 @@ import com.github.jvsena42.echo.ui.components.StatsBar import com.github.jvsena42.echo.ui.components.TagChip import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @Composable @@ -72,10 +71,7 @@ fun DeckDetailRoute( onEditDeck: (String) -> Unit = {}, onStudy: (String) -> Unit = {}, ) { - val viewModel = koinInject { parametersOf(deckId, authorPubky) } - DisposableEffect(viewModel) { - onDispose { viewModel.onDispose() } - } + val viewModel = koinViewModel { parametersOf(deckId, authorPubky) } val currentBack by rememberUpdatedState(onBack) val currentEditDeck by rememberUpdatedState(onEditDeck) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckEditorScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckEditorScreen.kt index ec7c74d..5b13468 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckEditorScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DeckEditorScreen.kt @@ -42,7 +42,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState @@ -65,7 +64,7 @@ import com.github.jvsena42.echo.presentation.decks.EditableCardModel import com.github.jvsena42.echo.ui.components.TagChip import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @Composable @@ -75,10 +74,7 @@ fun DeckEditorRoute( onEditCard: (deckId: String, cardId: String) -> Unit = { _, _ -> }, onSaved: (deckId: String) -> Unit = {}, ) { - val viewModel = koinInject { parametersOf(deckId) } - DisposableEffect(viewModel) { - onDispose { viewModel.onDispose() } - } + val viewModel = koinViewModel { parametersOf(deckId) } val currentBack by rememberUpdatedState(onBack) val currentEditCard by rememberUpdatedState(onEditCard) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DecksScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DecksScreen.kt index cf291aa..066ecba 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DecksScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/DecksScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState @@ -53,7 +52,7 @@ import com.github.jvsena42.echo.ui.components.EchoLoadingScreen import com.github.jvsena42.echo.ui.components.EchoPrimaryButton import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel @Composable fun DecksRoute( @@ -61,10 +60,7 @@ fun DecksRoute( onImportClick: () -> Unit = {}, onCreateDeckClick: () -> Unit = {}, ) { - val viewModel = koinInject() - DisposableEffect(viewModel) { - onDispose { viewModel.onDispose() } - } + val viewModel = koinViewModel() val currentDeckClick by rememberUpdatedState(onDeckClick) val currentImportClick by rememberUpdatedState(onImportClick) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt index 357e543..a14d5fd 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/decks/EditCardScreen.kt @@ -36,7 +36,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -64,6 +63,7 @@ import com.github.jvsena42.echo.ui.components.TagChip import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @Composable @@ -72,11 +72,8 @@ fun EditCardRoute( cardId: String, onBack: () -> Unit = {}, ) { - val viewModel = koinInject { parametersOf(deckId, cardId) } + val viewModel = koinViewModel { parametersOf(deckId, cardId) } val speaker = koinInject() - DisposableEffect(viewModel) { - onDispose { viewModel.onDispose() } - } val currentBack by rememberUpdatedState(onBack) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/discover/DiscoverScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/discover/DiscoverScreen.kt index 0ac3155..12a14a5 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/discover/DiscoverScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/discover/DiscoverScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -63,15 +62,14 @@ import com.github.jvsena42.echo.ui.components.EchoLoadingScreen import com.github.jvsena42.echo.ui.components.TagChip import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel @Composable fun DiscoverRoute( onOpenProfile: (String) -> Unit = {}, onOpenDeck: (deckId: String, author: String?) -> Unit = { _, _ -> }, ) { - val viewModel = koinInject() - DisposableEffect(viewModel) { onDispose { viewModel.onDispose() } } + val viewModel = koinViewModel() val currentOpenProfile by rememberUpdatedState(onOpenProfile) val currentOpenDeck by rememberUpdatedState(onOpenDeck) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/home/HomeScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/home/HomeScreen.kt index 7b77ca0..f14e149 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/home/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/home/HomeScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState @@ -36,7 +35,7 @@ import com.github.jvsena42.echo.presentation.home.HomeViewModel import com.github.jvsena42.echo.ui.components.EchoLoadingScreen import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel @Composable fun HomeRoute( @@ -46,10 +45,7 @@ fun HomeRoute( onOpenDeck: (String) -> Unit = {}, onSignedOut: () -> Unit = {}, ) { - val viewModel = koinInject() - DisposableEffect(viewModel) { - onDispose { viewModel.onDispose() } - } + val viewModel = koinViewModel() val currentCreate by rememberUpdatedState(onCreateDeck) val currentBrowse by rememberUpdatedState(onBrowseExamples) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PasteScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PasteScreen.kt index b5b7e45..e5c00b0 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PasteScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PasteScreen.kt @@ -35,7 +35,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState @@ -60,15 +59,14 @@ import com.github.jvsena42.echo.presentation.importflow.PreviewCard import com.github.jvsena42.echo.ui.components.EchoPrimaryButton import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel @Composable fun PasteRoute( onCancel: () -> Unit = {}, onNext: () -> Unit = {}, ) { - val viewModel = koinInject() - DisposableEffect(viewModel) { onDispose { viewModel.onDispose() } } + val viewModel = koinViewModel() val currentCancel by rememberUpdatedState(onCancel) val currentNext by rememberUpdatedState(onNext) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt index e332a69..2b5dc12 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/PublishDeckScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -72,15 +71,14 @@ import com.github.jvsena42.echo.ui.components.ImageSelection import com.github.jvsena42.echo.ui.components.TagChip import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel @Composable fun PublishDeckRoute( onBack: () -> Unit = {}, onPublished: (deckId: String) -> Unit = {}, ) { - val viewModel = koinInject() - DisposableEffect(viewModel) { onDispose { viewModel.onDispose() } } + val viewModel = koinViewModel() val currentBack by rememberUpdatedState(onBack) val currentPublished by rememberUpdatedState(onPublished) diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt index af47399..003774f 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/importflow/TriageScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState @@ -54,7 +53,7 @@ import com.github.jvsena42.echo.presentation.importflow.TriageUiState import com.github.jvsena42.echo.presentation.importflow.TriageViewModel import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel @Composable fun TriageRoute( @@ -62,8 +61,7 @@ fun TriageRoute( onEditCard: (Int) -> Unit = {}, onNext: () -> Unit = {}, ) { - val viewModel = koinInject() - DisposableEffect(viewModel) { onDispose { viewModel.onDispose() } } + val viewModel = koinViewModel() // Re-read the draft each time this screen resumes (e.g. after editing a card). LaunchedEffect(viewModel) { viewModel.refresh() } diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/onboarding/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/onboarding/OnboardingScreen.kt index 951c096..b9a99c6 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/onboarding/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/onboarding/OnboardingScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState @@ -49,14 +48,11 @@ import com.github.jvsena42.echo.presentation.onboarding.OnboardingViewModel import com.github.jvsena42.echo.ui.components.EchoPrimaryButton import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel @Composable fun OnboardingRoute(onNavigateHome: () -> Unit) { - val viewModel = koinInject() - DisposableEffect(viewModel) { - onDispose { viewModel.onDispose() } - } + val viewModel = koinViewModel() OnboardingScreen( viewModel = viewModel, onNavigateHome = onNavigateHome, diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/profile/FriendProfileScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/profile/FriendProfileScreen.kt index 07d1307..84b9618 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/profile/FriendProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/profile/FriendProfileScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState @@ -50,7 +49,7 @@ import com.github.jvsena42.echo.ui.components.DeckTile import com.github.jvsena42.echo.ui.components.EchoLoadingScreen import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @Composable @@ -59,8 +58,7 @@ fun FriendProfileRoute( onBack: () -> Unit = {}, onOpenDeck: (String) -> Unit = {}, ) { - val viewModel = koinInject { parametersOf(pubky) } - DisposableEffect(viewModel) { onDispose { viewModel.onDispose() } } + val viewModel = koinViewModel { parametersOf(pubky) } val currentOpenDeck by rememberUpdatedState(onOpenDeck) val clipboard = LocalClipboardManager.current diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/profile/ProfileScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/profile/ProfileScreen.kt index f3547d6..8be1b67 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/profile/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/profile/ProfileScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -69,15 +68,14 @@ import com.github.jvsena42.echo.ui.components.EchoLoadingScreen import com.github.jvsena42.echo.ui.components.EchoPrimaryButton import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel @Composable fun ProfileRoute( onSignedOut: () -> Unit = {}, onOpenSettings: () -> Unit = {}, ) { - val viewModel = koinInject() - DisposableEffect(viewModel) { onDispose { viewModel.onDispose() } } + val viewModel = koinViewModel() val currentSignedOut by rememberUpdatedState(onSignedOut) var errorMessage by remember { mutableStateOf(null) } diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/settings/SettingsScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/settings/SettingsScreen.kt index 3d1c15b..df96310 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/settings/SettingsScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/settings/SettingsScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -59,7 +58,7 @@ import com.github.jvsena42.echo.presentation.settings.SettingsViewModel import com.github.jvsena42.echo.ui.components.EchoLoadingScreen import com.github.jvsena42.echo.ui.theme.EchoTheme import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @Composable @@ -73,8 +72,7 @@ fun SettingsRoute( context.packageManager.getPackageInfo(context.packageName, 0).versionName }.getOrNull().orEmpty() } - val viewModel = koinInject { parametersOf(appVersion) } - DisposableEffect(viewModel) { onDispose { viewModel.onDispose() } } + val viewModel = koinViewModel { parametersOf(appVersion) } val currentSignedOut by rememberUpdatedState(onSignedOut) val clipboard = LocalClipboardManager.current diff --git a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/StudySessionScreen.kt b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/StudySessionScreen.kt index 9bc90a2..25a18b2 100644 --- a/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/StudySessionScreen.kt +++ b/composeApp/src/androidMain/kotlin/com/github/jvsena42/echo/ui/study/StudySessionScreen.kt @@ -45,7 +45,6 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -88,6 +87,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @Composable @@ -95,14 +95,10 @@ fun StudySessionRoute( deckId: String?, onClose: () -> Unit = {}, ) { - val viewModel = koinInject { parametersOf(deckId) } + val viewModel = koinViewModel { parametersOf(deckId) } val speaker = koinInject() val speechRecognizer = koinInject() - DisposableEffect(viewModel) { - onDispose { viewModel.onDispose() } - } - val currentClose by rememberUpdatedState(onClose) val context = LocalContext.current val scope = rememberCoroutineScope() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 235ad5d..e16ba6b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", vers androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } +androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } @@ -53,6 +54,7 @@ koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } +koin-core-viewmodel = { module = "io.insert-koin:koin-core-viewmodel", version.ref = "koin" } androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } kvault = { module = "com.liftric:kvault", version.ref = "kvault" } play-services-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "playServicesCodeScanner" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 0f34372..8c4e553 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -28,6 +28,10 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) api(libs.koin.core) + implementation(libs.koin.core.viewmodel) + // `api` so the ViewModel type stays visible to the platform UI layers (and the + // exported iOS framework) that consume the shared ViewModels. + api(libs.androidx.lifecycle.viewmodel) implementation(libs.kvault) } commonTest.dependencies { diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/di/SharedModule.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/di/SharedModule.kt index 9c06c3c..f0027b0 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/di/SharedModule.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/di/SharedModule.kt @@ -36,6 +36,7 @@ import com.github.jvsena42.echo.presentation.profile.FriendProfileViewModel import com.github.jvsena42.echo.presentation.profile.ProfileViewModel import com.github.jvsena42.echo.presentation.settings.SettingsViewModel import com.github.jvsena42.echo.presentation.study.StudySessionViewModel +import org.koin.core.module.dsl.viewModel import org.koin.dsl.module /** @@ -69,10 +70,10 @@ val sharedModule = module { TagRepositoryImpl(pubky = get(), session = get(), revalidator = get(), nexus = get()) } - factory { OnboardingViewModel(identityRepository = get()) } - factory { HomeViewModel(identityRepository = get(), deckRepository = get(), srsRepository = get()) } - factory { DecksLibraryViewModel(deckRepository = get(), identityRepository = get()) } - factory { params -> + viewModel { OnboardingViewModel(identityRepository = get()) } + viewModel { HomeViewModel(identityRepository = get(), deckRepository = get(), srsRepository = get()) } + viewModel { DecksLibraryViewModel(deckRepository = get(), identityRepository = get()) } + viewModel { params -> DeckDetailViewModel( deckId = params.get(0), authorPubky = params.values.getOrNull(1) as? String, @@ -82,8 +83,8 @@ val sharedModule = module { srsRepository = get(), ) } - factory { params -> StudySessionViewModel(deckId = params.getOrNull(), srsRepository = get(), deckRepository = get()) } - factory { params -> + viewModel { params -> StudySessionViewModel(deckId = params.getOrNull(), srsRepository = get(), deckRepository = get()) } + viewModel { params -> DeckEditorViewModel( deckId = params.getOrNull(), deckRepository = get(), @@ -91,7 +92,7 @@ val sharedModule = module { identityRepository = get(), ) } - factory { params -> + viewModel { params -> EditCardViewModel( deckId = params.get(0), cardId = params.get(1), @@ -100,10 +101,10 @@ val sharedModule = module { mediaRepository = get(), ) } - factory { PasteImportViewModel(importRepository = get()) } - factory { TriageViewModel(importRepository = get()) } - factory { ImageSheetViewModel(unsplashClient = get()) } - factory { + viewModel { PasteImportViewModel(importRepository = get()) } + viewModel { TriageViewModel(importRepository = get()) } + viewModel { ImageSheetViewModel(unsplashClient = get()) } + viewModel { PublishDeckViewModel( importRepository = get(), deckRepository = get(), @@ -111,10 +112,10 @@ val sharedModule = module { mediaRepository = get(), ) } - factory { ProfileViewModel(identityRepository = get(), deckRepository = get()) } - factory { params -> SettingsViewModel(identityRepository = get(), appVersion = params.getOrNull() ?: "") } - factory { DiscoverViewModel(discoveryRepository = get(), tagRepository = get()) } - factory { params -> + viewModel { ProfileViewModel(identityRepository = get(), deckRepository = get()) } + viewModel { params -> SettingsViewModel(identityRepository = get(), appVersion = params.getOrNull() ?: "") } + viewModel { DiscoverViewModel(discoveryRepository = get(), tagRepository = get()) } + viewModel { params -> FriendProfileViewModel( targetPubky = params.get(), identityRepository = get(), diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckDetailViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckDetailViewModel.kt index 8b39448..1a0bf53 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckDetailViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckDetailViewModel.kt @@ -1,5 +1,7 @@ package com.github.jvsena42.echo.presentation.decks +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.repository.CardRepository import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.IdentityRepository @@ -7,17 +9,14 @@ import com.github.jvsena42.echo.data.repository.SrsRepository import com.github.jvsena42.echo.domain.model.Card import com.github.jvsena42.echo.domain.model.Deck import com.github.jvsena42.echo.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @Suppress("LongParameterList") @@ -28,11 +27,7 @@ class DeckDetailViewModel( private val cardRepository: CardRepository, private val identityRepository: IdentityRepository, private val srsRepository: SrsRepository, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(DeckDetailUiState.Loading) val state: StateFlow = _state.asStateFlow() @@ -49,9 +44,9 @@ class DeckDetailViewModel( private fun load() { if (loadJob?.isActive == true) return - loadJob = scope.launch { + loadJob = viewModelScope.launch { Log.d(TAG, "load: deckId=$deckId") - _state.value = DeckDetailUiState.Loading + _state.update { DeckDetailUiState.Loading } val session = runCatching { identityRepository.currentSession() }.getOrNull() ?: runCatching { identityRepository.loadPersistedSession() }.getOrNull() @@ -67,7 +62,7 @@ class DeckDetailViewModel( .getOrNull() } if (deck == null) { - _state.value = DeckDetailUiState.Error("Deck not found.") + _state.update { DeckDetailUiState.Error("Deck not found.") } return@launch } @@ -76,68 +71,63 @@ class DeckDetailViewModel( val dueCount = runCatching { srsRepository.dueForDeck(deckId).size } .getOrDefault(0) val mastered = masteredPercent(cards) - _state.value = deck.toContent(cards, myPubky, dueCount, mastered) + _state.update { deck.toContent(cards, myPubky, dueCount, mastered) } Log.d(TAG, "load: cards=${cards.size} due=$dueCount mastered=$mastered") } .onFailure { err -> Log.e(TAG, "load: FAILED — ${err::class.simpleName}: ${err.message}", err) - _state.value = DeckDetailUiState.Error( + _state.update { DeckDetailUiState.Error( err.message ?: "Could not load deck.", - ) + ) } } } } fun onBackClick() { - scope.launch { _effects.emit(DeckDetailEffect.NavigateBack) } + viewModelScope.launch { _effects.emit(DeckDetailEffect.NavigateBack) } } fun onShareClick() { - scope.launch { + viewModelScope.launch { val deck = deckRepository.getLocal(deckId) ?: return@launch _effects.emit(DeckDetailEffect.Share(deck.pubkyUri.value)) } } fun onStudyClick() { - scope.launch { _effects.emit(DeckDetailEffect.NavigateStudy) } + viewModelScope.launch { _effects.emit(DeckDetailEffect.NavigateStudy) } } fun onEditClick() { - scope.launch { _effects.emit(DeckDetailEffect.NavigateEditDeck(deckId)) } + viewModelScope.launch { _effects.emit(DeckDetailEffect.NavigateEditDeck(deckId)) } } fun onDeleteDeck() { val current = _state.value as? DeckDetailUiState.Content ?: return - _state.value = current.copy(showDeleteConfirm = true) + _state.update { current.copy(showDeleteConfirm = true) } } fun onDismissDelete() { val current = _state.value as? DeckDetailUiState.Content ?: return - _state.value = current.copy(showDeleteConfirm = false) + _state.update { current.copy(showDeleteConfirm = false) } } fun onConfirmDelete() { val current = _state.value as? DeckDetailUiState.Content ?: return - _state.value = current.copy(showDeleteConfirm = false, isDeleting = true) - scope.launch { + _state.update { current.copy(showDeleteConfirm = false, isDeleting = true) } + viewModelScope.launch { Log.d(TAG, "onConfirmDelete: deckId=$deckId") deckRepository.delete(deckId) .onSuccess { _effects.emit(DeckDetailEffect.Deleted) } .onFailure { err -> Log.e(TAG, "onConfirmDelete: FAILED — ${err::class.simpleName}: ${err.message}", err) - _state.value = DeckDetailUiState.Error( + _state.update { DeckDetailUiState.Error( err.message ?: "Could not delete deck.", - ) + ) } } } } - fun onDispose() { - loadJob?.cancel() - scope.cancel() - } - /** * Share of cards whose review interval has reached SM-2's "mature" threshold. * `dueForDeck` has already warmed the per-session SRS cache, so [SrsRepository.stateFor] diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckEditorViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckEditorViewModel.kt index 15a3be4..fce864d 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckEditorViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DeckEditorViewModel.kt @@ -1,5 +1,7 @@ package com.github.jvsena42.echo.presentation.decks +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.repository.CardRepository import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.IdentityRepository @@ -10,11 +12,7 @@ import com.github.jvsena42.echo.domain.model.Deck import com.github.jvsena42.echo.domain.model.Tag import com.github.jvsena42.echo.util.Log import com.github.jvsena42.echo.util.epochMillis -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -29,11 +27,7 @@ class DeckEditorViewModel( private val deckRepository: DeckRepository, private val cardRepository: CardRepository, private val identityRepository: IdentityRepository, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(DeckEditorUiState()) val state: StateFlow = _state.asStateFlow() @@ -48,18 +42,18 @@ class DeckEditorViewModel( } private fun loadExisting() { - loadJob = scope.launch { + loadJob = viewModelScope.launch { Log.d(TAG, "loadExisting: deckId=$deckId") val deck = deckRepository.getLocal(deckId!!) ?: return@launch val cards = runCatching { cardRepository.listByDeck(deckId) }.getOrElse { emptyList() } - _state.value = DeckEditorUiState( + _state.update { DeckEditorUiState( isNew = false, coverEmoji = deck.coverEmoji ?: deck.title.firstOrNull()?.toString() ?: "", title = deck.title, description = deck.description ?: "", tags = deck.tags.map { it.value }, cards = cards.map { it.toEditable() }, - ) + ) } } } @@ -98,11 +92,11 @@ class DeckEditorViewModel( fun onCardClick(cardId: String) { val currentDeckId = deckId ?: return - scope.launch { _effects.emit(DeckEditorEffect.NavigateEditCard(currentDeckId, cardId)) } + viewModelScope.launch { _effects.emit(DeckEditorEffect.NavigateEditCard(currentDeckId, cardId)) } } fun onCloseClick() { - scope.launch { _effects.emit(DeckEditorEffect.NavigateBack) } + viewModelScope.launch { _effects.emit(DeckEditorEffect.NavigateBack) } } fun onSaveClick() { @@ -118,7 +112,7 @@ class DeckEditorViewModel( _state.update { it.copy(titleError = titleError, descriptionError = descriptionError) } return } - saveJob = scope.launch { + saveJob = viewModelScope.launch { _state.update { it.copy(isSaving = true, error = null) } Log.d(TAG, "save: title=${s.title}, cards=${s.cards.size}") @@ -167,12 +161,6 @@ class DeckEditorViewModel( } } - fun onDispose() { - loadJob?.cancel() - saveJob?.cancel() - scope.cancel() - } - private fun titleErrorFor(text: String): String? = if (text.length > TITLE_MAX_LENGTH) "Title must be $TITLE_MAX_LENGTH characters or fewer." else null diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DecksLibraryViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DecksLibraryViewModel.kt index 944d8fc..91e5da4 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DecksLibraryViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/DecksLibraryViewModel.kt @@ -1,30 +1,25 @@ package com.github.jvsena42.echo.presentation.decks +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.IdentityRepository import com.github.jvsena42.echo.domain.model.Deck import com.github.jvsena42.echo.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class DecksLibraryViewModel( private val deckRepository: DeckRepository, private val identityRepository: IdentityRepository, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(DecksLibraryUiState.Loading) val state: StateFlow = _state.asStateFlow() @@ -41,9 +36,9 @@ class DecksLibraryViewModel( private fun load() { if (loadJob?.isActive == true) return - loadJob = scope.launch { + loadJob = viewModelScope.launch { Log.d(TAG, "load: fetching decks") - _state.value = DecksLibraryUiState.Loading + _state.update { DecksLibraryUiState.Loading } val session = runCatching { identityRepository.currentSession() }.getOrNull() ?: runCatching { identityRepository.loadPersistedSession() }.getOrNull() val myPubky = session?.identity?.pubky @@ -51,39 +46,34 @@ class DecksLibraryViewModel( runCatching { deckRepository.listOwned() } .onSuccess { decks -> if (decks.isEmpty()) { - _state.value = DecksLibraryUiState.Empty + _state.update { DecksLibraryUiState.Empty } } else { - _state.value = DecksLibraryUiState.Content( + _state.update { DecksLibraryUiState.Content( deckCount = decks.size, decks = decks.map { it.toTileModel(myPubky) }, - ) + ) } } Log.d(TAG, "load: decks=${decks.size}") } .onFailure { err -> Log.e(TAG, "load: FAILED — ${err::class.simpleName}: ${err.message}", err) - _state.value = DecksLibraryUiState.Error( + _state.update { DecksLibraryUiState.Error( message = err.message ?: "Could not load decks.", - ) + ) } } } } fun onDeckClick(deckId: String) { - scope.launch { _effects.emit(DecksLibraryEffect.NavigateDeckDetail(deckId)) } + viewModelScope.launch { _effects.emit(DecksLibraryEffect.NavigateDeckDetail(deckId)) } } fun onImportClick() { - scope.launch { _effects.emit(DecksLibraryEffect.NavigateImport) } + viewModelScope.launch { _effects.emit(DecksLibraryEffect.NavigateImport) } } fun onCreateDeckClick() { - scope.launch { _effects.emit(DecksLibraryEffect.NavigateCreateDeck) } - } - - fun onDispose() { - loadJob?.cancel() - scope.cancel() + viewModelScope.launch { _effects.emit(DecksLibraryEffect.NavigateCreateDeck) } } private fun Deck.toTileModel(myPubky: String?): DeckTileModel = DeckTileModel( diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt index 693f9c2..2aa03c8 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/decks/EditCardViewModel.kt @@ -1,5 +1,7 @@ package com.github.jvsena42.echo.presentation.decks +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.repository.CardRepository import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.MediaRepository @@ -8,11 +10,7 @@ import com.github.jvsena42.echo.domain.model.CardSide import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.util.Log import com.github.jvsena42.echo.util.epochMillis -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -29,11 +27,7 @@ class EditCardViewModel( private val cardRepository: CardRepository, private val deckRepository: DeckRepository, private val mediaRepository: MediaRepository, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(EditCardUiState()) val state: StateFlow = _state.asStateFlow() @@ -48,7 +42,7 @@ class EditCardViewModel( } private fun load() { - loadJob = scope.launch { + loadJob = viewModelScope.launch { Log.d(TAG, "load: deckId=$deckId cardId=$cardId") val deck = deckRepository.getLocal(deckId) val card = cardRepository.get(deckId, cardId) @@ -58,7 +52,7 @@ class EditCardViewModel( } val cardIndex = deck?.cardIndex?.indexOfFirst { it.id == cardId }?.plus(1) ?: 0 val totalCards = deck?.cardCount ?: 0 - _state.value = EditCardUiState( + _state.update { EditCardUiState( deckTitle = deck?.title ?: "", cardIndex = cardIndex, totalCards = totalCards, @@ -68,7 +62,7 @@ class EditCardViewModel( backImageRef = card.back.imageRef, hasImage = card.front.imageRef != null || card.back.imageRef != null, hasAudio = card.front.audioRef != null || card.back.audioRef != null, - ) + ) } } } @@ -115,14 +109,14 @@ class EditCardViewModel( fun onSpeakFront() { val text = _state.value.frontText if (text.isNotBlank()) { - scope.launch { _effects.emit(EditCardEffect.Speak(text)) } + viewModelScope.launch { _effects.emit(EditCardEffect.Speak(text)) } } } fun onSpeakBack() { val text = _state.value.backText if (text.isNotBlank()) { - scope.launch { _effects.emit(EditCardEffect.Speak(text)) } + viewModelScope.launch { _effects.emit(EditCardEffect.Speak(text)) } } } @@ -149,7 +143,7 @@ class EditCardViewModel( _state.update { it.copy(frontError = frontError, backError = backError) } return } - saveJob = scope.launch { + saveJob = viewModelScope.launch { _state.update { it.copy(isSaving = true, error = null) } Log.d(TAG, "save: cardId=$cardId") @@ -187,7 +181,7 @@ class EditCardViewModel( } fun onDeleteCard() { - scope.launch { + viewModelScope.launch { Log.d(TAG, "delete: cardId=$cardId") cardRepository.delete(deckId, cardId) .onSuccess { _effects.emit(EditCardEffect.Deleted) } @@ -198,7 +192,7 @@ class EditCardViewModel( } fun onCancelClick() { - scope.launch { _effects.emit(EditCardEffect.NavigateBack) } + viewModelScope.launch { _effects.emit(EditCardEffect.NavigateBack) } } /** Upload a pending gallery image, or keep the chosen web/existing ref. */ @@ -221,12 +215,6 @@ class EditCardViewModel( else -> s.backImageRef } - fun onDispose() { - loadJob?.cancel() - saveJob?.cancel() - scope.cancel() - } - private fun cardTextErrorFor(text: String): String? = if (text.length > CARD_TEXT_MAX_LENGTH) "Card text must be $CARD_TEXT_MAX_LENGTH characters or fewer." else null diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/discover/DiscoverViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/discover/DiscoverViewModel.kt index 347f3b4..affb6ef 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/discover/DiscoverViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/discover/DiscoverViewModel.kt @@ -1,21 +1,20 @@ package com.github.jvsena42.echo.presentation.discover +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.repository.DiscoveryRepository import com.github.jvsena42.echo.data.repository.TagRepository import com.github.jvsena42.echo.domain.model.Deck import com.github.jvsena42.echo.domain.model.Tag import com.github.jvsena42.echo.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -26,11 +25,7 @@ import kotlinx.coroutines.launch class DiscoverViewModel( private val discoveryRepository: DiscoveryRepository, private val tagRepository: TagRepository, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(DiscoverUiState.Loading) val state: StateFlow = _state.asStateFlow() @@ -50,37 +45,37 @@ class DiscoverViewModel( private fun load() { if (loadJob?.isActive == true) return - loadJob = scope.launch { - _state.value = DiscoverUiState.Loading + loadJob = viewModelScope.launch { + _state.update { DiscoverUiState.Loading } runCatching { discoveryRepository.decksFromFollowing() } .onSuccess { decks -> feed = decks if (decks.isEmpty()) { - _state.value = DiscoverUiState.Empty + _state.update { DiscoverUiState.Empty } } else { - _state.value = DiscoverUiState.Content( + _state.update { DiscoverUiState.Content( tags = decks.flatMap { it.tags }.distinct(), trendingTags = emptyList(), selectedTag = null, decks = decks.map { it.toCard() }, - ) + ) } loadTrending() } Log.d(TAG, "load: feed=${decks.size}") } .onFailure { err -> Log.e(TAG, "load: FAILED — ${err.message}", err) - _state.value = DiscoverUiState.Error(err.message ?: "Could not load Discover.") + _state.update { DiscoverUiState.Error(err.message ?: "Could not load Discover.") } } } } /** Trending arrives after first paint — the feed never waits on the indexer. */ private fun loadTrending() { - scope.launch { + viewModelScope.launch { val trending = tagRepository.trending() val current = _state.value as? DiscoverUiState.Content ?: return@launch - _state.value = current.copy(trendingTags = trending - current.tags.toSet()) + _state.update { current.copy(trendingTags = trending - current.tags.toSet()) } } } @@ -88,24 +83,19 @@ class DiscoverViewModel( val current = _state.value as? DiscoverUiState.Content ?: return val next = if (tag == current.selectedTag) null else tag val filtered = if (next == null) feed else feed.filter { next in it.tags } - _state.value = current.copy(selectedTag = next, decks = filtered.map { it.toCard() }) + _state.update { current.copy(selectedTag = next, decks = filtered.map { it.toCard() }) } } fun onAddFriend() { - scope.launch { _effects.emit(DiscoverEffect.OpenAddFriend) } + viewModelScope.launch { _effects.emit(DiscoverEffect.OpenAddFriend) } } fun onOpenAuthor(pubky: String) { - scope.launch { _effects.emit(DiscoverEffect.OpenProfile(pubky)) } + viewModelScope.launch { _effects.emit(DiscoverEffect.OpenProfile(pubky)) } } fun onOpenDeck(authorPubky: String, deckId: String) { - scope.launch { _effects.emit(DiscoverEffect.OpenDeck(authorPubky, deckId)) } - } - - fun onDispose() { - loadJob?.cancel() - scope.cancel() + viewModelScope.launch { _effects.emit(DiscoverEffect.OpenDeck(authorPubky, deckId)) } } private fun Deck.toCard(): DiscoverDeck = DiscoverDeck( diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/home/HomeViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/home/HomeViewModel.kt index b4e0341..23cde5d 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/home/HomeViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/home/HomeViewModel.kt @@ -1,33 +1,28 @@ package com.github.jvsena42.echo.presentation.home +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.pubky.requiresReauth import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.IdentityRepository import com.github.jvsena42.echo.data.repository.SrsRepository import com.github.jvsena42.echo.domain.model.Deck import com.github.jvsena42.echo.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class HomeViewModel( private val identityRepository: IdentityRepository, private val deckRepository: DeckRepository, private val srsRepository: SrsRepository, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(HomeUiState.Loading) val state: StateFlow = _state.asStateFlow() @@ -44,9 +39,9 @@ class HomeViewModel( private fun load() { if (loadJob?.isActive == true) return - loadJob = scope.launch { + loadJob = viewModelScope.launch { Log.d(TAG, "load: fetching session + decks") - _state.value = HomeUiState.Loading + _state.update { HomeUiState.Loading } val session = runCatching { identityRepository.currentSession() }.getOrNull() ?: runCatching { identityRepository.loadPersistedSession() }.getOrNull() val greetingName = session?.identity?.displayName?.takeIf { it.isNotBlank() } @@ -55,7 +50,7 @@ class HomeViewModel( runCatching { deckRepository.listOwned() } .onSuccess { decks -> - _state.value = if (decks.isEmpty()) { + _state.update { if (decks.isEmpty()) { HomeUiState.Empty(greetingName) } else { val dueByDeck = runCatching { srsRepository.dueToday() } @@ -70,7 +65,7 @@ class HomeViewModel( doneToday = 0, decks = decks.map { it.toSummary(dueByDeck[it.id] ?: 0) }, ) - } + } } Log.d(TAG, "load: decks=${decks.size}") } .onFailure { err -> @@ -78,40 +73,35 @@ class HomeViewModel( if (err.requiresReauth()) { Log.d(TAG, "load: session expired — signing out") runCatching { identityRepository.signOut() } - _state.value = HomeUiState.Error( + _state.update { HomeUiState.Error( greetingName = greetingName, message = "Your session expired. Please sign in again.", - ) + ) } _effects.emit(HomeEffect.NavigateToOnboarding) } else { - _state.value = HomeUiState.Error( + _state.update { HomeUiState.Error( greetingName = greetingName, message = err.message ?: "Could not load decks.", - ) + ) } } } } } fun onStartStudyClick() { - scope.launch { _effects.emit(HomeEffect.NavigateStartStudy) } + viewModelScope.launch { _effects.emit(HomeEffect.NavigateStartStudy) } } fun onCreateDeckClick() { - scope.launch { _effects.emit(HomeEffect.NavigateCreateDeck) } + viewModelScope.launch { _effects.emit(HomeEffect.NavigateCreateDeck) } } fun onBrowseExamplesClick() { - scope.launch { _effects.emit(HomeEffect.NavigateBrowseExamples) } + viewModelScope.launch { _effects.emit(HomeEffect.NavigateBrowseExamples) } } fun onDeckClick(deckId: String) { - scope.launch { _effects.emit(HomeEffect.NavigateDeck(deckId)) } - } - - fun onDispose() { - loadJob?.cancel() - scope.cancel() + viewModelScope.launch { _effects.emit(HomeEffect.NavigateDeck(deckId)) } } private fun Deck.toSummary(dueCount: Int): DeckSummary = DeckSummary( diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PasteImportViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PasteImportViewModel.kt index 5557652..f2f29c8 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PasteImportViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PasteImportViewModel.kt @@ -1,14 +1,12 @@ package com.github.jvsena42.echo.presentation.importflow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.repository.ImportRepository import com.github.jvsena42.echo.domain.model.ColumnRole import com.github.jvsena42.echo.domain.model.Separator import com.github.jvsena42.echo.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -20,11 +18,7 @@ import kotlinx.coroutines.launch class PasteImportViewModel( private val importRepository: ImportRepository, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(PasteImportUiState()) val state: StateFlow = _state.asStateFlow() @@ -51,7 +45,7 @@ class PasteImportViewModel( private fun doParse(text: String) { parseJob?.cancel() - parseJob = scope.launch { + parseJob = viewModelScope.launch { importRepository.parse(text) .onSuccess { draft -> val mapping = draft.columnMapping.assignments @@ -88,17 +82,12 @@ class PasteImportViewModel( fun onNextClick() { if (!_state.value.isParsed) return - scope.launch { _effects.emit(PasteImportEffect.NavigatePublish) } + viewModelScope.launch { _effects.emit(PasteImportEffect.NavigatePublish) } } fun onCancelClick() { importRepository.clear() - scope.launch { _effects.emit(PasteImportEffect.NavigateBack) } - } - - fun onDispose() { - parseJob?.cancel() - scope.cancel() + viewModelScope.launch { _effects.emit(PasteImportEffect.NavigateBack) } } companion object { diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt index e48902c..810a57f 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModel.kt @@ -1,5 +1,7 @@ package com.github.jvsena42.echo.presentation.importflow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.IdentityRepository import com.github.jvsena42.echo.data.repository.ImportRepository @@ -15,11 +17,7 @@ import com.github.jvsena42.echo.domain.model.MediaRef import com.github.jvsena42.echo.domain.model.Tag import com.github.jvsena42.echo.util.Log import com.github.jvsena42.echo.util.epochMillis -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -36,11 +34,7 @@ class PublishDeckViewModel( private val deckRepository: DeckRepository, private val identityRepository: IdentityRepository, private val mediaRepository: MediaRepository, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(PublishDeckUiState()) val state: StateFlow = _state.asStateFlow() @@ -99,7 +93,7 @@ class PublishDeckViewModel( } fun onBackClick() { - scope.launch { _effects.emit(PublishDeckEffect.NavigateBack) } + viewModelScope.launch { _effects.emit(PublishDeckEffect.NavigateBack) } } fun onPublishClick() { @@ -113,7 +107,7 @@ class PublishDeckViewModel( return } - publishJob = scope.launch { + publishJob = viewModelScope.launch { _state.update { it.copy(isPublishing = true, error = null) } Log.d(TAG, "publish: title=${s.title}, cards=${importRepository.keptRows().size}") @@ -160,7 +154,7 @@ class PublishDeckViewModel( fun onUndoPublish() { val deckId = _state.value.publishedDeckId ?: return undoCountdownJob?.cancel() - scope.launch { + viewModelScope.launch { Log.d(TAG, "undo: deleting deckId=$deckId") deckRepository.delete(deckId) .onSuccess { @@ -180,12 +174,12 @@ class PublishDeckViewModel( val deckId = _state.value.publishedDeckId ?: return undoCountdownJob?.cancel() importRepository.clear() - scope.launch { _effects.emit(PublishDeckEffect.Published(deckId)) } + viewModelScope.launch { _effects.emit(PublishDeckEffect.Published(deckId)) } } private fun startUndoCountdown(deckId: String) { undoCountdownJob?.cancel() - undoCountdownJob = scope.launch { + undoCountdownJob = viewModelScope.launch { var remaining = UNDO_WINDOW_SECONDS while (remaining > 0) { delay(COUNTDOWN_TICK_MS) @@ -271,12 +265,6 @@ class PublishDeckViewModel( private fun descriptionErrorFor(text: String): String? = if (text.length > DESCRIPTION_MAX_LENGTH) "Description must be $DESCRIPTION_MAX_LENGTH characters or fewer." else null - fun onDispose() { - publishJob?.cancel() - undoCountdownJob?.cancel() - scope.cancel() - } - companion object { private const val TAG = "Echo/PublishVM" private const val UNDO_WINDOW_SECONDS = 10 diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/TriageViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/TriageViewModel.kt index 4787a96..2f5f3e4 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/TriageViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/importflow/TriageViewModel.kt @@ -1,12 +1,10 @@ package com.github.jvsena42.echo.presentation.importflow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.repository.ImportRepository import com.github.jvsena42.echo.domain.model.TriageDecision import com.github.jvsena42.echo.domain.model.frontBackOf -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -23,11 +21,7 @@ import kotlinx.coroutines.launch */ class TriageViewModel( private val importRepository: ImportRepository, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(TriageUiState()) val state: StateFlow = _state.asStateFlow() @@ -42,7 +36,7 @@ class TriageViewModel( fun refresh() { val draft = importRepository.currentDraft() if (draft == null) { - _state.value = TriageUiState() + _state.update { TriageUiState() } return } val decisions = importRepository.decisions() @@ -85,7 +79,7 @@ class TriageViewModel( fun onEditClick() { val card = _state.value.cards.getOrNull(_state.value.currentIndex) ?: return - scope.launch { _effects.emit(TriageEffect.NavigateEditCard(card.rowIndex)) } + viewModelScope.launch { _effects.emit(TriageEffect.NavigateEditCard(card.rowIndex)) } } /** Keep every card not yet discarded and go to publish. */ @@ -99,7 +93,7 @@ class TriageViewModel( } fun onBackClick() { - scope.launch { _effects.emit(TriageEffect.NavigateBack) } + viewModelScope.launch { _effects.emit(TriageEffect.NavigateBack) } } private fun proceed() { @@ -107,11 +101,7 @@ class TriageViewModel( _state.update { it.copy(error = "Keep at least one card to continue.") } return } - scope.launch { _effects.emit(TriageEffect.NavigatePublish) } - } - - fun onDispose() { - scope.cancel() + viewModelScope.launch { _effects.emit(TriageEffect.NavigatePublish) } } } diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/media/ImageSheetViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/media/ImageSheetViewModel.kt index 1727c74..4d889bf 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/media/ImageSheetViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/media/ImageSheetViewModel.kt @@ -1,12 +1,10 @@ package com.github.jvsena42.echo.presentation.media +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.unsplash.UnsplashClient import com.github.jvsena42.echo.data.unsplash.UnsplashPhoto -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -21,11 +19,7 @@ import kotlinx.coroutines.launch */ class ImageSheetViewModel( private val unsplashClient: UnsplashClient, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow( ImageSheetUiState(isUnsplashConfigured = unsplashClient.isConfigured), ) @@ -39,7 +33,7 @@ class ImageSheetViewModel( private fun loadInitial() { searchJob?.cancel() - searchJob = scope.launch { + searchJob = viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } unsplashClient.random() .onSuccess { photos -> _state.update { it.copy(photos = photos, isLoading = false) } } @@ -51,7 +45,7 @@ class ImageSheetViewModel( _state.update { it.copy(query = query) } if (!unsplashClient.isConfigured) return searchJob?.cancel() - searchJob = scope.launch { + searchJob = viewModelScope.launch { delay(DEBOUNCE_MS) _state.update { it.copy(isLoading = true, error = null) } unsplashClient.search(query) @@ -60,11 +54,6 @@ class ImageSheetViewModel( } } - fun onDispose() { - searchJob?.cancel() - scope.cancel() - } - private fun Throwable.error(): String = message ?: "Could not load images." companion object { diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/onboarding/OnboardingViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/onboarding/OnboardingViewModel.kt index 2ab087a..4034934 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/onboarding/OnboardingViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/onboarding/OnboardingViewModel.kt @@ -1,18 +1,17 @@ package com.github.jvsena42.echo.presentation.onboarding +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.repository.IdentityRepository import com.github.jvsena42.echo.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -26,11 +25,7 @@ import kotlinx.coroutines.launch class OnboardingViewModel( private val identityRepository: IdentityRepository, private val pubkyRingInstallUrl: String = DEFAULT_INSTALL_URL, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(OnboardingUiState.Idle) val state: StateFlow = _state.asStateFlow() @@ -41,11 +36,11 @@ class OnboardingViewModel( init { Log.d(TAG, "init: checking persisted session") - scope.launch { + viewModelScope.launch { val persisted = identityRepository.loadPersistedSession() if (persisted != null) { Log.d(TAG, "init: found persisted session pubky=${persisted.identity.pubky.take(PUBKY_LOG_PREFIX_LEN)}…") - _state.value = OnboardingUiState.Success(persisted) + _state.update { OnboardingUiState.Success(persisted) } _effects.emit(OnboardingEffect.NavigateHome) } else { Log.d(TAG, "init: no persisted session") @@ -58,45 +53,45 @@ class OnboardingViewModel( Log.d(TAG, "onSignInClick: ignored — sign-in already in progress") return } - signInJob = scope.launch { + signInJob = viewModelScope.launch { Log.d(TAG, "onSignInClick: state=Starting, calling beginSignIn") - _state.value = OnboardingUiState.Starting + _state.update { OnboardingUiState.Starting } val handleResult = identityRepository.beginSignIn() - val handle = handleResult.getOrElse { - Log.e(TAG, "onSignInClick: beginSignIn FAILED — ${it::class.simpleName}: ${it.message}", it) - _state.value = OnboardingUiState.Error( - it.message ?: "Could not start Pubky Ring sign-in.", - ) + val handle = handleResult.getOrElse { error -> + Log.e(TAG, "onSignInClick: beginSignIn FAILED — ${error::class.simpleName}: ${error.message}", error) + _state.update { + OnboardingUiState.Error(error.message ?: "Could not start Pubky Ring sign-in.") + } return@launch } Log.d(TAG, "onSignInClick: got authUrl=${handle.authUrl}") - _state.value = OnboardingUiState.AwaitingApproval + _state.update { OnboardingUiState.AwaitingApproval } Log.d(TAG, "onSignInClick: state=AwaitingApproval, emitting OpenDeeplink") _effects.emit(OnboardingEffect.OpenDeeplink(handle.authUrl)) Log.d(TAG, "onSignInClick: awaiting Pubky Ring approval…") val completion = handle.complete() - _state.value = OnboardingUiState.Verifying + _state.update { OnboardingUiState.Verifying } Log.d(TAG, "onSignInClick: state=Verifying, completion.success=${completion.isSuccess}") completion .onSuccess { session -> Log.d(TAG, "onSignInClick: SUCCESS pubky=${session.identity.pubky.take(PUBKY_LOG_PREFIX_LEN)}…") - _state.value = OnboardingUiState.Success(session) + _state.update { OnboardingUiState.Success(session) } _effects.emit(OnboardingEffect.NavigateHome) } .onFailure { err -> Log.e(TAG, "onSignInClick: completion FAILED — ${err::class.simpleName}: ${err.message}", err) - _state.value = OnboardingUiState.Error( + _state.update { OnboardingUiState.Error( err.message ?: "Sign-in was not completed.", - ) + ) } } } } fun onGetRingClick() { - scope.launch { _effects.emit(OnboardingEffect.OpenInstallPage(pubkyRingInstallUrl)) } + viewModelScope.launch { _effects.emit(OnboardingEffect.OpenInstallPage(pubkyRingInstallUrl)) } } /** @@ -108,18 +103,13 @@ class OnboardingViewModel( Log.w(TAG, "onDeeplinkUnavailable: no handler for pubkyauth:// — aborting flow") signInJob?.cancel() signInJob = null - _state.value = OnboardingUiState.Error( + _state.update { OnboardingUiState.Error( "Pubky Ring isn't installed. Install it to sign in.", - ) + ) } } fun onRetry() { - _state.value = OnboardingUiState.Idle - } - - fun onDispose() { - signInJob?.cancel() - scope.cancel() + _state.update { OnboardingUiState.Idle } } companion object { diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/profile/FriendProfileViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/profile/FriendProfileViewModel.kt index b99e4c3..ffc5977 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/profile/FriendProfileViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/profile/FriendProfileViewModel.kt @@ -1,15 +1,13 @@ package com.github.jvsena42.echo.presentation.profile +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.DiscoveryRepository import com.github.jvsena42.echo.data.repository.IdentityRepository import com.github.jvsena42.echo.domain.model.Deck import com.github.jvsena42.echo.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -29,11 +27,7 @@ class FriendProfileViewModel( private val identityRepository: IdentityRepository, private val discoveryRepository: DiscoveryRepository, private val deckRepository: DeckRepository, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(FriendProfileUiState(pubky = targetPubky)) val state: StateFlow = _state.asStateFlow() @@ -51,7 +45,7 @@ class FriendProfileViewModel( private fun load() { if (loadJob?.isActive == true) return - loadJob = scope.launch { + loadJob = viewModelScope.launch { _state.update { it.copy(isLoading = true) } val profile = identityRepository.fetchProfile(targetPubky).getOrNull() @@ -80,7 +74,7 @@ class FriendProfileViewModel( fun onToggleFollow() { if (followJob?.isActive == true) return - followJob = scope.launch { + followJob = viewModelScope.launch { val wasFollowing = _state.value.isFollowing // Optimistic flip; revert on failure. _state.update { it.copy(isFollowing = !wasFollowing, isProcessingFollow = true) } @@ -101,17 +95,11 @@ class FriendProfileViewModel( } fun onCopyPubky() { - scope.launch { _effects.emit(FriendProfileEffect.CopyToClipboard(targetPubky)) } + viewModelScope.launch { _effects.emit(FriendProfileEffect.CopyToClipboard(targetPubky)) } } fun onOpenDeck(deckId: String) { - scope.launch { _effects.emit(FriendProfileEffect.OpenDeck(targetPubky, deckId)) } - } - - fun onDispose() { - loadJob?.cancel() - followJob?.cancel() - scope.cancel() + viewModelScope.launch { _effects.emit(FriendProfileEffect.OpenDeck(targetPubky, deckId)) } } private fun Deck.toCard(): FriendDeck = FriendDeck( diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/profile/ProfileViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/profile/ProfileViewModel.kt index 6a75e45..781c1c6 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/profile/ProfileViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/profile/ProfileViewModel.kt @@ -1,14 +1,12 @@ package com.github.jvsena42.echo.presentation.profile +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.pubky.requiresReauth import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.IdentityRepository import com.github.jvsena42.echo.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -21,11 +19,7 @@ import kotlinx.coroutines.launch class ProfileViewModel( private val identityRepository: IdentityRepository, private val deckRepository: DeckRepository, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(ProfileUiState()) val state: StateFlow = _state.asStateFlow() @@ -43,7 +37,7 @@ class ProfileViewModel( private fun load() { if (loadJob?.isActive == true) return - loadJob = scope.launch { + loadJob = viewModelScope.launch { Log.d(TAG, "load: fetching profile + stats") _state.update { it.copy(isLoading = true) } @@ -115,7 +109,7 @@ class ProfileViewModel( fun onSaveClick() { if (saveJob?.isActive == true) return - saveJob = scope.launch { + saveJob = viewModelScope.launch { val current = _state.value _state.update { it.copy(isSaving = true) } Log.d(TAG, "onSaveClick: saving profile") @@ -151,7 +145,7 @@ class ProfileViewModel( fun onShareClick() { val pubky = _state.value.pubky if (pubky.isNotBlank()) { - scope.launch { _effects.emit(ProfileEffect.ShareProfile("pubky://$pubky")) } + viewModelScope.launch { _effects.emit(ProfileEffect.ShareProfile("pubky://$pubky")) } } } @@ -164,19 +158,13 @@ class ProfileViewModel( } fun onSignOutClick() { - scope.launch { + viewModelScope.launch { Log.d(TAG, "onSignOutClick: signing out") identityRepository.signOut() _effects.emit(ProfileEffect.NavigateToOnboarding) } } - fun onDispose() { - loadJob?.cancel() - saveJob?.cancel() - scope.cancel() - } - companion object { private const val TAG = "Echo/ProfileVM" } diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/settings/SettingsViewModel.kt index 47fa26b..c1e7035 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/settings/SettingsViewModel.kt @@ -1,12 +1,10 @@ package com.github.jvsena42.echo.presentation.settings +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.repository.IdentityRepository import com.github.jvsena42.echo.util.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -19,11 +17,7 @@ import kotlinx.coroutines.launch class SettingsViewModel( private val identityRepository: IdentityRepository, appVersion: String = "", - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(SettingsUiState(appVersion = appVersion)) val state: StateFlow = _state.asStateFlow() @@ -39,7 +33,7 @@ class SettingsViewModel( private fun load() { if (loadJob?.isActive == true) return - loadJob = scope.launch { + loadJob = viewModelScope.launch { Log.d(TAG, "load: fetching session") _state.update { it.copy(isLoading = true) } @@ -61,25 +55,19 @@ class SettingsViewModel( fun onCopyPubkyClick() { val pubky = _state.value.pubky if (pubky.isNotBlank()) { - scope.launch { _effects.emit(SettingsEffect.CopyToClipboard(pubky)) } + viewModelScope.launch { _effects.emit(SettingsEffect.CopyToClipboard(pubky)) } } } fun onSignOutClick() { if (signOutJob?.isActive == true) return - signOutJob = scope.launch { + signOutJob = viewModelScope.launch { Log.d(TAG, "onSignOutClick: signing out") identityRepository.signOut() _effects.emit(SettingsEffect.SignedOut) } } - fun onDispose() { - loadJob?.cancel() - signOutJob?.cancel() - scope.cancel() - } - companion object { private const val TAG = "Echo/SettingsVM" } diff --git a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt index 6d35ff0..8cee3b4 100644 --- a/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModel.kt @@ -1,5 +1,7 @@ package com.github.jvsena42.echo.presentation.study +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.github.jvsena42.echo.data.repository.DeckRepository import com.github.jvsena42.echo.data.repository.SrsRepository import com.github.jvsena42.echo.domain.model.Card @@ -9,17 +11,14 @@ import com.github.jvsena42.echo.domain.model.SrsGrade import com.github.jvsena42.echo.domain.model.previewIntervals import com.github.jvsena42.echo.util.Log import com.github.jvsena42.echo.util.epochMillis -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -34,11 +33,7 @@ class StudySessionViewModel( private val deckId: String?, private val srsRepository: SrsRepository, private val deckRepository: DeckRepository, - mainScope: CoroutineScope? = null, -) { - private val scope: CoroutineScope = - mainScope ?: CoroutineScope(SupervisorJob() + Dispatchers.Main) - +) : ViewModel() { private val _state = MutableStateFlow(StudySessionUiState.Loading) val state: StateFlow = _state.asStateFlow() @@ -63,8 +58,8 @@ class StudySessionViewModel( fun onRefresh() = load() private fun load() { - scope.launch { - _state.value = StudySessionUiState.Loading + viewModelScope.launch { + _state.update { StudySessionUiState.Loading } deckTitle = deckId?.let { resolveDeckTitle(it) }.orEmpty() runCatching { if (deckId == null) srsRepository.dueToday() else srsRepository.dueForDeck(deckId) @@ -78,7 +73,7 @@ class StudySessionViewModel( } .onFailure { err -> Log.e(TAG, "load: FAILED — ${err.message}", err) - _state.value = StudySessionUiState.Error(err.message ?: "Could not load cards.") + _state.update { StudySessionUiState.Error(err.message ?: "Could not load cards.") } } } } @@ -93,7 +88,7 @@ class StudySessionViewModel( fun onGrade(grade: SrsGrade) { if (gradeJob?.isActive == true) return val card = queue.getOrNull(index) ?: return - gradeJob = scope.launch { + gradeJob = viewModelScope.launch { srsRepository.review(card, grade) .onFailure { Log.e(TAG, "grade: FAILED — ${it.message}", it) } reviewedCount++ @@ -110,7 +105,7 @@ class StudySessionViewModel( if (s !is StudySessionUiState.Reviewing || !s.revealed || !s.speakEnabled) return val expected = queue.getOrNull(index)?.back?.text?.takeIf { it.isNotBlank() } ?: return setSpeakPhase(SpeakPhase.Listening) - scope.launch { _effects.emit(StudySessionEffect.StartSpeechRecognition(expected)) } + viewModelScope.launch { _effects.emit(StudySessionEffect.StartSpeechRecognition(expected)) } } fun onSpeechResult(text: String) { @@ -133,7 +128,7 @@ class StudySessionViewModel( fun onSpeakRetry() { val expected = queue.getOrNull(index)?.back?.text?.takeIf { it.isNotBlank() } ?: return setSpeakPhase(SpeakPhase.Listening) - scope.launch { _effects.emit(StudySessionEffect.StartSpeechRecognition(expected)) } + viewModelScope.launch { _effects.emit(StudySessionEffect.StartSpeechRecognition(expected)) } } fun onSpeakDismiss() { @@ -144,7 +139,7 @@ class StudySessionViewModel( speakPhase = phase val s = _state.value if (s is StudySessionUiState.Reviewing) { - _state.value = s.copy(speakPhase = phase) + _state.update { s.copy(speakPhase = phase) } } } @@ -152,35 +147,30 @@ class StudySessionViewModel( val card = queue.getOrNull(index) ?: return val text = (if (revealed) card.back.text else card.front.text)?.takeIf { it.isNotBlank() } ?: return - scope.launch { _effects.emit(StudySessionEffect.Speak(text)) } + viewModelScope.launch { _effects.emit(StudySessionEffect.Speak(text)) } } fun onClose() { - scope.launch { _effects.emit(StudySessionEffect.Close) } - } - - fun onDispose() { - gradeJob?.cancel() - scope.cancel() + viewModelScope.launch { _effects.emit(StudySessionEffect.Close) } } private fun emitCurrent() { if (queue.isEmpty()) { - _state.value = StudySessionUiState.Empty(deckTitle) + _state.update { StudySessionUiState.Empty(deckTitle) } return } val card = queue.getOrNull(index) if (card == null) { - _state.value = StudySessionUiState.Complete(reviewedCount) + _state.update { StudySessionUiState.Complete(reviewedCount) } return } - scope.launch { + viewModelScope.launch { // Cache is warmed by the queue build; a null state means a new (never-reviewed) card. val srsState = srsRepository.stateFor(card.id) val labels = srsState.previewIntervals(card.id, epochMillis()) val title = deckTitle.ifBlank { resolveDeckTitle(card.deckId) }.ifBlank { card.deckId } val deck = deckRepository.getLocal(card.deckId) - _state.value = StudySessionUiState.Reviewing( + _state.update { StudySessionUiState.Reviewing( deckTitle = title, position = index + 1, total = queue.size, @@ -194,7 +184,7 @@ class StudySessionViewModel( speakPhase = speakPhase, deckId = card.deckId, frontImageRef = card.front.imageRef, - ) + ) } } } diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/discover/DiscoverViewModelTest.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/discover/DiscoverViewModelTest.kt index 00a3c38..143c3a3 100644 --- a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/discover/DiscoverViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/discover/DiscoverViewModelTest.kt @@ -4,10 +4,15 @@ import com.github.jvsena42.echo.domain.model.Tag import com.github.jvsena42.echo.testing.FakeDiscoveryRepository import com.github.jvsena42.echo.testing.RecordingTagRepository import com.github.jvsena42.echo.testing.testDeck +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -19,10 +24,21 @@ class DiscoverViewModelTest { private val discovery = FakeDiscoveryRepository() private val tagRepo = RecordingTagRepository() - private fun TestScope.viewModel() = DiscoverViewModel( + private val mainDispatcher = StandardTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(mainDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel() = DiscoverViewModel( discoveryRepository = discovery, tagRepository = tagRepo, - mainScope = this, ) private fun seedFeed() { diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/home/HomeViewModelTest.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/home/HomeViewModelTest.kt index 219e13d..45baf85 100644 --- a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/home/HomeViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/home/HomeViewModelTest.kt @@ -8,12 +8,18 @@ import com.github.jvsena42.echo.testing.FakeSrsRepository import com.github.jvsena42.echo.testing.fakeSession import com.github.jvsena42.echo.testing.testCard import com.github.jvsena42.echo.testing.testDeck +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -26,11 +32,22 @@ class HomeViewModelTest { private val deckRepo = FakeDeckRepository() private val srsRepo = FakeSrsRepository() - private fun TestScope.viewModel() = HomeViewModel( + private val mainDispatcher = StandardTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(mainDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel() = HomeViewModel( identityRepository = identityRepo, deckRepository = deckRepo, srsRepository = srsRepo, - mainScope = this, ) /** Subscribes eagerly so effects emitted by the init-launched load are not dropped. */ diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModelTest.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModelTest.kt index 5e6c49a..7d49961 100644 --- a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/importflow/PublishDeckViewModelTest.kt @@ -6,12 +6,17 @@ import com.github.jvsena42.echo.testing.FakeImportRepository import com.github.jvsena42.echo.testing.FakeMediaRepository import com.github.jvsena42.echo.testing.TEST_PUBKY import com.github.jvsena42.echo.testing.testDraft +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -29,12 +34,23 @@ class PublishDeckViewModelTest { private val identityRepo = FakeIdentityRepository() private val mediaRepo = FakeMediaRepository() - private fun TestScope.viewModel() = PublishDeckViewModel( + private val mainDispatcher = StandardTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(mainDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel() = PublishDeckViewModel( importRepository = importRepo, deckRepository = deckRepo, identityRepository = identityRepo, mediaRepository = mediaRepo, - mainScope = this, ) // ── validation ─────────────────────────────────────────────────────── diff --git a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModelTest.kt b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModelTest.kt index 56f39b9..996180a 100644 --- a/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/github/jvsena42/echo/presentation/study/StudySessionViewModelTest.kt @@ -5,10 +5,15 @@ import com.github.jvsena42.echo.testing.FakeDeckRepository import com.github.jvsena42.echo.testing.FakeSrsRepository import com.github.jvsena42.echo.testing.testCard import com.github.jvsena42.echo.testing.testDeck +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -20,11 +25,22 @@ class StudySessionViewModelTest { private val srsRepo = FakeSrsRepository() private val deckRepo = FakeDeckRepository() - private fun TestScope.viewModel(deckId: String? = "deck1") = StudySessionViewModel( + private val mainDispatcher = StandardTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(mainDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel(deckId: String? = "deck1") = StudySessionViewModel( deckId = deckId, srsRepository = srsRepo, deckRepository = deckRepo, - mainScope = this, ) private suspend fun seedDeck() {