feat(shared): graduate saved-device transfer experience

Add the VniDrop-specific Compose architecture skill, unify invitation and targeted transfer drafts, and promote Saved devices to an adaptive first-class destination.
This commit is contained in:
2026-08-12 21:45:18 +02:00
parent 2ac9166b34
commit 6ab658fea2
105 changed files with 2748 additions and 9764 deletions

View File

@@ -1,118 +0,0 @@
---
description: Token-optimized writing standards for skill reference files. Apply when creating or editing reference .md files in the references/ directory.
globs: references/*.md
alwaysApply: false
---
# Reference File Writing Standards
Every reference file must be token-efficient. Agents already know standard APIs — provide patterns, pitfalls, and project-specific rules, not tutorials.
## File Structure
1. **Line 1: `# Title`** — concise topic name
2. **Line 2-3: One-liner scope** — what this file covers and when to use it, not a marketing paragraph
3. **Cross-links** — point to related canonical files (e.g., "For shared architecture concepts, see [architecture.md](architecture.md)")
4. **`References:` block** (optional) — external URLs for provenance; keep, they're cheap
5. **No Table of Contents** — agents navigate by headings, not TOC lists
## Writing Rules
| Rule | Do | Don't |
|---|---|---|
| Tables over prose | `\| Issue \| Fix \|` table | Multi-paragraph explanations |
| Rules over explanations | State the rule directly | Explain "why" unless non-obvious |
| Examples over descriptions | One BAD/GOOD code pair | Three paragraphs describing the concept |
| No tutorial content | Show the pattern/pitfall | Explain what `StateFlow` or `PagingSource` is |
| One-liner intros | "SQLite persistence via Room (KMP-ready since 2.7.0)" | "Room is a powerful persistence library that provides an abstraction layer over SQLite..." |
| Trim filler words | Direct statements | "It is important to note that...", "In order to...", "You should consider..." |
## DRY Cross-Referencing
Each concept has ONE canonical home. Other files link to it instead of duplicating.
| Concept | Canonical home | Other files do |
|---|---|---|
| MVI ViewModel collection pattern | `architecture.md` § Reactive Data Collection | 5-8 line domain stub + cross-link |
| State modeling (forms, calculators) | `architecture.md` § State Modeling | Cross-link |
| Effect delivery (Channel vs SharedFlow) | `architecture.md` § Effect Delivery | Cross-link |
| Generic test setup (runTest, Turbine) | `testing.md` | Domain-specific test factory + cross-link |
| Koin module patterns | `koin.md` | 1-2 line binding example + cross-link |
| Hilt module patterns | `hilt.md` | 1-2 line binding example + cross-link |
| Nav 3 + DI wiring | `navigation-3-di.md` | Condensed example + cross-link |
## Section Templates
### MVI Integration (in data-layer files)
Keep to ~5-8 lines: state the domain-specific mapping rule + cross-link.
```markdown
## MVI Integration
Map entities to domain models at the repository boundary. Never pass raw [DataType] to the UI.
For the ViewModel collection pattern, see [architecture.md](architecture.md) — Reactive Data Collection.
```
### DI Integration (in data-layer files)
1-2 line binding examples + link.
```markdown
## DI Integration
Always provide [Type] as a **singleton**.
\`\`\`kotlin
// Koin: single<Type> { createType(get()) }
// Hilt: @Provides @Singleton fun provideType(...): Type = ...
\`\`\`
For full module patterns, see [koin.md](koin.md) or [hilt.md](hilt.md).
```
### Anti-Patterns (table format)
```markdown
## Anti-Patterns
| Anti-pattern | Why it is harmful | Better replacement |
|---|---|---|
| [pattern] | [consequence] | [fix] |
```
### Performance / Critical Rules (table format)
```markdown
## Critical Rules
| Rule | Why |
|---|---|
| [rule] | [brief rationale] |
```
## Code Examples
- Use BAD/GOOD pairs — show the mistake and the fix side by side
- No redundant comments like `// Import the module` — only explain non-obvious intent
- Keep examples minimal: show the pattern, not a full app
- Use `<latest>` for dependency versions with a comment: `// search: "library latest version"`
## Token Budget
- Target: **under 3,500 tokens** per reference file (hard max: 4,000)
- Estimate: characters / 4
- If a file grows past 3,500 tokens, split into base + advanced (e.g., `animations.md` + `animations-advanced.md`)
## Checklist for New Reference Files
- [ ] One-liner scope, no marketing intro
- [ ] No Table of Contents
- [ ] Tables instead of multi-paragraph prose
- [ ] BAD/GOOD code examples for key pitfalls
- [ ] Anti-patterns table at the end
- [ ] Cross-links to canonical homes (no duplicated patterns)
- [ ] MVI/DI/Testing sections use stubs + cross-links
- [ ] Under 3,500 tokens
- [ ] Linked from `SKILL.md` (both trigger list and reference catalog)

View File

@@ -1,204 +1,144 @@
---
name: compose-skill
license: MIT
description: >
Jetpack Compose and Compose Multiplatform (KMP/CMP) architecture skill.
Only use when the user explicitly mentions "compose-skill", "@compose-skill",
or "use compose skill" in their message. Do NOT auto-activate based on
keyword matching — this skill should only be triggered by direct user request.
description: VniDrop-specific Compose Multiplatform UI and Kotlin presentation architecture. Use when designing, implementing, refactoring, or reviewing code under shared/ for Android, Windows, or Linux: screens, ViewModels, routes, navigation, adaptive layouts, platform adapters, icons, resources, accessibility, and UI tests.
---
# Jetpack Compose & Compose Multiplatform
# VniDrop KMP UI
This skill covers the full Compose app development lifecycle — from architecture and state management through UI, networking, persistence, performance, accessibility, cross-platform sharing, build configuration, and distribution. Jetpack Compose and Compose Multiplatform share the same core APIs and mental model. **Not all Jetpack libraries work in `commonMain`** — many remain Android-only. A subset of AndroidX libraries now publish multiplatform artifacts (e.g., `lifecycle-viewmodel`, `lifecycle-runtime-compose`, `datastore-preferences`), but availability and API surface vary by version. **Before adding any Jetpack/AndroidX dependency to `commonMain`, verify the artifact is published for all required targets by checking Maven Central or the library's official documentation.** CMP uses `expect/actual` or interfaces for platform-specific code. MVI (Model-View-Intent) is the recommended architecture, but the skill adapts to existing project conventions.
Build VniDrop's Android and desktop UI without weakening its domain model, platform identity, or Rust streaming invariants.
## Existing Project Policy
## Start here
**Do not force migration.** If a project already follows MVI with its own conventions (different base class, different naming, different file layout), respect that. Adapt to the project's existing patterns. The architecture pattern — unidirectional data flow with Event, State, and Effect — is what matters, not a specific base class or framework. Only suggest structural changes when the user asks for them or when the existing code has clear architectural violations (business logic in composables, scattered state mutations, etc.).
1. Read the root `AGENTS.md` and `shared/AGENTS.md` completely.
2. Read `CONTEXT.md` and only the ADRs relevant to the feature.
3. Inspect the nearby feature, its tests, and its Android/JVM adapters before designing.
4. Identify the module, interface, seam, and adapters. Prefer a deep module: small interface, substantial hidden behavior, one test surface.
5. Model state and platform behavior before drawing pixels.
6. Implement the smallest complete product flow; add regressions at the lowest useful layer.
7. Run `make test-shared`; run `make check-shared` for a production UI handoff.
## Workflow
## Scope and ownership
When helping with Jetpack Compose or Compose Multiplatform code, follow this process:
- `shared/commonMain` owns shared domain-facing presentation state, feature behavior, semantic UI structure, and reusable visual primitives.
- `androidMain` owns Android pickers, SAF, MediaStore, system surfaces, and Android-native presentation where needed.
- `jvmMain` owns Windows/Linux filesystem, desktop integration, and platform-native presentation where needed.
- Apple uses the native SwiftUI app under `apple/`. Do not move Apple presentation into KMP.
- Rust owns transfer payload streaming, authorization, durable lifecycle, and transfer persistence. Kotlin must not become the payload path.
1. **Read the existing code first for context** — check conventions, base classes, and layout. For small UI or logic asks, restrict your reading to the immediately relevant files to save time. Do not map out the entire project architecture unless a structural refactor is requested.
2. **Identify the concern** — is this architecture, state modeling, performance, navigation, DI, animation, cross-platform, or testing?
3. **Apply the core rules below** — the decision heuristics and defaults in this file cover most cases.
4. **Consult the right reference** — load the relevant file from `references/` only when deeper guidance is needed. Use the [Quick Routing](#quick-routing) in the Detailed References section to pick the right file.
5. **Verify dependencies before recommending** — before adding or upgrading any dependency, verify coordinates, target support, and API shape via a documentation MCP tool or official docs (see [Dependency Verification Rule](#dependency-verification-rule)).
6. **Flag anti-patterns contextually** — if the user's code violates best practices, call it out for production code. For quick prototypes or minor UI tweaks, prioritize answering their specific question over lecturing them on strict rules.
7. **Write the minimal correct solution** — do not over-engineer. Prefer feature-specific code over generic frameworks.
## Architecture
## Dependency Verification Rule
Use VniDrop's MVVM-style modules:
**Before recommending any new dependency or version upgrade, verify:**
- Immutable `*State` exposed through `StateFlow`.
- Named ViewModel methods for user actions. Do not introduce a generic `onEvent` hierarchy.
- Route: obtain/collect state, invoke platform adapters, collect effects, and perform navigation.
- Screen: render state and emit explicit callbacks.
- Leaf composables: accept narrow state and callbacks; retain only visual-local state such as focus, scroll, or animation.
- `AppGraph` wires dependencies. Do not introduce Hilt, Koin, or a second graph.
1. **Coordinates** — Confirm the exact Maven coordinates (`group:artifact:version`) exist and are current.
2. **Target support** — Confirm the artifact supports the project's targets (Android, iOS, Desktop, `commonMain`). Do not assume a Jetpack library works in `commonMain` unless verified.
3. **API shape** — Confirm the API you plan to use actually exists in that version. Function signatures, parameter names, and return types change between major versions.
Design for depth and locality:
**How to verify:**
- **Documentation MCP tool** (preferred) — If a documentation MCP server is available (e.g., Context7), verify exact tool names and schemas first, then use it to fetch current official documentation for the library.
- **Official docs** — Search the library's official documentation or release notes.
- **Maven Central / Google Maven** — Check artifact availability and supported platforms.
- Put behavior behind a small interface used by callers and tests.
- Keep internal seams private. Do not add an interface until behavior genuinely varies.
- An Android/JVM/test adapter set is a real seam; a single implementation is not.
- Do not hide callback explosion in an `Actions` data class. That changes syntax, not depth.
- Do not add use-case classes or pass-through repositories around `CoreGateway`.
- Preserve `Invitation transfer`, `Targeted transfer`, `Transfer draft`, `Saved device`, and `Device relationship` as distinct terms from `CONTEXT.md`.
**If verification is not possible** (no documentation tool, no network access, docs unavailable), **provide the standard or latest known dependency snippet anyway.** Add a brief comment (e.g., `// Verify latest version`) so the user isn't blocked.
For structural work, read [references/architecture.md](references/architecture.md). Open no other reference in the same turn unless the task changes materially.
## Fetching Up-to-Date Documentation
## Platform-native experience
When adding a new dependency, upgrading major versions, or verifying latest API patterns, use a **documentation MCP tool** (e.g., Context7) if available. Before invoking, verify the tool's exact name and parameter schema — tool names vary across environments.
Every supported platform should feel native. Sharing implementation is a means, not the goal.
1. **Resolve library ID** — if the tool requires a resolution step, call it first.
2. **Query docs** — call with the resolved ID and a specific question.
- Prefer shared behavior and state, but allow repeated Android, Windows, and Linux presentation implementations when native interaction, layout, menus, dialogs, shortcuts, density, or system integration differ.
- Do not force the lowest-common-denominator UI merely to maximize `commonMain` code.
- Keep duplicated platform presentation thin and semantic; do not duplicate domain rules or transfer state machines.
- Use `expect`/`actual`, platform source sets, or injected adapters only at genuine seams.
- Android should follow Material interaction and navigation conventions.
- Windows should use Fluent iconography and desktop interaction conventions.
- Linux/Desktop should use the existing Lucide family and desktop conventions.
**Alternative**: Users can add `use context7` (or equivalent) to their prompt. Bundled references remain the primary source for architectural patterns and MVI guidance; use documentation tools for API-specific and version-specific queries.
### Native icons
## Core Architecture: MVI or MVVM
- Use semantic `AppIcon` values rendered through `PlatformIcon`.
- Android resolves Material icons, Windows resolves Fluent icons, and Linux/Desktop resolves Lucide icons.
- When adding an icon, provide the appropriate resource for every supported family. Do not reuse one platform's asset everywhere because it is convenient.
- Prefer the native system icon or platform icon family when a platform exposes a stronger convention. A platform-specific implementation is acceptable.
- Give actionable icons a localized content description; decorative icons use `null`.
- Do not inline arbitrary Material icons or hard-code drawable selection in feature composables.
Both MVI and MVVM use **unidirectional data flow**: UI renders state → user acts → ViewModel updates state → UI re-renders. The difference is how UI actions reach the ViewModel.
For platform-specific UI decisions, read [references/platform-native-ui.md](references/platform-native-ui.md). Open no other reference in the same turn unless the task changes materially.
- **MVI**: `sealed interface Event` + single `onEvent()` entry point
- **MVVM**: Named public functions (`onTitleChanged()`, `save()`)
## VniDrop transfer invariants
Both patterns use:
- **State** — immutable data class that fully describes the screen, owned via `StateFlow`
- **Effect** — one-shot commands (navigate, snackbar, share) delivered via `Channel`
- Invitation transfer and Targeted transfer may share a Transfer draft implementation, but never erase their distinct destination, authorization, lifecycle, or result types.
- Public Targeted operations remain transfer-ID-only. Never expose authorization material to Kotlin.
- A saved-device display name resolves as local label, then authenticated remote display name, then a localized unnamed fallback. Endpoint ID is secondary diagnostic identity.
- Android folder sharing expands a SAF tree into file descriptors plus safe relative names. Never pass an Android directory FD to Rust.
- Desktop may pass filesystem directories marked as directories for Rust traversal.
- Platform source adapters keep descriptors and leases alive for the complete core call and close them exactly once.
- App-owned picker copies are released on replacement, removal, explicit dismissal, or successful creation. Never delete original user sources.
- Picker cancellation and creation failure preserve the current valid Transfer draft.
**Default recommendation:** Preserve the project's existing pattern when it is coherent. For new projects, choose based on team preference and screen complexity. See [Architecture & State Management](references/architecture.md) for the decision guide, then [mvi.md](references/mvi.md) or [mvvm.md](references/mvvm.md) for implementation details.
### Transfer draft architecture
### UI Rendering Boundary
Use one deep, session-scoped composition module for Invitation and Targeted creation:
These boundaries apply to both MVI and MVVM:
- Concrete MVVM module with `TransferDraftState`, named methods, and semantic outputs.
- Domain-specific `openInvitation` and `openTargeted`; Targeted receiver is locked for the session.
- Routes invoke file/folder picker adapters and navigate from semantic creation results.
- The module owns selection, opaque source IDs, automatic-name provenance, validation, retry, single-flight submission, destination revalidation, and temporary-copy lifecycle.
- One private platform source-adapter seam serves Android, JVM, and tests.
- Multiple files or one folder; do not add mixed file-plus-folder drafts without an explicit product decision.
- Targeted mode omits Invitation sender-name and access-policy controls.
- Operational failure preserves the draft; successful creation emits the correct domain identity for the host to open.
- **Route** composable: obtains ViewModel, collects state via `collectAsStateWithLifecycle()`, collects effects via `CollectEffect` (see [compose-essentials.md](references/compose-essentials.md)), binds navigation/snackbar/platform APIs
- **Screen** composable: stateless renderer — receives state and callbacks (MVI: `onEvent`, MVVM: individual callbacks), renders the screen, adapts callbacks for leaf composables
- **Leaf** composables: render sub-state, emit specific callbacks, keep only tiny visual-local state (focus, scroll, animation)
## UI system
## Decision Heuristics
- Use `LocalVniDropColors` and `VniDropThemeTokens`; do not hard-code product colors.
- Use existing `WindowClass`, `LocalUiPlatform`, `contentWindowClassFor`, and shell/navigation helpers.
- Prefer semantic feature modules over generic visual abstractions.
- Keep stable keys for device and transfer lists.
- Preserve minimum touch targets, keyboard access, focus order, readable contrast, and meaningful semantics.
- Treat phone, tablet/rail, Windows desktop, and Linux desktop as deliberate presentations—not scaled copies.
- Composable functions render state and emit events, never decide business rules
- If a value can be derived from state, do not store it redundantly unless async/persistence/performance justifies it
- Event handling in the ViewModel owns state transitions; composables do not mutate state
- UI-local state is acceptable only for ephemeral visual concerns: focus, scroll, animation progress, expansion toggles
- Do not push animation-only flags into global screen state unless business logic depends on them
- Pass the narrowest possible state to leaf composables
- MVI: implement `onEvent()` as the single entry point; MVVM: implement named functions for user actions
- Do not introduce a use case for every repository call
- Cross-platform sharing prioritizes business logic and presentation state before platform behavior
- Least recomposition is achieved by state shape and read boundaries first, Compose APIs second
- When a project has an existing MVI base class or pattern, use it — don't introduce a competing abstraction
## Strings and resources
## State Modeling
- `localization/strings.json` is the only source of truth for product strings.
- Run the localization generator after editing it.
- Never hand-edit generated Compose XML, Apple catalogs, or accessors.
- Use `Res.string.*` in `commonMain`; never Android `R` there.
- Do not synthesize English product copy in ViewModels, including automatic transfer names.
- Resolve semantic strings near presentation or inject a small formatter when behavior requires localized text.
For calculator/form screens, split state into four buckets:
## Dependencies
1. **Editable input** — raw text and choice values as the user edits them
2. **Derived display/business** — parsed, validated, calculated values
3. **Persisted domain snapshot** — saved entity for dirty tracking or reset
4. **Transient UI-only** — purely visual, not business-significant
- Prefer existing dependencies and platform facilities.
- Before adding AndroidX/Jetpack to `commonMain`, verify coordinates, exact API shape, and every required KMP target using official documentation or artifact metadata.
- If verification is unavailable, stop and report the uncertainty. Do not add an unverified production dependency with a “check later” comment.
- Do not add navigation, DI, persistence, networking, or image-loading frameworks unless the requested feature proves the need.
| Concern | Where | Example |
|---|---|---|
| Raw field text | `state` fields | `"12"`, `"12."`, `""` |
| Parsed/derived | `state` computed props or fields | `val hasRequiredFields: Boolean` |
| Validation | `state.validationErrors` or similar | `mapOf("name" to "Required")` |
| Loading/refresh | `state` flags | `isSaving = true` |
| One-off UI commands | `Effect` via Channel | snackbar, navigate, share |
| Scroll/focus/animation | local Compose state | `LazyListState`, focus requester |
## Testing
## Recommended Defaults
- `commonTest`: state machines and feature behavior through the module interface; use focused fakes.
- `jvmTest`: Compose interaction, semantics, keyboard behavior, and adaptive phone/desktop presentation.
- Platform tests: Android SAF/MediaStore and desktop filesystem/native integration.
- Test complete states where relevant: empty, loading, content, busy, error, confirmation, interruption, and terminal outcomes.
- Test both Invitation and Targeted modes through the shared Transfer draft interface.
- Assert native icon-family selection and accessibility semantics when adding platform actions.
- Prefer deterministic gates and virtual time; avoid fixed sleeps.
- Delete obsolete shallow tests after equivalent interface-level coverage exists.
Apply these unless the project already follows a different coherent pattern.
## Anti-patterns
| Concern | Default |
|---|---|
| ViewModel | One ViewModel per screen (`commonMain` for CMP, feature package for Android-only). MVI: `onEvent(Event)` entry point; MVVM: named functions |
| State source of truth | `StateFlow<FeatureState>` owned by the ViewModel |
| Event handling | MVI: `onEvent(event)` with `when` expression; MVVM: named functions. Both map user actions to state updates, effect emissions, and async launches |
| Side effects | `Effect` sent via `Channel<Effect>(Channel.BUFFERED)` for UI-consumed one-shots (navigate, snackbar). Async work (network, persistence) launched in `viewModelScope` |
| Async loading | Keep previous content, flip loading flag, cancel outdated jobs, update state on completion |
| Dumb UI contract | Render props, emit explicit callbacks, keep only ephemeral visual state local |
| Resource access | Semantic keys/enums in state; resolve strings/icons close to UI. CMP uses `Res.string` / `Res.drawable` (not Android `R`). See [Resources](references/resources.md) |
| Platform separation | CMP: share in `commonMain`, `expect/actual` (verify Kotlin 1.9 vs 2.0+ via `build.gradle.kts` or ask user) or interfaces, Koin DI by default. Android-only: standard package, Hilt or Koin DI |
| Navigation | ViewModel emits semantic navigation effect; route/navigation layer executes it |
| Persistence (settings) | DataStore Preferences in `commonMain` for key-value settings; Typed DataStore (JSON) for structured settings objects; Room for relational/queried data. See [DataStore](references/datastore.md) |
| Testing | ViewModel event→state→effect tests via Turbine in `commonTest`; validators/calculators tested as pure functions; platform bindings tested per target |
## Do / Don't Quick Reference
### Do
- Model raw editable text separately from parsed values
- Keep state immutable and equality-friendly
- Reuse unchanged nested objects when possible
- Emit semantic effects instead of making platform calls from event handling
- Preserve old content during refresh
- Map domain data to UI state close to the presentation boundary
- Use feature-specific ViewModel names
- Key list items by stable domain ID
- Import all types and functions at the top of the file; use `import ... as ...` aliases to resolve name clashes
- Guard no-op state emissions (don't update state if nothing changed)
- Respect the project's existing MVI conventions
### Don't
- Parse numbers in composable bodies
- Run network requests from composables
- Store `MutableState`, controllers, lambdas, or platform objects in screen state
- Encode snackbar/navigation as "consume once" booleans in state — use effects
- Keep every minor visual toggle in the ViewModel state
- Pass entire state to every child composable
- Wrap every repository call in a use case class
- Wipe the screen with a full-screen spinner during refresh
- Force-migrate a working codebase to a different architecture or base class
- Use fully qualified package paths inline (e.g., `com.example.pkg.SomeClass.method()`) — always import at file top
## Detailed References
**Do not load reference files for basic Compose usage.** If you already know how to build the required UI or logic, write the code immediately. **Load exactly one reference file only when the task involves advanced concepts** (e.g., Paging 3, Nav 3 setup). Pick the right file below — do not load files speculatively.
### Quick Routing
- **Recomposition too frequent, stability, or Compose Compiler Metrics** → [performance.md](references/performance.md)
- **Channel vs SharedFlow, Flow operators, structured concurrency, or exception handling** → [coroutines-flow.md](references/coroutines-flow.md)
- **Backpressure, callbackFlow, Mutex/Semaphore, or Turbine testing** → [coroutines-flow-advanced.md](references/coroutines-flow-advanced.md)
- **Nav 3 routes, tabs, scenes, deep links, or back stack patterns** → [navigation-3.md](references/navigation-3.md)
- **Nav 2 NavHost, tabs, deep links, nested graphs, or animations** → [navigation-2.md](references/navigation-2.md)
- **Wiring Hilt or Koin with navigation** → [navigation-3-di.md](references/navigation-3-di.md) or [navigation-2-di.md](references/navigation-2-di.md) based on version
- **Migrating from Nav 2 to Nav 3** → [navigation-migration.md](references/navigation-migration.md)
- **Paging 3 setup, PagingSource, filters, LoadState, or transformations** → [paging.md](references/paging.md)
- **Offline-first paging with Room and RemoteMediator** → [paging-offline.md](references/paging-offline.md)
- **Paging MVI integration, paging tests, or paging anti-patterns** → [paging-mvi-testing.md](references/paging-mvi-testing.md)
- **Ktor client setup, plugins, DTOs, API service, or repository pattern** → [networking-ktor.md](references/networking-ktor.md)
- **Auth (bearer), WebSockets, or SSE** → [networking-ktor-auth.md](references/networking-ktor-auth.md)
- **Network layer architecture, plugin composition, or error handling strategy** → [networking-ktor-architecture.md](references/networking-ktor-architecture.md)
- **Choosing Hilt vs Koin** → [dependency-injection.md](references/dependency-injection.md) first, then the chosen framework's file
- **Accessibility audit, semantics, touch targets, or WCAG contrast** → [accessibility.md](references/accessibility.md)
- **Animation API selection (animate*AsState, Animatable, transitions, AnimatedVisibility)** → [animations.md](references/animations.md)
- **Shared element transitions, gesture-driven animations, Canvas, or graphicsLayer** → [animations-advanced.md](references/animations-advanced.md)
- **Code review or anti-pattern detection** → [anti-patterns.md](references/anti-patterns.md) first, then domain-specific files as needed
- **Exposing Kotlin to Swift, SKIE, or Flow→AsyncSequence** → [ios-swift-interop.md](references/ios-swift-interop.md)
- **ViewModel pipeline, state modeling, domain layer, or inter-feature communication** → [architecture.md](references/architecture.md)
- **MVI pipeline, Event/State/Effect, onEvent pattern, or effect delivery** → [mvi.md](references/mvi.md)
- **MVVM pipeline, ViewModel named functions, or direct-callback UI wiring** → [mvvm.md](references/mvvm.md)
- **File organization, naming conventions, or disciplined screen architecture** → [clean-code.md](references/clean-code.md)
- **Three phases, state primitives, side effects, or modifiers** → [compose-essentials.md](references/compose-essentials.md)
- **M3 theme, dynamic color, M3 components, or adaptive layouts** → [material-design.md](references/material-design.md)
- **AsyncImage, image cache, SVG, or Coil 3** → [image-loading.md](references/image-loading.md)
- **LazyColumn, LazyRow, keys, grids, pager, or scroll state** → [lists-grids.md](references/lists-grids.md)
- **Nav 2 vs Nav 3 decision or MVI navigation rules** → [navigation.md](references/navigation.md)
- **Loading states, skeleton/shimmer, or inline validation UX** → [ui-ux.md](references/ui-ux.md)
- **Turbine, ViewModel tests, Macrobenchmark, or lean test matrix** → [testing.md](references/testing.md)
- **DataStore Preferences, Typed DataStore, or KMP DataStore** → [datastore.md](references/datastore.md)
- **Room entities, DAOs, migrations, relationships, or Room MVI integration** → [room-database.md](references/room-database.md)
- **Ktor `@Resource` routes or type-safe API definitions** → [networking-ktor.md](references/networking-ktor.md) § Type-Safe Resources
- **MockEngine, network testing, or Koin/Hilt network DI** → [networking-ktor-testing.md](references/networking-ktor-testing.md)
- **Koin CMP setup, Nav 3 Koin integration, or scoped modules** → [koin.md](references/koin.md)
- **Hilt Android setup, @HiltViewModel, scopes, or Hilt testing** → [hilt.md](references/hilt.md)
- **commonMain sharing, expect/actual, or platform bridges** → [cross-platform.md](references/cross-platform.md)
- **CMP Res class, qualifiers, localization, or Android resource interop** → [resources.md](references/resources.md)
- **AGP 9+, version catalog, convention plugins, or composite builds** → [gradle-build.md](references/gradle-build.md)
- **GitHub Actions CI/CD, desktop packaging, signing, or notarization** → [ci-cd-distribution.md](references/ci-cd-distribution.md)
## Validation
Run `./scripts/validate.sh` to scan the skill package against the [agentskills.io spec](https://agentskills.io/specification). It checks token budgets, broken links, file structure, and content quality. Fix any errors before committing.
- Business rules, core calls, ticket parsing, or filesystem work in composables.
- A global mutable draft shared by Send and Saved devices.
- Payload bytes streamed through Kotlin.
- Android directory FDs.
- Raw endpoint IDs as primary saved-device names.
- Generic `onEvent`, forced MVI, Hilt/Koin migrations, or use-case-per-method architecture.
- One universal icon set or one platform's interaction model imposed on every platform.
- Duplicated domain behavior justified as “native UI.” Only presentation duplication is acceptable.
- Hand-edited generated localization outputs.
- New generic wrappers whose deletion merely moves calls to the caller.

View File

@@ -1,4 +1,4 @@
interface:
display_name: "compose-skill"
short_description: "AI agent skill for Jetpack Compose and Compose Multiplatform — architecture, state, navigation, DI, performance, cross-platform, and code review"
default_prompt: "Use $compose-skill to build, refactor, or review Compose/CMP features, adapting to the project's architecture (MVI recommended for new work), and verifying dependencies before recommending them."
display_name: "VniDrop KMP UI"
short_description: "Build native-feeling VniDrop KMP interfaces"
default_prompt: "Use $compose-skill to design or implement a native-feeling VniDrop KMP UI feature."

View File

@@ -1,195 +0,0 @@
# Accessibility
## Content Descriptions
Every `Image` and `Icon` composable must have an explicit `contentDescription`:
- **Decorative** (no information conveyed): `contentDescription = null`
- **Meaningful** (conveys information): localized string via `stringResource()`
```kotlin
// Decorative — purely visual, screen reader skips it
Icon(Icons.Default.Star, contentDescription = null)
// Meaningful — screen reader announces it
Image(
painter = painterResource(Res.drawable.profile_avatar),
contentDescription = stringResource(Res.string.user_avatar_description)
)
```
Flag any `Image` with a non-obvious resource name and `contentDescription = null` that lacks a comment explaining why it is decorative.
## Semantics API
Use `Modifier.semantics { }` to add or override accessibility information.
| Property | Purpose | Example values |
|---|---|---|
| `contentDescription` | Override screen reader announcement | `"Profile picture of $name"` |
| `role` | Declare interactive role | `Role.Button`, `Role.Image`, `Role.Switch`, `Role.Tab`, `Role.RadioButton`, `Role.Checkbox` |
| `stateDescription` | Describe current state | `"Expanded"`, `"Selected"`, `"3 of 5"` |
| `heading` | Mark as section heading | `heading()` |
```kotlin
Box(
modifier = Modifier.semantics {
contentDescription = "Profile picture of ${user.name}"
role = Role.Image
}
)
```
Prefer built-in Material components (`Button`, `Switch`, `Checkbox`) over manual `role` assignment — they include correct semantics automatically.
## Grouping and Overriding Semantics
### mergeDescendants
Groups a composable's children into a single screen reader announcement. Use when children together form one logical unit.
```kotlin
// GOOD — screen reader announces "4.5 stars, 128 reviews" as one item
Row(modifier = Modifier.semantics(mergeDescendants = true) { }) {
Icon(Icons.Default.Star, contentDescription = null)
Text("4.5 stars")
Text("(128 reviews)")
}
```
```kotlin
// BAD — screen reader stops on each child separately, fragmenting the meaning
Row {
Icon(Icons.Default.Star, contentDescription = "Star icon")
Text("4.5 stars")
Text("(128 reviews)")
}
```
### clearAndSetSemantics
Replaces all auto-generated and child semantics with a single custom description. Use when the auto-generated text is verbose or misleading.
```kotlin
Row(modifier = Modifier.clearAndSetSemantics {
contentDescription = "Rating: 4.5 stars from 128 reviews"
}) {
StarRating(4.5f)
Text("(128 reviews)")
}
```
| Need | Use |
|---|---|
| Group children into one announcement, keep their text | `semantics(mergeDescendants = true)` |
| Replace all child semantics with a custom string | `clearAndSetSemantics { }` |
## Touch Targets
Minimum interactive size: **48 x 48 dp**.
- Use `Modifier.minimumInteractiveComponentSize()` on custom interactive elements to enforce this automatically.
- Material components (`Button`, `IconButton`, `Switch`, etc.) handle this internally — do not add redundant padding.
```kotlin
// Custom clickable element — enforce minimum touch target
Box(
modifier = Modifier
.minimumInteractiveComponentSize()
.clickable { onAction() }
) {
Icon(Icons.Default.Add, contentDescription = "Add item")
}
```
## Color and Contrast
WCAG AA minimum contrast ratios:
| Text type | Minimum ratio |
|---|---|
| Normal text (<18sp) | 4.5 : 1 |
| Large text (18sp+ or 14sp bold+) | 3 : 1 |
Never use color as the **only** way to convey information. Always pair with an icon, text label, or pattern.
```kotlin
// BAD — only color differentiates status
Box(modifier = Modifier.background(if (isOnline) Color.Green else Color.Red))
// GOOD — icon + text + color
Row {
Icon(
imageVector = if (isOnline) Icons.Default.CheckCircle else Icons.Default.Cancel,
contentDescription = null,
)
Text(if (isOnline) "Online" else "Offline")
}
```
Use theme tokens (`MaterialTheme.colorScheme`) rather than hardcoded colors — theme tokens are designed to meet contrast requirements across light/dark modes.
## Custom Interactive Elements
When using `Modifier.clickable` on a non-Button composable, add semantic role and click label:
```kotlin
Card(
modifier = Modifier
.clickable(onClickLabel = "Open book details") { onBookClick(book.id) }
.semantics { role = Role.Button }
) {
Text(book.title)
}
```
Prefer `Button` / `IconButton` / `TextButton` over custom clickable elements when possible — they include correct semantics, touch targets, and visual feedback out of the box.
## Custom Accessibility Actions
For composables with multiple actions (e.g., a list item with favorite, share, delete), expose named accessibility actions so screen reader users can discover and invoke them without navigating to individual buttons:
```kotlin
Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("Add to favorites") { onFavorite(); true },
CustomAccessibilityAction("Share") { onShare(); true },
)
}
```
The lambda returns `true` if the action was handled successfully.
## MVI Integration
Accessibility does not change the MVI architecture. Key placement rules:
| Concern | Where | Why |
|---|---|---|
| Semantic descriptions (`contentDescription`, `stateDescription`) | Screen / Leaf composables | These are UI-layer concerns — resolve from state close to rendering |
| Semantic keys/enums for dynamic descriptions | `State` data class | e.g., `statusLabel: StringKey` — the UI resolves to a localized string |
| `Modifier.semantics` | Composable `modifier` chains | Applied in the UI layer, never in ViewModel |
| Accessibility-triggered actions (e.g., custom action callbacks) | `onEvent` callbacks → ViewModel | Same as any user interaction — goes through the event pipeline |
Keep accessibility descriptions in the **UI layer**, not in state. State holds semantic keys (enums, string resource keys); the Screen/Leaf composable resolves them to localized strings via `stringResource()`.
## Do / Don't
### Do
- Provide `contentDescription` for every meaningful `Image` and `Icon`
- Use `mergeDescendants` for logically grouped content
- Use `clearAndSetSemantics` when auto-generated text is misleading
- Enforce 48dp minimum touch targets on custom interactive elements
- Pair color with icons/text for status indicators
- Use `MaterialTheme.colorScheme` tokens for contrast-safe colors
- Test with a screen reader on each target platform
### Don't
- Leave `contentDescription = null` on meaningful images without a comment
- Apply `role` manually when a Material component already provides it
- Add extra padding on Material components that already meet touch target requirements
- Rely on color alone to communicate state changes
- Put localized accessibility strings in ViewModel state — use semantic keys and resolve in UI
- Hardcode accessibility text — use `stringResource()` for localization

View File

@@ -1,238 +0,0 @@
# Animations — Advanced Patterns
Shared element transitions, gesture-driven animations, Canvas drawing, and graphicsLayer optimization. For core animation APIs (animate*AsState, Animatable, updateTransition, AnimatedVisibility, AnimatedContent, AnimationSpec) and the animation API decision table, see [animations.md](animations.md).
## Shared Element Transitions
Seamless transitions between composables that share visual content (e.g., list item -> detail screen). Available in both Jetpack Compose and Compose Multiplatform (since CMP 1.7+).
### Core setup
```kotlin
SharedTransitionLayout {
AnimatedContent(showDetails, label = "shared") { targetState ->
if (!targetState) {
ListItem(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
)
} else {
DetailScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
)
}
}
}
```
### sharedElement vs sharedBounds
| | `sharedElement` | `sharedBounds` |
|---|---|---|
| Content | Same content in both states | Visually different content |
| Rendering | Only target content rendered during transition | Both entering and exiting content visible |
| Use for | Hero transitions (same image/icon) | Container transforms (card -> full screen) |
| Text | Avoid (use `sharedBounds`) | Preferred (handles font changes) |
### Modifier usage
```kotlin
Image(
modifier = Modifier.sharedElement(
rememberSharedContentState(key = "image-$id"),
animatedVisibilityScope = animatedVisibilityScope,
)
)
Box(
modifier = Modifier.sharedBounds(
rememberSharedContentState(key = "bounds-$id"),
animatedVisibilityScope = animatedVisibilityScope,
enter = fadeIn(), exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds(),
)
)
```
### Unique keys
```kotlin
data class SharedElementKey(val id: Long, val origin: String, val type: SharedElementType)
enum class SharedElementType { Bounds, Image, Title, Background }
```
### Customize transitions
```kotlin
Modifier.sharedElement(
state = rememberSharedContentState(key = "image"),
animatedVisibilityScope = scope,
boundsTransform = BoundsTransform { initial, target ->
keyframes {
durationMillis = 300
initial at 0 using ArcMode.ArcBelow using FastOutSlowInEasing
target at 300
}
},
)
```
### resizeMode
- `ScaleToBounds()` — scales child layout graphically. Recommended for `Text`.
- `RemeasureToBounds` — re-measures child each frame. Recommended for different aspect ratios.
### With Navigation
Wrap `NavHost` in `SharedTransitionLayout`. Pass both scopes to screens:
```kotlin
SharedTransitionLayout {
NavHost(navController, startDestination = "list") {
composable("list") {
ListScreen(this@SharedTransitionLayout, this@composable)
}
composable("detail/{id}") {
DetailScreen(this@SharedTransitionLayout, this@composable)
}
}
}
```
### Async images (Coil)
For full Coil 3 guidance (API choice, caching strategy, SVG, and CMP resource loading), see [Image Loading](image-loading.md).
```kotlin
AsyncImage(
model = ImageRequest.Builder(LocalPlatformContext.current)
.data(url)
.placeholderMemoryCacheKey("image-$id")
.memoryCacheKey("image-$id")
.build(),
modifier = Modifier.sharedElement(
rememberSharedContentState(key = "image-$id"),
animatedVisibilityScope = scope,
),
)
```
### Overlays and clipping
- `renderInSharedTransitionScopeOverlay()` — keep elements (bottom bar, FAB) on top during transition
- `clipInOverlayDuringTransition` — clip shared element to parent bounds
- `skipToLookaheadSize()` — prevent text reflow during size transitions
### Modifier order
Size modifiers AFTER `sharedElement()`. Inconsistent modifier order between matched elements causes visual jumps.
## Gesture-Driven Animations
### Tap to animate
```kotlin
val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
coroutineScope {
while (true) {
awaitPointerEventScope {
val position = awaitFirstDown().position
launch { offset.animateTo(position) }
}
}
}
}) {
Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
}
```
Interruption: tapping during animation cancels current and starts new, maintaining velocity.
### Swipe to dismiss
```kotlin
fun Modifier.swipeToDismiss(onDismissed: () -> Unit) = composed {
val offsetX = remember { Animatable(0f) }
pointerInput(Unit) {
val decay = splineBasedDecay<Float>(this)
coroutineScope {
while (true) {
val velocityTracker = VelocityTracker()
offsetX.stop()
awaitPointerEventScope {
val pointerId = awaitFirstDown().id
horizontalDrag(pointerId) { change ->
launch { offsetX.snapTo(offsetX.value + change.positionChange().x) }
velocityTracker.addPosition(change.uptimeMillis, change.position)
}
}
val velocity = velocityTracker.calculateVelocity().x
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
offsetX.updateBounds(-size.width.toFloat(), size.width.toFloat())
launch {
if (targetOffsetX.absoluteValue <= size.width) {
offsetX.animateTo(0f, initialVelocity = velocity)
} else {
offsetX.animateDecay(velocity, decay)
onDismissed()
}
}
}
}
}.offset { IntOffset(offsetX.value.roundToInt(), 0) }
}
```
Key patterns: `snapTo` during drag (sync with finger), `animateDecay` for fling, `animateTo(0f)` for snap-back, `VelocityTracker` for fling velocity.
## Canvas and Custom Drawing
### Canvas composable
```kotlin
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(color = Color.Blue, radius = 100f, center = center)
drawRect(color = Color.Red, topLeft = Offset(50f, 50f), size = Size(200f, 200f))
drawLine(Color.Green, start = Offset.Zero, end = Offset(size.width, size.height), strokeWidth = 4f)
}
```
### Drawing modifiers
`Modifier.drawBehind { }` draws behind child content; `Modifier.drawWithContent { drawContent(); … }` draws over or around it.
### Animate canvas content
```kotlin
val progress by animateFloatAsState(if (active) 1f else 0f, label = "progress")
Canvas(Modifier.size(200.dp)) {
drawArc(Color.Blue, startAngle = -90f, sweepAngle = 360f * progress, useCenter = false, style = Stroke(8.dp.toPx()))
}
```
Canvas draws in the Drawing phase — no recomposition needed for visual updates.
## graphicsLayer for Efficient Animation
`graphicsLayer` transforms at the Drawing phase level, avoiding recomposition entirely:
```kotlin
Box(modifier = Modifier.graphicsLayer {
scaleX = animatedScale.value
rotationZ = animatedRotation.value
alpha = animatedAlpha.value
translationX = animatedOffset.value
shadowElevation = animatedElevation.value.toPx()
})
```
```kotlin
// BAD: recomposes every frame
Box(Modifier.scale(scaleX))
// GOOD: transforms in draw phase
Box(Modifier.graphicsLayer { scaleX = animatedScale.value })
```

View File

@@ -1,191 +0,0 @@
# Animations
animate*AsState, Animatable, updateTransition, AnimatedVisibility, AnimatedContent, and AnimationSpec patterns. Works on all CMP targets. For shared element transitions, gesture-driven motion, and graphicsLayer, see [animations-advanced.md](animations-advanced.md).
References:
- [Choose an animation API (Android)](https://developer.android.com/develop/ui/compose/animation/choose-api)
- [Quick guide (Android)](https://developer.android.com/develop/ui/compose/animation/quick-guide)
## MVI Rules for Animation State
- Animation state is **local UI state** — keep in composables, not reducers
- Reducer state = business/UI meaning, not visual tween progress
- Never put `buttonBounceProgress`, `errorShakeCounter`, `skeletonAlpha`, `rowRemovalAnimationPhase` in ViewModel state
## Choosing the Right API
| Question | API |
|---|---|
| SVG/icon animation? | `AnimatedVectorDrawable` (Android), Lottie/Compottie (CMP) |
| Infinite repeat? | `rememberInfiniteTransition` |
| Switching composables? | `AnimatedContent` or `Crossfade` |
| Appear/disappear? | `AnimatedVisibility` |
| Size change? | `Modifier.animateContentSize()` |
| Multiple props together? | `updateTransition` |
| Different timing per prop? | `Animatable` with sequential `animateTo` |
| Single prop with target? | `animate*AsState` |
| Gesture-driven? | `Animatable` with `animateTo`/`snapTo` |
| List item insert/remove/reorder? | `Modifier.animateItem()` |
## AnimationSpec Reference
| Spec | When to use | Key detail |
|---|---|---|
| `spring` (default) | General purpose, interruption-safe | Maintains velocity on target change; `dampingRatio` (bounciness), `stiffness` (speed) |
| `tween` | Need exact duration control | `durationMillis`, `delayMillis`, `easing` (`FastOutSlowInEasing`, `LinearEasing`, etc.) |
| `keyframes` | Specific values at timestamps | `value at millis using easing` |
| `keyframesWithSplines` | Smooth 2D curved paths | `Offset at fraction` |
| `repeatable` / `infiniteRepeatable` | Looping | `iterations`, `repeatMode` (Reverse/Restart) |
| `snap` | Instant jump | Optional `delayMillis` |
**Prefer `spring`** — handles interruption smoothly. `tween` snaps to a new curve on interruption, which feels jarring.
## animate*AsState — Single Value
```kotlin
val alpha by animateFloatAsState(if (enabled) 1f else 0.5f, label = "alpha")
val color by animateColorAsState(if (selected) Color.Blue else Color.Gray, label = "color")
val padding by animateDpAsState(if (expanded) 16.dp else 0.dp, label = "padding")
val offset by animateIntOffsetAsState(if (moved) IntOffset(100, 100) else IntOffset.Zero, label = "offset")
```
Available types: `Float`, `Color`, `Dp`, `Size`, `Offset`, `Rect`, `Int`, `IntOffset`, `IntSize`. Custom types via `animateValueAsState` with `TwoWayConverter`.
**Performance tips:**
- `Modifier.drawBehind { drawRect(animatedColor) }` is more performant than `Modifier.background()` for animated colors
- `Modifier.graphicsLayer { scaleX = scale; scaleY = scale }` for transforms — Drawing phase only
- Set `textMotion = TextMotion.Animated` for smooth text scale transitions
## Animatable — Coroutine-Based Control
```kotlin
val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
LaunchedEffect(targetPosition) { offset.animateTo(targetPosition) }
Box(Modifier.offset { offset.value.toIntOffset() })
```
| Operation | Purpose |
|---|---|
| `animateTo(target)` | Animate to target (suspends) |
| `snapTo(value)` | Instant set (gesture sync) |
| `animateDecay(velocity, decay)` | Fling deceleration |
| `stop()` | Cancel animation |
| `updateBounds(lower, upper)` | Constrain range |
```kotlin
// Sequential
LaunchedEffect(Unit) {
alphaAnim.animateTo(1f)
yAnim.animateTo(100f)
}
// Concurrent
LaunchedEffect(Unit) {
launch { alphaAnim.animateTo(1f) }
launch { yAnim.animateTo(100f) }
}
```
New `animateTo` cancels ongoing animation and continues from current value/velocity — no jumpiness.
## updateTransition — Multi-Property State Machine
```kotlin
enum class CardState { Collapsed, Expanded }
val transition = updateTransition(cardState, label = "card")
val size by transition.animateDp(label = "size") { state ->
when (state) { CardState.Collapsed -> 64.dp; CardState.Expanded -> 128.dp }
}
val color by transition.animateColor(label = "color") { state ->
when (state) { CardState.Collapsed -> Color.Gray; CardState.Expanded -> Color.Red }
}
```
Per-transition timing: `transitionSpec = { when { Expanded isTransitioningTo Collapsed -> spring(stiffness = 50f); else -> tween(500) } }`.
Start immediately: `MutableTransitionState(Collapsed).apply { targetState = Expanded }`.
Coordinated children: `transition.AnimatedVisibility(visible = { it == Expanded }) { ... }` and `transition.AnimatedContent { ... }`.
## rememberInfiniteTransition
Shimmer, pulsing indicators, loading spinners:
```kotlin
val infiniteTransition = rememberInfiniteTransition(label = "infinite")
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
label = "alpha",
)
```
## AnimatedVisibility
```kotlin
AnimatedVisibility(
visible = isVisible,
enter = fadeIn() + slideInVertically { -40.dp.roundToPx() },
exit = slideOutVertically() + fadeOut(),
) { Text("Hello") }
```
| Enter | Exit |
|---|---|
| `fadeIn` | `fadeOut` |
| `slideIn` / `slideInHorizontally` / `slideInVertically` | `slideOut` / `slideOutHorizontally` / `slideOutVertically` |
| `scaleIn` | `scaleOut` |
| `expandIn` / `expandHorizontally` / `expandVertically` | `shrinkOut` / `shrinkHorizontally` / `shrinkVertically` |
Combine with `+`. Per-child: `Modifier.animateEnterExit(enter = ..., exit = ...)`. Use `EnterTransition.None`/`ExitTransition.None` on parent to let children define their own.
## AnimatedContent
```kotlin
AnimatedContent(
targetState = uiState,
transitionSpec = {
if (targetState > initialState)
slideInVertically { it } + fadeIn() togetherWith slideOutVertically { -it } + fadeOut()
else
slideInVertically { -it } + fadeIn() togetherWith slideOutVertically { it } + fadeOut()
using SizeTransform(clip = false)
},
label = "content",
) { target ->
when (target) {
UiState.Loading -> LoadingScreen()
UiState.Success -> SuccessScreen()
UiState.Error -> ErrorScreen()
}
}
```
`SizeTransform` controls size animation between states. Always use the lambda parameter (`target`), not the outer variable.
## Performance Rules
- `spring` as default — handles interruption, physically natural
- `Modifier.offset { }` (lambda) defers to Layout phase
- `graphicsLayer { }` for visual transforms — Drawing phase only, cheapest
- `drawBehind` for animated colors instead of `background()`
- `animateContentSize` BEFORE size modifiers in chain
- In `AnimatedContent`/`AnimatedVisibility`: use lambda parameter, not outer variable
## Anti-Patterns
| Anti-pattern | Why | Fix |
|---|---|---|
| Animation state in ViewModel | Pollutes business state | Local `animate*AsState` or `Animatable` |
| `Modifier.scale()`/`.offset()` | Recomposition every frame | `graphicsLayer { scaleX = ...; translationX = ... }` |
| Animating every change | Jittery UI | Animate meaningful transitions only |
| `animateContentSize` after size modifiers | No effect | Place BEFORE `size`/`fillMaxWidth` |
| Outer variable in AnimatedContent | Stale during exit | Use lambda parameter |
| `tween`/`snap` everywhere | Jarring interruption | Prefer `spring` |
| Animating padding/size every frame | Expensive Layout phase | Prefer `graphicsLayer` transforms |
## Advanced Patterns
For shared element transitions, gesture-driven animations, Canvas, and graphicsLayer optimization, see [animations-advanced.md](animations-advanced.md).

View File

@@ -1,109 +0,0 @@
# Anti-Patterns
Quick-reference table of cross-cutting patterns that hurt MVI Compose Multiplatform codebases. Domain-specific anti-patterns (navigation, networking, paging, DI, etc.) live in their respective reference files — see the "Detailed in" column.
For overengineering patterns (bloated base classes, unnecessary use cases, 4-type MVI), see [clean-code.md](clean-code.md).
## Cross-Cutting Anti-Patterns
| Anti-pattern | Why it is harmful | Better replacement | Detailed in |
|---|---|---|---|
| Business logic inside composables | forks source of truth, hurts testability, reruns during composition | move logic into ViewModel/domain services | [architecture.md](architecture.md) |
| Giant god-ViewModel | blast radius too large, slow reasoning, hard ownership | one ViewModel per screen or independent flow | [architecture.md](architecture.md) |
| Scattered `updateState`/`sendEffect` with no structure | state transitions hard to trace, mutations across callbacks | disciplined `onEvent()` as single entry point | [clean-code.md](clean-code.md) |
| Unstable state models (mutable collections, lambdas in state) | defeats Compose skipping, more recomposition | immutable data classes, immutable collections | [performance.md](performance.md) |
| Duplicated derived data (`total`, `formattedTotal`, `hasTotal` all stored) | bugs from drift, harder transitions | keep canonical value + derive via computed property | [architecture.md](architecture.md) |
| Broad state reads in parent composables | recomposition cascades to all children | slice state, pass only required props to each child | [performance.md](performance.md) |
| Mutable state passed deep into tree | hidden writes, unpredictable data flow | explicit props + callbacks | [compose-essentials.md](compose-essentials.md) |
| One-off events stored as consumable state (`showSnackbarOnce = true`) | event replay on config change, stale effects | separate `Effect` via `Channel` | [architecture.md](architecture.md) |
| No-op state emissions (copy state when nothing changed) | wasted recomposition cycles | guard unchanged values before updating | [performance.md](performance.md) |
| Full-screen loading wipes existing content | bad UX, layout jumps, lost user trust | keep old content + inline refresh indicator | [ui-ux.md](ui-ux.md) |
| ViewModel doing platform work directly (share, analytics, navigation) | breaks testability, platform coupling | emit effects, handle in Route composable | [architecture.md](architecture.md) |
| Animation state in ViewModel for no reason (`shakeCount`, `alpha`) | pollutes business state | local composable animation state | [animations.md](animations.md) |
| Display strings stored too early (ViewModel emits pre-baked formatted text) | locale inflexibility, state duplication, harder reuse | keep canonical values until presentation boundary | [architecture.md](architecture.md) |
| Poor lazy list keys (no key or index-based) | state jumps between rows, broken animations | stable key by domain ID | [lists-grids.md](lists-grids.md) |
| Too many trivial composables (wrappers around single `Text`/`Spacer`) | fragmentation, harder reading | extract only meaningful boundaries | [clean-code.md](clean-code.md) |
| Platform abstraction too early (interfaces for everything before pain) | unnecessary indirection, poor fit | share business logic first, abstract real platform capabilities only | [cross-platform.md](cross-platform.md) |
| Forcing MVI migration on existing codebase | churn without value, team friction | respect existing patterns, introduce MVI for new features only | [clean-code.md](clean-code.md) |
| Inline fully qualified package paths | hurts readability, clutters business logic, hides intent behind package noise | import at file top; use `import ... as ...` for name clashes | [clean-code.md](clean-code.md) |
## Examples
### Business logic inside composables
```kotlin
// BAD — logic in composable; untestable, reruns on every recomposition
@Composable
fun CheckoutScreen(viewModel: CheckoutViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
val total = state.items.sumOf { it.price * it.qty } // business logic here
val tax = total * 0.08
Text("Total: $${"$"}total Tax: $${"$"}tax")
}
// GOOD — derive in ViewModel/state, composable only renders
data class CheckoutState(
val items: List<LineItem> = emptyList(),
val total: Double = 0.0,
val tax: Double = 0.0,
)
@Composable
fun CheckoutScreen(state: CheckoutState, onEvent: (CheckoutEvent) -> Unit) {
Text("Total: ${state.total} Tax: ${state.tax}")
}
```
### One-off events as consumable state booleans
```kotlin
// BAD — event replays on config change, race between read and reset
data class UiState(val showSnackbar: Boolean = false)
LaunchedEffect(state.showSnackbar) {
if (state.showSnackbar) {
snackbarHostState.showSnackbar("Saved")
viewModel.onEvent(DismissSnackbar) // consumer must remember to reset
}
}
// GOOD — Channel delivers exactly once, survives config change
sealed interface Effect { data class ShowSnackbar(val msg: String) : Effect }
CollectEffect(viewModel.effects) { effect ->
when (effect) {
is Effect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.msg)
}
}
```
## Domain-Specific Anti-Patterns
These reference files contain their own anti-pattern sections with detailed BAD/GOOD code examples:
| Domain | Reference | What it covers |
|---|---|---|
| Architecture & MVI | [architecture.md](architecture.md) | event handling, state modeling, effect misuse, domain layer violations |
| Overengineering | [clean-code.md](clean-code.md) | bloated base classes, 4-type MVI, use case wrappers, naming |
| Coroutines & Flow | [coroutines-flow.md](coroutines-flow.md) | GlobalScope, blocking dispatchers, unbound scopes, stateIn misuse |
| Performance | [performance.md](performance.md) | recomposition, stability, state shape, read boundaries |
| Compose Essentials | [compose-essentials.md](compose-essentials.md) | side effects, modifier ordering, CompositionLocal |
| Animations | [animations.md](animations.md) | ViewModel animation state, graphicsLayer misuse, over-animating |
| Lists & Grids | [lists-grids.md](lists-grids.md) | keys, nested scrolling, contentType |
| UI/UX | [ui-ux.md](ui-ux.md) | disappearing content, layout jumps, loading states |
| Navigation (shared) | [navigation.md](navigation.md) | MVI navigation rules, anti-patterns for both Nav 2 and Nav 3 |
| Paging | [paging-mvi-testing.md](paging-mvi-testing.md) | PagingData in UiState, key misuse, LoadState handling |
| Networking | [networking-ktor-testing.md](networking-ktor-testing.md) | MockEngine, DI integration, testing anti-patterns |
| Network Architecture | [networking-ktor-architecture.md](networking-ktor-architecture.md) | plugin composition, error strategy, client lifecycle, result wrapper choice |
| Room Database | [room-database.md](room-database.md) | entity design, DAO patterns, migrations |
| DataStore | [datastore.md](datastore.md) | singleton enforcement, blocking reads, corruption |
| DI (Koin) | [koin.md](koin.md) | module organization, scoping, ViewModel injection |
| DI (Hilt) | [hilt.md](hilt.md) | module structure, scoping, testing |
| Image Loading | [image-loading.md](image-loading.md) | cache policy, transformations, placeholder usage |
| Testing | [testing.md](testing.md) | missing ViewModel tests, mocking DI, testing internals |
| Cross-Platform | [cross-platform.md](cross-platform.md) | expect/actual misuse, premature abstraction |
| iOS Interop | [ios-swift-interop.md](ios-swift-interop.md) | naming, nullability, Flow bridging |
| Resources | [resources.md](resources.md) | Android R vs CMP Res, qualifier usage |
| Material Design | [material-design.md](material-design.md) | theme setup, component choice, adaptive layouts |
| Accessibility | [accessibility.md](accessibility.md) | missing semantics, touch targets, contrast |
| Gradle & Build | [gradle-build.md](gradle-build.md) | hardcoded versions, buildSrc, convention plugin timing |

View File

@@ -1,204 +1,100 @@
# Architecture & State Management
# VniDrop presentation architecture
Shared architecture concepts for MVI and MVVM. Load first for architecture questions, then see [mvi.md](mvi.md) or [mvvm.md](mvvm.md) for pattern-specific details.
Use for feature structure, state ownership, deepening, or ViewModel/UI seams.
Preservation rule: if the project already has a coherent screen architecture pattern (MVI, MVVM, or variant), preserve it unless the user explicitly asks to migrate or the current pattern cannot satisfy a required constraint.
## Source of Truth
Per screen:
- **Screen behavior:** `StateFlow<ScreenState>` owned by the screen state holder, often a ViewModel
- **Persisted data:** repository / database / remote service
- **Local visual-only concerns:** local Compose state in the route or leaf composable
Do not mix them.
## Choosing a State Owner
| Situation | Default owner | Why |
|---|---|---|
| Visual state for one composable subtree | Local Compose state | Smallest scope, easiest reuse |
| Complex UI logic, no business/data responsibilities | Plain state holder class | Testable without ViewModel |
| Screen-level business rules, async, persistence, effects | ViewModel | Lifecycle integration, screen state ownership |
A ViewModel is one implementation of a screen state holder, not a requirement for every composable.
## MVI vs MVVM Decision Guide
Both use unidirectional data flow with `StateFlow<State>` and `Channel<Effect>`. The difference is how UI actions reach the ViewModel.
| Criterion | MVI | MVVM |
|---|---|---|
| UI-to-VM contract | `sealed interface Event` + `onEvent()` | Named public functions |
| Boilerplate | Higher (sealed class + when) | Lower (direct calls) |
| Testing input | Single `onEvent()` entry point | Multiple function entry points |
| Best for | Many events, event logging, analytics | Simpler screens, less ceremony |
**Choose MVI when:** project uses MVI, many user actions to enumerate, need exhaustive event contracts.
**Choose MVVM when:** project uses MVVM, few actions, team prefers direct function calls.
**Default:** preserve the project's existing pattern.
## When to Use Lighter Patterns
- Purely presentational leaf composables
- Small screens with trivial local state and no async/persistence
- Prototypes unless user asks to formalize
- Do not invent reducers, result types, or global frameworks unless they earn their keep
## Domain Layer
Pure business logic. Zero platform dependencies — runs in `commonTest` without emulators.
| Rule | Rationale |
|---|---|
| Zero platform imports | Testable anywhere, shareable |
| Domain models ≠ DTOs or entities | Decouples from API/DB schema |
| Repository interfaces in domain, impls in data | Dependency inversion |
| Mappers at data boundary | Domain ignores serialization (see [networking-ktor.md](networking-ktor.md)) |
| Use cases only for multi-step orchestration | Don't wrap single repo calls |
```kotlin
data class Item(val id: String, val name: String, val status: ItemStatus)
interface ItemRepository {
suspend fun getById(id: String): Item?
suspend fun save(item: Item)
}
class CreateItemUseCase(private val repository: ItemRepository, private val validator: ItemValidator) {
suspend operator fun invoke(name: String, status: ItemStatus): Result<Item> {
val errors = validator.validate(name)
if (errors.isNotEmpty()) return Result.failure(ValidationException(errors))
val item = Item(id = uuid(), name = name.trim(), status = status)
repository.save(item)
return Result.success(item)
}
}
```
## Inter-Feature Communication
| Need | Pattern | Why |
|---|---|---|
| React to event from another feature | Event bus (`SharedFlow`) | Fire-and-forget, many listeners |
| Navigate to another feature | Feature API contract (`:api` module) | Type-safe, no impl dependency |
| Pass data back | Feature API + callback | Structured return, testable |
| Shared data stream (current user) | Shared repository in `core` | Persistent state, not one-shot |
**Anti-patterns:** importing another feature's ViewModel, global "god event bus" with 50 events, cross-feature data via `CompositionLocal`.
For the full api/impl split pattern, see [navigation-3-di.md](navigation-3-di.md) Modularization section.
## Module Dependency Rules
## Module shape
```text
app -> feature:*:impl, feature:*:api, core:*
feature:*:impl -> feature:*:api (any feature), core:*
feature:*:api -> core:designsystem (route types only)
core:data -> core:network, core:database, core:datastore
Route
├─ collects StateFlow
├─ invokes platform adapters
├─ collects semantic effects
└─ navigates
Deep presentation module
├─ immutable state
├─ named methods
├─ domain orchestration
└─ internal seams
CoreGateway + platform adapters
```
| Forbidden | Why |
| Concern | Owner |
|---|---|
| `feature:impl` → another `feature:impl` | Circular risk |
| `feature:api` → any `feature` | API contracts must be leaf dependencies |
| `core:*``feature:*` or `app` | Core cannot depend on consumers |
| Domain → Data layer | Domain declares interfaces, data implements |
| Domain-facing presentation state | Feature ViewModel/module |
| Platform picker invocation | Route |
| Navigation | Route/app navigation |
| Transfer creation and durable state | Rust through `CoreGateway` |
| Source preparation | Android/JVM adapter |
| Rendering | Screen and leaf composables |
| One-shot feedback | Existing effect/`UiMessageController` pattern |
## State Modeling for Forms and Calculators
## Depth checks
Split into four buckets:
- Apply the deletion test: deleting a deep module must spread meaningful behavior back across multiple callers.
- The interface is the test surface. If tests need private state, reshape the module.
- Keep adapters internal unless callers genuinely select them.
- Prefer a concrete class when only one implementation exists.
- Do not create an `Actions` bag to conceal a wide interface.
- Do not split by arbitrary file size when the pieces still share one interface and invariant set.
1. **Editable input** — raw text/choice values as the user edits
2. **Derived/computed** — parsed, validated, calculated values
3. **Persisted snapshot** — existing saved entity for dirty tracking
4. **Transient UI-only** — only when purely visual and not business-significant
## State rules
| Concern | Where | Example |
|---|---|---|
| Raw field text | `state` | `"12"`, `"12."`, `""` |
| Parsed value | computed property or `state` | `val amount get() = amountText.toDoubleOrNull()` |
| Validation | `state.errors` | `mapOf("area" to "Required")` |
| Calculated totals | `state` or computed | subtotal, tax |
| Loading/refresh | `state` flags | `isSaving`, `isLoading` |
| One-off commands | `Effect` via Channel | snackbar, navigate |
| Scroll/focus/animation | local Compose state | `LazyListState`, expansion toggle |
- Make states immutable and equality-friendly.
- Store durable/product-significant state in the module; keep focus, scroll, animation, and transient expansion local to Compose.
- Derive values instead of storing duplicates.
- Track provenance when derivation must stop after user editing, such as automatic Transfer draft names.
- Model loading without discarding valid content.
- Freeze invalid concurrent operations explicitly in state.
Use computed properties for trivial derivations:
## Transfer draft seam
The external seam is a session-scoped MVVM module used by both callers.
```kotlin
data class CreateItemState(
val title: String = "",
val amount: String = "",
val isSaving: Boolean = false,
val errors: Map<String, String> = emptyMap()
) {
val canSave: Boolean get() = title.isNotBlank() && amount.isNotBlank()
val hasErrors: Boolean get() = errors.isNotEmpty()
class TransferDraftViewModel(...) : ViewModel() {
val state: StateFlow<TransferDraftState>
val outputs: Flow<TransferDraftOutput>
fun openInvitation(defaultSenderName: String)
fun openTargeted(device: LockedSavedDevice)
fun chooseFiles()
fun chooseFolder()
fun onPickerResult(requestId: Long, result: Result<List<PickedShareFile>>)
fun changeTransferName(value: String)
fun removeSource(id: DraftSourceId)
fun submit()
fun dismiss()
}
```
**Avoid duplicated state:** don't store `total` + `formattedTotal` + `totalText`, or `showErrorDialog` + `pendingError` when one implies the other.
The exact implementation may evolve, but preserve these invariants:
## Where Logic Belongs
- One immutable destination per open session.
- One in-flight picker and submission.
- Correlate picker callbacks; discard stale owned copies.
- Atomic source replacement.
- Failure preserves the draft.
- Success/dismissal releases owned copies exactly once.
- Semantic creation output; no navigation inside the module.
- Invitation and Targeted results remain distinct.
| Logic | Where |
## Migration order
1. Extract Invitation composition and interface-level tests.
2. Switch Targeted creation to the same module.
3. Delete the one-shot Saved-device picker flow.
4. Promote Saved devices into its own route.
5. Add Targeted transfer detail and lifecycle presentation.
6. Remove the experimental UI gate atomically after parity.
## Anti-patterns
| Anti-pattern | Better replacement |
|---|---|
| Validation | ViewModel/domain — never in composable body |
| Calculations | Pure calculator/domain service called by ViewModel |
| Async orchestration | ViewModel — launch/cancel, debounce, ignore stale |
| Side effects | ViewModel via `Effect` or `viewModelScope.launch` |
| Local UI state | Composable — `LazyListState`, focus, animation, expansion, tooltip |
Not acceptable in composables: validation, derived totals, data loading, submit enablement, business decisions.
## Effect Delivery
`Channel<Effect>(Channel.BUFFERED)` with `receiveAsFlow()` — default for single-consumer effects. Buffers for reliable delivery, single consumer, no replay. `SharedFlow(replay=0)` acceptable for truly fire-and-forget signals. Preserve existing `SharedFlow` effect mechanism when consistent.
## Reactive Data Collection
```kotlin
private fun collectData() {
viewModelScope.launch {
repository.observe()
.catch { sendEffect(ShowError(it.message ?: "Load failed")) }
.collect { data -> updateState { copy(items = data, isLoading = false) } }
}
}
```
Room and DataStore `Flow` queries auto-re-emit on changes. Map data-layer types to domain models at the repository boundary.
## State Collection and Slicing
**Default:** collect whole screen state once at the route boundary, slice downward.
- `Route` collects `StateFlow<ScreenState>`
- `Screen` receives `ScreenState`
- Leaves receive **only what they need**
- Do **not** make leaves observe the ViewModel directly
### Callbacks at Boundaries
- MVI: `onEvent(Event)` at route/screen boundary; leaves prefer specific callbacks
- MVVM: individual callbacks at screen boundary; same narrowing for leaves
- Reusable components must not know your event contract or ViewModel type
## Adapting to Existing Projects
| Project has | Action |
|---|---|
| MVI with base class (`MviHost`, `BaseViewModel`) | Use it. Don't introduce competing base. See [mvi.md](mvi.md) |
| MVVM without strict MVI | Preserve it. Match conventions. See [mvvm.md](mvvm.md) |
| Plain state holder classes | Valid. Only move to ViewModel when screen needs async/persistence/lifecycle |
| 4-type MVI (Event, Result, State, Effect) | Use `Result` as project expects. Don't strip out |
| No architecture | Choose MVI or MVVM per guide above. Trivial screens: local state is fine |
## Scaling Notes
- Small screens: one file for contract + ViewModel
- Medium: split contract, ViewModel, screen, route
- Large: extract calculation, validation, formatting into dedicated collaborators
- Do **not** create nested state holders for every card/section by default — only when independent lifecycle, async, tests, and real reuse justify it
| Parent ViewModels duplicate source rules | One Transfer draft module |
| `FileSystemService` creates transfers | Source adapter prepares; module calls `CoreGateway` |
| Generic transfer type erases domain | Explicit Invitation/Targeted variants |
| Route contains state machine | Route binds adapters and navigation only |
| Tests mock internals | Test through module state/methods/outputs |

View File

@@ -1,290 +0,0 @@
# CI/CD & Distribution
CI/CD and native distribution for Compose Multiplatform: Android, Desktop (JVM), and iOS.
## 1. Distribution Overview
| Platform | Output | Gradle Task | Notes |
|----------|--------|-------------|-------|
| Android | APK/AAB | `assembleRelease`/`bundleRelease` | Standard distribution |
| Desktop macOS | DMG | `packageDmg` | Needs signing for Gatekeeper |
| Desktop Windows | MSI | `packageMsi` | Optional signing |
| Desktop Linux | DEB | `packageDeb` | Package manager format |
| iOS | .app/.ipa | Xcode Archive | Gradle builds framework only |
## 2. GitHub Actions — Android
```yaml
name: Android Build
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew :androidApp:assembleRelease
- uses: actions/upload-artifact@v4
with:
name: android-apk
path: androidApp/build/outputs/apk/release/*.apk
```
### With Signing
```yaml
- name: Decode Keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > release.keystore
- run: ./gradlew :androidApp:assembleRelease
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
```
## 3. GitHub Actions — Desktop Multi-Platform
```yaml
name: Desktop Build
on:
workflow_dispatch:
inputs:
build_macos: { type: boolean, default: true }
build_windows: { type: boolean, default: true }
build_linux: { type: boolean, default: true }
jobs:
build-macos:
if: ${{ inputs.build_macos }}
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'temurin' }
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew :desktopApp:packageDmg
- uses: actions/upload-artifact@v4
with:
name: macos-dmg
path: desktopApp/build/compose/binaries/main/dmg/*.dmg
build-windows:
if: ${{ inputs.build_windows }}
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'temurin' }
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew :desktopApp:packageMsi
- uses: actions/upload-artifact@v4
with:
name: windows-msi
path: desktopApp/build/compose/binaries/main/msi/*.msi
build-linux:
if: ${{ inputs.build_linux }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'temurin' }
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew :desktopApp:packageDeb
- uses: actions/upload-artifact@v4
with:
name: linux-deb
path: desktopApp/build/compose/binaries/main/deb/*.deb
```
## 4. Desktop App Module
```kotlin
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler)
}
kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(projects.composeApp)
}
}
}
compose.desktop {
application {
mainClass = "com.example.MainKt"
// Required for DataStore/serialization
jvmArgs += listOf(
"--add-opens", "java.base/java.lang=ALL-UNNAMED",
"--add-opens", "java.base/sun.nio.ch=ALL-UNNAMED"
)
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "MyApp"
packageVersion = "1.0.0"
modules("jdk.unsupported")
macOS {
bundleID = "com.example.myapp"
iconFile.set(project.file("icons/icon.icns"))
// signing { sign.set(true); identity.set("Developer ID Application: ...") }
}
windows {
iconFile.set(project.file("icons/icon.ico"))
upgradeUuid = "YOUR-UUID" // Keep constant across versions
}
linux {
iconFile.set(project.file("icons/icon.png"))
}
}
}
}
```
## 5. iOS Xcode Integration
iOS uses Xcode, not Gradle. Gradle builds the shared framework; Xcode embeds it.
### Framework in `composeApp`
```kotlin
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "ComposeApp"
isStatic = true // Required for App Store
}
}
}
```
### Xcode Build Phase Script
Add "Run Script" before "Compile Sources":
```bash
cd "$SRCROOT/.."
./gradlew :composeApp:embedAndSignAppleFrameworkForXcode
```
### Swift Entry Point
```swift
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
init() { AppKt.doInitKoin() }
var body: some Scene {
WindowGroup {
ComposeViewControllerRepresentable().ignoresSafeArea()
}
}
}
struct ComposeViewControllerRepresentable: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
```
## 6. Signing
### Android
```kotlin
android {
signingConfigs {
create("release") {
storeFile = file("release.keystore")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
}
buildTypes {
release { signingConfig = signingConfigs.getByName("release") }
}
}
```
### macOS (Direct Distribution)
```kotlin
macOS {
signing {
sign.set(true)
identity.set("Developer ID Application: Your Name (TEAM_ID)")
}
notarization {
appleID.set("your-email@example.com")
password.set("@keychain:AC_PASSWORD")
teamID.set("YOUR_TEAM_ID")
}
}
```
### iOS
Handled by Xcode via `CODE_SIGN_STYLE = Automatic` and `DEVELOPMENT_TEAM`.
## 7. Adding Desktop to Existing CMP Project
1. Add `jvm()` target in `composeApp`:
```kotlin
kotlin {
jvm()
sourceSets {
jvmMain.dependencies { /* desktop deps */ }
}
}
```
2. Add KSP for JVM: `add("kspJvm", libs.room.compiler)`
3. Create `desktopApp` module with `compose.desktop {}` config
4. Add `include(":desktopApp")` to `settings.gradle.kts`
## 8. Gradle Tasks
| Platform | Build | Package | Run |
|----------|-------|---------|-----|
| Android | `assembleRelease` | `bundleRelease` | — |
| Desktop | `jvmJar` | `packageDmg`/`packageMsi`/`packageDeb` | `run` |
| iOS | `compileKotlinIosArm64` | Xcode Archive | Xcode |
## 9. Troubleshooting
| Issue | Solution |
|-------|----------|
| `InaccessibleObjectException` | Add `--add-opens` JVM args |
| "App is damaged" on macOS | Enable code signing |
| Framework not found in Xcode | Check `FRAMEWORK_SEARCH_PATHS` |
| Windows MSI won't upgrade | Keep `upgradeUuid` constant |

View File

@@ -1,210 +0,0 @@
# Clean Code & Avoiding Overengineering
## Disciplined vs Bloated vs Overengineered MVI
### Disciplined MVI
One feature ViewModel, one clear state model, one `onEvent()` function, small number of effects, explicit UI contracts, shared business logic, direct feature names.
### Bloated MVI
Too many tiny sealed types, every action wrapped twice, separate mapper/presenter/handler for trivial screens, verbose generic layers with little value.
### Overengineered MVI
Generic frameworks and base abstractions replace feature code, trivial repository calls get use-case wrappers, and 4-type MVI with mandatory pure reducers appear before screens actually need them.
## Decision Rules
### When an Event sealed class is enough
Almost always. Use one sealed interface per feature.
### When event hierarchies become excessive
When you see: `UserEvent`, `UiEvent`, `SystemEvent`, `InternalEvent`, `ViewEvent`, `ActionEvent` — three wrappers before any feature logic — child components that need to know root feature events.
### When to model effects separately
When the action leaves the ViewModel's state-management scope: network, persistence, delay/debounce, navigation, snackbar, haptics, share, analytics. Do **not** create an effect for plain synchronous state changes.
### When you need a Result/PartialState type (4th type)
Rarely. Consider it only when: the same state transition is triggered by many different sources (events, async completions, WebSocket messages, push notifications) and you want to centralize all transitions in one pure function. For most screens, `onEvent()` handling state updates directly is simpler and more readable.
### When a generic base ViewModel helps
When you have 10+ features and the boilerplate of `MutableStateFlow` + `Channel` + `onEvent()` is genuinely repetitive. A thin base class or interface that provides `updateState()`, `sendEffect()`, and `currentState` is fine. A base class that forces `handleEvent()` + `reduce()` + `dispatch()` + `asyncAction()` is overengineering unless the entire team has agreed on it.
### When a screen should have a dedicated ViewModel
When the screen has: async data, multi-field editing, validation, derived calculations, navigation effects, retry/refresh flow, persistent draft/original comparison.
### When a lighter state holder is enough
For purely visual tab selection, local expansion, local scroll affordance, tooltip/menu visibility. That is local UI state, not architecture.
### When to extract reusable UI
When the component has real reuse, a stable API, and a meaningful visual/behavioral boundary. Examples: `MoneyField`, `ResultCard`, `ValidationMessage`, `SettingsToggleRow`.
### When not to extract
Do not extract: one-line wrappers around `Text`, wrappers that only forward modifiers, components "reusable" in theory but used once, components whose props are harder to understand than the inline code.
### When a use case is useful
When logic is multi-step, reused, policy-heavy, test-worthy on its own, and not just repository pass-through.
### When a use case is ceremony
```kotlin
class GetSettingsUseCase(private val repository: SettingsRepository) {
suspend operator fun invoke() = repository.getSettings()
}
```
That is usually ceremony.
## Comparison Table
| Area | Good architecture | Overengineering |
|---|---|---|
| ViewModel | `ProductViewModel` with `onEvent()` | `BaseMviViewModel<State, Intent, Effect, Result>` with `handleEvent()` + `reduce()` |
| Events | one feature sealed interface | multi-layer intent taxonomy |
| State updates | inline `updateState { copy(...) }` in `onEvent()` | separate `Result` type + pure `reduce()` function for simple screens |
| Effects | only for impure one-shot actions | effects for trivial synchronous transitions |
| UI | route + dumb screen + meaningful leaves | every row has its own ViewModel/presenter |
| Use cases | used for real domain logic | one wrapper per repository call |
| Modules | feature-first (see Module Dependency Rules in architecture.md for multi-module arrows) | giant "domain/data/presentation" package islands |
| Platform abstractions | introduced when needed | abstracted preemptively everywhere |
| Navigation | semantic effect + route binding | global command bus + abstract navigator hierarchy |
| Naming | `ProductState`, `ProductEvent` | `FeatureContract.State`, `FeatureContract.Action` |
### Feature-first organization
**Default:** organize by feature first, then by internal layers only when needed.
Good:
```text
feature-product/
domain/
data/
presentation/
ui/
```
Bad:
```text
presentation/
product/
settings/
history/
domain/
product/
settings/
history/
data/
product/
settings/
history/
```
The second form becomes a horizontal maze fast.
## Naming Conventions
| Concept | Recommended | Avoid |
|---|---|---|
| Event | `ProductEvent` | `ProductActionEventIntent` |
| State | `ProductState` | `ProductViewState`, `Contract.State` |
| Effect | `ProductEffect` | `ProductCommandEffectSideEffect`, `SingleLiveEvent` |
| Contract file | `ProductContract.kt` | separate files per type for small screens |
| ViewModel | `ProductViewModel` | `BaseProductViewModel` |
| Route | `ProductRoute` | `ProductContainerFragmentLikeThing` |
| Screen | `ProductScreen` | `ProductView` |
| Leaf component | `ResultCard`, `ProductForm` | `ProductFormWidgetComponentView` |
## Import Hygiene
**Strict rule:** never write fully qualified package paths inline. Always import at the top of the file. Use `import ... as ...` with a descriptive alias when two types share the same simple name.
### BAD — inline fully qualified name
```kotlin
val unit = com.example.app.data.db.entity.enums.WeightUnit.entries
.find { it.name == rawValue }
```
### GOOD — proper import
```kotlin
import com.example.app.data.db.entity.enums.WeightUnit
val unit = WeightUnit.entries.find { it.name == rawValue }
```
### GOOD — import alias for name clashes
```kotlin
import com.example.app.data.db.entity.enums.WeightUnit as DbWeightUnit
import com.example.app.domain.model.WeightUnit
val dbUnit = DbWeightUnit.entries.find { it.name == rawValue }
val domainUnit = WeightUnit.fromDb(dbUnit)
```
**Alias naming:** prefix or suffix with the distinguishing layer — `Db`, `Domain`, `Ui`, `Api`, `Dto`.
## Code Examples
For base ViewModel patterns (abstract class and interface + delegate), see [architecture.md](architecture.md).
For when a thin base helps versus an overengineered stack, see Decision Rules → "When a generic base ViewModel helps."
### BAD: 4-type MVI forced on every screen
Event → Result mapping is 1:1 with no transformation; the `Result` type adds nothing for a simple currency picker.
```kotlin
class CurrencyViewModel : MviViewModel<CurrencyEvent, CurrencyResult, CurrencyState, CurrencyEffect>(...) {
override fun handleEvent(e: CurrencyEvent) = when (e) {
is CurrencyEvent.OnSelected -> dispatch(CurrencyResult.CurrencySelected(e.currency))
}
override fun reduce(r: CurrencyResult, s: CurrencyState) = reduce(s) {
when (r) {
is CurrencyResult.CurrencySelected -> {
effect(CurrencyEffect.NavigateBack(r.currency))
state(s.copy(selected = r.currency))
}
}
}
}
```
### GOOD: same screen with 3-type MVI
```kotlin
sealed interface CurrencyEvent { data class OnSelected(val currency: Currency) : CurrencyEvent }
data class CurrencyState(val selected: Currency? = null)
sealed interface CurrencyEffect { data class NavigateBack(val currency: Currency) : CurrencyEffect }
class CurrencyViewModel : ViewModel() {
private val _state = MutableStateFlow(CurrencyState())
val state = _state.asStateFlow()
private val _effect = Channel<CurrencyEffect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()
fun onEvent(event: CurrencyEvent) = when (event) {
is CurrencyEvent.OnSelected -> {
_state.update { it.copy(selected = event.currency) }
_effect.trySend(CurrencyEffect.NavigateBack(event.currency))
}
}
}
```
Direct, readable, testable. No intermediate type.
### GOOD: MVI ViewModel with async work
Full annotated example with `CreateItemViewModel` (standalone and base-class variants): see [architecture.md](architecture.md) Code Examples section.

View File

@@ -1,232 +0,0 @@
# Compose Essentials
Foundational Compose patterns that complement MVI architecture. Consult this when working with Compose APIs directly.
## Three Phases Model
Every frame consists of three phases. Understanding which phase reads state prevents unnecessary recompositions.
1. **Composition** — executes composable functions, evaluates state reads. State reads here trigger recomposition of the entire scope.
2. **Layout** — calculates size and position, runs `measure` and `layout` blocks. Can read state without triggering composition recomposition.
3. **Drawing** — emits draw operations, runs `Canvas` and custom `DrawScope`.
This is why deferred state reads via lambda modifiers work:
```kotlin
// BAD: reads in composition phase, triggers recomposition on every offset change
Box(modifier = Modifier.offset(offsetX.dp, 0.dp))
// GOOD: reads in layout phase, skips composition entirely
Box(modifier = Modifier.offset { IntOffset(offsetX.value.toInt(), 0) })
```
Similarly, `Modifier.graphicsLayer { alpha = animatedAlpha.value }` reads state in the draw phase, avoiding recomposition for visual-only changes.
## State Primitives
### Primitive Specializations
Use type-specific state holders to avoid boxing overhead:
```kotlin
val count = mutableIntStateOf(0) // no boxing
val progress = mutableFloatStateOf(0f) // no boxing
val enabled = mutableStateOf(true) // Boolean has no specialization
val name = mutableStateOf("Alice") // general-purpose
```
**Pitfall:** Using `mutableStateOf<Int>()` instead of `mutableIntStateOf()` causes unnecessary boxing on every read/write.
### SnapshotStateList and SnapshotStateMap
Observable collections that trigger recomposition on structural changes:
```kotlin
val items = remember { mutableStateListOf<Item>() }
items.add(Item(1, "First")) // triggers recomposition
items[0] = items[0].copy(name = "Updated") // triggers recomposition
items[0].name = "Updated" // does NOT trigger recomposition (in-place mutation)
```
In MVI, prefer immutable collections (`ImmutableList`) in state models. `SnapshotStateList` is acceptable for UI-local state only.
### Saver for rememberSaveable
Custom types require explicit `Saver` for `rememberSaveable`:
```kotlin
data class FilterState(val query: String, val category: Int)
val filterSaver = Saver<FilterState, String>(
save = { "${it.query}:${it.category}" },
restore = { parts -> FilterState(parts.split(":")[0], parts.split(":")[1].toInt()) }
)
var filter by rememberSaveable(stateSaver = filterSaver) {
mutableStateOf(FilterState("", 0))
}
```
In MVI, `rememberSaveable` is only for small UI-local state — screen business state belongs in the ViewModel. `rememberSaveable` is multiplatform and works in CMP `commonMain`.
## Side Effects
### LaunchedEffect — Coroutines Scoped to Composition
Launches a coroutine tied to the composable's lifecycle. Cancelled when the key changes or composable leaves composition.
```kotlin
// Key = Unit: runs once when composable enters composition
LaunchedEffect(Unit) { setupOnce() }
// Key = specific value: reruns when value changes
LaunchedEffect(userId) { loadUserData(userId) }
// Multiple keys: reruns if ANY key changes
LaunchedEffect(userId, postId) { loadUserAndPost(userId, postId) }
```
In MVI, `LaunchedEffect` belongs at the route level for collecting UI effects. Do not use it for business logic in leaf composables.
### DisposableEffect — For Cleanup
```kotlin
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event -> /* handle */ }
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
```
Always pair registration with `onDispose` cleanup.
### rememberCoroutineScope — From Event Handlers
```kotlin
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { fetchData() } }) { Text("Fetch") }
```
In MVI, prefer dispatching events to the ViewModel instead. Use `rememberCoroutineScope` only for UI-local async work (e.g., scroll animation, snackbar).
Use `rememberUpdatedState` to capture latest callback values in long-running effects without restarting them.
`SideEffect { }` runs after every successful composition — use sparingly for stateless synchronization.
`produceState` bridges imperative state sources into Compose state; prefer ViewModel's `StateFlow` in MVI.
### Effect Ordering
Effects execute in declaration order after composition. `SideEffect` runs after every composition, `DisposableEffect` setup runs after composition, `LaunchedEffect` coroutines are scheduled asynchronously.
### collectAsStateWithLifecycle
Use `collectAsStateWithLifecycle()` instead of `collectAsState()` to collect only when the composable is in STARTED state:
```kotlin
val state by viewModel.state.collectAsStateWithLifecycle()
```
This prevents collection during background states and avoids unnecessary work. `collectAsStateWithLifecycle` is available in both Android and Compose Multiplatform via `androidx.lifecycle:lifecycle-runtime-compose`. Verify your project's lifecycle version supports your KMP targets before using it in `commonMain`.
### CollectEffect — Lifecycle-Aware Effect Collection
```kotlin
@Composable
fun <E> CollectEffect(effect: Flow<E>, onEffect: (E) -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(effect, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
effect.collect { onEffect(it) }
}
}
}
```
Collect one-off effects at the route level when STARTED; usage patterns live in [mvi.md](mvi.md).
## Modifier Ordering
Order matters. Modifiers apply left-to-right in the chain:
```kotlin
// Red background wraps padded content
Modifier.background(Color.Red).padding(16.dp).size(100.dp)
// Padding is inside the sized box, then background wraps everything
Modifier.size(100.dp).padding(16.dp).background(Color.Red)
```
### Always accept Modifier parameter
```kotlin
// GOOD: composable accepts modifier for caller customization
@Composable
fun ResultCard(derived: ProductDerived?, modifier: Modifier = Modifier) {
Card(modifier = modifier) { /* ... */ }
}
```
## Slot Pattern
Accept `@Composable` lambda parameters for flexible, reusable containers:
```kotlin
@Composable
fun SectionCard(
modifier: Modifier = Modifier,
title: @Composable () -> Unit,
content: @Composable () -> Unit,
) {
Card(modifier = modifier) {
Column(Modifier.padding(16.dp)) {
title()
Spacer(Modifier.height(8.dp))
content()
}
}
}
// Usage
SectionCard(
title = { Text("Breakdown", style = MaterialTheme.typography.titleMedium) },
content = { ProductBreakdownContent(derived) },
)
```
Slots accept `@Composable` lambdas, not pre-composed values. This ensures composition is deferred and scope-aware.
## Composable Extraction Guidelines
| Signal | Prefer |
|--------|--------|
| Reused in multiple places, or a single clear visual/behavioral responsibility | Extract |
| Easier to test in isolation, or independent recomposition skipping helps | Extract |
| Single use, trivial wrapper around one `Text`/`Icon`, or more parameters than inline clarity | Don't extract |
| Tightly coupled logic that reads clearer inline | Don't extract |
## CompositionLocal
Provides implicit parameters without threading through the hierarchy.
### When to use
- Theming (`MaterialTheme`, `Colors`, `Typography`)
- Platform integration (`LocalDensity`, `LocalLifecycleOwner`; `LocalContext` on Android, `LocalPlatformContext` in CMP)
- Infrequently changing cross-cutting concerns
### When NOT to use
- Frequently changing values (causes widespread recomposition)
- Values only 1-2 levels deep (pass directly)
- Dependencies that should use DI
```kotlin
// GOOD: theme/density accessed via CompositionLocal
val density = LocalDensity.current
// BAD: custom CompositionLocal for a value only used in one subtree
val LocalTitle = staticCompositionLocalOf<String> { "" }
```
In MVI, avoid custom CompositionLocals for feature state. State flows through the ViewModel → route → screen → leaves via explicit parameters.

View File

@@ -1,119 +0,0 @@
# Coroutines & Flow — Advanced Patterns
Backpressure strategies, bridging callback APIs to Flow, concurrency primitives, and testing with Turbine. For core coroutine and Flow patterns (StateFlow/SharedFlow/Channel, operators, dispatchers, scopes, exception handling, stateIn/shareIn), see [coroutines-flow.md](coroutines-flow.md).
## Backpressure
When producer emits faster than consumer processes:
| Strategy | Behavior | Use when |
|---|---|---|
| Default (no buffer) | Producer suspends until consumer processes | Simple sequential work |
| `buffer(capacity)` | Queue between producer and consumer | Smooth speed spikes, process every item |
| `conflate()` | Drop old values, keep only latest | UI updates, progress bars — stale data unnecessary |
| `collectLatest { }` | Cancel previous processing when new value arrives | Search — only final result matters |
```kotlin
// Search with collectLatest: only the last query completes
queryFlow
.debounce(300)
.distinctUntilChanged()
.collectLatest { query ->
val results = repository.search(query) // cancelled if new query arrives
_state.update { it.copy(results = results) }
}
```
### flowOn
`flowOn` changes the dispatcher for upstream operators and automatically buffers at the context switch:
```kotlin
repository.observeProducts() // runs on IO
.map { it.toDomain() } // runs on IO
.flowOn(Dispatchers.IO) // everything above runs on IO
.collect { updateUi(it) } // runs on caller's dispatcher (Main)
```
## callbackFlow and channelFlow
### callbackFlow — bridge listener APIs to Flow
Use `callbackFlow` to convert callback-based platform APIs into a `Flow`. In CMP, place these wrappers in `expect/actual` declarations or platform source sets.
```kotlin
// Android example — LocationManager (place in androidMain for CMP)
fun LocationManager.locationUpdates(): Flow<Location> = callbackFlow {
val listener = LocationListener { location ->
trySend(location) // non-blocking, thread-safe
}
requestLocationUpdates(GPS_PROVIDER, 1000L, 0f, listener)
awaitClose { removeUpdates(listener) } // mandatory cleanup
}
```
**Rules:**
- Use `trySend()` (non-blocking) not `send()` (suspending) from callbacks
- `awaitClose { }` is mandatory — omitting it throws `IllegalStateException`
- The cleanup block in `awaitClose` unregisters the listener
### channelFlow — concurrent production
```kotlin
fun loadDashboard(): Flow<DashboardSection> = channelFlow {
launch { send(DashboardSection.Profile(fetchProfile())) }
launch { send(DashboardSection.Stats(fetchStats())) }
launch { send(DashboardSection.Feed(fetchFeed())) }
}
```
Use `channelFlow` when producing values from multiple concurrent coroutines. Use `callbackFlow` specifically for wrapping external callback APIs.
## Concurrency Primitives
### Mutex — mutual exclusion
```kotlin
private val mutex = Mutex()
private var tokenCache: String? = null
suspend fun getToken(): String = mutex.withLock {
tokenCache ?: refreshToken().also { tokenCache = it }
}
```
Use Mutex for: token refresh synchronization, shared mutable state protection, sequential access to resources.
### Semaphore — limited concurrency
```kotlin
private val semaphore = Semaphore(permits = 3)
suspend fun downloadFile(url: String): ByteArray = semaphore.withPermit {
httpClient.get(url).body()
}
```
Use Semaphore for: rate-limiting concurrent network calls, limiting parallel file operations.
### Why not synchronized?
`synchronized` blocks the thread. Coroutines suspend — blocking a thread holding a coroutine defeats the purpose. Use `Mutex.withLock` instead of `synchronized` in coroutine code.
## Testing with Turbine
### Turbine API quick reference
| Function | Purpose |
|---|---|
| `flow.test { }` | Start collecting and asserting |
| `awaitItem()` | Wait for next emission, fail if timeout |
| `awaitComplete()` | Assert flow completes |
| `awaitError()` | Assert flow throws |
| `expectNoEvents()` | Assert no emissions pending |
| `cancelAndIgnoreRemainingEvents()` | Clean up after assertions |
| `cancelAndConsumeRemainingEvents()` | Cancel and return remaining events |
`runTest` from `kotlinx-coroutines-test` provides deterministic coroutine execution — delays are skipped automatically. Use `advanceUntilIdle()` to process all pending coroutines.
For full ViewModel event→state→effect testing patterns with Turbine, see [testing.md](testing.md).

View File

@@ -1,188 +0,0 @@
# Kotlin Coroutines & Flow
Coroutines and Flow primitives for Compose apps: StateFlow, SharedFlow, Channel, operators, dispatchers, scopes, and exception handling. Works on all CMP targets.
References:
- [Coroutines best practices (Android)](https://developer.android.com/kotlin/coroutines/coroutines-best-practices)
- [Exception handling (Kotlin docs)](https://kotlinlang.org/docs/exception-handling.html)
- [Turbine (GitHub)](https://github.com/cashapp/turbine)
## StateFlow vs SharedFlow vs Channel
| | StateFlow | SharedFlow | Channel |
|---|---|---|---|
| Holds current value | Yes (replay=1, conflated) | No (configurable replay) | No |
| New collector gets | Latest value immediately | Replayed values (if configured) | Nothing (consumed) |
| Delivery | All collectors | All collectors | One receiver |
| Duplicate filtering | `distinctUntilChanged` built-in | None | None |
| Use for | UI state | Broadcasting events | One-off effects |
### MVI mapping
```kotlin
class ProductViewModel : ViewModel() {
private val _state = MutableStateFlow(ProductState())
val state: StateFlow<ProductState> = _state.asStateFlow()
private val _effects = Channel<ProductEffect>(Channel.BUFFERED)
val effects: Flow<ProductEffect> = _effects.receiveAsFlow()
}
```
### When to use which
- **Screen state** (loading, data, errors, form input) → `StateFlow`
- **One-off UI effects** (navigate, snackbar, haptic) → `Channel(BUFFERED)` collected via `CollectEffect`
- **Broadcasting to multiple collectors** (analytics, logging) → `SharedFlow` with appropriate replay
- **Hot data streams** (search results reacting to query) → cold `Flow` converted via `stateIn`
### Common mistakes
- StateFlow for one-off events → shows twice on config change (new collector gets latest)
- `SharedFlow(replay=0)` for mandatory effects → lost when UI detached
- `Channel()` default (RENDEZVOUS) → suspends sender if no receiver; use `Channel.BUFFERED`
## Flow Operators Quick Reference
### Transforming
| Operator | Purpose |
|---|---|
| `map { }` | Transform each value |
| `mapNotNull { }` | Transform and drop nulls |
| `filter { }` | Keep values matching predicate |
| `take(n)` / `drop(n)` | Take first n / skip first n |
### Flattening
| Operator | Behavior | Use when |
|---|---|---|
| `flatMapLatest { }` | Cancel previous inner flow | Search queries — only latest |
| `flatMapConcat { }` | Sequential, wait for completion | Order matters |
| `flatMapMerge { }` | Concurrent inner flows | Parallel, order irrelevant |
### Combining
| Operator | Behavior | Use when |
|---|---|---|
| `combine(flowA, flowB) { a, b -> }` | Emit when ANY emits, latest from each | Multiple independent state sources |
| `zip(flowA, flowB) { a, b -> }` | Paired emissions only | Synchronized pairs |
| `merge(flowA, flowB)` | Interleave emissions | Unified event stream |
**Gotcha:** `combine` waits until every upstream emits at least once before producing output.
### Timing / Error / Side effects
| Operator | Purpose |
|---|---|
| `debounce(300)` | Wait for pause (search input) |
| `sample(1000)` | Latest at fixed intervals |
| `distinctUntilChanged()` | Skip consecutive duplicates |
| `catch { }` | Handle upstream errors, can `emit()` fallback |
| `retry(3)` / `retryWhen { cause, attempt -> }` | Retry with optional backoff |
| `onEach { }` / `onStart { }` / `onCompletion { }` | Side effects |
### Terminal operators
| Operator | Purpose |
|---|---|
| `collect { }` / `collectLatest { }` | Collect values (suspends) |
| `first()` / `toList()` | Single value / all values |
| `launchIn(scope)` | Start collection in scope |
| `stateIn(scope)` / `shareIn(scope)` | Convert to hot StateFlow/SharedFlow |
## Dispatchers
| Dispatcher | Use for | CMP support |
|---|---|---|
| `Dispatchers.Main` | UI state updates, composable callbacks | All targets |
| `Dispatchers.IO` | Network, database, file I/O | All targets (since 1.7+) |
| `Dispatchers.Default` | CPU-heavy computation, sorting, parsing | All targets |
**Main-safe rule:** the callee switches dispatchers, not the caller:
```kotlin
class ProductRepository(
private val api: ProductApi,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun getProducts(): List<Product> = withContext(ioDispatcher) {
api.getProducts().toDomain()
}
}
// Caller: viewModelScope.launch { repository.getProducts() } — safe from Main
```
Inject dispatchers as constructor params for testability.
## Structured Concurrency and Scopes
| Scope | Lifecycle | Use for |
|---|---|---|
| `viewModelScope` | ViewModel cleared | ViewModel coroutines (CMP `commonMain` since lifecycle 2.8+) |
| `lifecycleScope` | Lifecycle destroyed | Android Activity/Fragment only |
| `rememberCoroutineScope()` | Leaves composition | Compose event handlers |
| `coroutineScope { }` | All children complete | Parallel decomposition (one fails → all cancel) |
| `supervisorScope { }` | Child failure independent | Independent parallel tasks |
Use `supervisorScope` when tasks are independent (dashboard sections). Use `coroutineScope` when all must succeed together. Never use `GlobalScope` — no lifecycle, memory leak. Never create unbound `CoroutineScope(Job())` without lifecycle management.
## Exception Handling
### launch vs async
`launch`: exception propagates immediately. `async`: exception deferred until `await()`.
```kotlin
viewModelScope.launch {
try {
val data = repository.fetchData()
_state.update { it.copy(data = data, isLoading = false) }
} catch (e: IOException) {
_state.update { it.copy(error = "Network error", isLoading = false) }
}
}
```
### CancellationException — never swallow
```kotlin
// BAD: catch(e: Exception) catches CancellationException — zombie coroutine
// GOOD:
try { suspendingWork() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { handleError(e) }
```
## stateIn and shareIn
Convert cold `Flow` to hot `StateFlow`/`SharedFlow`. Always declare as `val`, never per function call.
```kotlin
val products: StateFlow<List<Product>> = repository.observeProducts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
```
| Strategy | Starts | Stops | Use for |
|---|---|---|---|
| `WhileSubscribed(5000)` | First collector | 5s after last gone | ViewModel state — stops upstream when UI gone |
| `Lazily` | First collector | Never (scope cancel) | Expensive-to-restart shared resources |
| `Eagerly` | Immediately | Never (scope cancel) | Data needed before first collector |
## Anti-Patterns
| Anti-pattern | Why it hurts | Fix |
|---|---|---|
| `GlobalScope.launch { }` | No lifecycle, memory leak | `viewModelScope` or structured scope |
| `runBlocking` on Main | Blocks UI, ANR | `launch` / `async` from coroutine scope |
| Swallowing `CancellationException` | Zombie coroutines | Always rethrow |
| Blocking I/O on `Dispatchers.Default` | Starves CPU pool | `Dispatchers.IO` |
| Non-suspending loop without `ensureActive()` | Ignores cancellation | Check `isActive` / `ensureActive()` |
| `stateIn` per function call | Leaks hot flows | Declare as `val`, create once |
| `catch (e: Throwable)` | Catches everything including OOM | `catch (e: Exception)` + rethrow `CancellationException` |
| Hardcoded `Dispatchers.IO` | Untestable | Inject dispatcher as constructor param |
| `combine` without initial values | No output until all emit | `onStart { emit(default) }` |
## Advanced Patterns
For backpressure, callbackFlow/channelFlow, Mutex/Semaphore, and Turbine testing, see [coroutines-flow-advanced.md](coroutines-flow-advanced.md).

View File

@@ -1,232 +0,0 @@
# Cross-Platform (KMP) Specifics
## Sharing Strategy
Share these first: reducers, ViewModels, validators, calculators, formatting policies, screen state models, most screen UI.
Keep platform-specific until proven otherwise: permissions, share sheets, clipboard, haptics, file pickers, notifications, deep links, review prompts, platform input traits, OS navigation shell.
## Placement Guide
### What belongs in `commonMain`
Feature state, intents/messages, reducer/ViewModel logic, calculators, validators, eligibility, repository interfaces, use cases that earn their keep, shared composables, presentation mapping, semantic nav effects and error keys.
### What should remain platform-specific
Runtime permissions, share/open sheet, haptics, clipboard, URLs, billing, notifications, biometrics, manifest/delegate deep links, OS widgets/shortcuts.
### Placement Table
| Concern | Default placement | Why |
|---|---|---|
| reducer/ViewModel | `commonMain` | pure, testable, reusable |
| validator/calculator | `commonMain` | pure domain logic |
| repository contract | `commonMain` | shared dependency boundary |
| haptics/share/clipboard | interface + platform impl | app capability, easy to fake |
| locale/number/date formatter | interface or shared library | locale-sensitive behavior |
| resource identifiers | `commonMain` UI | shared UI uses shared resources |
| permission prompt flow | platform-specific | OS-specific behavior |
| safe-area / keyboard handling | route/UI boundary | platform behavior differs |
| navigation controller binding | platform/UI shell | ViewModel should not know controller type |
| analytics SDK integration | platform or shared facade | real implementation differs |
### Dependency Verification for commonMain
**Before claiming `commonMain`:** confirm multiplatform artifacts exist. Much of AndroidX is still Android-only; some libs publish KMP (e.g. `lifecycle-viewmodel`, `datastore-preferences`) with version-dependent surfaces. Check Maven for `-jvm`, `-iosarm64`, `-iosX64`, etc.; use context7 `resolve-library-id` + `query-docs` when available. **If unverifiable**, say so—use platform placement or wrapper interfaces.
## Interfaces vs expect/actual
### Default recommendation
Use **interfaces** for app capabilities: haptics, clipboard, share, URL opener, analytics, date/number formatting, file opener.
Use **`expect/actual`** for thin platform facts or one-off helpers when an interface buys little.
### Practical rule
- **Interface** when the capability has lifetime, DI, fakes, or multiple implementations
- **`expect/actual`** when it is a tiny platform hook with no domain meaning
### Dependency Injection
Heavy/async/hardware services (GPS, biometrics, keystore): `commonMain` interface + Koin (or similar) for platform impls. Reserve `expect/actual` for tiny sync primitives (UUID, dates, clipboard).
## Platform Bridge Patterns
The rules above cover *when* to prefer interfaces vs `expect/actual`; below is *how* to wire each pattern.
### Choosing the Right Bridge
| Need | Pattern | Why |
|---|---|---|
| Service with lifecycle, state, or async (player, auth, payments, analytics) | Interface + DI | Testable, fakeable, swappable impls |
| Stateless platform fact (UUID, platform name, default locale) | `expect/actual` function | No DI overhead for a one-liner |
| Reuse existing platform type in common signature | `expect class` + `actual typealias` | Rare — prefer interface when possible |
### Pattern 1: Interface + DI (Primary)
Contract in `commonMain`; platform modules supply impls; DI binds them. ViewModel depends only on the interface. Koin setup: [koin.md](koin.md).
```kotlin
// commonMain
interface Player { fun play(uri: String); fun pause(); fun release() }
// androidMain
class AndroidPlayer(private val context: Context) : Player {
private val mp = MediaPlayer()
override fun play(uri: String) { mp.setDataSource(context, uri.toUri()); mp.start() }
override fun pause() = mp.pause()
override fun release() = mp.release()
}
// iosMain
class IosPlayer : Player {
private var av: AVPlayer? = null
override fun play(uri: String) { av = AVPlayer(uRL = NSURL(string = uri)); av?.play() }
override fun pause() { av?.pause() }
override fun release() { av = null }
}
// androidMain
val androidPlayerModule = module { single<Player> { AndroidPlayer(get()) } }
// iosMain
val iosPlayerModule = module { single<Player> { IosPlayer() } }
class PlayerViewModel(private val player: Player) : ViewModel() {
fun onEvent(e: PlayerEvent) {
when (e) { is PlayerEvent.Play -> player.play(e.uri); PlayerEvent.Pause -> player.pause() }
}
}
```
### Pattern 2: expect/actual for Thin Primitives
Stateless one-liners, no DI/interface/fakes:
```kotlin
// commonMain
expect fun randomUUID(): String
// androidMain
actual fun randomUUID(): String = java.util.UUID.randomUUID().toString()
// iosMain
actual fun randomUUID(): String = platform.Foundation.NSUUID().UUIDString()
```
### Pattern 3: expect/actual with Typealias
When a platform type already matches the contract:
```kotlin
// commonMain
expect class PlatformDate {
fun toEpochMillis(): Long
}
// jvmMain
actual typealias PlatformDate = java.time.Instant
// nativeMain
actual class PlatformDate(private val nsDate: NSDate) {
actual fun toEpochMillis(): Long = (nsDate.timeIntervalSince1970 * 1000).toLong()
}
```
Prefer interface+DI for fakes or when types do not match 1:1.
### Bridge Anti-Patterns
- `expect/actual` for lifecycle/state/async → interface+DI
- Platform imports in `commonMain` (compiler flags; still catch in review)
- Fat `expect/actual` → thin bridge, logic in impls
- Skipping interfaces when tests need fakes
## Lifecycle
`lifecycle-viewmodel` / `lifecycle-runtime-compose` can expose `ViewModel`, `viewModelScope`, `collectAsStateWithLifecycle` in `commonMain`; not all Lifecycle APIs are MP—depends on androidx/KMP.
- Artifact must publish KMP targets (`-jvm`, `-iosarm64`, …) and expose the API on MP (many APIs stay Android-only); match project targets.
- **Confirm versions** via context7 or AndroidX notes; if not, say so—wrap platform lifecycle behind interfaces if needed.
- **Typical in `commonMain` (re-verify):** `ViewModel`, `viewModelScope`, `collectAsStateWithLifecycle`, `koinViewModel()`.
## State Restoration
- `rememberSaveable`: small local UI state only
- Cross-platform drafts: rehydrate from persistence, not assumed OS restoration parity
- Serialize ViewModel state only when product requires it
## Keyboard, Focus, and Input
- Test text input on real iOS hardware; isolate quirks at the UI/platform edge
- No keyboard workaround flags in reducer state; shared UI uses inset/safe-area layout
- Keep selection/composition local per field when needed
## Safe Area and Layout
Insets-aware shared layouts; verify safe areas, keyboard overlap, sheets, nav chrome. Never put “iOS safe-area hack” into feature state.
## Platform Capabilities
Model haptics, clipboard, share as semantic effects; shell executes them.
```kotlin
enum class HapticType { Confirm, Error, Selection }
interface Haptics { fun perform(type: HapticType) }
sealed interface ProductEffect {
data class TriggerHaptic(val type: HapticType) : ProductEffect
data class ShareQuote(val text: String) : ProductEffect
}
interface ShareText { suspend fun share(text: String) }
```
## Resources
CMP shared resources (strings, images, fonts, qualifiers, localization, Gradle setup). Full API surface: **[Multiplatform Resources](resources.md)**.
```kotlin
enum class ValidationMessageKey { Required, InvalidNumber, MustBePositive }
@Composable
fun ValidationMessage(messageKey: ValidationMessageKey?) {
val text = when (messageKey) {
ValidationMessageKey.Required -> stringResource(Res.string.error_required)
ValidationMessageKey.InvalidNumber -> stringResource(Res.string.error_invalid_number)
ValidationMessageKey.MustBePositive -> stringResource(Res.string.error_must_be_positive)
null -> return
}
Text(text = text, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
```
## Code Examples
### GOOD: shared calculator
```kotlin
class PriceCalculator {
fun calculate(d: PriceDraft): PriceDerived {
val w = if (d.includeWaste) 1.10 else 1.0
val mat = d.area * d.materialRate * w
val lab = d.area * d.laborRate
val sub = mat + lab
val tax = sub * (d.taxPercent / 100.0)
return PriceDerived(mat, lab, sub, tax, sub + tax)
}
}
```
### BAD: platform leakage in state
```kotlin
@Immutable
data class ProductState(
val input: ProductInput = ProductInput(),
val iosKeyboardInsetHack: Int = 0,
val androidHapticPattern: String = "",
val shareSheetPresented: Boolean = false,
)
```
Platform leakage.

View File

@@ -1,197 +0,0 @@
# DataStore
Key-value and typed preferences via Kotlin coroutines and Flow. For structured/relational data, use [Room](room-database.md).
References:
- [DataStore documentation](https://developer.android.com/topic/libraries/architecture/datastore)
- [Set up DataStore for KMP](https://developer.android.com/kotlin/multiplatform/datastore)
## When to Use
| Need | Solution | Why |
|------|----------|-----|
| Key-value settings (theme, locale, flags) | Preferences DataStore | No schema, simple key-value, reactive Flow |
| Typed settings object with multiple fields | Typed DataStore (JSON serializer) | Type-safe, schema evolution via `@Serializable` data class |
| Structured data with queries, indexes, relations | Room | SQL-backed, compile-time verified, supports Paging |
| Large binary blobs or files | Filesystem | DataStore is not designed for large payloads |
**Scope rule:** If you need `WHERE`, `JOIN`, or more than ~100 entries, use Room.
## Critical Rules
1. **One instance per file** — never create multiple `DataStore` instances for the same file. Enforce via DI singleton.
2. **Immutable types only**`T` in `DataStore<T>` must be immutable. Mutating breaks transactional consistency.
3. **No mixing SingleProcess / MultiProcess** — if any access point uses `MultiProcessDataStoreFactory`, all must.
## Setup
> **Always search online for the latest stable versions** before adding dependencies.
```kotlin
// KMP: shared/build.gradle.kts
commonMain.dependencies {
implementation("androidx.datastore:datastore-preferences:<latest>")
// For Typed DataStore: also add androidx.datastore:datastore + kotlinx-serialization-json
}
```
For Typed DataStore, also add the `kotlin.plugin.serialization` Gradle plugin. See [official setup](https://developer.android.com/topic/libraries/architecture/datastore#setup).
## KMP Instance Creation
Define factory in `commonMain`; platform source sets provide the file path:
```kotlin
// commonMain
fun createDataStore(producePath: () -> String): DataStore<Preferences> =
PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() })
internal const val PREFS_FILE = "app_settings.preferences_pb"
// androidMain
fun createDataStore(context: Context): DataStore<Preferences> = createDataStore(
producePath = { context.filesDir.resolve(PREFS_FILE).absolutePath }
)
// iosMain
fun createDataStore(): DataStore<Preferences> = createDataStore(
producePath = {
val dir = NSFileManager.defaultManager.URLForDirectory(
NSDocumentDirectory, NSUserDomainMask, null, false, null
)
requireNotNull(dir).path + "/$PREFS_FILE"
}
)
// jvmMain (Desktop) — use app-specific folder, NOT java.io.tmpdir
fun createDataStore(): DataStore<Preferences> = createDataStore(
producePath = {
val appDir = File(System.getProperty("user.home"), ".myapp").apply { mkdirs() }
File(appDir, PREFS_FILE).absolutePath
}
)
```
**Android-only shortcut:** `val Context.settingsDataStore by preferencesDataStore(name = "settings")`.
## Preferences DataStore
| Type | Factory |
|------|---------|
| `Int` | `intPreferencesKey("name")` |
| `Long` | `longPreferencesKey("name")` |
| `Double` | `doublePreferencesKey("name")` |
| `Float` | `floatPreferencesKey("name")` |
| `Boolean` | `booleanPreferencesKey("name")` |
| `String` | `stringPreferencesKey("name")` |
| `Set<String>` | `stringSetPreferencesKey("name")` |
### Repository pattern (read + write)
```kotlin
object PrefsKeys {
val DARK_MODE = booleanPreferencesKey("dark_mode")
val LOCALE = stringPreferencesKey("locale")
val ONBOARDING_DONE = booleanPreferencesKey("onboarding_done")
}
class SettingsRepository(private val dataStore: DataStore<Preferences>) {
val settings: Flow<UserSettings> = dataStore.data
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
.map { prefs -> UserSettings(darkMode = prefs[PrefsKeys.DARK_MODE] ?: false) }
suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { it[PrefsKeys.DARK_MODE] = enabled }
}
suspend fun clearAll() { dataStore.edit { it.clear() } }
}
```
Always handle `IOException` with `.catch` — the file may be unreadable on first launch or after corruption. `edit` is an atomic read-write-modify transaction.
## Typed DataStore (JSON)
For settings with multiple related fields, use `DataStore<T>` with `kotlinx.serialization`:
```kotlin
@Serializable
data class AppSettings(
val darkMode: Boolean = false,
val locale: String = "en",
val itemsPerPage: Int = 20,
)
object AppSettingsSerializer : Serializer<AppSettings> {
override val defaultValue = AppSettings()
override suspend fun readFrom(input: InputStream): AppSettings =
try { Json.decodeFromString(input.readBytes().decodeToString()) }
catch (e: SerializationException) { throw CorruptionException("Cannot read settings", e) }
override suspend fun writeTo(t: AppSettings, output: OutputStream) =
output.write(Json.encodeToString(t).encodeToByteArray())
}
val settingsDataStore: DataStore<AppSettings> = DataStoreFactory.create(
serializer = AppSettingsSerializer,
corruptionHandler = ReplaceFileCorruptionHandler { AppSettings() },
produceFile = { File(context.filesDir, "app_settings.json") }
)
// Read: settingsDataStore.data
// Write: settingsDataStore.updateData { it.copy(locale = "fr") }
```
## SharedPreferences Migration
```kotlin
val dataStore: DataStore<Preferences> by preferencesDataStore(
name = "settings",
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, "legacy_shared_prefs"))
}
)
```
Migration runs once on first access. Old file deleted after success.
## MVI Integration
Map `Preferences` to domain models at the repository boundary — never pass `Preferences` or raw key lookups into the ViewModel or UI.
For the ViewModel collection pattern (collecting repository `Flow` into state via `viewModelScope`), see [architecture.md](architecture.md) — Reactive Data Collection.
## DI Integration
Always provide `DataStore` as a **singleton** — multiple instances for the same file cause `IllegalStateException`.
```kotlin
// Koin: single<DataStore<Preferences>> { createDataStore(get()) }
// Hilt: @Provides @Singleton fun provideDataStore(...): DataStore<Preferences> = ...
```
For full module patterns, see [koin.md](koin.md) or [hilt.md](hilt.md).
## Testing
```kotlin
private fun createTestDataStore(testDir: File): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
scope = TestScope(UnconfinedTestDispatcher()),
produceFile = { File(testDir, "test.preferences_pb") }
)
```
Use a temp directory per test and `deleteRecursively()` in teardown. For ViewModel tests, bypass DataStore with a fake repository backed by `MutableStateFlow`. For testing patterns, see [testing.md](testing.md).
## Anti-Patterns
| Anti-pattern | Why it is harmful | Better replacement |
|---|---|---|
| Multiple `DataStore` instances for same file | `IllegalStateException`, data corruption | DI singleton (`@Singleton` / `single`) |
| `runBlocking` on main thread | Blocks UI, ANRs | Collect `data` Flow in `viewModelScope` |
| Large objects/lists in DataStore | Entire file read/written every operation | Use Room for structured/large data |
| Missing `.catch` on `dataStore.data` | `IOException` crashes app | `.catch { if (it is IOException) emit(default) }` |
| No corruption handler | Corrupted file breaks reads permanently | `ReplaceFileCorruptionHandler` with defaults |
| `java.io.tmpdir` for Desktop | Data lost on reboot | Use app data dir (`~/Library/Application Support/` etc.) |
| Reading preferences inside composables | Recomposition storms | Read in repository/ViewModel, expose as `StateFlow` |
| Passing raw `Preferences` to UI | Leaks storage implementation | Map to domain model at repository boundary |

View File

@@ -1,82 +0,0 @@
# Dependency Injection in Compose Projects
Shared DI guidance for Jetpack Compose and Compose Multiplatform. For framework-specific setup, see [Koin](koin.md) or [Hilt](hilt.md).
References:
- [Koin](koin.md) — Koin setup, modules, Nav 3 integration, scopes, testing
- [Hilt](hilt.md) — Hilt setup, modules, scopes, instrumented testing
## When to Use Hilt vs Koin
| Criterion | Hilt | Koin |
|---|---|---|
| Platform | Android-only | Multiplatform (Android, iOS, Desktop, Web) |
| Dependency resolution | Compile-time | Runtime (DSL) or compile-time (Koin Annotations + KSP) |
| Error detection | Build-time | Runtime — use `verify()` in tests; KSP annotations add compile-time checks |
| Setup complexity | Higher (Gradle plugins, annotations) | Lower (DSL modules); annotations optional |
| Compose Multiplatform | Not supported | Full support |
| Navigation 3 | `hiltViewModel()` in `entry<T>` blocks; multibinding entry providers — see [navigation-3-di.md](navigation-3-di.md) | `navigation<T>` DSL + `koinEntryProvider()` — see [navigation-3-di.md](navigation-3-di.md) |
| Navigation 2 | `hiltViewModel()` in composable destinations; graph-scoped VMs — see [navigation-2-di.md](navigation-2-di.md) | `koinViewModel()`, `koinNavViewModel()`, `sharedKoinViewModel()` — see [navigation-2-di.md](navigation-2-di.md) |
**Default recommendation:**
- **Android-only projects**: Hilt is the default recommendation. Koin is also valid if the team prefers it or the project may become multiplatform later.
- **Compose Multiplatform projects**: Use Koin — Hilt does not support non-Android targets.
For detailed setup, modules, scoping, and testing, see the dedicated references: [koin.md](koin.md) and [hilt.md](hilt.md). This file stays focused on the framework decision — do not duplicate implementation details here.
## Shared DI Concepts
These principles apply regardless of framework choice:
### Constructor injection as the default
Always inject dependencies through the constructor. Field injection (`@Inject lateinit var`) couples the class to the DI framework and makes testing harder.
### Interface-based design
Bind interfaces to implementations — repositories, data sources, and platform services should be defined as interfaces. This enables swapping implementations in tests without mocking the DI framework.
```kotlin
// Define interface
interface UserRepository {
suspend fun getUser(id: String): User
}
// Bind implementation via DI
// Koin: single<UserRepository> { UserRepositoryImpl(get()) }
// Hilt: @Binds abstract fun bind(impl: UserRepositoryImpl): UserRepository
```
### Scope lifecycle alignment
| Scope | When to use | Examples |
|---|---|---|
| Singleton | Lives for app lifetime | API client, database, analytics |
| Activity-retained | Survives config changes | User session, auth state |
| ViewModel-scoped | Tied to a feature screen | Feature-specific calculators, validators |
| Factory (new each time) | Stateless or short-lived | Formatters, mappers |
Over-scoping wastes memory; under-scoping creates redundant instances. Match the scope to the dependency's actual lifetime.
### Module organization
Organize DI modules by feature, not by type. Each feature module declares its own dependencies:
```text
feature-product/
ProductModule → repository, calculator, validator, ViewModel
feature-settings/
SettingsModule → repository, ViewModel
core/
CoreModule → API client, database, platform bindings
```
Combine feature modules in the app module. Platform-specific bindings go in platform modules (`androidMain`, `iosMain`).
### Testing principle
Swap real implementations with fakes via DI configuration — don't mock the DI framework itself. Both Koin and Hilt support module replacement in tests:
- **Koin**: `appModule.verify()` for graph verification, module overrides in tests
- **Hilt**: `@TestInstallIn` to replace modules, `hilt-android-testing` for instrumented tests
For ViewModel unit testing (framework-agnostic), see [testing.md](testing.md).

View File

@@ -1,298 +0,0 @@
# Gradle & Build Configuration
Gradle best practices for Compose Multiplatform (CMP) and Android-only Jetpack Compose projects, including AGP 9+ changes.
## 1. Project Structure Patterns
### CMP Project (Android + iOS + optional Desktop)
```text
MyApp/
├── settings.gradle.kts
├── build.gradle.kts # Root: plugins with apply false
├── gradle.properties
├── gradle/libs.versions.toml
├── composeApp/ # KMP shared library
│ └── src/{commonMain,androidMain,iosMain,jvmMain}
├── androidApp/ # Thin Android shell (required by AGP 9+)
├── desktopApp/ # Optional: Desktop JVM entry point
└── iosApp/ # Xcode project (NOT a Gradle module)
```
**Key points:**
- `composeApp` is a KMP library containing all shared code
- `androidApp` is a thin shell — AGP 9's `com.android.application` cannot coexist with KMP plugin
- `iosApp` is a standalone Xcode project, not a Gradle module
### Android-Only Project
```text
MyApp/
├── settings.gradle.kts
├── build.gradle.kts
├── gradle/libs.versions.toml
├── app/ # Main application module
├── feature-*/ # Feature modules
└── core-*/ # Shared modules (ui, data, domain)
```
## 2. Version Catalog (`libs.versions.toml`)
Four sections: `[versions]`, `[libraries]`, `[plugins]`, `[bundles]`. Use comment headers to group by domain.
```toml
[versions]
# ---- Build ----
agp = "9.0.1"
kotlin = "2.3.10"
ksp = "2.3.10-1.0.30"
compose-multiplatform = "1.10.1"
# ---- AndroidX ----
androidx-lifecycle = "2.9.1"
# ---- Networking ----
ktor = "3.2.0"
[libraries]
# BOM-managed libs omit version.ref
compose-bom = { module = "androidx.compose:compose-bom", version = "2026.03.00" }
compose-material3 = { module = "androidx.compose.material3:material3" }
# Regular libs use version.ref
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
```
**Naming:** kebab-case keys → dot accessors (`koin-core``libs.koin.core`). BOM-managed libraries omit `version.ref`. Use `# ---- Section ----` comment headers to visually group entries by domain.
## 3. Bundles (`[bundles]`)
`[bundles]` groups libraries **always added together** into one alias — convenience only; no change to resolution or alignment. Create bundles when two+ libs are added as a set; group by domain and use comment headers like `[versions]`/`[libraries]`.
```kotlin
implementation(libs.bundles.androidx.base)
implementation(libs.bundles.androidx.lifecycle)
```
**CMP projects** rarely need bundles because `commonMain.dependencies` already groups everything in one place.
## 4. `settings.gradle.kts`
```kotlin
rootProject.name = "MyApp"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {
repositories {
google { content { includeGroupByRegex("com\\.android.*|com\\.google.*|androidx.*") } }
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google { content { includeGroupByRegex("com\\.android.*|com\\.google.*|androidx.*") } }
mavenCentral()
}
}
include(":composeApp", ":androidApp")
```
## 5. Root `build.gradle.kts`
Declare plugins with `apply false`. No `allprojects {}`/`subprojects {}` — use convention plugins at scale.
```kotlin
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.kmp.library) apply false
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.ksp) apply false
}
```
## 6. AGP 9+ Changes
### Built-in Kotlin
AGP 9 includes Kotlin. Do NOT apply `org.jetbrains.kotlin.android` in Android app modules.
```kotlin
// ✅ AGP 9+
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
}
```
### New KMP Library Plugin
Use `com.android.kotlin.multiplatform.library` for KMP modules targeting Android.
```kotlin
// ✅ AGP 9+ KMP module
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kmp.library)
}
```
### New `compileSdk` DSL
```kotlin
// Application modules
android {
compileSdk { version = release(35) }
}
// KMP library modules (inside kotlin { androidLibrary {} })
kotlin {
androidLibrary {
compileSdk = 35 // Integer still works here
}
}
```
### Kotlin Block Outside Android
On AGP 9+, `kotlin {}` must NOT be nested inside `android {}`.
```kotlin
// ✅ Correct
kotlin { jvmToolchain(21) }
android { /* ... */ }
// ❌ Wrong
android { kotlin { jvmToolchain(21) } }
```
## 7. Module Patterns
### CMP Shared Module (`composeApp`)
```kotlin
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kmp.library)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.ksp)
}
kotlin {
androidLibrary {
namespace = "com.example.shared"
compileSdk = 35
minSdk = 26
}
listOf(iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.material3)
// Add other common dependencies
}
}
}
dependencies {
listOf("kspAndroid", "kspIosArm64", "kspIosSimulatorArm64").forEach {
add(it, libs.room.compiler)
}
}
```
Android app module: thin shell with `com.android.application` + `compose-compiler` plugins, depending on `projects.composeApp`.
Desktop module: KMP plugin + `compose.desktop.currentOs`, entry point via `compose.desktop { application { mainClass = "..." } }`.
## 8. `gradle.properties`
```properties
# Performance
org.gradle.configuration-cache=true
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC
# Kotlin
kotlin.code.style=official
# Android
android.useAndroidX=true
android.nonTransitiveRClass=true
# CMP (if targeting iOS)
kotlin.mpp.enableCInteropCommonization=true
```
## 9. KSP Wiring
```kotlin
dependencies {
listOf("kspAndroid", "kspIosArm64", "kspIosSimulatorArm64").forEach {
add(it, libs.room.compiler)
add(it, libs.koin.ksp.compiler)
}
}
ksp {
arg("KOIN_USE_COMPOSE_VIEWMODEL", "true")
}
tasks.withType<KotlinCompile>().configureEach {
dependsOn(tasks.withType<KspTask>())
}
```
## 10. Composite Builds
Conditional `includeBuild` for local library dev (use `if (path.exists())` so CI works without checkout):
```kotlin
// settings.gradle.kts
val localLibPath = file("../my-library")
if (localLibPath.exists()) {
includeBuild(localLibPath) {
dependencySubstitution {
substitute(module("com.example:my-library")).using(project(":my-library"))
}
}
}
```
## 11. Convention Plugins
Introduce convention plugins when 3+ modules duplicate config. Use `build-logic/` included build pattern. Not needed for small projects (≤3 modules).
## 12. Do / Don't
| Do | Don't |
|----|-------|
| Version catalog for all dependencies | Hardcode versions in build files |
| Enable configuration cache, build cache | Use `buildSrc` for versions |
| `TYPESAFE_PROJECT_ACCESSORS` | `allprojects {}`/`subprojects {}` blocks |
| Separate `androidApp` from KMP shared (AGP 9+) | Apply `kotlin-android` on AGP 9+ |
| `apply false` at root | Nest `kotlin {}` inside `android {}` |
| Conditional `includeBuild` for local dev | Unconditional `includeBuild` (breaks CI) |
| Convention plugins for 3+ modules | Over-engineer small projects |

View File

@@ -1,278 +0,0 @@
# Dependency Injection with Hilt (Android-only)
Compile-time DI for Android-only Compose projects with ViewModel and lifecycle integration.
For Hilt vs Koin decision guidance and shared DI concepts, see [dependency-injection.md](dependency-injection.md). For Koin (multiplatform), see [koin.md](koin.md).
References:
- [Hilt Android docs](https://developer.android.com/training/dependency-injection/hilt-android)
- [Hilt with Compose](https://developer.android.com/develop/ui/compose/libraries#hilt)
- [Hilt ViewModel](https://developer.android.com/training/dependency-injection/hilt-jetpack#viewmodels)
## Setup
### Gradle configuration
```kotlin
// project-level build.gradle.kts
plugins {
alias(libs.plugins.hilt) apply false
}
// app-level build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Compose integration
implementation(libs.hilt.navigation.compose)
}
```
## Application Class
```kotlin
@HiltAndroidApp
class MyApplication : Application()
```
Every Hilt app requires an `@HiltAndroidApp`-annotated Application class.
## Modules
### @Provides — when you need to construct the instance yourself
Use for third-party classes, builder patterns, or anything where you control creation logic:
```kotlin
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideApiClient(): ApiClient = ApiClient()
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()
}
```
### @Binds — when mapping an interface to its implementation
Use for interface-to-implementation bindings. More efficient than `@Provides` (no method body needed, generates less code):
```kotlin
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
@Binds
@Singleton
abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository
}
```
### Feature-scoped modules — @InstallIn(ViewModelComponent)
Use `ViewModelComponent` when dependencies are only needed within a ViewModel and should be cleaned up when the ViewModel is cleared. Use `SingletonComponent` for app-wide shared instances (API clients, databases).
```kotlin
@Module
@InstallIn(ViewModelComponent::class)
object ProductModule {
@Provides
@ViewModelScoped
fun provideProductCalculator(): ProductCalculator = ProductCalculator()
@Provides
@ViewModelScoped
fun provideProductValidator(): ProductValidator = ProductValidator()
}
```
## ViewModel Injection
### Basic ViewModel
```kotlin
@HiltViewModel
class ProductViewModel @Inject constructor(
private val calculator: ProductCalculator,
private val repository: ProductRepository,
) : ViewModel() {
// StateFlow<State>, Channel<Effect>, onEvent() — see architecture.md
}
```
### ViewModel with SavedStateHandle — when params come from navigation routes
Hilt auto-injects `SavedStateHandle` populated with navigation arguments. Use when the ViewModel receives serializable route params:
```kotlin
@HiltViewModel
class DetailViewModel @Inject constructor(
private val repository: ItemRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val itemId: String = checkNotNull(savedStateHandle["itemId"])
init {
loadItem(itemId)
}
}
```
### ViewModel with @AssistedInject — when params come from the caller, not navigation
Use when the ViewModel needs values that aren't in navigation arguments (e.g., a complex object, a callback, or a value computed in the composable):
```kotlin
@HiltViewModel(assistedFactory = DetailViewModel.Factory::class)
class DetailViewModel @AssistedInject constructor(
private val repository: ItemRepository,
@Assisted private val itemId: String,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(itemId: String): DetailViewModel
}
}
// Caller passes the value explicitly
@Composable
fun DetailRoute(itemId: String) {
val viewModel = hiltViewModel<DetailViewModel, DetailViewModel.Factory> { factory ->
factory.create(itemId)
}
}
```
Prefer `SavedStateHandle` for navigation arguments (simpler, survives process death). Use `@AssistedInject` only when `SavedStateHandle` can't carry the data.
## Compose Integration
```kotlin
@AndroidEntryPoint
class MainActivity : ComponentActivity() { /* setContent { ... } */ }
@Composable
fun ProductRoute(viewModel: ProductViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
ProductScreen(state = state, onEvent = viewModel::onEvent)
}
```
Every Activity hosting Hilt-injected composables requires `@AndroidEntryPoint`. Use the standard MVI Route/Screen pattern: collect state via `collectAsStateWithLifecycle()`, collect effects via `CollectEffect`, pass `onEvent` to Screen.
## Navigation Integration
**For Nav 3 + Hilt patterns** (entry-scoped ViewModels, multibinding entry providers), see [navigation-3-di.md](navigation-3-di.md) — that is the preferred approach for new projects. For Nav 2 + Hilt patterns (graph-scoped VMs, `@AssistedInject`), see [navigation-2-di.md](navigation-2-di.md).
The patterns below apply to **Navigation Compose (Nav 2)** projects that use Hilt. They remain valid for existing codebases but should not be the starting point for new work.
### Nav 2: hiltViewModel() in composable destinations
Use `hiltViewModel()` as a default parameter in any `composable()` destination — each destination gets its own ViewModel instance scoped to the `NavBackStackEntry`.
### Nav 2: Navigation-scoped ViewModel — when multiple destinations share state
Use when destinations within the same Nav 2 navigation graph need a shared ViewModel (e.g., a multi-step checkout flow where Cart, Shipping, and Payment screens share `CheckoutViewModel`):
```kotlin
val parentEntry = remember(navController) {
navController.getBackStackEntry("checkout_graph")
}
val sharedViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
```
## Scopes
| Scope | Lifecycle | Use case |
|---|---|---|
| `@Singleton` | Application | API clients, databases, shared preferences |
| `@ActivityRetainedScoped` | Activity (survives config change) | User session, auth state |
| `@ViewModelScoped` | ViewModel | Feature-specific services, calculators |
| `@ActivityScoped` | Activity instance | Activity-bound resources |
| `@FragmentScoped` | Fragment instance | Fragment-bound resources (rare in Compose) |
## Hilt in MVI
The only Hilt-specific wiring is `@HiltViewModel` + `@Inject constructor`. The MVI pattern (Event/State/Effect, `onEvent()`) is framework-agnostic — DI only affects constructor injection and injection-site calls.
## Testing
For ViewModel unit tests (no Hilt needed), see [testing.md](testing.md).
### Dependencies
```kotlin
dependencies {
androidTestImplementation(libs.hilt.android.testing)
kspAndroidTest(libs.hilt.compiler)
}
```
### Hilt instrumented testing
```kotlin
@HiltAndroidTest
class CreateItemScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()
@Inject
lateinit var repository: ItemRepository
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun saveButton_enabledWhenFieldsFilled() {
composeRule.setContent {
CreateItemScreen(
state = CreateItemState(title = "Test", amount = "100"),
onEvent = {},
)
}
composeRule.onNodeWithText("Save").assertIsEnabled()
}
}
@Module
@InstallIn(SingletonComponent::class)
@TestInstallIn(components = [SingletonComponent::class], replaces = [RepositoryModule::class])
object FakeRepositoryModule {
@Provides
@Singleton
fun provideItemRepository(): ItemRepository = FakeItemRepository()
}
```
## Anti-Patterns
| Anti-pattern | Why it is harmful | Better approach |
|---|---|---|
| Injecting Context into ViewModel | Lifecycle mismatch, leaks | Use `@ApplicationContext` or move platform code to Repository |
| Injecting Activity/Fragment into ViewModel | Memory leaks | Pass data via SavedStateHandle or route arguments |
| `@Inject` on ViewModel without `@HiltViewModel` | ViewModel not managed by Hilt | Always use `@HiltViewModel` with `@Inject constructor` |
| Manual ViewModel instantiation | Bypasses Hilt injection | Use `hiltViewModel()` in Compose |
| Installing ViewModel dependencies in `SingletonComponent` | Unnecessary lifecycle extension | Use `ViewModelComponent` or `ViewModelScoped` |

View File

@@ -1,198 +0,0 @@
# Image Loading (Coil 3 + Compose Multiplatform)
Production-focused guidance for loading remote and local images in Jetpack Compose and Compose Multiplatform using Coil 3.
References:
- [Coil Compose docs](https://coil-kt.github.io/coil/compose/)
- [Coil Getting Started](https://coil-kt.github.io/coil/getting_started/)
- [Coil Image Loaders](https://coil-kt.github.io/coil/image_loaders/)
- [Coil Network Images](https://coil-kt.github.io/coil/network/)
- [Coil Extending the Image Pipeline](https://raw.githubusercontent.com/coil-kt/coil/main/docs/image_pipeline.md)
- [Coil SVG support](https://coil-kt.github.io/coil/svgs/)
- [Coil Recipes](https://coil-kt.github.io/coil/recipes/)
- [Coil 3 upgrade notes](https://coil-kt.github.io/coil/upgrading_to_coil3/)
## Setup and Dependencies
Coil 3 does not include network loading by default. Add `coil-compose` and exactly one network integration.
```kotlin
// Shared for Compose UI
implementation("io.coil-kt.coil3:coil-compose:<version>")
// Android/JVM only
implementation("io.coil-kt.coil3:coil-network-okhttp:<version>")
// Multiplatform-friendly network options
implementation("io.coil-kt.coil3:coil-network-ktor2:<version>")
// or
implementation("io.coil-kt.coil3:coil-network-ktor3:<version>")
```
If you use Ktor networking, add platform engines for your targets (Android, Apple, JVM).
## Choose the Right API
| Use case | Best API | Why |
|---|---|---|
| Most image rendering in UI | `AsyncImage` | Best default; resolves image size from constraints |
| Need a `Painter` or manual request restart/state observation | `rememberAsyncImagePainter` | More control, lower-level painter API |
| Need composable slots per loading state and need first-frame state correctness | `SubcomposeAsyncImage` | Slot API with immediate state, but slower |
### Performance note
`SubcomposeAsyncImage` uses subcomposition and is generally less suitable for dense `LazyColumn`/`LazyGrid` cells. Prefer `AsyncImage` for list-heavy screens.
## Default AsyncImage Pattern
Prefer one reusable pattern for avatar/card/list images:
```kotlin
AsyncImage(
model = ImageRequest.Builder(LocalPlatformContext.current)
.data(imageUrl)
.crossfade(true)
.build(),
placeholder = painterResource(Res.drawable.placeholder),
error = painterResource(Res.drawable.image_error),
fallback = painterResource(Res.drawable.image_fallback),
contentDescription = title, // null only for decorative images
contentScale = ContentScale.Crop,
modifier = Modifier.clip(RoundedCornerShape(12.dp)),
)
```
For accessibility, provide `contentDescription` unless the image is purely decorative.
## ImageLoader Configuration
Create one shared `ImageLoader` per app process. Multiple loaders fragment memory/disk caches and reduce hit rates.
```kotlin
setSingletonImageLoaderFactory { context ->
ImageLoader.Builder(context)
.crossfade(true)
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("image_cache"))
.maxSizePercent(0.02)
.build()
}
.build()
}
```
For libraries, prefer `coil-core` and pass your own `ImageLoader` instead of overriding the app singleton.
## Extended Pipeline
Coil's pipeline is extensible and executes in this order:
1. `Interceptor`
2. `Mapper`
3. `Keyer`
4. `Fetcher`
5. `Decoder`
Register custom components once when building `ImageLoader`:
```kotlin
val imageLoader = ImageLoader.Builder(context)
.components {
add(CustomCacheInterceptor())
add(ItemMapper())
add(ItemKeyer())
add(PartialUrlFetcher.Factory())
add(SvgDecoder.Factory())
}
.build()
```
### Decision table: Need X -> Customize Y
| Need | Customize | Why |
|---|---|---|
| Add request retry/short-circuit/global policy | `Interceptor` | Wraps entire pipeline; can modify/proceed/return early. Cross-cutting: timeouts, retries, custom cache layer, metrics. |
| Accept custom model type in `.data(...)` | `Mapper` | Normalizes domain data to a supported type (for example `ProductImage` → URL string). |
| Keep custom data memory-cacheable | `Keyer` | Stable memory cache key segment for custom models. If a custom `Fetcher` introduces a new data type, add a matching `Keyer` so memory caching works. |
| Support custom source/protocol | `Fetcher.Factory<T>` | Data transport: custom scheme, signed URLs, alternate client. |
| Decode custom encoded data/format | `Decoder.Factory` | Converts fetched source to a renderable image. |
| Add auth headers for all image requests | Network fetcher + client interceptor | Centralized networking behavior. |
| Per-request dynamic headers | `ImageRequest.httpHeaders(...)` | Scoped request-level networking metadata. |
### Compose Multiplatform placement
- Domain-level model wrappers and mapping intent in `commonMain`; OkHttp/Android-only client setup in platform source sets; prefer Ktor network for broad CMP.
- One shared `ImageLoader` configuration per app entry point.
### Pipeline anti-patterns
| Anti-pattern | Problem |
|---|---|
| Registering pipeline components per screen/composable; duplicating what request options already cover (`httpHeaders`, cache policy, size resolver) | Fragments caches; redundant complexity |
| Custom `Fetcher` without a stable `Keyer`; volatile data (timestamps, random values) in cache keys | Poor memory cache hit rate |
| Heavy blocking work in `Interceptor` without bounds/timeouts; platform-only types in `commonMain` pipeline contracts | Jank; wrong layering for CMP |
For HTTP cache semantics with OkHttp, register `CacheControlCacheStrategy` with the network fetcher when you need response `Cache-Control` behavior.
## Caching Strategy
Default request cache policies are enabled; override `memoryCachePolicy` / `diskCachePolicy` / `networkCachePolicy` only when you need non-default behavior.
### Stable keys for smooth transitions
Use stable keys when the same logical image appears in multiple places (list → detail, shared element).
```kotlin
ImageRequest.Builder(LocalPlatformContext.current)
.data(url)
.memoryCacheKey("image-$id")
.placeholderMemoryCacheKey("image-$id")
.build()
```
`placeholderMemoryCacheKey` helps avoid visual flashes by reusing an in-memory result as the placeholder for the next request.
## Transformations
Use `.transformations(...)` (for example `RoundedCornersTransformation`) only for pixel-level changes to decoded output. Prefer `Modifier.clip` / shapes for UI-only effects; transformations materialize bitmaps and can collapse animated images to one frame.
## SVG
```kotlin
implementation("io.coil-kt.coil3:coil-svg:<version>")
```
Coil auto-detects and decodes SVGs after this dependency is on the classpath. Register `SvgDecoder.Factory()` explicitly only if you need non-default wiring.
## Compose Multiplatform Resources
To load images from Compose Multiplatform resources with Coil, use `Res.getUri(...)`:
```kotlin
AsyncImage(
model = Res.getUri("drawable/sample.jpg"),
contentDescription = null,
)
```
Use string URIs from `Res.getUri`. Direct compile-safe handles like `Res.drawable.someImage` are not currently passed directly as Coil models.
## List and Shared-Element Patterns
- Prefer `AsyncImage` in list cells.
- Keep item size predictable to avoid layout thrash.
- Use stable item keys (`LazyColumn`/`LazyGrid`) and stable cache keys (`memoryCacheKey`) together.
- For shared-element transitions, reuse memory cache key + placeholder memory cache key between source and destination.
- If you must use `rememberAsyncImagePainter`, provide a size resolver (`rememberConstraintsSizeResolver`) to avoid always loading original size.
## Preview, Testing, and Debugging
- Compose preview has no network access by default. Use `LocalAsyncImagePreviewHandler` to inject deterministic preview images.
- Enable `DebugLogger` only in debug builds when diagnosing request/decoder/cache behavior.
- For testability in large apps, inject a custom/fake `ImageLoader` instead of relying on global singleton state.

View File

@@ -1,208 +0,0 @@
# iOS Swift Interop
## Kotlin → Swift Naming
| Kotlin construct | Swift equivalent |
|---|---|
| Top-level function `fun foo()` in `Bar.kt` | `BarKt.foo()` |
| `object AppInit` | `AppInit.shared` |
| `companion object` member | Direct on class: `MyClass.value` |
| `sealed class UiState` | Class hierarchy (or SKIE exhaustive enum) |
| `suspend fun load()` | SKIE: `async func load()` |
```swift
// Entry point top-level function in MainViewController.kt
let controller = MainViewControllerKt.MainViewController()
```
## Nullability & Type Bridging
| Kotlin | Swift | Notes |
|---|---|---|
| `String` | `String` | Non-null bridged directly |
| `String?` | `String?` | Optional bridged directly |
| `Int` / `Long` | `Int32` / `Int64` | Not Swift `Int` — use explicit cast |
| `Unit` | `KotlinUnit` | Awkward return — avoid in public API |
**Collections:** Kotlin `List<T>` bridges to `[T]` as a read-only copy. Mutability and structural sharing are lost at the boundary. Pass collections across the boundary sparingly — batch, don't iterate.
## Coroutines → Swift Async
| Approach | When to use | Trade-off |
|---|---|---|
| **SKIE** | Default for new CMP projects | Automatic `async`/`AsyncSequence`; adds build plugin |
| **KMP-NativeCoroutines** | Existing projects already using it | Annotation-driven; SKIE preferred for greenfield |
### SKIE (recommended)
SKIE converts `suspend` functions to Swift `async` automatically:
```kotlin
// commonMain
suspend fun loadItems(): List<Item> = repository.getAll()
```
```swift
let items = try await viewModel.loadItems() // SKIE-generated async bridge
```
## Flow → Swift Observation
This is how iOS observes `StateFlow<UiState>` — the critical MVI bridge.
### SKIE: Flow → AsyncSequence
SKIE converts `Flow` to `AsyncSequence`:
```swift
func observeState() async {
for await state in viewModel.state { self.uiState = state }
}
```
### Manual StateFlow wrapper
Without SKIE, expose a callback-based observer from Kotlin; Swift holds the returned cancel closure and invokes it in `deinit`.
```kotlin
// iosMain
class IosStateCollector<T>(private val flow: StateFlow<T>, private val scope: CoroutineScope) {
private var job: Job? = null
fun observe(onChange: (T) -> Unit): () -> Unit {
job = scope.launch(Dispatchers.Main) { flow.collect { onChange(it) } }
return { job?.cancel() }
}
}
```
## Sealed Classes in Swift
### Without SKIE — non-exhaustive
```swift
if let loading = state as? UiState.Loading { showSpinner() }
else if let success = state as? UiState.Success { render(items: success.items) }
else if let error = state as? UiState.Error { showError(error.message) }
// No exhaustiveness check silent bugs when a new sealed subclass is added
```
### With SKIE — exhaustive Swift enum
```swift
switch onEnum(of: state) {
case .loading: showSpinner()
case .success(let s): render(items: s.items)
case .error(let e): showError(e.message)
} // Compiler error if a new sealed subclass is added
```
### Edge cases
- **Generic sealed classes** — SKIE cannot convert generics to Swift enums; use concrete types at the iOS boundary (e.g., `ItemListState` not `ListState<Item>`)
- **Nested sealed hierarchies** — SKIE flattens names: `UiState.Error.Network``.errorNetwork`
- **Opt out** — annotate with `@SealedInterop.Disabled` to skip SKIE conversion for a specific class
## iOS API Design Rules
- Keep the public API surface small — use `internal` visibility + `@HiddenFromObjC` to exclude Kotlin internals from the generated ObjC header
- Avoid generics in public iOS-facing API — ObjC/Swift interop erases or boxes them unpredictably
- Prefer data classes over deep class hierarchies at the boundary — simpler Swift mapping
- Set `isStatic = true` in framework configuration for static linkage (smaller binary, faster startup)
- Minimize Kotlin↔Swift boundary crossings in hot paths — batch data, don't iterate across the boundary
- Avoid `suspend` functions that return `Unit` — Swift receives `KotlinUnit`, requiring callers to discard it explicitly
- Expose sealed classes with concrete (non-generic) type parameters for SKIE compatibility
## Compose in SwiftUI App
Use `ComposeUIViewController` to embed a Compose screen inside an existing SwiftUI application. This is the standard path for incremental adoption — add Compose features to a SwiftUI app without rewriting native screens.
### Kotlin entry point
```kotlin
// iosMain
fun MainViewController(): UIViewController = ComposeUIViewController { App() }
```
### Swift bridge
Wrap the `UIViewController` in a `UIViewControllerRepresentable` for SwiftUI:
```swift
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
```
Use `ComposeView()` anywhere in SwiftUI hierarchy — `NavigationStack`, tab bar, sheet, or as the root view.
### When to use
| Scenario | Approach |
|---|---|
| Entire app is Compose | `ComposeUIViewController` as the root in `@main App` |
| Hybrid app — some screens SwiftUI, some Compose | Embed `ComposeView` per-feature inside SwiftUI navigation |
| Single Compose widget in a SwiftUI screen | Embed `ComposeView` with a fixed `frame` modifier |
## Native iOS Views in Compose
Use `UIKitView` to embed UIKit or SwiftUI components inside a Compose screen. This is how you use platform-native views (maps, camera, webview) that have no Compose equivalent on iOS.
### `UIKitView` basics
```kotlin
UIKitView(
factory = { MKMapView() },
modifier = Modifier.size(300.dp),
update = { mapView -> mapView.setRegion(region, animated = true) }
)
```
- **`factory`** — creates the `UIView` instance once (like `AndroidView`'s factory)
- **`update`** — called on recomposition to sync Compose state into the native view
- **`modifier`** — standard Compose modifier for sizing and layout
### Embedding SwiftUI views
SwiftUI views can't be used directly in `UIKitView`. Wrap them in a `UIHostingController` and pass the controller to a Kotlin factory function:
```kotlin
// iosMain
@OptIn(ExperimentalForeignApi::class)
fun ComposeEntryPointWithNativeView(
createViewController: () -> UIViewController
): UIViewController = ComposeUIViewController {
Column(Modifier.fillMaxSize()) {
Text("Compose content above")
UIKitViewController(
factory = createViewController,
modifier = Modifier.size(300.dp)
)
}
}
```
```swift
MainViewControllerKt.ComposeEntryPointWithNativeView {
UIHostingController(rootView: MySwiftUIMapView())
}
```
### Decision table
| Need | Use |
|---|---|
| UIKit view (`MKMapView`, `WKWebView`, `AVCaptureSession`) | `UIKitView(factory = { ... })` directly in Kotlin |
| SwiftUI view (`Map`, custom SwiftUI component) | Wrap in `UIHostingController`, pass via `UIKitViewController` |
| Complex native screen with its own navigation | Keep it in SwiftUI/UIKit, embed Compose screens via `ComposeUIViewController` instead |
## Anti-Patterns
- **Generic `Resource<T>` sealed class exposed to Swift** — SKIE can't convert it; use concrete result types like `ItemListResult`
- **Observing StateFlow without cancellation cleanup** — memory leak when the view controller is deallocated
- **Returning `Unit` from public API** — becomes `KotlinUnit` in Swift; use a callback or return a meaningful type
- **Crossing ObjC boundary in a loop** — each call has marshaling overhead; collect results in Kotlin, return the batch
- **Exposing mutable Kotlin collections to Swift** — mutations won't reflect; return immutable snapshots
- **Skipping `@HiddenFromObjC`** — pollutes the Swift API surface with internal helpers
- **Recreating UIKit views on every recomposition** — `factory` in `UIKitView` runs once; put state-dependent updates in `update`, not `factory`
- **Skipping `update` in `UIKitView`** — Compose state changes won't propagate to the native view; always implement `update` to sync mutable properties

View File

@@ -1,260 +0,0 @@
# Dependency Injection with Koin
Multiplatform DI for Compose projects with ViewModel, Compose, and Navigation 3 integration.
For Hilt vs Koin decision guidance and shared DI concepts, see [dependency-injection.md](dependency-injection.md). For Hilt (Android-only), see [hilt.md](hilt.md).
References:
- [Koin for Compose](https://insert-koin.io/docs/reference/koin-compose/compose)
- [Koin Navigation 3](https://insert-koin.io/docs/reference/koin-compose/navigation3)
## Package Selection
### CMP projects (recommended)
```kotlin
commonMain.dependencies {
implementation(platform("io.insert-koin:koin-bom:$koin_version"))
implementation("io.insert-koin:koin-core")
implementation("io.insert-koin:koin-compose")
implementation("io.insert-koin:koin-compose-viewmodel")
implementation("io.insert-koin:koin-compose-viewmodel-navigation") // Nav 3
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$serialization_version")
}
```
### Android-only projects
```kotlin
dependencies {
implementation("io.insert-koin:koin-androidx-compose:$koin_version") // includes compose + viewmodel
implementation("io.insert-koin:koin-compose-viewmodel-navigation:$koin_version")
}
```
| Package | Purpose |
|---|---|
| `koin-core` | Core DI engine (multiplatform) |
| `koin-compose` | Base Compose API (`koinInject`) |
| `koin-compose-viewmodel` | ViewModel injection (`koinViewModel`) |
| `koin-compose-viewmodel-navigation` | Nav 3 entry provider integration |
| `koin-androidx-compose` | Android convenience (includes compose + viewmodel) |
Platform support: Android, iOS, Desktop — full. Web — experimental.
## Setup and Starting Koin
Initialize outside Compose with a shared `initKoin` and platform-specific config lambda:
```kotlin
// commonMain
fun initKoin(config: KoinAppDeclaration? = null) {
startKoin {
config?.invoke(this)
modules(appModule, featureModules)
}
}
// Android — Application class
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
initKoin { androidContext(this@MyApplication); androidLogger() }
}
}
```
iOS — call from Swift. `do` prefix added because `init` is reserved:
```swift
import ComposeApp
@main struct iOSApp: App {
init() { InitKoinKt.doInitKoin(config: nil) }
var body: some Scene { WindowGroup { ContentView() } }
}
```
Alternative — Compose-managed: `KoinApplication(configuration = koinConfiguration { modules(appModule) }) { MainScreen() }`
## Defining Modules
```kotlin
val appModule = module {
// Classic DSL (manual wiring)
single<UserRepository> { UserRepositoryImpl() }
factory { ProductValidator() }
viewModelOf(::ProductViewModel)
// Compiler Plugin DSL (auto-wiring — requires Koin Compiler Plugin)
single<ProductCalculator>() // auto-resolves constructor params
single<UserRepositoryImpl>() bind UserRepository::class // bind exposes impl as interface
viewModel<ProductViewModel>()
}
```
| DSL | Lifecycle | When to use |
|---|---|---|
| `single { }` | App lifetime (singleton) | Stateless services, repositories, API clients, databases |
| `factory { }` | New instance per call | Stateful/short-lived — validators, formatters, use-cases with request state |
| `scoped { }` | Bound to a Koin scope | Shared within a flow (e.g., checkout) but not globally |
| `viewModelOf(::Class)` | ViewModel lifecycle | Survives recomposition + config changes, cleared when owner destroyed |
### Annotations (KSP)
Compile-time safety with multiplatform support. Requires KSP plugin + `koin-annotations`.
```kotlin
plugins { id("com.google.devtools.ksp") }
kotlin {
sourceSets.commonMain.dependencies {
implementation("io.insert-koin:koin-annotations:$koin_annotations_version")
}
sourceSets.named("commonMain").configure {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}
}
dependencies {
add("kspCommonMainMetadata", "io.insert-koin:koin-ksp-compiler:$koin_annotations_version")
add("kspAndroid", "io.insert-koin:koin-ksp-compiler:$koin_annotations_version")
// ... add for each target (kspIosArm64, kspIosSimulatorArm64, etc.)
}
ksp {
arg("KOIN_USE_COMPOSE_VIEWMODEL", "true") // multiplatform ViewModel DSL
arg("KOIN_CONFIG_CHECK", "true") // compile-time verification
}
```
| Annotation | Equivalent DSL | Purpose |
|---|---|---|
| `@Single` | `single { }` | Singleton |
| `@Factory` | `factory { }` | New instance each time |
| `@KoinViewModel` | `viewModelOf(::Class)` | ViewModel declaration |
| `@InjectedParam` | `parametersOf(...)` | Runtime parameter |
| `@Module` + `@ComponentScan` | `module { }` | Auto-discover annotated classes in package |
Use generated `.module` property: `modules(AppModule().module)`.
### Feature-first module organization
```kotlin
val productModule = module {
single<ProductRepository> { ProductRepositoryImpl(get()) }
viewModelOf(::ProductViewModel)
}
val appModule = module { includes(productModule, settingsModule, coreModule) }
```
### Platform-specific implementations
Use `expect/actual` modules when implementations differ per platform:
```kotlin
// commonMain
expect val platformModule: Module
// androidMain
actual val platformModule = module { single<HapticFeedback> { AndroidHapticFeedback(get()) } }
// iosMain
actual val platformModule = module { single<HapticFeedback> { IosHapticFeedback() } }
startKoin { modules(appModule, platformModule) }
```
For platform dependencies (e.g., Android `Context`) in `expect/actual` classes, use `KoinComponent` with `inject()` — justified because constructors must match across platforms. Avoid `KoinComponent` elsewhere.
## Injection in Compose
```kotlin
// Any dependency
val service: MyService = koinInject()
// ViewModel — lifecycle-aware
val viewModel = koinViewModel<HomeViewModel>()
// With runtime parameters
val viewModel = koinViewModel<DetailViewModel> { parametersOf(itemId) }
// Keyed — unique instance per entity
val viewModel = koinViewModel<DetailViewModel>(key = "detail_$itemId", parameters = { parametersOf(itemId) })
```
Inject as default parameters for testability: `fun MyScreen(service: MyService = koinInject())`.
| Function | Platform | When to use |
|---|---|---|
| `koinInject<T>()` | All | Non-ViewModel dependencies inside `@Composable` |
| `koinViewModel<T>()` | All | ViewModel — lifecycle-aware, survives recomposition |
| `koinActivityViewModel<T>()` | Android | Share ViewModel across all composables in an Activity |
| `koinEntryProvider<T>()` | All | Wire Nav 3 `NavDisplay` to Koin `navigation<T>` entries |
| `parametersOf(...)` | All | Pass runtime values to `koinViewModel` or `koinInject` |
| `get<T>()` | All | Resolve inside `module { }` only — never in composables |
## Navigation 3 Integration
Two approaches for Nav 3 + DI. For full patterns, entry-scoped ViewModels, and modularization, see [navigation-3-di.md](navigation-3-di.md).
```kotlin
// Koin DSL — entries declared in modules
val appModule = module {
navigation<HomeRoute> { HomeScreen(viewModel = koinViewModel()) }
navigation<DetailRoute> { route -> DetailScreen(viewModel = koinViewModel { parametersOf(route.id) }) }
}
NavDisplay(backStack = backStack, onBack = { backStack.removeLastOrNull() }, entryProvider = koinEntryProvider())
```
For Nav 2 patterns, see [navigation-2-di.md](navigation-2-di.md). For migration, see [navigation-migration.md](navigation-migration.md).
## Scopes
```kotlin
val appModule = module {
scope<CheckoutFlow> {
scoped { CheckoutState() }
viewModel<CheckoutViewModel>()
}
}
```
`scope<T>` works on all platforms. On Android, `activityRetainedScope { }` survives config changes (same idea, platform-specific).
## Koin in MVI
MVI is framework-agnostic — see [architecture.md](architecture.md). The Koin-specific parts are constructor injection and `koinViewModel()`:
```kotlin
class ProductViewModel(private val repository: ProductRepository) : ViewModel() {
// StateFlow<State>, Channel<Effect>, onEvent() — see architecture.md
}
// Module: viewModelOf(::ProductViewModel)
// Route: val viewModel = koinViewModel<ProductViewModel>()
```
## Testing
`verify()` performs a dry-run check — catches missing declarations before runtime:
```kotlin
class KoinModuleCheck : KoinTest {
@Test
fun verifyAllModules() {
appModule.verify(extraTypes = listOf(SavedStateHandle::class))
}
}
// commonTest.dependencies { implementation("io.insert-koin:koin-test:$koin_version") }
```
For ViewModel event→state→effect testing, see [testing.md](testing.md).
## Anti-Patterns
| Anti-pattern | Why it is harmful | Better approach |
|---|---|---|
| `factory { MyViewModel() }` for ViewModels | Not lifecycle-aware, new instance on recomposition | `viewModelOf(::MyViewModel)` |
| Not using `parametersOf` for runtime params | Constructor params unresolved | `koinViewModel { parametersOf(id) }` |
| `koin-compose` without `koin-compose-viewmodel` | `koinViewModel()` unavailable | Add `koin-compose-viewmodel` |
| Calling `startKoin` multiple times | `KoinAppAlreadyStartedException` | Call once, use `loadKoinModules` for dynamic additions |
| Android `Context` in `commonMain` modules | Breaks multiplatform | `expect/actual` platform modules |

View File

@@ -1,161 +0,0 @@
# Lists & Grids
Compose patterns for lazy layouts, applied within MVI architecture.
## LazyColumn and LazyRow
Only compose visible items — use for large or dynamic lists. For small fixed lists (<10 items), prefer `Column`/`Row`.
```kotlin
LazyColumn(modifier = Modifier.fillMaxSize()) {
item { HeaderSection() }
items(items = users, key = { it.id }) { user ->
UserRow(user = user, onOpen = onOpenUser)
}
item { FooterSection() }
}
```
### DSL patterns
- `item { }` — single composable (header, footer, divider)
- `items(list, key) { }` — from a list with stable keys
- `itemsIndexed(list) { index, item -> }` — when index is needed
## Keys
Always provide stable, unique keys when the list can change.
```kotlin
// GOOD: stable domain ID
items(users, key = { it.id }) { user -> UserRow(user) }
// BAD: index-based — state corrupts on reorder/remove
items(users, key = { index }) { user -> UserRow(user) }
// BAD: no key — Compose can't distinguish items reliably
items(users) { user -> UserRow(user) }
```
**Rule:** Use domain IDs, not indices. Without stable keys, removing an item corrupts the state of remaining items.
## ContentType for Recycling
Use `contentType` when rendering different item types to enable layout reuse:
```kotlin
sealed class FeedItem {
data class Header(val title: String) : FeedItem()
data class Post(val id: String, val content: String) : FeedItem()
}
LazyColumn {
items(
items = feedItems,
key = { when (it) { is FeedItem.Header -> it.title; is FeedItem.Post -> it.id } },
contentType = { when (it) { is FeedItem.Header -> "header"; is FeedItem.Post -> "post" } }
) { item ->
when (item) {
is FeedItem.Header -> SectionHeader(item.title)
is FeedItem.Post -> PostCard(item)
}
}
}
```
Without `contentType`, all items compete for one reuse pool. With it, items reuse layout state efficiently within their type.
## Grids and Pager
### LazyVerticalGrid
```kotlin
// Fixed columns
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
items(items, key = { it.id }) { item -> GridItem(item) }
}
// Adaptive columns (responsive) — preferred for responsive layouts
LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 120.dp)) {
items(items, key = { it.id }) { item -> GridItem(item) }
}
```
### LazyVerticalStaggeredGrid
For Pinterest-style variable-height layouts:
```kotlin
LazyVerticalStaggeredGrid(columns = StaggeredGridCells.Fixed(2)) {
items(images, key = { it.id }) { image -> ImageCard(image) }
}
```
### HorizontalPager / VerticalPager
```kotlin
val pagerState = rememberPagerState(pageCount = { pages.size })
HorizontalPager(state = pagerState) { page ->
PageContent(pages[page])
}
// Programmatic scroll
LaunchedEffect(targetPage) { pagerState.animateScrollToPage(targetPage) }
```
## Scroll State and Derived Logic
```kotlin
val listState = rememberLazyListState()
// GOOD: derivedStateOf for scroll-dependent UI
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 2 }
}
LazyColumn(state = listState) {
items(items, key = { it.id }) { item -> ItemRow(item) }
}
if (showScrollToTop) {
FloatingActionButton(onClick = { scope.launch { listState.animateScrollToItem(0) } }) {
Icon(Icons.Default.ArrowUpward, contentDescription = "Scroll to top")
}
}
```
Keep `LazyListState` local — do not put scroll position in the MVI ViewModel state.
## Nested Scrolling
```kotlin
// BAD: verticalScroll inside LazyColumn — two scroll containers fight for input
LazyColumn {
item {
Column(Modifier.verticalScroll(rememberScrollState())) { /* conflict */ }
}
}
// OK: nested LazyRow inside LazyColumn (different axes)
LazyColumn {
item { LazyRow { items(horizontalItems) { HorizontalCard(it) } } }
items(verticalItems) { VerticalRow(it) }
}
```
For complex scenarios, use `Modifier.nestedScroll()` with a custom `NestedScrollConnection`.
## List Anti-Patterns
| Anti-pattern | Fix |
|---|---|
| No keys on mutable lists | Always provide stable domain ID keys |
| Index-based keys | Use `it.id`, not position index |
| Expensive computation inside item lambda | Compute upstream in reducer, pass pre-computed data |
| Inline `filter`/`sort` inside `items {}` | Sort/filter in reducer or ViewModel before emitting state |
| `LazyColumn` for 5 fixed items | Use `Column` for small fixed lists |
| Creating new objects in `key` lambda | Use primitive stable identifiers |
| Missing `contentType` on multi-type lists | Provide `contentType` for efficient reuse |
For paginated lists with network/database loading, see [Paging 3](paging.md).

View File

@@ -1,246 +0,0 @@
# Material 3 Theming & Components
## TL;DR Defaults
| Concern | Default |
|---|---|
| Theme entry point | `MaterialTheme(colorScheme, typography, shapes)` wrapping app content |
| Dynamic color | Enable on Android 12+; fall back to brand `ColorScheme` on older APIs |
| Dark/light | Follow system via `isSystemInDarkTheme()`; expose user override if needed |
| Color pairing | Always pair `primary`/`onPrimary`, `surface`/`onSurface`, `*Container`/`on*Container` |
| Typography | Use default M3 type scale; override only specific slots for branding |
| Shapes | Use default M3 shape scale; override per-slot (`small`, `medium`, `large`) |
| Scaffold | Use `Scaffold` for screens with app bars, FAB, snackbar, or bottom bar |
| Navigation | `NavigationSuiteScaffold` auto-switches bar/rail by window size |
| Snackbar | `SnackbarHostState` in Route; show via `Effect` from ViewModel |
| Bottom sheet | `ModalBottomSheet` with `SheetState`; control via `show()`/`hide()` |
| Dialog | `AlertDialog` for simple confirm/dismiss; custom `Dialog` for complex content |
| Adaptive layout | Derive window size class once at app level; pass down as state |
## Theming Baseline
### Theme Setup
```kotlin
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
```
### Key Rules
- Define `LightColorScheme` and `DarkColorScheme` using `lightColorScheme()` / `darkColorScheme()`.
- Generate brand colors via [Material Theme Builder](https://m3.material.io/theme-builder) for guaranteed tonal palettes.
- Dynamic color is Android-only; CMP projects fall back to brand schemes on non-Android targets.
## Color Roles and Dark/Light
### Role Pairing Rules
| Container | Content on it |
|---|---|
| `primary` | `onPrimary` |
| `primaryContainer` | `onPrimaryContainer` |
| `secondary` | `onSecondary` |
| `secondaryContainer` | `onSecondaryContainer` |
| `tertiary` | `onTertiary` |
| `tertiaryContainer` | `onTertiaryContainer` |
| `surface` | `onSurface` |
| `surfaceVariant` | `onSurfaceVariant` |
| `error` | `onError` |
| `errorContainer` | `onErrorContainer` |
### Accessibility Guardrails
- Always use the correct `on*` color for text/icons on a container.
- Do not mix unrelated pairs (e.g., `tertiaryContainer` background with `primaryContainer` text).
- M3 tonal palettes guarantee 3:1+ contrast when paired correctly.
### Do / Don't
| Do | Don't |
|---|---|
| `containerColor = primary`, `contentColor = onPrimary` | `containerColor = primary`, `contentColor = tertiaryContainer` |
| Access colors via `MaterialTheme.colorScheme.*` | Hardcode hex colors in components |
| Test both light and dark themes | Assume light-only usage |
## Typography and Shapes
### Typography
M3 defines 15 text styles across 5 categories:
| Category | Sizes |
|---|---|
| Display | `displayLarge`, `displayMedium`, `displaySmall` |
| Headline | `headlineLarge`, `headlineMedium`, `headlineSmall` |
| Title | `titleLarge`, `titleMedium`, `titleSmall` |
| Body | `bodyLarge`, `bodyMedium`, `bodySmall` |
| Label | `labelLarge`, `labelMedium`, `labelSmall` |
**Default**: Use M3 defaults. Override individual slots for brand fonts:
```kotlin
val AppTypography = Typography(
titleLarge = TextStyle(fontFamily = BrandFont, fontWeight = FontWeight.SemiBold, fontSize = 22.sp)
)
```
### Shapes
M3 shape scale: `extraSmall`, `small`, `medium`, `large`, `extraLarge`.
**Default**: Use M3 defaults. Override only when brand requires specific corner radii:
```kotlin
val AppShapes = Shapes(
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp)
)
```
## Component Decision Matrix
### Scaffold
| Slot | When to use |
|---|---|
| `topBar` | Screen has a top app bar |
| `bottomBar` | Screen has bottom navigation or bottom app bar |
| `floatingActionButton` | Primary action needs FAB |
| `snackbarHost` | Screen can show snackbars |
| `content` | Main screen content; receives `PaddingValues` to apply |
**Rule**: Always apply `innerPadding` from `Scaffold` to content root.
### Top App Bar
| Variant | Use case | Scroll / default |
|---|---|---|
| `TopAppBar` (small) | Simple screens, minimal actions | Default: `pinnedScrollBehavior` unless you need collapse |
| `CenterAlignedTopAppBar` | Single primary action, centered title | Same bar family as small |
| `MediumTopAppBar` | Moderate navigation, collapsible on scroll | `exitUntilCollapsedScrollBehavior` (also `enterAlwaysScrollBehavior` where needed) |
| `LargeTopAppBar` | Hero screens, prominent title, collapsible | Same scroll behavior family as medium |
### Navigation
| Window size | Component |
|---|---|
| Compact (phones portrait) | `NavigationBar` (bottom) |
| Medium/Expanded (tablets, landscape) | `NavigationRail` (side) |
| Auto-switch | `NavigationSuiteScaffold` |
**Default**: Use `NavigationSuiteScaffold` for apps with 3-5 top-level destinations. It adapts automatically.
```kotlin
NavigationSuiteScaffold(
navigationSuiteItems = {
destinations.forEach { dest ->
item(
selected = currentDest == dest,
onClick = { currentDest = dest },
icon = { Icon(dest.icon, contentDescription = null) },
label = { Text(dest.label) }
)
}
}
) { DestinationContent(currentDest) }
```
### Bottom Sheet
| Type | Use case |
|---|---|
| `ModalBottomSheet` | Overlays content, dismissible |
| `BottomSheetScaffold` | Persistent sheet integrated with screen |
**State control**: Use `rememberModalBottomSheetState()` + `SheetState.show()`/`hide()`.
**MVI pattern**: ViewModel emits `Effect.ShowSheet`; Route composable calls `sheetState.show()` in `LaunchedEffect`.
### Snackbar
**Setup**: `SnackbarHostState` remembered in Route; passed to `Scaffold.snackbarHost`.
**Pattern**:
```kotlin
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is Effect.ShowSnackbar -> {
val result = snackbarHostState.showSnackbar(effect.message, effect.actionLabel)
if (result == SnackbarResult.ActionPerformed) viewModel.onEvent(Event.SnackbarAction)
}
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { /* ... */ }
```
### Dialog
| Type | Use case |
|---|---|
| `AlertDialog` | Simple title + text + confirm/dismiss buttons |
| `Dialog` + `Card` | Complex content, forms, custom layouts |
**MVI pattern**: Dialog visibility controlled by `state.showDialog: Boolean`. Confirm/dismiss dispatch events.
## Adaptive Layout Defaults
### Window Size Classes
| Class | Width breakpoint | Typical devices |
|---|---|---|
| Compact | < 600dp | Phones portrait |
| Medium | 600dp 840dp | Tablets portrait, large unfolded |
| Expanded | ≥ 840dp | Tablets landscape, desktop |
**Rule**: Compute `WindowSizeClass` once at app/activity level via `currentWindowAdaptiveInfo()`. Pass derived layout decisions down as state.
### Canonical Layouts
| Layout | Use case | Compose component |
|---|---|---|
| List-detail | Master list + detail pane | `ListDetailPaneScaffold`, `NavigableListDetailPaneScaffold` |
| Supporting pane | Main content + supplementary info | `SupportingPaneScaffold`, `NavigableSupportingPaneScaffold` |
| Feed | Grid of browsable content | `LazyVerticalGrid` with `GridCells.Adaptive` |
**Default**: For list-detail apps, use `NavigableListDetailPaneScaffold` which handles pane visibility and back navigation.
**Adaptive navigation:** read `windowSizeClass` (or related adaptive info) once at the root and pass derived flags (e.g. whether to show a top app bar) into your main screen composable.
## M2 to M3 Migration Notes
| M2 | M3 |
|---|---|
| `Colors` | `ColorScheme` |
| `lightColors()` / `darkColors()` | `lightColorScheme()` / `darkColorScheme()` |
| `BottomNavigation` | `NavigationBar` |
| `BottomNavigationItem` | `NavigationBarItem` |
| `ModalBottomSheetLayout` | `ModalBottomSheet` |
| `ModalDrawer` | `ModalNavigationDrawer` |
| `Scaffold` with `scaffoldState` | `Scaffold` with `snackbarHost` slot |
| `BackdropScaffold` | `BottomSheetScaffold` or custom |
| `TopAppBar` elevation | `TopAppBar` with `scrollBehavior` |
**Key change**: M3 `Scaffold` no longer has `drawerState`. Use `ModalNavigationDrawer` wrapping `Scaffold` instead.

View File

@@ -1,220 +0,0 @@
# MVI (Event/State/Effect)
MVI pattern: sealed Event contract processed by a single `onEvent()` entry point. Use when the project has chosen MVI.
For shared architecture concepts (state owner selection, domain layer, module rules), see [architecture.md](architecture.md).
## The 3 MVI Types
A non-trivial screen using MVI defines 3 types: `Event`, `State`, `Effect`.
### Event
User actions from UI: button clicks, field changes, lifecycle-start, retry, refresh, back press. Events are the **only** input from the UI into the screen state holder, processed by a single `onEvent()` function.
### State
Immutable data class that fully describes what the screen should render. Given the same state, the screen always looks the same. One state per screen, owned by the screen state holder via `StateFlow<State>`.
State should be **equality-friendly** — use `data class` with immutable collections. Computed properties (`val hasRequiredFields get() = name.isNotBlank()`) are acceptable for trivial derivations. Store canonical values; derive display values at the UI boundary.
### Effect
One-off UI commands that don't belong in state: navigate, show snackbar, trigger haptic, copy/share, open browser.
**Why effects are not state:** if you model "show snackbar" as a boolean in state, you need "consume" logic to flip it back — a classic source of bugs. Effects fire once and are gone.
## Event Naming
Events should be named from the **user's perspective** — what happened, not what should happen.
| Good | Bad |
|---|---|
| `OnSaveClick` | `SaveCategory` |
| `OnTitleChanged` | `UpdateTitle` |
| `OnRetryClick` | `RetryRequest` |
| `OnBackClick` | `NavigateBack` |
The event describes a user action; the ViewModel decides how to handle it.
## State Modeling
Use immutable `data class` with computed properties for derivations. For detailed guidance (forms, calculators, avoiding duplicated state), see [architecture.md](architecture.md) — State Modeling for Forms and Calculators.
## Effect Delivery
For Channel vs SharedFlow guidance, see [architecture.md](architecture.md) — Effect Delivery. Default: `Channel<Effect>(Channel.BUFFERED)` with `receiveAsFlow()`.
## Event Processing Flow
```text
UI gesture / lifecycle signal
→ Event dispatched via onEvent()
→ ViewModel processes the event in a when() block
→ Synchronous events: updateState { copy(...) }
→ Side effects: sendEffect(effect)
→ Async work: viewModelScope.launch { ... }
→ On async completion: updateState { copy(...) } + sendEffect(...)
```
**Key insight:** `onEvent()` is the single decision point. It decides what happens for each event — update state, send an effect, launch async work, or some combination. This keeps all event→reaction logic in one place.
## Screen State Holder Anatomy
A screen state holder using MVI has three responsibilities:
1. **State ownership** — holds `MutableStateFlow<State>`, exposes `StateFlow<State>`
2. **Effect delivery** — holds `Channel<Effect>` or the project's equivalent, exposes `Flow<Effect>`
3. **Event processing** — implements `onEvent()` to handle all events
State is updated via a thread-safe `update` function (e.g., `MutableStateFlow.update { it.copy(...) }` or a wrapper like `updateState { copy(...) }`). Effects are sent via `channel.trySend(effect)`.
## UI Rendering Boundary
### Route composable
Obtains the screen state holder (via `koinViewModel()`, `hiltViewModel()`, manual construction), collects state once via lifecycle-aware collector, collects effects via `CollectEffect` or equivalent, binds navigation/snackbar/sheet/platform APIs.
### Screen composable
Stateless render function receiving state plus `onEvent: (Event) -> Unit` callback.
### Leaf composables
Render sub-state, emit specific callbacks, keep only tiny visual-local state. Do not pass `onEvent` to reusable leaves — adapt to specific callbacks.
### Domain and Data Layer Boundaries
See [architecture.md](architecture.md) — Domain Layer and Where Logic Belongs.
## When MVI Is Appropriate
- Project already uses MVI with a base class or convention
- Screen has many user actions and you want them enumerated in one sealed type
- Team values explicit event contracts for debugging, analytics, or time-travel debugging
- You need exhaustive `when` handling for all UI actions
- Complex screens with interrelated state transitions
## Code Examples
### BAD: business logic inside composables
```kotlin
@Composable
fun LoanCalculatorScreen() {
var amountText by rememberSaveable { mutableStateOf("") }
var rateText by rememberSaveable { mutableStateOf("") }
var yearsText by rememberSaveable { mutableStateOf("") }
// Calculation/validation omitted — belongs in state holder, not here.
Column {
OutlinedTextField(value = amountText, onValueChange = { amountText = it })
OutlinedTextField(value = rateText, onValueChange = { rateText = it })
OutlinedTextField(value = yearsText, onValueChange = { yearsText = it })
Text("Monthly payment: …")
Button(onClick = { /* … */ }) { Text("Calculate") }
}
}
```
Problems: logic and validation live in the composable, hard to test, and recomposition becomes the execution model.
### GOOD: MVI contract — Event, State, Effect
```kotlin
sealed interface CreateItemEvent {
data class OnTitleChanged(val title: String) : CreateItemEvent
data class OnAmountChanged(val amount: String) : CreateItemEvent
data object OnSaveClick : CreateItemEvent
data object OnBackClick : CreateItemEvent
}
data class CreateItemState(
val title: String = "",
val amount: String = "",
val isSaving: Boolean = false,
val errors: Map<String, String> = emptyMap()
) {
val canSave: Boolean get() = title.isNotBlank() && amount.isNotBlank()
}
sealed interface CreateItemEffect {
data object NavigateBack : CreateItemEffect
data class ShowMessage(val text: String) : CreateItemEffect
}
```
### GOOD: ViewModel with onEvent
Full `save()` (validation + `viewModelScope.launch`): identical body to [mvvm.md](mvvm.md) — **GOOD: ViewModel with named functions**; here it is invoked from `onEvent` instead of public named functions.
```kotlin
class CreateItemViewModel(
private val repository: ItemRepository,
) : ViewModel() {
private val _state = MutableStateFlow(CreateItemState())
val state: StateFlow<CreateItemState> = _state.asStateFlow()
private val _effect = Channel<CreateItemEffect>(Channel.BUFFERED)
val effect: Flow<CreateItemEffect> = _effect.receiveAsFlow()
fun onEvent(event: CreateItemEvent) {
when (event) {
is CreateItemEvent.OnTitleChanged -> _state.update { it.copy(title = event.title, errors = it.errors - "title") }
is CreateItemEvent.OnAmountChanged -> _state.update { it.copy(amount = event.amount, errors = it.errors - "amount") }
CreateItemEvent.OnSaveClick -> save()
CreateItemEvent.OnBackClick -> _effect.trySend(CreateItemEffect.NavigateBack)
}
}
// save(): validate, set isSaving, launch coroutine, update state, trySend ShowMessage / NavigateBack on success or failure
private fun save() { /* … */ }
}
```
### GOOD: Same pattern with a base class or interface
`class CreateItemViewModel(...) : ViewModel(), MviHost<CreateItemEvent, CreateItemState, CreateItemEffect>` — same `onEvent` / `save()` shape; `updateState` / `sendEffect` from the host. Full base-class pattern: [clean-code.md](clean-code.md), [architecture.md](architecture.md).
### GOOD: Route/Screen/Leaf split
Layering: [architecture.md](architecture.md) — **State Collection and Slicing**. Full Route + `CollectEffect` sample: [mvvm.md](mvvm.md) — Route/Screen/Leaf (swap named callbacks for `onEvent`).
```kotlin
@Composable
fun CreateItemRoute(vm: CreateItemViewModel = koinViewModel(), snackbar: SnackbarHostState, onBack: () -> Unit) {
val state by vm.state.collectAsStateWithLifecycle()
CollectEffect(vm.effect) { e -> when (e) {
CreateItemEffect.NavigateBack -> onBack()
is CreateItemEffect.ShowMessage -> snackbar.showSnackbar(e.text)
}}
CreateItemScreen(state, vm::onEvent)
}
@Composable
fun CreateItemScreen(state: CreateItemState, onEvent: (CreateItemEvent) -> Unit) {
Column {
OutlinedTextField(state.title, { onEvent(CreateItemEvent.OnTitleChanged(it)) })
OutlinedTextField(state.amount, { onEvent(CreateItemEvent.OnAmountChanged(it)) })
Button(onClick = { onEvent(CreateItemEvent.OnSaveClick) }, enabled = !state.isSaving && state.canSave) {
Text(if (state.isSaving) "Saving..." else "Save")
}
}
}
```
### GOOD: Event model for form-heavy screens
```kotlin
enum class FormField { Area, MaterialRate, LaborRate, TaxPercent, Notes }
sealed interface FormEvent {
data class FieldChanged(val field: FormField, val raw: String) : FormEvent
data class IncludeWasteChanged(val enabled: Boolean) : FormEvent
data object SubmitClicked : FormEvent
data object RetryClicked : FormEvent
data object ScreenShown : FormEvent
data object ClearClicked : FormEvent
}
```
Pragmatic default for large forms: specific intent names for screen-level actions, generic `FieldChanged(field, raw)` only when many fields are structurally similar.

View File

@@ -1,236 +0,0 @@
# MVVM (ViewModel with Named Functions)
MVVM pattern: ViewModel with named public functions instead of sealed events. Use when the project has chosen MVVM.
For shared architecture concepts (state owner selection, domain layer, module rules), see [architecture.md](architecture.md).
## The 2 MVVM Types
A non-trivial screen using MVVM defines 2 types: `State`, `Effect`. User actions call named ViewModel functions directly instead of dispatching sealed events.
### State
Immutable data class that fully describes what the screen should render. Given the same state, the screen always looks the same. One state per screen, owned by the ViewModel via `StateFlow<State>`.
State should be **equality-friendly** — use `data class` with immutable collections. Computed properties (`val hasRequiredFields get() = name.isNotBlank()`) are acceptable for trivial derivations. Store canonical values; derive display values at the UI boundary.
### Effect
One-off UI commands that don't belong in state: navigate, show snackbar, trigger haptic, copy/share, open browser.
**Why effects are not state:** if you model "show snackbar" as a boolean in state, you need "consume" logic to flip it back — a classic source of bugs. Effects fire once and are gone.
## State Modeling
Use immutable `data class` with computed properties for derivations. For detailed guidance (forms, calculators, avoiding duplicated state), see [architecture.md](architecture.md) — State Modeling for Forms and Calculators.
## Effect Delivery
For Channel vs SharedFlow guidance, see [architecture.md](architecture.md) — Effect Delivery. Default: `Channel<Effect>(Channel.BUFFERED)` with `receiveAsFlow()`.
### Effects from Named Functions
Effects are emitted directly from named functions instead of an `onEvent()` dispatcher:
```kotlin
fun onBackClick() {
_effect.trySend(CreateItemEffect.NavigateBack)
}
fun save() {
// ... validation and async work ...
_effect.trySend(CreateItemEffect.ShowMessage("Saved"))
_effect.trySend(CreateItemEffect.NavigateBack)
}
```
## Screen State Holder Anatomy
A MVVM ViewModel has three responsibilities:
1. **State ownership** — holds `MutableStateFlow<State>`, exposes `StateFlow<State>`
2. **Effect delivery** — holds `Channel<Effect>` or the project's equivalent, exposes `Flow<Effect>`
3. **Named action functions** — public functions for each user action
State is updated via a thread-safe `update` function (e.g., `MutableStateFlow.update { it.copy(...) }` or a wrapper like `updateState { copy(...) }`). Effects are sent via `channel.trySend(effect)`.
## UI Rendering Boundary
### Route composable
Obtains the ViewModel (via `koinViewModel()`, `hiltViewModel()`, manual construction), collects state once via lifecycle-aware collector, collects effects via `CollectEffect` or equivalent, binds navigation/snackbar/sheet/platform APIs.
The route passes individual callbacks to the screen:
```kotlin
@Composable
fun CreateItemRoute(
viewModel: CreateItemViewModel = koinViewModel(),
snackbarHostState: SnackbarHostState,
onNavigateBack: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
CollectEffect(viewModel.effect) { effect ->
when (effect) {
CreateItemEffect.NavigateBack -> onNavigateBack()
is CreateItemEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.text)
}
}
CreateItemScreen(
state = state,
onTitleChange = viewModel::onTitleChanged,
onAmountChange = viewModel::onAmountChanged,
onSaveClick = viewModel::save,
)
}
```
### Screen composable
Stateless render function receiving state plus individual callbacks:
```kotlin
@Composable
fun CreateItemScreen(
state: CreateItemState,
onTitleChange: (String) -> Unit,
onAmountChange: (String) -> Unit,
onSaveClick: () -> Unit,
) {
Column {
OutlinedTextField(
value = state.title,
onValueChange = onTitleChange,
isError = state.errors.containsKey("title"),
label = { Text("Title") },
)
OutlinedTextField(
value = state.amount,
onValueChange = onAmountChange,
isError = state.errors.containsKey("amount"),
label = { Text("Amount") },
)
Button(
onClick = onSaveClick,
enabled = !state.isSaving && state.canSave,
) {
Text(if (state.isSaving) "Saving..." else "Save")
}
}
}
```
### Leaf composables
Render sub-state, emit specific callbacks, keep only tiny visual-local state. Receive only what they need; do not pass the ViewModel to leaves.
### Domain and Data Layer Boundaries
See [architecture.md](architecture.md) — Domain Layer and Where Logic Belongs.
## When MVVM Is Appropriate
- Project already uses MVVM conventions
- Screen is straightforward with few user actions
- Team prefers less boilerplate and direct function calls
- Migrating from Android View-based MVVM to Compose
- Named functions provide sufficient discoverability for the screen's complexity
## Code Examples
### GOOD: State and Effect definitions
```kotlin
data class CreateItemState(
val title: String = "",
val amount: String = "",
val isSaving: Boolean = false,
val errors: Map<String, String> = emptyMap()
) {
val canSave: Boolean get() = title.isNotBlank() && amount.isNotBlank()
}
sealed interface CreateItemEffect {
data object NavigateBack : CreateItemEffect
data class ShowMessage(val text: String) : CreateItemEffect
}
```
### GOOD: ViewModel with named functions
```kotlin
class CreateItemViewModel(
private val repository: ItemRepository,
) : ViewModel() {
private val _state = MutableStateFlow(CreateItemState())
val state: StateFlow<CreateItemState> = _state.asStateFlow()
private val _effect = Channel<CreateItemEffect>(Channel.BUFFERED)
val effect: Flow<CreateItemEffect> = _effect.receiveAsFlow()
fun onTitleChanged(title: String) {
_state.update { it.copy(title = title, errors = it.errors - "title") }
}
fun onAmountChanged(amount: String) {
_state.update { it.copy(amount = amount, errors = it.errors - "amount") }
}
fun onBackClick() {
_effect.trySend(CreateItemEffect.NavigateBack)
}
fun save() {
val current = _state.value
val errors = /* validate current.title / current.amount */
if (errors.isNotEmpty()) {
_state.update { it.copy(errors = errors) }
return
}
_state.update { it.copy(isSaving = true, errors = emptyMap()) }
viewModelScope.launch {
try {
repository.create(current.title.trim(), current.amount.toDouble())
_state.update { it.copy(isSaving = false) }
_effect.trySend(CreateItemEffect.ShowMessage("Saved"))
_effect.trySend(CreateItemEffect.NavigateBack)
} catch (e: Exception) {
_state.update { it.copy(isSaving = false) }
_effect.trySend(CreateItemEffect.ShowMessage("Failed: ${e.message}"))
}
}
}
}
```
### GOOD: Route/Screen/Leaf split
See the Route example above in **UI Rendering Boundary** for the full Route/Screen split (including `CollectEffect` and callback wiring).
### GOOD: Callback grouping for complex screens
For screens with many actions, group related callbacks into a single interface to reduce parameter count:
```kotlin
interface CreateItemActions {
fun onTitleChanged(title: String)
fun onAmountChanged(amount: String)
fun onCategorySelected(category: Category)
fun onTagsChanged(tags: List<Tag>)
fun onSaveClick()
fun onDeleteClick()
fun onBackClick()
}
@Composable
fun CreateItemScreen(
state: CreateItemState,
actions: CreateItemActions,
) {
// Use actions.onTitleChanged, actions.onSaveClick, etc.
}
```
The ViewModel can implement this interface directly. This provides structure without the ceremony of a sealed event class.

View File

@@ -1,139 +0,0 @@
# Navigation 2 + Dependency Injection
DI wiring for Nav 2 destinations: destination-scoped and graph-scoped ViewModels with Hilt and Koin.
For Nav 2 core reference (NavHost, tabs, deep links, animations), see [navigation-2.md](navigation-2.md).
For shared navigation concepts and anti-patterns, see [navigation.md](navigation.md).
## Hilt Integration
### hiltViewModel in composable destinations
Each `composable()` destination gets its own ViewModel instance scoped to the `NavBackStackEntry`:
```kotlin
composable<Detail> { backStackEntry ->
val viewModel = hiltViewModel<DetailViewModel>()
DetailScreen(viewModel = viewModel)
}
```
### SavedStateHandle for navigation arguments
Hilt auto-injects `SavedStateHandle` populated with navigation arguments. The ViewModel receives route params without manual extraction:
```kotlin
@HiltViewModel
class DetailViewModel @Inject constructor(
private val repository: ItemRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val itemId: String = checkNotNull(savedStateHandle["itemId"])
}
```
### Graph-scoped shared ViewModel
Share a ViewModel across all destinations within a nested navigation graph (e.g., a multi-step checkout flow):
```kotlin
composable("checkout/cart") { entry ->
val parentEntry = remember(entry) { navController.getBackStackEntry("checkout") }
val sharedViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
CartScreen(viewModel = sharedViewModel)
}
```
All destinations in the `checkout` graph share the same `CheckoutViewModel` instance, which is cleared when the graph is popped from the back stack.
### @AssistedInject for non-navigation params
When a ViewModel needs values that aren't in navigation arguments and can't go through `SavedStateHandle`:
```kotlin
@HiltViewModel(assistedFactory = EditorViewModel.Factory::class)
class EditorViewModel @AssistedInject constructor(
private val repository: DocRepository,
@Assisted private val mode: EditMode,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(mode: EditMode): EditorViewModel
}
}
// Composable destination
composable<Editor> {
val viewModel = hiltViewModel<EditorViewModel, EditorViewModel.Factory> { factory ->
factory.create(EditMode.CREATE)
}
}
```
Prefer `SavedStateHandle` for navigation arguments (simpler, survives process death). Use `@AssistedInject` only when `SavedStateHandle` can't carry the data.
## Koin Integration
### koinViewModel in composable destinations
Standard ViewModel injection using `koinViewModel()`:
```kotlin
composable<Detail> {
val detail: Detail = it.toRoute()
DetailScreen(viewModel = koinViewModel { parametersOf(detail.itemId) })
}
```
### koinNavViewModel — auto-populated SavedStateHandle
`koinNavViewModel()` automatically populates the ViewModel's `SavedStateHandle` with navigation arguments. The ViewModel receives route params via its constructor without manual extraction:
```kotlin
class DetailViewModel(
private val repository: ItemRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val itemId: String = checkNotNull(savedStateHandle["itemId"])
}
// Module declaration
val featureModule = module {
viewModelOf(::DetailViewModel)
}
// Composable destination — SavedStateHandle auto-populated with nav args
composable("detail/{itemId}") {
val viewModel = koinNavViewModel<DetailViewModel>()
DetailScreen(viewModel = viewModel)
}
```
### sharedKoinViewModel — graph-scoped sharing
Share a ViewModel within a navigation graph. The shared instance lives as long as the graph's back stack entry:
```kotlin
navigation(startDestination = "checkout/cart", route = "checkout") {
composable("checkout/cart") { entry ->
val sharedVm = entry.sharedKoinViewModel<CheckoutViewModel>(navController)
CartScreen(viewModel = sharedVm)
}
composable("checkout/shipping") { entry ->
val sharedVm = entry.sharedKoinViewModel<CheckoutViewModel>(navController)
ShippingScreen(viewModel = sharedVm)
}
}
```
This is the Koin equivalent of Hilt's `hiltViewModel(navController.getBackStackEntry("checkout"))` pattern.
### Quick reference — Koin Nav 2 injection functions
| Function | Purpose |
|---|---|
| `koinViewModel<T>()` | Standard injection — new instance per destination |
| `koinNavViewModel<T>()` | Like `koinViewModel` but auto-populates `SavedStateHandle` with nav arguments |
| `sharedKoinViewModel<T>(navController)` | Share ViewModel within a navigation graph (experimental) |
| `koinViewModel(parameters = { parametersOf(...) })` | Pass runtime values to the ViewModel constructor |

View File

@@ -1,251 +0,0 @@
# Navigation 2
NavHost, NavController, and graph DSL for Jetpack Compose navigation. Nav 2 is **not deprecated** and remains fully supported.
For shared navigation concepts (MVI rules, anti-patterns, version decision guide), see [navigation.md](navigation.md).
For DI wiring (Hilt/Koin + Nav 2), see [navigation-2-di.md](navigation-2-di.md).
For migrating to Nav 3, see [navigation-migration.md](navigation-migration.md).
References:
- [Navigation Compose docs](https://developer.android.com/guide/navigation/get-started)
- [Type-safe navigation (2.8+)](https://developer.android.com/guide/navigation/design/type-safety)
- [Navigation with Compose](https://developer.android.com/develop/ui/compose/navigation)
- [Animate transitions](https://developer.android.com/guide/navigation/use-graph/animate-transitions)
## Core Concepts
Nav 2 has three building blocks:
1. **NavController** — imperative controller that manages the back stack and navigation actions
2. **NavHost** — composable container that maps routes to composable destinations
3. **NavGraph** — the navigation graph defined via the `NavHost` DSL
## Basic Setup with String Routes
```kotlin
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home") {
HomeScreen(onNavigateToDetail = { id -> navController.navigate("detail/$id") })
}
composable("detail/{itemId}") { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId") ?: return@composable
DetailScreen(itemId = itemId, onBack = { navController.navigateUp() })
}
}
}
```
How you wire ViewModels and state inside each `composable` block depends on your project's architecture — see [navigation.md](navigation.md) for the MVI boundary pattern where navigation is driven by ViewModel effects.
## Type-Safe Routes (2.8+)
From Navigation Compose 2.8+, routes can be `@Serializable` types instead of strings. This is the recommended approach for new Nav 2 code:
```kotlin
@Serializable data object Home
@Serializable data class Detail(val itemId: String)
NavHost(navController = navController, startDestination = Home) {
composable<Home> {
HomeScreen(onNavigateToDetail = { id -> navController.navigate(Detail(id)) })
}
composable<Detail> { backStackEntry ->
val detail: Detail = backStackEntry.toRoute()
DetailScreen(itemId = detail.itemId, onBack = { navController.navigateUp() })
}
}
```
## Navigation Arguments (Legacy String Routes)
For pre-2.8 projects using string routes:
```kotlin
composable(
route = "detail/{itemId}?sort={sort}",
arguments = listOf(
navArgument("itemId") { type = NavType.StringType },
navArgument("sort") { type = NavType.StringType; defaultValue = "name" },
)
) { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId") ?: return@composable
val sort = backStackEntry.arguments?.getString("sort") ?: "name"
DetailScreen(itemId = itemId, sortBy = sort)
}
```
Type-safe routes (2.8+) are the recommended default — the `navArgument` DSL is for legacy codebases.
## Common Navigation Actions
```kotlin
navController.navigate("detail/$id")
navController.navigate("detail/$id") {
popUpTo("home") { inclusive = false }
launchSingleTop = true
}
navController.navigateUp()
navController.popBackStack()
// Type-safe (2.8+)
navController.navigate(Detail(id)) {
popUpTo<Home> { inclusive = false }
launchSingleTop = true
}
```
## Top-Level Tabs with NavigationBar
Use `NavigationBar` with `currentBackStackEntryAsState()`. Track selection with `destination.hierarchy` and `hasRoute(route::class)`.
```kotlin
@Serializable sealed interface TopLevelRoute {
@Serializable data object Home : TopLevelRoute
@Serializable data object Search : TopLevelRoute
@Serializable data object Profile : TopLevelRoute
}
@Composable
fun MainScreen() {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val tabs = listOf(
Triple(TopLevelRoute.Home, Icons.Default.Home, "Home"),
Triple(TopLevelRoute.Search, Icons.Default.Search, "Search"),
Triple(TopLevelRoute.Profile, Icons.Default.Person, "Profile"),
)
Scaffold(
bottomBar = {
NavigationBar {
tabs.forEach { (route, icon, label) ->
val selected =
currentDestination?.hierarchy?.any { it.hasRoute(route::class) } == true
NavigationBarItem(
selected = selected,
onClick = {
navController.navigate(route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(icon, contentDescription = label) },
label = { Text(label) },
)
}
}
},
) { padding ->
NavHost(
navController = navController,
startDestination = TopLevelRoute.Home,
modifier = Modifier.padding(padding),
) {
composable<TopLevelRoute.Home> { HomeScreen(navController) }
composable<TopLevelRoute.Search> { SearchScreen(navController) }
composable<TopLevelRoute.Profile> { ProfileScreen(navController) }
}
}
}
```
## Deep Links
Type-safe (2.8+):
```kotlin
composable<Detail>(
deepLinks = listOf(
navDeepLink<Detail>(basePath = "https://example.com/detail")
)
) { backStackEntry ->
val detail: Detail = backStackEntry.toRoute()
DetailScreen(detail.itemId)
}
```
## Navigate with Results
Pass data back via `SavedStateHandle` on back stack entries (avoids bloating route arguments):
```kotlin
// Sender: set on previous entry, then pop
Button(onClick = {
navController.previousBackStackEntry?.savedStateHandle?.set("filter_result", selectedFilter)
navController.navigateUp()
}) { Text("Apply") }
// Receiver: observe on current entry
val filterResult = navController.currentBackStackEntry
?.savedStateHandle
?.getStateFlow<String?>("filter_result", null)
?.collectAsStateWithLifecycle()
```
## Nested Navigation Graphs
Group related destinations under a nested graph:
```kotlin
NavHost(navController = navController, startDestination = "home") {
composable("home") { HomeScreen(navController) }
navigation(startDestination = "checkout/cart", route = "checkout") {
composable("checkout/cart") { CartScreen(navController) }
composable("checkout/shipping") { ShippingScreen(navController) }
composable("checkout/payment") { PaymentScreen(navController) }
}
}
```
Type-safe: use `navigation<Graph>(startDestination = Route)` with `@Serializable` types — same structure as above.
## Animations
Default transitions on `NavHost`:
```kotlin
NavHost(
navController = navController,
startDestination = Home,
enterTransition = { slideInHorizontally(initialOffsetX = { it }) + fadeIn() },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) + fadeOut() },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) + fadeIn() },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) + fadeOut() },
) { /* destinations */ }
```
## Conditional Navigation (Auth Guards)
Redirect via `startDestination` and clear login from the stack after success:
```kotlin
@Composable
fun AppNavigation(isAuthenticated: Boolean) {
val navController = rememberNavController()
val startDestination = if (isAuthenticated) Home else Login
NavHost(navController = navController, startDestination = startDestination) {
composable<Login> {
LoginScreen(onLoginSuccess = {
navController.navigate(Home) {
popUpTo<Login> { inclusive = true }
}
})
}
composable<Home> { HomeScreen(navController) }
composable<Detail> { DetailScreen(navController) }
}
}
```

View File

@@ -1,174 +0,0 @@
# Navigation 3 + Dependency Injection
DI wiring for Nav 3 entries: entry-scoped ViewModels, modularization, and multi-module entry providers with Hilt and Koin.
For Nav 3 core reference (routes, NavDisplay, scenes, animations), see [navigation-3.md](navigation-3.md).
For shared navigation concepts and anti-patterns, see [navigation.md](navigation.md).
## Entry-Scoped ViewModels
Nav 3 scopes ViewModels to entries via `rememberViewModelStoreNavEntryDecorator()`. Each entry gets its own `ViewModelStoreOwner` — VMs are created when the entry is added to the back stack and cleared when popped.
### BAD: Globally-scoped ViewModel for per-screen data
```kotlin
val viewModel: DetailViewModel = viewModel() // scoped too broadly, not entry-scoped
```
### GOOD: Entry-scoped ViewModel
```kotlin
// Requires rememberViewModelStoreNavEntryDecorator() in entryDecorators
val viewModel: DetailViewModel = viewModel() // scoped to entry via decorator
```
For shared state across entries, lift state to a parent composable or use a shared ViewModel at the Activity/App scope.
## Hilt Integration
For general Hilt setup, modules, and scopes, see [hilt.md](hilt.md). Below covers Nav 3specific patterns only.
### hiltViewModel in entry blocks (Android only)
```kotlin
entry<Home> {
val viewModel = hiltViewModel<HomeViewModel>()
HomeScreen(viewModel = viewModel)
}
```
### Factory parameters with @AssistedInject
When the ViewModel needs values from the navigation key that aren't in `SavedStateHandle`:
```kotlin
entry<Create> { createKey ->
val viewModel = hiltViewModel<CreationViewModel, CreationViewModel.Factory>(
creationCallback = { factory -> factory.create(originalImageUrl = createKey.fileName) },
)
CreationScreen(viewModel = viewModel)
}
```
### Multibinding entry providers for modularization
Each feature module contributes an entry builder via Hilt multibindings. The app module aggregates them automatically:
```kotlin
// Feature module
@Module @InstallIn(ActivityRetainedComponent::class)
object FeatureAModule {
@IntoSet @Provides
fun provideEntryBuilder(): EntryProviderScope<NavKey>.() -> Unit = {
featureAEntryBuilder()
}
}
// App module — MainActivity
@Inject
lateinit var entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<NavKey>.() -> Unit>
NavDisplay(
entryProvider = entryProvider {
entryBuilders.forEach { builder -> this.builder() }
},
// ...
)
```
## Koin Integration
For general Koin setup, modules, and scopes, see [koin.md](koin.md). Below covers Nav 3specific patterns only.
### koinViewModel in entry blocks (Android + CMP)
```kotlin
entry<Details> { key ->
val viewModel = koinViewModel<DetailViewModel> { parametersOf(key.id) }
DetailScreen(viewModel = viewModel)
}
```
### Koin navigation DSL + koinEntryProvider
Declare navigation entries inside Koin modules. Koin aggregates them automatically — no manual entry provider needed:
```kotlin
val appModule = module {
navigation<HomeRoute> { HomeScreen(viewModel = koinViewModel()) }
navigation<DetailRoute> { route ->
DetailScreen(viewModel = koinViewModel { parametersOf(route.id) })
}
}
NavDisplay(
backStack = rememberNavBackStack(HomeRoute),
onBack = { backStack.removeLastOrNull() },
entryProvider = koinEntryProvider(),
)
```
### Platform-specific extensions
| Function | Platform | Description |
|---|---|---|
| `koinEntryProvider<T>()` | All (CMP) | Composable entry provider — use in `commonMain` |
| `getEntryProvider<T>()` | Android | Eager entry provider via `AndroidScopeComponent` |
## Modularization
### api / impl module split
```text
feature-home/
api/
HomeNavKey.kt -- @Serializable data object HomeNavKey : NavKey
impl/
HomeScreen.kt -- composable UI
HomeEntryBuilder.kt -- extension function on EntryProviderScope
```
- **api** — contains only the `NavKey` route definitions. Other features depend on this.
- **impl** — contains UI, ViewModels, and entry builder. Depends on its own api + other features' api modules.
### Entry builder extension functions
Each feature exposes an extension function; the app module aggregates them:
```kotlin
// feature-home/impl
fun EntryProviderScope<NavKey>.homeEntry(navigator: Navigator) {
entry<HomeNavKey> {
HomeScreen(onItemClick = { navigator.navigate(DetailsNavKey(it)) })
}
}
// app module
NavDisplay(
entryProvider = entryProvider {
homeEntry(navigator)
searchEntry(navigator)
profileEntry(navigator)
},
// ...
)
```
How you wire the ViewModel and state inside each entry depends on your project's architecture. Navigation is driven by ViewModel effects — the route layer translates semantic effects to back-stack operations.
### Koin module aggregation (CMP)
```kotlin
// Feature module
val featureModule = module {
navigation<HomeNavKey> { HomeScreen(viewModel = koinViewModel()) }
navigation<ProfileNavKey> { ProfileScreen(viewModel = koinViewModel()) }
}
// App module
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = koinEntryProvider(),
)
```

View File

@@ -1,229 +0,0 @@
# Navigation 3
Navigation 3 for Compose and CMP: you own the back stack as state, the library renders it. Verify artifact maturity before production use.
For shared navigation concepts (MVI rules, anti-patterns, version decision guide), see [navigation.md](navigation.md).
For DI wiring (Hilt/Koin + Nav 3), see [navigation-3-di.md](navigation-3-di.md).
For migrating from Nav 2, see [navigation-migration.md](navigation-migration.md).
References:
- [Android Nav 3 docs](https://developer.android.com/guide/navigation/navigation-3)
- [Nav 3 state management](https://developer.android.com/guide/navigation/navigation-3/save-state)
- [nav3-recipes repo](https://github.com/android/nav3-recipes)
- [CMP Nav 3 recipes](https://github.com/terrakok/nav3-recipes)
## Core Architecture
Nav 3 has four building blocks:
1. **Keys**`@Serializable` types identifying destinations
2. **Back stack** — a `SnapshotStateList` you own and mutate directly
3. **NavEntry** — wraps a key with composable content and optional metadata
4. **NavDisplay** — observes back stack, resolves keys via entry provider, picks a Scene, renders
```text
User interaction
-> backStack.add(key) / backStack.removeLastOrNull()
-> NavDisplay observes change
-> entryProvider resolves key -> NavEntry
-> SceneStrategy picks layout
-> Scene renders content
```
| Type | Role |
|---|---|
| `NavKey` | Marker interface for serializable destination keys |
| `NavEntry` | Key + composable content + metadata map |
| `NavDisplay` | Observes back stack, manages scenes and animations |
| `Scene` / `SceneStrategy` | Decides layout (single pane, list-detail, dialog) |
| `NavEntryDecorator` | Cross-cutting concern (ViewModel scoping, saveable state) |
## Route Definition
Define routes as `@Serializable` data classes/objects. Group with sealed interfaces for type safety:
```kotlin
@Serializable sealed interface AppRoute : NavKey
@Serializable data object Home : AppRoute
@Serializable data class Details(val id: String) : AppRoute
@Serializable data object Settings : AppRoute
```
For platform-specific types in route arguments, provide a custom `KSerializer`. In CMP, prefer `String` paths or `expect/actual` wrappers.
## Back Stack Creation and Persistence
```kotlin
// Recommended — persists across config changes and process death (keys must be @Serializable + NavKey)
val backStack = rememberNavBackStack(Home)
// Simple — no persistence, prototyping only
val backStack = remember { mutableStateListOf<Any>(Home) }
```
### CMP: Polymorphic serialization for non-JVM
Non-JVM CMP targets need `SavedStateConfiguration` plus a `SerializersModule` with polymorphic `NavKey` subclasses (e.g. `subclassesOfSealed<AppRoute>()`).
Details: [Nav 3 state management](https://developer.android.com/guide/navigation/navigation-3/save-state).
## NavDisplay Configuration
```kotlin
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
sceneStrategy = listDetailStrategy,
transitionSpec = { slideInHorizontally(initialOffsetX = { it }) togetherWith slideOutHorizontally(targetOffsetX = { -it }) },
popTransitionSpec = { slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) },
entryProvider = entryProvider {
entry<Home> {
HomeScreen(onNavigateToDetails = { id -> backStack.add(Details(id)) })
}
entry<Details>(metadata = mapOf("pane" to "detail")) { key ->
DetailScreen(id = key.id, onNavigateBack = { backStack.removeLastOrNull() })
}
},
)
```
Each `entry<Key>` receives the typed key. Pass `metadata` to control scene placement and per-entry animations. For ViewModel/state wiring inside entries, see [navigation.md](navigation.md) and [navigation-3-di.md](navigation-3-di.md).
## Top-Level Tabs and Dashboard Navigation
```kotlin
data class TopLevelNavItem(val selectedIcon: ImageVector, val unselectedIcon: ImageVector, val label: String)
val TOP_LEVEL_ITEMS = mapOf(
Home to TopLevelNavItem(Icons.Filled.Home, Icons.Outlined.Home, "Home"),
Search to TopLevelNavItem(Icons.Filled.Search, Icons.Outlined.Search, "Search"),
Profile to TopLevelNavItem(Icons.Filled.Person, Icons.Outlined.Person, "Profile"),
)
@Stable
class NavigationState(val backStack: SnapshotStateList<NavKey>, val topLevelKeys: Set<NavKey>) {
val currentKey: NavKey get() = backStack.last()
val currentTopLevelKey: NavKey? get() = backStack.lastOrNull { it in topLevelKeys }
}
class Navigator(private val state: NavigationState) {
fun navigate(key: NavKey) {
if (key in state.topLevelKeys) {
while (state.backStack.size > 1) state.backStack.removeLast()
if (state.backStack.lastOrNull() != key) state.backStack[0] = key
} else { state.backStack.add(key) }
}
fun goBack() { state.backStack.removeLastOrNull() }
}
```
Use `NavigationSuiteScaffold` (or custom scaffold) with `NavDisplay` inside.
## ViewModel Scoping
Always include both entry decorators:
```kotlin
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), // preserves rememberSaveable while on stack
rememberViewModelStoreNavEntryDecorator(), // per-entry ViewModelStoreOwner
)
```
VMs created when entry added, cleared when popped. For DI-specific injection patterns, see [navigation-3-di.md](navigation-3-di.md).
## Scenes and Adaptive Layouts
### DialogSceneStrategy
```kotlin
entry<ConfirmDialog>(metadata = DialogSceneStrategy.dialog()) { key ->
AlertDialog(onDismissRequest = { backStack.removeLastOrNull() }, /* ... */)
}
```
### BottomSheetSceneStrategy
```kotlin
entry<FilterSheet>(metadata = BottomSheetSceneStrategy.bottomSheet()) { key ->
FilterContent(onApply = { backStack.removeLastOrNull() })
}
```
### Material 3 Adaptive list-detail
```kotlin
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
NavDisplay(
sceneStrategy = listDetailStrategy,
entryProvider = entryProvider {
entry<ConversationList>(metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = { Text("Select a conversation") }
)) { ConversationListScreen(onSelect = { backStack.add(ConversationDetail(it)) }) }
entry<ConversationDetail>(metadata = ListDetailSceneStrategy.detailPane()) { key ->
ConversationDetailScreen(key.id)
}
},
)
```
Automatically adapts: side-by-side on wide screens, single pane on narrow.
### Chaining strategies
```kotlin
val strategy = dialogStrategy then bottomSheetStrategy then listDetailStrategy
// First match wins. SinglePaneSceneStrategy is always implicit fallback.
```
## Animations
### Global transitions on NavDisplay
Set `transitionSpec`, `popTransitionSpec`, and `predictivePopTransitionSpec` on `NavDisplay` (see configuration example above).
### Per-entry overrides via metadata
```kotlin
entry<ModalRoute>(
metadata = NavDisplay.transitionSpec {
slideInVertically(initialOffsetY = { it }) togetherWith ExitTransition.KeepUntilTransitionsFinished
} + NavDisplay.popTransitionSpec {
EnterTransition.None togetherWith slideOutVertically(targetOffsetY = { it })
}
) { ModalScreen() }
```
## Back Stack Manipulation Patterns
```kotlin
backStack.add(Details("123")) // forward
backStack.removeLastOrNull() // back
backStack.removeAll { it is Details }; backStack.add(Details(newId)) // replace duplicate Details
backStack.clear(); backStack.addAll(listOf(Home, Details(deepLinkId))) // synthetic stack (e.g. deep link)
while (backStack.size > 1) backStack.removeLast(); backStack[0] = targetKey // tabs: pop to root, swap root key
```
## Deep Links
Nav 3 does not parse deep links — you own this. Pattern: parse URI → extract args into `NavKey` → build synthetic back stack → set before first composition.
```kotlin
// Android Activity or CMP entry point
val backStack = rememberNavBackStack(Home)
LaunchedEffect(deepLinkId) {
if (deepLinkId != null) {
backStack.clear()
backStack.addAll(listOf(Home, Details(deepLinkId)))
}
}
```
Registration lives in platform entry points: `AndroidManifest.xml` intent filters, App Delegate/SceneDelegate on iOS, URL handlers on Desktop. Back stack construction logic can live in shared `commonMain`.

View File

@@ -1,120 +0,0 @@
# Migrating from Nav 2 to Nav 3
Nav 2 → Nav 3 migration based on [official docs](https://developer.android.com/guide/navigation/migrate-to-nav3). Nav 2 is **not deprecated** — migration is optional.
For Nav 3 full reference, see [navigation-3.md](navigation-3.md).
For Nav 2 full reference, see [navigation-2.md](navigation-2.md).
For shared concepts and decision guide, see [navigation.md](navigation.md).
## Key Conceptual Shifts
| Nav 2 | Nav 3 |
|---|---|
| `NavController` owns the back stack | You own the back stack (`SnapshotStateList`) |
| `NavHost` renders composable destinations | `NavDisplay` observes the back stack and renders entries |
| Routes are strings or `@Serializable` types | Keys are `@Serializable` types implementing `NavKey` |
| Imperative navigation (`navController.navigate()`) | List manipulation (`backStack.add()`, `backStack.removeLastOrNull()`) |
| `NavGraph` groups destinations | No separate graph — entries are resolved by the `entryProvider` |
| Deep links parsed by Navigation library | Deep links parsed by your code — you construct the back stack |
| Graph-scoped ViewModels via `getBackStackEntry()` | Entry-scoped ViewModels via `rememberViewModelStoreNavEntryDecorator()` |
| `currentBackStackEntryAsState()` for selected tab | Direct back stack inspection (`backStack.last()`) |
| `saveState`/`restoreState` for tab persistence | Persistent per-tab stacks or root swap pattern |
## Migration Steps
### 1. Replace route types with NavKey
```kotlin
// Nav 2
@Serializable data object Home
@Serializable data class Detail(val id: String)
// Nav 3
@Serializable data object Home : NavKey
@Serializable data class Detail(val id: String) : NavKey
```
### 2. Replace NavController with a SnapshotStateList back stack
```kotlin
// Nav 2
val navController = rememberNavController()
navController.navigate(Detail(id))
// Nav 3
val backStack = rememberNavBackStack(Home)
backStack.add(Detail(id))
```
### 3. Replace NavHost with NavDisplay
Replace `NavHost` + `composable<T>` with `NavDisplay` + `entryProvider` + `entry<T>`. Each `composable` block becomes an `entry` block; `navController.navigate()` becomes `backStack.add()`. For full `NavDisplay` API, decorators, and DI wiring, see [navigation-3.md](navigation-3.md) and [navigation-3-di.md](navigation-3-di.md).
### 4. Replace graph-scoped ViewModels with entry decorators
Nav 3 scopes ViewModels to entries automatically via `rememberViewModelStoreNavEntryDecorator()`. For shared state across entries, lift state to a parent composable or use a shared ViewModel at the Activity/App scope.
**Nav 2 graph-scoped pattern:**
```kotlin
val parentEntry = remember(entry) { navController.getBackStackEntry("checkout") }
val sharedViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
```
**Nav 3 equivalent — lift to parent or share via DI:**
```kotlin
// Option 1: shared ViewModel at a higher scope
val sharedViewModel: CheckoutViewModel = viewModel() // Activity-scoped
// Option 2: state hoisting in a parent composable
// The parent composable holds shared state, passes it to child entries
```
### 5. Replace deep link integration
Nav 3 does not parse deep links — parse URIs in your platform entry point and construct the back stack manually:
```kotlin
// Nav 2
composable<Detail>(
deepLinks = listOf(navDeepLink<Detail>(basePath = "https://example.com/detail"))
) { /* ... */ }
// Nav 3
LaunchedEffect(deepLinkId) {
if (deepLinkId != null) {
backStack.clear()
backStack.addAll(listOf(Home, Detail(deepLinkId)))
}
}
```
### 6. Replace tab navigation
```kotlin
// Nav 2 — NavigationBar + currentBackStackEntryAsState + saveState/restoreState
navController.navigate(tab.route) {
popUpTo(startDest) { saveState = true }
launchSingleTop = true
restoreState = true
}
// Nav 3 — direct back stack manipulation
while (backStack.size > 1) backStack.removeLast()
backStack[0] = targetTopLevelKey
```
## Incremental Migration
You do not have to migrate everything at once. The official docs recommend:
1. **Start with leaf screens** that have simple navigation — they are the easiest to convert since they have few navigation dependencies
2. **Move shared/graph-scoped ViewModels last** — these require the most restructuring (entry decorators replace graph scoping)
3. **Keep Nav 2 running alongside Nav 3** during transition if needed — they can coexist in the same app
4. **Convert navigation effects** — update ViewModel effect handlers from `navController.navigate()` calls to `backStack.add()` calls one screen at a time
5. **Test each migrated screen** independently before moving to the next
### Coexistence strategy
During migration, Nav 2 and Nav 3 can coexist in the same app. Use Nav 3 for new feature modules while keeping Nav 2 for existing screens. Bridge between them at the Activity level — a Nav 2 destination can launch an Activity/Fragment that hosts Nav 3, or vice versa.

View File

@@ -1,91 +0,0 @@
# Navigation
Shared navigation concepts for Nav 2 and Nav 3. Load first, then see version-specific references.
References:
- [Nav 3 official docs](https://developer.android.com/guide/navigation/navigation-3)
- [Nav 2 official docs](https://developer.android.com/guide/navigation/get-started)
- [Kotlin CMP Nav 3 docs](https://kotlinlang.org/docs/multiplatform/compose-navigation-3.html)
## Nav 2 vs Nav 3 Decision Guide
| Criterion | Nav 3 (NavDisplay) | Nav 2 (NavHost / NavController) |
|---|---|---|
| Back stack ownership | You own it (`SnapshotStateList`) | Library owns it (`NavController`) |
| Navigation model | List manipulation — `add()`, `removeLastOrNull()` | Imperative — `navigate()`, `popBackStack()` |
| MVI alignment | Natural — back stack is state you mutate | Requires bridging — controller calls in effect handlers |
| Deep link parsing | You parse URIs, construct back stack manually | Built-in `NavDeepLink` parsing |
| Scenes / adaptive layouts | First-class: dialog, bottom sheet, list-detail | Manual: separate composable overlays |
| CMP support | Full (Android, iOS, Desktop, Web) | Android-only (JetBrains forks exist but differ) |
| Maturity | Newer — verify artifact stability for production | Stable, battle-tested |
| Fragment interop | None | Full Fragment/Activity integration |
**When to use Nav 3:**
- New Compose projects following MVI architecture
- Compose Multiplatform projects targeting multiple platforms
- Projects wanting direct back stack control as state
- Projects needing adaptive layout scenes (list-detail, dialog, bottom sheet)
**When to use Nav 2:**
- Existing codebases already built on `NavHost`/`NavController`
- Projects requiring built-in deep link parsing via `NavDeepLink`
- Hybrid Compose + Fragment apps where Nav 2 provides Fragment integration
- Teams that prefer the declarative `NavGraph` DSL
## Navigation in MVI
The architectural rule: **ViewModels emit semantic effects; the route layer handles navigation.** This rule applies identically to both Nav 2 and Nav 3.
```kotlin
sealed interface ItemEffect {
data object NavigateBack : ItemEffect
data class OpenDetails(val id: String) : ItemEffect
}
// Nav 3 route layer — manipulates back stack
CollectEffect(viewModel.effect) { effect ->
when (effect) {
is ItemEffect.NavigateBack -> backStack.removeLastOrNull()
is ItemEffect.OpenDetails -> backStack.add(Details(effect.id))
}
}
// Nav 2 route layer — calls NavController
CollectEffect(viewModel.effect) { effect ->
when (effect) {
is ItemEffect.NavigateBack -> navController.navigateUp()
is ItemEffect.OpenDetails -> navController.navigate(Detail(effect.id))
}
}
```
### Rules
- Never call navigation during composition — always in `LaunchedEffect` or event handler callbacks
- Never pass the back stack (Nav 3) or `NavController` (Nav 2) to the ViewModel or leaf composables
- ViewModel emits semantic effects (`NavigateBack`, `OpenDetails(id)`)
- Route/navigation layer translates effects to navigation calls
- Keep navigation logic at the route boundary, not in screens or leaves
## Anti-Patterns
| Anti-pattern | Applies to | Why it hurts | Better replacement |
|---|---|---|---|
| Navigating during composition | Both | Triggers on every recomposition, causes infinite loops | Navigate in `LaunchedEffect` or event handler callbacks |
| Passing NavController/back stack to ViewModel | Both | Violates MVI boundary, navigation becomes business logic | ViewModel emits semantic effects; route handles navigation |
| String-based routes without type safety | Both | No compile-time checking, argument mismatch at runtime | `@Serializable` data classes/objects |
| Missing `onBack` handler | Nav 3 | System back gesture does nothing | Always provide `onBack = { backStack.removeLastOrNull() }` |
| Globally-scoped ViewModel for per-screen data | Both | Data leaks across screens, not cleared on pop | Entry-scoped VMs (Nav 3 decorators) or destination-scoped VMs (Nav 2) |
| Recreating back stacks on tab switch | Both | Loses user navigation history within tabs | Persistent per-tab stacks (Nav 3) or `saveState`/`restoreState` (Nav 2) |
| Missing entry decorators | Nav 3 | ViewModels leak, saveable state lost | Always include both `rememberSaveableStateHolderNavEntryDecorator` and `rememberViewModelStoreNavEntryDecorator` |
| Using Nav 2 in new MVI codebases | Nav 3 preferred | Nav 3's user-owned back stack aligns better with MVI state ownership | Prefer Nav 3 `NavDisplay` for new MVI-first projects; Nav 2 remains valid for existing codebases |
## Version-Specific References
Load the file that matches your task:
- **Nav 3 routes, tabs, scenes, deep links, or back stack patterns** → [navigation-3.md](navigation-3.md)
- **Nav 2 NavHost, tabs, deep links, nested graphs, or animations** → [navigation-2.md](navigation-2.md)
- **Wiring Hilt or Koin with Nav 3** → [navigation-3-di.md](navigation-3-di.md)
- **Wiring Hilt or Koin with Nav 2** → [navigation-2-di.md](navigation-2-di.md)
- **Migrating from Nav 2 to Nav 3** → [navigation-migration.md](navigation-migration.md)

View File

@@ -1,237 +0,0 @@
# Network Architecture Decisions
Optional patterns for projects that outgrow the simple approach in [networking-ktor.md](networking-ktor.md). Use these when the project needs richer error classification, centralized request handling, or production instrumentation. For auth see [networking-ktor-auth.md](networking-ktor-auth.md). For testing see [networking-ktor-testing.md](networking-ktor-testing.md).
## Error Handling Strategy
Choose one approach and use it consistently across the project.
### Decision: `Result<T>` vs custom sealed class
| Criterion | `Result<T>` (Kotlin stdlib) | Custom `ApiResult<T>` |
|---|---|---|
| Operators | Built-in: `map`, `fold`, `getOrNull`, `onSuccess`, `onFailure` | Define your own |
| Error info | `Throwable` only — inspect exception type at use site | Sealed subclasses with structured data per error kind |
| UI branching | `when (e) { is IOException -> ... }` | `when (error) { is ApiResult.Unauthorized -> ... }` |
| Maintenance | Zero — stdlib | Team maintains the sealed class |
| Best for | Most apps, prototypes, APIs with few error-type branches | Apps needing per-error-type UI flows (login redirect, retry prompt, offline message) |
`Result<T>` is the simpler default. A custom sealed class is justified when the UI needs to branch on many distinct error types and inspecting exception classes becomes unwieldy.
### Option A — Kotlin `Result<T>`
```kotlin
suspend inline fun <reified T> HttpClient.safeRequest(
block: HttpRequestBuilder.() -> Unit,
): Result<T> = runCatching { request { block() }.body<T>() }
// Repository usage
override suspend fun getItems(): Result<List<Item>> {
return client.safeRequest<ItemListDto> { url("items") }
.map { it.items.toDomain() }
}
// ViewModel consumption
viewModelScope.launch {
repository.getItems()
.onSuccess { items -> _state.update { it.copy(items = items) } }
.onFailure { error ->
when (error) {
is ClientRequestException -> handleHttpError(error.response.status.value)
is IOException -> _state.update { it.copy(error = "No connection") }
else -> _state.update { it.copy(error = "Something went wrong") }
}
}
}
```
### Option B — Custom `ApiResult<T>`
```kotlin
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
sealed class Failure : ApiResult<Nothing>() {
data class HttpError(val code: Int, val message: String?, val serverMessage: String? = null) : Failure()
data class NetworkError(val message: String? = null) : Failure()
data class Timeout(val message: String? = null) : Failure()
data class Unauthorized(val serverMessage: String? = null) : Failure()
data class SerializationError(val message: String? = null) : Failure()
data class Unknown(val throwable: Throwable) : Failure()
}
}
inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (this) {
is ApiResult.Success -> ApiResult.Success(transform(data))
is ApiResult.Failure -> this
}
inline fun <T, R> ApiResult<T>.fold(
onSuccess: (T) -> R,
onFailure: (ApiResult.Failure) -> R,
): R = when (this) {
is ApiResult.Success -> onSuccess(data)
is ApiResult.Failure -> onFailure(this)
}
fun <T> ApiResult<T>.getOrNull(): T? = (this as? ApiResult.Success)?.data
```
## Safe Request Wrapper
A `safeRequest` extension centralizes error handling so repositories stay focused on data mapping. This is one valid project-level pattern — not required for every project.
Pair with `expectSuccess = false` so the wrapper inspects status codes instead of catching Ktor's response exceptions:
```kotlin
suspend inline fun <reified T> HttpClient.safeRequest(
block: HttpRequestBuilder.() -> Unit,
): ApiResult<T> {
return try {
val response = request { block() }
when (response.status.value) {
in 200..299 -> ApiResult.Success(response.body<T>())
else -> classifyStatus(response.status.value, tryParseError(response))
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
classifyException(e)
}
}
```
For 204 No Content responses, use `Unit` as the type parameter: `safeRequest<Unit> { ... }`.
### Server error message extraction
Parse backend error envelopes safely — never fail if the error body is malformed:
```kotlin
@Serializable
data class ErrorDto(
val message: String? = null,
val error: String? = null,
val detail: String? = null,
) {
val displayMessage: String? get() = message ?: error ?: detail
}
suspend fun tryParseError(response: HttpResponse): String? = runCatching {
response.body<ErrorDto>().displayMessage
}.getOrNull()
```
## Exception Classification
Map Ktor exceptions to error types. Used inside `safeRequest` with `ApiResult`, or at the ViewModel level with `Result<T>`.
```kotlin
fun classifyException(e: Exception): ApiResult.Failure = when (e) {
is HttpRequestTimeoutException,
is ConnectTimeoutException,
is SocketTimeoutException,
-> ApiResult.Failure.Timeout("Request timed out")
is IOException,
is UnresolvedAddressException,
-> ApiResult.Failure.NetworkError("No internet connection")
is SerializationException,
is JsonConvertException,
is MissingFieldException,
-> ApiResult.Failure.SerializationError("Invalid response format")
is ClientRequestException -> when (e.response.status.value) {
401 -> ApiResult.Failure.Unauthorized()
else -> ApiResult.Failure.HttpError(e.response.status.value, "Request failed")
}
is ServerResponseException -> ApiResult.Failure.HttpError(
e.response.status.value, "Server error",
)
else -> ApiResult.Failure.Unknown(e)
}
fun classifyStatus(code: Int, serverMessage: String? = null): ApiResult.Failure = when (code) {
401 -> ApiResult.Failure.Unauthorized(serverMessage)
403 -> ApiResult.Failure.HttpError(code, "Access denied", serverMessage)
404 -> ApiResult.Failure.HttpError(code, "Not found", serverMessage)
429 -> ApiResult.Failure.HttpError(code, "Too many requests", serverMessage)
in 400..599 -> ApiResult.Failure.HttpError(code, if (code < 500) "Request failed" else "Server error", serverMessage)
else -> ApiResult.Failure.HttpError(code, "Unexpected error", serverMessage)
}
```
`CancellationException` must always be re-thrown — never swallow it. It breaks structured concurrency.
## Plugin Composition
### What goes where
| Concern | Where | Why |
|---|---|---|
| Base URL, content type, static headers | `defaultRequest {}` | Runs per-request, reads live state |
| JSON parsing | `ContentNegotiation` | Core plugin |
| Timeouts | `HttpTimeout` | Default for every project |
| Logging | `Logging` | Debug aid — sanitize `Authorization` in production |
| Token load and refresh | `Auth` plugin | Built-in retry cycle — see [networking-ktor-auth.md](networking-ktor-auth.md) |
| Retry on server errors | `HttpRequestRetry` | Add when the API has transient failures worth retrying |
| Compression | `ContentEncoding` | Add for bandwidth-sensitive APIs |
### Plugin install order
Install order matters — plugins execute in installation order for requests, reverse order for responses.
```
ContentNegotiation → Auth → HttpRequestRetry → HttpTimeout → ContentEncoding
```
Install `HttpRequestRetry` before `HttpTimeout` so retries work on timeout errors. `Auth` handles 401s independently from `HttpRequestRetry` — keep these concerns separate.
## Custom Client Plugins
*Advanced — use when built-in plugins don't cover the need.*
Build reusable interceptors with `createClientPlugin` for analytics, header injection, or response logging:
```kotlin
val ApiKeyPlugin = createClientPlugin("ApiKeyPlugin", ::ApiKeyConfig) {
val apiKey = pluginConfig.apiKey
onRequest { request, _ ->
request.headers.append("X-Api-Key", apiKey)
}
}
class ApiKeyConfig {
var apiKey: String = ""
}
val client = HttpClient(engine) {
install(ApiKeyPlugin) {
apiKey = "my-secret-key"
}
}
```
For global response observation (analytics, session expiry), use `onResponse` in a similar plugin without changing error handling.
## Debug vs Production Logging
| Concern | Debug | Production |
|---|---|---|
| Ktor `Logging` plugin | `LogLevel.BODY` | `LogLevel.HEADERS` or not installed |
| `sanitizeHeader` | Optional | Required for `Authorization` |
## Anti-Patterns
| Anti-pattern | Why it hurts | Better approach |
|---|---|---|
| `HttpClient` per request | Connection pool waste, resource leaks | Shared singleton via DI |
| Swallowing `CancellationException` | Breaks structured concurrency, coroutine never cancels | Re-throw explicitly |
| Logging request bodies in production | Leaks sensitive data (tokens, PII) | `LogLevel.HEADERS` or off; `sanitizeHeader` for auth |
| Mixing `expectSuccess = true` with manual status inspection | `ClientRequestException` thrown before you inspect status | Pick one: `expectSuccess = true` + catch exceptions, or `false` + check `response.status` |
| Random plugin install order | Retries fire before timeout, auth conflicts with retry | Follow documented composition order |
| Forced specific result wrapper | Doesn't adapt to team conventions or project scale | Present `Result`/`ApiResult` as a project decision |

View File

@@ -1,204 +0,0 @@
# Networking — Auth, WebSockets & SSE
Bearer token auth, WebSocket messaging, and Server-Sent Events for Ktor client. For core HttpClient setup see [networking-ktor.md](networking-ktor.md). For testing see [networking-ktor-testing.md](networking-ktor-testing.md).
References:
- [Ktor bearer auth](https://ktor.io/docs/client-bearer-auth.html)
- [Ktor WebSockets](https://ktor.io/docs/client-websockets.html)
- [Ktor SSE](https://ktor.io/docs/client-server-sent-events.html)
## Bearer Token Auth
Use Ktor's `Auth` plugin with `bearer` for token management. The plugin handles loading cached tokens, attaching them to requests, and refreshing on 401 automatically.
### Default approach — `markAsRefreshTokenRequest()`
The Ktor-documented pattern uses `markAsRefreshTokenRequest()` inside `refreshTokens` so the refresh request itself is not intercepted by the auth plugin. This avoids circular auth loops without needing a separate client.
```kotlin
fun createAuthenticatedClient(
engine: HttpClientEngine,
baseUrl: String,
tokenStorage: TokenStorage,
onSessionExpired: () -> Unit,
): HttpClient {
return HttpClient(engine) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
defaultRequest { url(baseUrl) }
install(Auth) {
bearer {
loadTokens {
val tokens = tokenStorage.getTokens()
BearerTokens(tokens.accessToken, tokens.refreshToken)
}
refreshTokens {
val refreshToken = oldTokens?.refreshToken
?: return@refreshTokens null
try {
markAsRefreshTokenRequest()
val response = client.post("auth/refresh") {
contentType(ContentType.Application.Json)
setBody(RefreshRequest(refreshToken))
}.body<TokenResponse>()
tokenStorage.saveTokens(response.accessToken, response.refreshToken)
BearerTokens(response.accessToken, response.refreshToken)
} catch (e: Exception) {
onSessionExpired()
null
}
}
sendWithoutRequest { request ->
request.url.pathSegments.none { it in listOf("login", "register") }
}
}
}
}
}
```
**Key points:**
- `markAsRefreshTokenRequest()` — prevents the refresh call from being intercepted by the `Auth` plugin, avoiding infinite loops.
- `oldTokens` — provided by Ktor's `RefreshTokensParams` receiver, gives access to the expired tokens.
- `sendWithoutRequest` — controls which endpoints skip authentication entirely (login, register, public endpoints).
- Return `null` from `refreshTokens` to signal that refresh failed — Ktor will not retry the original request.
### TokenStorage interface
Implement with DataStore, encrypted SharedPreferences, or Keychain depending on platform. The interface uses app-owned types — convert to `BearerTokens` only at the plugin boundary.
```kotlin
interface TokenStorage {
suspend fun getTokens(): AuthTokens
suspend fun saveTokens(accessToken: String, refreshToken: String)
suspend fun clearTokens()
}
data class AuthTokens(val accessToken: String, val refreshToken: String)
```
## Advanced: Isolated Refresh Client
Some teams prefer a dedicated `HttpClient` for the refresh call — one with no `Auth` plugin installed — to guarantee the refresh request cannot trigger another auth cycle. This is a valid alternative when the team wants explicit separation, but `markAsRefreshTokenRequest()` achieves the same goal with less ceremony.
```kotlin
private suspend fun refreshBearerToken(
baseUrl: String,
tokenStorage: TokenStorage,
onSessionExpired: () -> Unit,
): BearerTokens? {
val tokens = tokenStorage.getTokens()
val refreshToken = tokens.refreshToken.ifBlank { null } ?: return null
return try {
HttpClient {
install(ContentNegotiation) { json() }
}.use { refreshClient ->
val response = refreshClient.post(baseUrl + "auth/refresh") {
contentType(ContentType.Application.Json)
setBody(RefreshRequest(refreshToken))
}.body<TokenResponse>()
tokenStorage.saveTokens(response.accessToken, response.refreshToken)
BearerTokens(response.accessToken, response.refreshToken)
}
} catch (e: Exception) {
onSessionExpired()
null
}
}
```
If using this pattern, call it from inside `refreshTokens` instead of using `client` directly. Close the refresh client after use (`.use {}` handles this).
## WebSocket Support
### Dependencies
Add `ktor-client-websockets` to your version catalog and `commonMain` dependencies.
### Connection and messaging
```kotlin
val client = HttpClient(engine) {
install(WebSockets) {
pingIntervalMillis = 30_000
}
}
client.webSocket("wss://api.example.com/ws") {
send(Frame.Text(Json.encodeToString(SubscribeMessage("items"))))
for (frame in incoming) {
when (frame) {
is Frame.Text -> {
val message = Json.decodeFromString<ServerMessage>(frame.readText())
// handle message
}
is Frame.Close -> break
else -> Unit
}
}
}
```
### Session reference for external control
```kotlin
val session = client.webSocketSession("wss://api.example.com/ws")
session.send(Frame.Text("hello"))
val response = session.incoming.receive() as Frame.Text
session.close()
```
### Serialization converter
Type-safe WebSocket messaging using kotlinx.serialization:
```kotlin
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
client.webSocket("wss://api.example.com/ws") {
sendSerialized(SubscribeMessage("items"))
val message = receiveDeserialized<ServerMessage>()
}
```
## Server-Sent Events (SSE)
SSE provides server-push updates over HTTP. Unlike WebSockets, SSE is unidirectional (server to client) and works over standard HTTP. SSE support is built into `ktor-client-core` — no extra dependency needed.
### Basic usage
```kotlin
val client = HttpClient(engine) {
install(SSE)
}
client.sse("https://api.example.com/events") {
incoming.collect { event ->
println("Event: ${event.event}")
println("Data: ${event.data}")
println("ID: ${event.id}")
}
}
```
### When to use SSE vs WebSocket
| Criterion | SSE | WebSocket |
|---|---|---|
| Direction | Server -> Client only | Bidirectional |
| Protocol | HTTP (standard) | WebSocket (protocol upgrade) |
| Auto-reconnect | Built-in | Manual |
| Binary data | No (text only) | Yes |
| Use case | Live feeds, notifications, progress, streaming AI | Chat, gaming, real-time collaboration |
Prefer SSE for server-push scenarios. Use WebSockets when the client also needs to send frequent messages.

View File

@@ -1,153 +0,0 @@
# Networking — Testing & DI
MockEngine testing patterns and Koin/Hilt DI integration for Ktor client. For core HttpClient setup see [networking-ktor.md](networking-ktor.md). For error handling patterns see [networking-ktor-architecture.md](networking-ktor-architecture.md).
References:
- [Ktor testing](https://ktor.io/docs/client-testing.html)
- [Ktor MockEngine](https://api.ktor.io/ktor-client-mock/io.ktor.client.engine.mock/-mock-engine/index.html)
## Testing with MockEngine
### Setup
```kotlin
// commonTest
testImplementation("io.ktor:ktor-client-mock:$ktor_version")
```
### Testing API calls
```kotlin
@Test
fun `getItem returns mapped domain model`() = runTest {
val mockEngine = MockEngine { request ->
assertEquals("/items/123", request.url.encodedPath)
respond(
content = """{"id":"123","name":"Test","status":"active","created_at":1700000000}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = createHttpClient(mockEngine, "https://api.example.com/")
val repo = ItemRepositoryImpl(ItemApi(client))
val result = repo.getItem("123")
assertEquals("Test", result.name)
}
```
### Testing error handling
```kotlin
@Test
fun `getItem throws on 404`() = runTest {
val mockEngine = MockEngine {
respond(content = """{"error":"not found"}""", status = HttpStatusCode.NotFound)
}
val client = HttpClient(mockEngine) {
expectSuccess = true
install(ContentNegotiation) { json() }
}
val api = ItemApi(client)
assertFailsWith<ClientRequestException> { api.getItem("999") }
}
```
If using a `safeRequest` wrapper (see [networking-ktor-architecture.md](networking-ktor-architecture.md)), test the wrapper's return type instead:
```kotlin
@Test
fun `safeRequest returns failure on 404`() = runTest {
val mockEngine = MockEngine {
respond(content = """{"error":"not found"}""", status = HttpStatusCode.NotFound)
}
val client = HttpClient(mockEngine) {
expectSuccess = false
install(ContentNegotiation) { json() }
}
val result = client.safeRequest<ItemDto> { url("items/999") }
assertTrue(result.isFailure) // or check sealed class variant
}
```
### Request assertions
Verify request method, headers, body, and query parameters:
```kotlin
@Test
fun `createItem sends correct request`() = runTest {
val mockEngine = MockEngine { request ->
assertEquals(HttpMethod.Post, request.method)
assertEquals("application/json", request.body.contentType?.toString())
val body = (request.body as TextContent).text
assertTrue(body.contains("\"name\":\"Widget\""))
respond(
content = """{"id":"1","name":"Widget","status":"active","created_at":1700000000}""",
status = HttpStatusCode.Created,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = createHttpClient(mockEngine, "https://api.example.com/")
val api = ItemApi(client)
val result = api.createItem(CreateItemRequest(name = "Widget"))
assertEquals("Widget", result.name)
}
```
### Multiple responses
MockEngine can return different responses based on path:
```kotlin
val mockEngine = MockEngine { request ->
when (request.url.encodedPath) {
"/items" -> respond(
content = """{"items":[],"total":0}""",
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
"/items/1" -> respond(
content = """{"id":"1","name":"Test","status":"active","created_at":1700000000}""",
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
else -> respondError(HttpStatusCode.NotFound)
}
}
```
### Engine injection for testability
Accept `HttpClientEngine` as a constructor parameter so you can inject `MockEngine` in tests:
```kotlin
// Production: ItemApi(createHttpClient(OkHttp.create(), baseUrl))
// Test: ItemApi(createHttpClient(MockEngine { ... }, baseUrl))
```
Share the same `createHttpClient` factory in production and tests to keep plugin configuration consistent.
## DI Integration
Provide `HttpClient` and `HttpClientEngine` as singletons. Use `expect/actual` platform modules for engine selection (OkHttp on Android, Darwin on iOS):
```kotlin
// Koin: single { createHttpClient(engine = get(), baseUrl = "https://api.example.com/") }
// Hilt: @Provides @Singleton fun provideHttpClient(): HttpClient = createHttpClient(...)
```
For full Koin module patterns (including platform engine modules), see [koin.md](koin.md). For Hilt module patterns, see [hilt.md](hilt.md).
## Anti-Patterns
| Anti-pattern | Why it hurts | Better replacement |
|---|---|---|
| DTOs used directly in UI state | UI coupled to API contract, breaks on API changes | Map to domain models at repository boundary |
| Network calls in composables | Violates UDF, untestable, reruns on recomposition | Call from ViewModel, expose via StateFlow |
| No timeout configuration | Requests hang indefinitely on bad networks | Set `connectTimeoutMillis`, `requestTimeoutMillis`, `socketTimeoutMillis` |
| Hardcoded base URLs | Can't switch environments (dev/staging/prod) | Inject base URL via config or DI |
| Parsing/mapping in the API service | Mixes concerns, harder to test | API service returns DTOs; repository maps to domain |
| Creating a new `HttpClient` per test | Tests miss plugin-config mismatches | Use the same `createHttpClient` factory with `MockEngine` |
| No compression | Wastes bandwidth on text-heavy APIs | `install(ContentEncoding) { gzip() }` |

View File

@@ -1,270 +0,0 @@
# Networking with Ktor Client
Default Ktor client setup for Compose Multiplatform and Android projects. Advanced topics in separate files:
- [Architecture decisions](networking-ktor-architecture.md) — result wrappers, error classification, plugin composition *(optional)*
- [Auth, WebSockets & SSE](networking-ktor-auth.md) — bearer tokens, realtime *(use when needed)*
- [Testing & DI](networking-ktor-testing.md) — MockEngine, Koin/Hilt wiring
References:
- [Ktor client overview](https://ktor.io/docs/client.html)
- [Ktor client plugins](https://ktor.io/docs/client-plugins.html)
- [Ktor content negotiation](https://ktor.io/docs/client-serialization.html)
## Dependencies and Platform Engines
### Version catalog
```toml
[versions]
ktor = "<latest>" # verify: https://ktor.io/docs/releases.html or Maven Central
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
```
As needed: `ktor-client-auth`, `ktor-client-websockets`, `ktor-client-resources`, `ktor-client-encoding` (same `version.ref = "ktor"` pattern).
### build.gradle.kts
```kotlin
commonMain.dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
}
commonTest.dependencies {
implementation(libs.ktor.client.mock)
}
```
### Platform engine selection
| Platform | Engine | Dependency |
|---|---|---|
| Android | OkHttp | `ktor-client-okhttp` |
| iOS | Darwin (NSURLSession) | `ktor-client-darwin` |
| JVM/Desktop | CIO | `ktor-client-cio` |
| All (testing) | MockEngine | `ktor-client-mock` |
For CMP, select the engine per source set. For Android-only, use OkHttp directly.
## HttpClient Configuration
Create a single, reusable `HttpClient` instance. Never create one per request.
```kotlin
fun createHttpClient(engine: HttpClientEngine, baseUrl: String): HttpClient {
return HttpClient(engine) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true // ignore unknown JSON fields
coerceInputValues = true // null → defaults for non-null props
encodeDefaults = true // include defaults when serializing
})
}
defaultRequest {
url(baseUrl)
headers.append("Accept", "application/json")
}
install(HttpTimeout) {
connectTimeoutMillis = 15_000
requestTimeoutMillis = 30_000
socketTimeoutMillis = 15_000
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.HEADERS
sanitizeHeader { it == "Authorization" }
}
}
}
```
This is the minimal production-ready client. Add plugins incrementally when the project needs them — auth, retry, compression, and content encoding are covered in the sub-references.
Set `isLenient = true` only for non-standard APIs; it accepts malformed JSON and can hide data issues in production.
### `expectSuccess` — choose based on error strategy
| Setting | Behavior | Use when |
|---|---|---|
| `true` (Ktor default) | Throws `ClientRequestException` / `ServerResponseException` on non-2xx | Using `try/catch` or `runCatching` for error handling |
| `false` | Returns the response regardless of status | Inspecting `response.status` manually in a custom wrapper |
Both are valid. Pick one approach and apply it consistently. See [networking-ktor-architecture.md](networking-ktor-architecture.md) for wrapper patterns that pair with `expectSuccess = false`.
## DTO Models and Serialization
```kotlin
@Serializable
data class ItemListDto(
val items: List<ItemDto>,
val total: Int,
@SerialName("next_page") val nextPage: String? = null,
)
@Serializable
data class ItemDto(
val id: String,
val name: String,
val status: StatusDto = StatusDto.ACTIVE,
@SerialName("created_at") val createdAt: Long,
)
@Serializable
enum class StatusDto {
@SerialName("active") ACTIVE,
@SerialName("archived") ARCHIVED,
}
```
Always `@Serializable` on DTOs, `@SerialName` when JSON keys differ, default values for optional fields. DTOs mirror the API contract — no business logic.
## DTO-to-Domain Mappers
Map at the repository boundary. Domain models have no serialization annotations.
```kotlin
data class Item(val id: String, val name: String, val status: ItemStatus, val createdAt: Long)
enum class ItemStatus { ACTIVE, ARCHIVED }
fun ItemDto.toDomain() = Item(
id = id,
name = name,
status = ItemStatus.valueOf(status.name),
createdAt = createdAt,
)
fun List<ItemDto>.toDomain() = map { it.toDomain() }
```
## API Service Layer
Wrap `HttpClient` in a service class with typed methods:
```kotlin
class ItemApi(private val client: HttpClient) {
suspend fun getItems(page: Int = 1, limit: Int = 20): ItemListDto {
return client.get("items") {
parameter("page", page)
parameter("limit", limit)
}.body()
}
suspend fun getItem(id: String): ItemDto = client.get("items/$id").body()
suspend fun createItem(request: CreateItemRequest): ItemDto {
return client.post("items") {
contentType(ContentType.Application.Json)
setBody(request)
}.body()
}
suspend fun deleteItem(id: String) { client.delete("items/$id") }
}
@Serializable
data class CreateItemRequest(val name: String)
```
## Repository Pattern
The repository maps DTOs to domain models and handles errors. The error-handling approach is a project decision — see [networking-ktor-architecture.md](networking-ktor-architecture.md) for `Result<T>` vs custom sealed class options.
### Simple approach — exceptions bubble up
```kotlin
interface ItemRepository {
suspend fun getItems(): List<Item>
suspend fun getItem(id: String): Item
}
class ItemRepositoryImpl(private val api: ItemApi) : ItemRepository {
override suspend fun getItems(): List<Item> {
return api.getItems().items.toDomain()
}
override suspend fun getItem(id: String): Item {
return api.getItem(id).toDomain()
}
}
```
The ViewModel catches exceptions and updates state. This works well for simpler apps. For `Result` / richer error classification, see [networking-ktor-architecture.md](networking-ktor-architecture.md).
### Offline-first pattern
Local DB as the single source of truth. Repository syncs remote data into local storage. UI observes the local `Flow`.
```kotlin
class OfflineFirstItemRepository(
private val api: ItemApi,
private val dao: ItemDao,
) : ItemRepository {
val items: Flow<List<Item>> = dao.observeAll().map { it.map { e -> e.toDomain() } }
suspend fun refresh() {
val remote = api.getItems().items
dao.replaceAll(remote.map { it.toEntity() })
}
}
```
## Type-Safe Resources (Optional)
The Ktor Resources plugin maps `@Resource`-annotated data classes to HTTP paths for compile-time URL safety. Add `ktor-client-resources` to the catalog and `implementation(libs.ktor.client.resources)`; `install(Resources)` alongside `ContentNegotiation`. Reference: [Ktor type-safe requests](https://ktor.io/docs/client-resources.html).
```kotlin
import io.ktor.resources.*
import kotlinx.serialization.Serializable
@Serializable
@Resource("/articles")
class Articles {
@Serializable
@Resource("{id}")
class ById(val parent: Articles = Articles(), val id: Int)
@Serializable
@Resource("search")
class Search(val parent: Articles = Articles(), val query: String, val page: Int = 1)
}
// Nested paths and query params resolve from the resource tree, e.g. /articles/42, /articles/search?query=compose&page=1
val articles: List<ArticleDto> = client.get(Articles()).body()
val article: ArticleDto = client.get(Articles.ById(id = 42)).body()
val results: ArticleListDto = client.get(Articles.Search(query = "compose")).body()
val created: ArticleDto = client.post(Articles()) {
contentType(ContentType.Application.Json)
setBody(CreateArticleRequest(title = "New Article"))
}.body()
client.delete(Articles.ById(id = 42))
```

View File

@@ -1,150 +0,0 @@
# Paging 3 — MVI Integration & Testing
MVI dual-flow pattern for paging, testing strategies, and anti-patterns. This file builds on the core Paging setup in [paging.md](paging.md).
References:
- [Paging testing](https://developer.android.com/topic/libraries/architecture/paging/test)
## MVI Integration
PagingData must be a **separate Flow** from the MVI ViewModel state. The ViewModel handles non-paging concerns (filters, selection mode, errors). PagingData flows independently.
```kotlin
class ItemListViewModel(
private val repository: ItemRepository,
) : ViewModel() {
// MVI state — non-paging concerns
private val _state = MutableStateFlow(ItemListState())
val state: StateFlow<ItemListState> = _state.asStateFlow()
// PagingData — separate Flow, reacts to filter changes
private val _statusFilter = MutableStateFlow(StatusFilter.ALL)
val items: Flow<PagingData<ItemUi>> = _statusFilter
.distinctUntilChanged()
.flatMapLatest { status ->
Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { repository.itemPagingSource(status) },
).flow.map { pagingData -> pagingData.map { it.toUi() } }
}
.cachedIn(viewModelScope)
fun onEvent(event: ItemListEvent) {
when (event) {
is ItemListEvent.FilterChanged -> {
_statusFilter.value = event.filter
_state.update { it.copy(selectedFilter = event.filter) }
}
is ItemListEvent.ItemClicked -> {
// emit navigation effect
}
is ItemListEvent.SelectionToggled -> {
_state.update { it.copy(selectedIds = it.selectedIds.toggle(event.id)) }
}
}
}
}
```
### Route collects both flows
The route composable collects both the MVI state and PagingData flow, then passes them to the stateless screen composable. Use a DI-agnostic ViewModel parameter.
```kotlin
@Composable
fun ItemListRoute(viewModel: ItemListViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
val pagingItems = viewModel.items.collectAsLazyPagingItems()
ItemListScreen(
state = state,
pagingItems = pagingItems,
onEvent = viewModel::onEvent,
)
}
```
The screen composable is dumb — it receives `LazyPagingItems` and state as props, emits events as callbacks.
## Testing
### PagingSource unit test
```kotlin
@Test
fun `load returns page of items`() = runTest {
val mockApi = MockItemApi(items = listOf(item1, item2))
val pagingSource = ItemPagingSource(api = mockApi, query = "")
val result = pagingSource.load(
PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
)
assertTrue(result is PagingSource.LoadResult.Page)
val page = result as PagingSource.LoadResult.Page
assertEquals(2, page.data.size)
assertEquals(null, page.prevKey)
assertEquals(2, page.nextKey)
}
@Test
fun `load returns error on network failure`() = runTest {
val mockApi = MockItemApi(error = IOException("Network error"))
val pagingSource = ItemPagingSource(api = mockApi, query = "")
val result = pagingSource.load(
PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
)
assertTrue(result is PagingSource.LoadResult.Error)
}
```
### Testing with asSnapshot
```kotlin
@Test
fun `items flow loads first two pages`() = runTest {
val viewModel = ItemListViewModel(FakeRepository())
val items = viewModel.items.asSnapshot {
scrollTo(index = 30)
}
assertTrue(items.size >= 30)
assertEquals("item_1", items.first().id)
}
```
### Testing transformations
```kotlin
@Test
fun `paging data maps dto to ui model`() = runTest {
val dtos = listOf(ItemDto(id = "1", title = "Test", amount = 100.0))
val pagingSource = dtos.asPagingSourceFactory().invoke()
val pager = TestPager(PagingConfig(pageSize = 10), pagingSource)
val result = pager.refresh() as PagingSource.LoadResult.Page
assertEquals(1, result.data.size)
assertEquals("1", result.data.first().id)
}
```
## Anti-Patterns
| Anti-pattern | Why it hurts | Fix |
|---|---|---|
| `PagingData` inside `UiState` StateFlow | Any non-paging state change re-emits the wrapping StateFlow, creating a new flow for `collectAsLazyPagingItems()` and resetting scroll position ([official codelab](https://github.com/android/codelab-android-paging) uses separate flows) | Expose PagingData as **separate** `Flow` |
| New `Pager` per recomposition | Duplicate network requests, lost pagination state | Store `Flow` as `val` in ViewModel |
| Reusing `PagingSource` instance | Crash: "PagingSource was re-used" | Always create new instance in `pagingSourceFactory` |
| Missing `cachedIn(viewModelScope)` | Data lost on config change, duplicate loads | Always call `cachedIn` |
| Missing list keys | Scroll jumps, state corruption on updates | `itemKey { it.id }` with stable domain IDs |
| `combine` on `PagingData` flows | "Collecting from multiple PagingData concurrently" error | Use `flatMapLatest` for parameter changes |
| Calling `refresh()` in composable body | Infinite refresh loop on every recomposition | Call from event handler or `LaunchedEffect` |
| No `LoadState` handling | Broken UX: no loading indicator, no error recovery | Handle `refresh`, `append`, `prepend` states |
| Transformations after `cachedIn` | Transformations lost on cache hit | Apply `.map { }` / `.filter { }` **before** `cachedIn` |
| Catching generic `Exception` in PagingSource | Hides bugs, swallows unexpected errors | Catch `IOException`, `HttpException` specifically |

View File

@@ -1,130 +0,0 @@
# Paging 3 — Offline-First with RemoteMediator
Room as the single source of truth, network as the refresh trigger. This file builds on the core Paging setup in [paging.md](paging.md).
References:
- [Network + database paging](https://developer.android.com/topic/libraries/architecture/paging/v3-network-db)
## RemoteMediator.initialize
Override `initialize()` to control whether RemoteMediator triggers a remote refresh on first load. This determines if cached data is shown immediately or if a network request fires first.
```kotlin
@OptIn(ExperimentalPagingApi::class)
override suspend fun initialize(): InitializeAction {
val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)
val lastUpdated = db.remoteKeyDao().getLastUpdated("items") ?: 0L
return if (System.currentTimeMillis() - lastUpdated < cacheTimeout) {
InitializeAction.SKIP_INITIAL_REFRESH
} else {
InitializeAction.LAUNCH_INITIAL_REFRESH
}
}
```
| Return value | Behavior |
|---|---|
| `LAUNCH_INITIAL_REFRESH` | Triggers `REFRESH` load immediately — fetches fresh data from network before showing cached data. **Default** if `initialize()` is not overridden. |
| `SKIP_INITIAL_REFRESH` | Shows cached Room data immediately, only fetches from network on user-triggered refresh or append. Use when cache is still fresh. |
## RemoteMediator Implementation
```kotlin
@OptIn(ExperimentalPagingApi::class)
class ItemRemoteMediator(
private val api: ItemApi,
private val db: AppDatabase,
) : RemoteMediator<Int, ItemEntity>() {
override suspend fun initialize(): InitializeAction {
val lastUpdated = db.remoteKeyDao().getLastUpdated("items") ?: 0L
val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)
return if (System.currentTimeMillis() - lastUpdated < cacheTimeout) {
InitializeAction.SKIP_INITIAL_REFRESH
} else {
InitializeAction.LAUNCH_INITIAL_REFRESH
}
}
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ItemEntity>,
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val remoteKey = db.remoteKeyDao().getRemoteKey("items")
remoteKey?.nextPage ?: return MediatorResult.Success(endOfPaginationReached = true)
}
}
return try {
val response = api.getItems(page = page, limit = state.config.pageSize)
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.itemDao().clearAll()
db.remoteKeyDao().delete("items")
}
db.itemDao().insertAll(response.items.map { it.toEntity() })
db.remoteKeyDao().insert(
RemoteKey(
id = "items",
nextPage = if (response.items.isEmpty()) null else page + 1,
lastUpdated = System.currentTimeMillis(),
)
)
}
MediatorResult.Success(endOfPaginationReached = response.items.isEmpty())
} catch (e: IOException) {
MediatorResult.Error(e)
} catch (e: HttpException) {
MediatorResult.Error(e)
}
}
}
```
## Pager Wiring
```kotlin
@OptIn(ExperimentalPagingApi::class)
val items: Flow<PagingData<ItemEntity>> = Pager(
config = PagingConfig(pageSize = 20),
remoteMediator = ItemRemoteMediator(api, db),
pagingSourceFactory = { db.itemDao().pagingSource() },
).flow.cachedIn(viewModelScope)
```
The `PagingSource` reads from Room. The `RemoteMediator` fetches from network and writes to Room. The UI observes the Room-backed `PagingSource`.
**LoadState with RemoteMediator:** use `loadState.source.refresh` (not `loadState.refresh`) in UI code. The convenience `loadState.refresh` may report network completion before Room finishes writing, causing the loading indicator to disappear too early. See [official guidance](https://developer.android.com/topic/libraries/architecture/paging/v3-compose).
## Remote Keys
```kotlin
@Entity(tableName = "remote_keys")
data class RemoteKey(
@PrimaryKey val id: String,
val nextPage: Int?,
val lastUpdated: Long = System.currentTimeMillis(),
)
@Dao
interface RemoteKeyDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(key: RemoteKey)
@Query("SELECT * FROM remote_keys WHERE id = :id")
suspend fun getRemoteKey(id: String): RemoteKey?
@Query("SELECT lastUpdated FROM remote_keys WHERE id = :id")
suspend fun getLastUpdated(id: String): Long?
@Query("DELETE FROM remote_keys WHERE id = :id")
suspend fun delete(id: String)
}
```

View File

@@ -1,219 +0,0 @@
# Paging 3
Paging 3 setup, PagingSource, transformations, and LazyColumn integration.
References:
- [Paging 3 with Compose](https://developer.android.com/topic/libraries/architecture/paging/v3-compose)
- [Load and display paged data](https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data)
- [LoadState management](https://developer.android.com/topic/libraries/architecture/paging/load-state)
## Critical Performance Rules
1. **PagingData must be a separate Flow, NEVER inside UiState** — wrapping in `data class UiState(val pagingData: PagingData<T>)` causes scroll-to-top on any state change. Use two separate properties: `state: StateFlow<UiState>` + `pagingDataFlow: Flow<PagingData>`. See [anti-patterns](paging-mvi-testing.md#anti-patterns)
2. **Never create a new Pager per recomposition** — store the Flow as a `val` in ViewModel
3. **Always `cachedIn(viewModelScope)`** — prevents data loss on config change
4. **Always provide stable keys**`itemKey { it.id }` prevents scroll jumps
5. **Use `flatMapLatest` for parameter changes** — not `combine` on PagingData flows
## Dependencies
```kotlin
// Android / commonMain
implementation("androidx.paging:paging-compose:3.3.6")
implementation("androidx.paging:paging-common:3.3.6")
testImplementation("androidx.paging:paging-testing:3.3.6")
```
KMP support (since 3.3.0-alpha02): `paging-common` and `paging-compose` work in `commonMain` (Android, JVM, iOS). `paging-runtime` is Android-only (RecyclerView adapters, not needed in Compose). Verify Web/WASM support for your version.
## Core Data Flow
```text
PagingSource -> Pager(config, factory) -> Flow<PagingData<T>>
-> .cachedIn(viewModelScope) -> collectAsLazyPagingItems() -> LazyColumn/Grid/Pager
```
| Component | Role |
|---|---|
| `PagingSource<Key, Value>` | Loads pages from a single source |
| `RemoteMediator` | Coordinates network + local DB ([paging-offline.md](paging-offline.md)) |
| `Pager` | Creates `Flow<PagingData>` from config + source |
| `PagingConfig` | Page size, prefetch, placeholders |
| `LazyPagingItems<T>` | Compose wrapper for consuming PagingData |
## PagingSource Implementation
```kotlin
class ItemPagingSource(
private val api: ItemApi,
private val query: String,
) : PagingSource<Int, ItemDto>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ItemDto> {
val page = params.key ?: 1
return try {
val response = api.getItems(page = page, limit = params.loadSize, query = query)
LoadResult.Page(
data = response.items,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.items.isEmpty()) null else page + 1,
)
} catch (e: IOException) { LoadResult.Error(e) }
catch (e: HttpException) { LoadResult.Error(e) }
}
override fun getRefreshKey(state: PagingState<Int, ItemDto>): Int? =
state.anchorPosition?.let { pos ->
state.closestPageToPosition(pos)?.let { it.prevKey?.plus(1) ?: it.nextKey?.minus(1) }
}
}
```
**Rules:** factory must return a **new instance** every call. Catch specific exceptions. Return `null` for `prevKey`/`nextKey` to signal end. For cursor-based APIs, use `String` key type with `nextCursor`.
## Pager and ViewModel Setup
```kotlin
class ItemListViewModel(private val repository: ItemRepository) : ViewModel() {
private val _uiState = MutableStateFlow(ItemListState())
val uiState: StateFlow<ItemListState> = _uiState.asStateFlow()
// PagingData as SEPARATE Flow — never put inside UiState
val items: Flow<PagingData<ItemUi>> = Pager(
config = PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false, initialLoadSize = 40),
pagingSourceFactory = { repository.itemPagingSource() },
).flow
.map { pagingData -> pagingData.map { it.toUi() } }
.cachedIn(viewModelScope)
}
data class ItemListState(val selectedFilter: FilterType = FilterType.ALL, val selectedIds: Set<String> = emptySet())
```
| PagingConfig param | Purpose |
|---|---|
| `pageSize` | Items per page (required) |
| `prefetchDistance` | Distance from edge to trigger next load |
| `enablePlaceholders` | Show null placeholders for unloaded items |
| `initialLoadSize` | Items on first request |
## PagingSource Invalidation
Call `PagingSource.invalidate()` after mutations. The factory returns a new instance; Paging reloads from `getRefreshKey`.
```kotlin
class ItemRepository(private val api: ItemApi) {
private var currentPagingSource: ItemPagingSource? = null
fun itemPagingSource(query: String = ""): PagingSource<Int, ItemDto> =
ItemPagingSource(api, query).also { currentPagingSource = it }
fun invalidate() { currentPagingSource?.invalidate() }
}
```
## Filter and Search with Dynamic Parameters
Use `flatMapLatest` to create a new Pager when parameters change. Combine multiple filter flows, then `flatMapLatest`:
```kotlin
class ItemListViewModel(private val repository: ItemRepository) : ViewModel() {
private val _query = MutableStateFlow("")
private val _statusFilter = MutableStateFlow(StatusFilter.ALL)
fun onQueryChanged(query: String) { _query.value = query }
fun onStatusChanged(status: StatusFilter) { _statusFilter.value = status }
val items: Flow<PagingData<ItemUi>> = combine(
_query.debounce(300).distinctUntilChanged(),
_statusFilter.distinctUntilChanged(),
) { query, status -> query to status }
.flatMapLatest { (query, status) ->
Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { repository.itemPagingSource(query = query, status = status) },
).flow.map { pagingData -> pagingData.map { it.toUi() } }
}
.cachedIn(viewModelScope)
}
```
**Rules:** `distinctUntilChanged()` before `flatMapLatest` avoids redundant Pager creation. `debounce` on text prevents excessive calls. `cachedIn` must come **after** `flatMapLatest`, not inside it. For single-filter, omit `combine` and use the single flow directly.
## Compose UI with LazyPagingItems
```kotlin
@Composable
fun ItemListScreen(uiState: ItemListState, pagingItems: LazyPagingItems<ItemUi>, onEvent: (ItemListEvent) -> Unit) {
LazyColumn {
items(
count = pagingItems.itemCount,
key = pagingItems.itemKey { it.id },
contentType = pagingItems.itemContentType { "item" },
) { index ->
pagingItems[index]?.let { item ->
ItemRow(item = item, isSelected = uiState.selectedIds.contains(item.id),
onClick = { onEvent(ItemListEvent.ItemClicked(item.id)) })
}
}
}
}
```
| Operation | What it does |
|---|---|
| `pagingItems[index]` | Access item **and** trigger load |
| `pagingItems.peek(index)` | Access **without** triggering load |
| `pagingItems.retry()` | Retry last failed load |
| `pagingItems.refresh()` | Reload all data (never call from composable body) |
| `pagingItems.itemKey { }` | Stable keys |
| `pagingItems.itemContentType { }` | Content type for layout reuse |
Works with **all** lazy layouts (`LazyColumn`, `LazyVerticalGrid`, `HorizontalPager`). Prefer `items` with `itemKey`/`itemContentType` over `itemsIndexed` — indices shift during prepend.
## LoadState Handling
| State | refresh | append/prepend |
|---|---|---|
| `Loading` | Initial load or pull-to-refresh | Loading next/previous page |
| `Error(throwable)` | Initial load failed | Page load failed |
| `NotLoading(endReached)` | Idle | No more pages / idle |
**Pattern:** branch on `pagingItems.loadState.refresh` — full-screen loading/error/empty only when `itemCount == 0`; with items, use top `LinearProgressIndicator` for refresh and append-row loading/error + `retry()`.
**RemoteMediator note:** check `loadState.source.refresh` instead of `loadState.refresh` — the convenience property may report complete before Room finishes writing.
## PagingData Transformations
Apply on the outer `Flow` **before** `cachedIn`. Transformations after `cachedIn` are lost on cache hit.
```kotlin
val items: Flow<PagingData<ListItem>> = Pager(config, pagingSourceFactory)
.flow
.map { pagingData ->
pagingData
.map { dto -> ListItem.ContentItem(dto.toUi()) }
.filter { it.item.status != ItemStatus.DELETED }
.insertSeparators { before, after ->
when {
before == null -> ListItem.DateHeader("Today")
after == null -> null
before.dateGroup != after.dateGroup -> ListItem.DateHeader(after.dateGroup)
else -> null
}
}
}
.cachedIn(viewModelScope)
sealed interface ListItem {
data class ContentItem(val item: ItemUi) : ListItem
data class DateHeader(val label: String) : ListItem
}
```
When using `insertSeparators`, provide unique keys per type (`"item_${id}"`, `"header_${label}"`) and distinct `contentType` values.
## Related References
- **Offline-first paging with Room and RemoteMediator** → [paging-offline.md](paging-offline.md)
- **MVI dual-flow pattern, testing, and anti-patterns** → [paging-mvi-testing.md](paging-mvi-testing.md)

View File

@@ -1,166 +0,0 @@
# Performance & Recomposition
## Three Phases and Primitive Specializations
Compose executes Composition, Layout, and Drawing phases per frame. State reads in later phases skip earlier phases — moving reads from Composition to Layout/Drawing eliminates recomposition for those reads. Use `Modifier.offset { }` (lambda) instead of `Modifier.offset()`. Use `mutableIntStateOf()`/`mutableFloatStateOf()` instead of `mutableStateOf<Int>()` to avoid boxing. See [compose-essentials.md](compose-essentials.md) for full explanation and code examples.
## Performance Mistakes and Fixes
| # | Issue | Fix |
|---|---|---|
| 1 | Unstable parameters (`MutableList`, lambdas in state models, anonymous objects) | Immutable data classes + immutable collections |
| 2 | Broad state observation — parent reads whole state, ripples through tree | Collect once at route, slice aggressively for leaves |
| 3 | Large state passed everywhere — many nodes observe unused fields | Pass only what each child renders |
| 4 | Callback recreation in hot paths (large lazy lists, nested rows) | `remember(key, callback)` for repeated rows |
| 5 | Expensive calculations during composition (parse, sort, filter, format) | Move upstream to ViewModel/domain |
| 6 | `remember` misuse — caching business state, hiding architecture issues | Use only for local UI state, expensive local objects, hot callback adaptation |
| 7 | `derivedStateOf` misuse — wrapping cheap expressions | Use only when derived from rapidly changing Compose state with coarse output |
| 8 | `rememberSaveable` misuse — entire screen state, large graphs | Use only for tiny UI-local values surviving recreation |
| 9 | State reads too high in tree (`LazyListState`, animation, keyboard state) | Read close to use |
| 10 | List recomposition — missing keys, unstable items, inline filters/sorts | Stable keys, immutable models, pre-computed data |
| 11 | Reducer emits excessive updates — same state, rebuilds on every keystroke | Guard identical transitions, emit only on semantic change |
| 12 | Ephemeral visual state in global screen state (shimmer alpha, pulse phase) | Keep visual-only state local |
| 13 | Equality pitfalls — lambdas in data classes, random IDs, mutable collections | No lambdas/mutables in data classes, stable IDs |
| 14 | Abusing `@Immutable`/`@Stable` to silence compiler | Use only to describe truth — `@Immutable` for truly immutable, `@Stable` rare in app code |
| 15 | Raw text input in MVI causing stutter (25+ fields) | `TextFieldState`/`BasicTextField2`, group fields into nested data classes, isolate read scopes |
| 16 | State reads in Composition phase for layout/draw values | Lambda modifiers: `Modifier.offset { IntOffset(scrollOffset, 0) }` |
## API Decision Table
| API | Use it for | Do not use it for |
|---|---|---|
| `remember` | local objects/state across recompositions | business state, repo results, derived domain data |
| `rememberSaveable` | small UI-local state needing restoration | whole screen state, large graphs, domain objects |
| `derivedStateOf` | reducing downstream updates from fast-changing Compose state | cheap string concatenation, reducer-owned derivations |
| `key` | preserving identity in dynamic children/lists | hiding bad state models |
| `LaunchedEffect` | collecting UI effects, startup event, one-shot route work | screen business logic in leaves |
| `DisposableEffect` | register/unregister listeners with cleanup | long-running business jobs |
| `produceState` | bridging external async/callback source to local Compose state | replacing a real ViewModel |
| `snapshotFlow` | turning Compose state reads into `Flow` operators | normal state rendering |
| `collectAsState` | collect `StateFlow` into Compose | collecting everywhere in the tree |
| lifecycle-aware collection | Lifecycle host integration (multiplatform since lifecycle 2.8+) | common leaf components |
| stable callbacks | hot repeated UI paths | every single callback everywhere |
## Code Examples
### BAD: calculating derived results in a composable
```kotlin
@Composable
fun CalculatorResult(state: CalculatorState) {
val area = state.input.areaText.toDoubleOrNull() ?: 0.0
val materialRate = state.input.materialRateText.toDoubleOrNull() ?: 0.0
val subtotal = (area * materialRate)
Text("Subtotal: $subtotal")
}
```
### GOOD: derive upstream, narrow reads
```kotlin
@Composable
fun CalculatorScreen(state: CalculatorState, onEvent: (CalculatorEvent) -> Unit) {
Header(title = "Estimator")
CalculatorForm(
input = state.input,
validation = state.validation,
enabled = !state.isRefreshingQuote,
onAreaChanged = { onEvent(CalculatorEvent.FieldChanged(FormField.Area, it)) },
)
ResultCard(derived = state.derived, isRefreshing = state.isRefreshingQuote)
}
@Composable
fun CalculatorResult(derived: CalculatorDerived?) {
Text(text = derived?.subtotal?.toString() ?: "")
}
```
### BAD: unstable list items
```kotlin
data class HistoryRowState(
val id: String, val title: String,
val tags: MutableList<String>, // unstable
val onClick: () -> Unit, // lambda in data class
)
```
### GOOD: immutable models, stable keys, callback stability
```kotlin
@Immutable
data class HistoryRowUi(val id: String, val title: String, val subtitle: String)
@Composable
fun HistoryList(items: ImmutableList<HistoryRowUi>, onOpen: (String) -> Unit) {
LazyColumn {
items(items = items, key = { it.id }) { item ->
val onClick = remember(item.id, onOpen) { { onOpen(item.id) } }
ListItem(
headlineContent = { Text(item.title) },
supportingContent = { Text(item.subtitle) },
modifier = Modifier.clickable(onClick = onClick),
)
}
}
}
```
### GOOD: correct `derivedStateOf`
```kotlin
val listState = rememberLazyListState()
val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 2 } }
```
### BAD: unnecessary `derivedStateOf`
```kotlin
val text by remember { derivedStateOf { if (canSubmit) "Submit" else "Fix errors" } }
// Just write: Text(if (canSubmit) "Submit" else "Fix errors")
```
### GOOD: guard identical transitions
```kotlin
private fun onAreaEdited(raw: String) {
val old = _state.value
if (old.input.areaText == raw) return
_state.value = old.copy(input = old.input.copy(areaText = raw))
}
```
## Compiler and Build Optimizations
- **Strong Skipping Mode** — enable via compiler flags; allows composables with unstable parameters to skip based on instance equality (`===`)
- **Stability config** — use `stability_config.conf` to mark external classes as stable: `com.example.network.dto.*`, `kotlinx.datetime.Instant`
- **Compose Compiler Metrics** — audit `restartable`/`skippable` characteristics regularly
## Baseline Profiles (Android)
Pre-compile hot code paths via Jetpack Macrobenchmark to reduce startup time and jank:
```kotlin
@RunWith(AndroidBenchmarkRunner::class)
class StartupBenchmark {
@get:Rule val benchmarkRule = MacrobenchmarkRule()
@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
setupBlock = { pressHome(); startActivityAndWait() }
) { /* interact with app */ }
}
```
Target <16.67ms per frame for 60fps. Use `FrameTimingMetric()` for scroll/interaction benchmarks.
### R8/ProGuard Rules for Compose (Android only)
```proguard
-keep @androidx.compose.runtime.Stable class **
-keep @androidx.compose.runtime.Immutable class **
```

View File

@@ -0,0 +1,66 @@
# Platform-native UI
Use when Android, Windows, and Linux should intentionally render or behave differently.
## Native-first rule
Share domain behavior and semantic state. Duplicate presentation when sharing would make a platform feel foreign.
| Platform | Default visual language | Typical native differences |
|---|---|---|
| Android | Material + Material icons | bottom navigation, sheets, system pickers, back behavior, touch density |
| Windows | Fluent icons + desktop conventions | sidebar/command placement, context menus, keyboard shortcuts, window chrome |
| Linux | Lucide + desktop conventions | desktop menus, filesystem flows, window integration |
Acceptable duplication:
```text
commonMain: SavedDeviceState + named actions + semantic models
androidMain: AndroidSavedDeviceScreen
jvmMain: WindowsSavedDeviceScreen / LinuxSavedDeviceScreen
```
Unacceptable duplication:
```text
androidMain/jvmMain each reimplement pairing, transfer validation,
source cleanup, lifecycle mapping, or CoreGateway orchestration
```
## Choosing the seam
Use platform-specific implementation when at least one differs materially:
- Interaction convention or navigation placement.
- System picker, menu, dialog, notification, or window integration.
- Keyboard/mouse versus touch behavior.
- Icon family or system-provided symbol.
- Accessibility semantics required by the host platform.
- Layout density and information hierarchy.
Keep a shared composable when only spacing or a token changes and the interaction model remains native on every platform.
## Icons
- Add a semantic `AppIcon` case, not a feature-local drawable choice.
- Supply Material, Fluent, and Lucide resources.
- Render with `PlatformIcon` so `LocalUiPlatform` selects the family.
- Use a native system icon through a platform implementation when it communicates better than the bundled family.
- Localize content descriptions for actions. Decorative icons use `null`.
- Test `resourceFor`/family selection and important semantics.
## Adaptive layout
- Use existing `WindowClass`, platform helpers, and app shell before inventing breakpoints.
- Phone flow may be full-screen or sheet-based.
- Desktop may use persistent navigation, side panels, dialogs, context menus, and denser information.
- Do not merely enlarge phone controls on desktop.
- Do not compress desktop controls into touch-hostile phone layouts.
## Review questions
- Does this screen look and behave expectedly on each platform?
- Did sharing code force a non-native interaction?
- Is duplicated code presentation-only?
- Are domain rules still local to one shared module?
- Does every actionable icon have the correct family and semantics?

View File

@@ -1,206 +0,0 @@
# Compose Multiplatform Resources
## Android R vs CMP Res
Android uses `R` — a generated class with integer IDs. Compose Multiplatform uses `Res` — a generated class with typed accessors. The API surface is intentionally similar, but the types and import paths differ.
| Concern | Android (Jetpack Compose) | Compose Multiplatform |
|---|---|---|
| Generated class | `R` (integer resource IDs) | `Res` (typed resource objects) |
| String access | `stringResource(R.string.app_name)` | `stringResource(Res.string.app_name)` |
| Drawable access | `painterResource(R.drawable.icon)` | `painterResource(Res.drawable.icon)` |
| Plural access | `pluralStringResource(R.plurals.items, count)` | `pluralStringResource(Res.plurals.items, count)` |
| Font access | `FontFamily(Font(R.font.inter))` | `FontFamily(Font(Res.font.inter))` |
| String array | `stringArrayResource(R.array.items)` | `stringArrayResource(Res.array.items)` |
| Resource directory | `res/` (under each source set) | `composeResources/` (under each source set) |
| Import path | `import com.example.app.R` | `import project.module.generated.resources.Res` |
| Suspend access | N/A | `getString(Res.string.app_name)` |
| Raw file access | `context.assets.open("file.bin")` | `Res.readBytes("files/file.bin")` |
| Platform URI | `ContentResolver` / asset URI | `Res.getUri("files/video.mp4")` |
**Import convention:** `{group}.{module}.generated.resources.Res`. Individual accessors imported separately:
```kotlin
import project.composeapp.generated.resources.Res
import project.composeapp.generated.resources.app_name
import project.composeapp.generated.resources.my_image
```
## Directory Structure
Place resources under `composeResources/` in the owning source set. `commonMain` for shared, platform source sets for platform-specific.
```text
commonMain/composeResources/
├── drawable/ PNG, JPG, BMP, WebP, Android XML vectors, SVG (all except Android)
│ ├── drawable-dark/ dark theme variants
│ └── drawable-xxhdpi/ density-specific variants
├── font/ TTF, OTF
├── values/ strings.xml (strings, string-arrays, plurals) — base locale
│ ├── values-es/ Spanish
│ ├── values-fr/ French
│ └── values-ja/ Japanese
└── files/ raw files, any sub-hierarchy
└── myDir/data.json
```
Qualifiers use hyphens and can combine: `drawable-en-rUS-mdpi-dark`. Fallback: unqualified resource.
## Gradle Setup
```kotlin
kotlin {
sourceSets {
commonMain.dependencies {
implementation(compose.components.resources)
}
}
}
compose.resources {
publicResClass = true // default: internal; needed for library modules
packageOfResClass = "com.example.app.resources" // default: {group}.{module}.generated.resources
generateResClass = auto // auto | always
}
```
For `androidLibrary` targets (AGP 8.8.0+), enable explicitly: `kotlin { androidLibrary { androidResources.enable = true } }`.
Build the project to generate/regenerate the `Res` class and typed accessors.
## Drawables and Images
Store in `composeResources/drawable/`. Use `painterResource` as the primary API — returns `Painter` for both raster and vector. Works synchronously except web (empty on first composition, then loads).
```kotlin
Image(painter = painterResource(Res.drawable.my_image), contentDescription = null)
val bitmap: ImageBitmap = imageResource(Res.drawable.photo) // raster only
val vector: ImageVector = vectorResource(Res.drawable.ic_arrow) // XML vector only
```
## Icons
Use Material Symbols XML icons from [Google Fonts Icons](https://fonts.google.com/icons). Download the Android XML variant, place in `composeResources/drawable/`, set `android:fillColor` to `#000000`, remove `android:tint`.
```kotlin
Image(
painter = painterResource(Res.drawable.ic_settings),
contentDescription = "Settings",
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
)
```
## Strings, Templates, Arrays, and Plurals
Store in `composeResources/values/strings.xml`. Each element generates a typed accessor on `Res`.
| Type | XML | Composable API | Suspend API |
|---|---|---|---|
| String | `<string name="k">text</string>` | `stringResource(Res.string.k)` | `getString(Res.string.k)` |
| Template | `<string name="k">Hello, %1$s!</string>` | `stringResource(Res.string.k, name)` | `getString(Res.string.k, name)` |
| String array | `<string-array name="k"><item>A</item></string-array>` | `stringArrayResource(Res.array.k)` | `getStringArray(Res.array.k)` |
| Plurals | `<plurals name="k"><item quantity="one">%1$d item</item><item quantity="other">%1$d items</item></plurals>` | `pluralStringResource(Res.plurals.k, count, count)` | `getPluralString(Res.plurals.k, count, count)` |
Canonical example:
```xml
<resources>
<string name="app_name">My App</string>
<string name="welcome">Hello, %1$s! You have %2$d new messages.</string>
<string-array name="categories">
<item>Electronics</item>
<item>Clothing</item>
</string-array>
<plurals name="items_count">
<item quantity="one">%1$d item</item>
<item quantity="other">%1$d items</item>
</plurals>
</resources>
```
Special characters: `\n`, `\t`, `\uXXXX`. Unlike Android, no need to escape `@` or `?`. For plurals, the first `count` selects the form; additional args are format arguments. No functional difference between `$s` and `$d`. Supported quantities: `zero`, `one`, `two`, `few`, `many`, `other` — not all apply to every language.
## Fonts
Store `.ttf`/`.otf` in `composeResources/font/`. `Font()` is a **composable** in CMP (unlike Android), so dependent `TextStyle`/`Typography` construction must also be composable:
```kotlin
@Composable
fun AppTypography(): Typography {
val fontFamily = FontFamily(
Font(Res.font.Inter_Regular, FontWeight.Normal),
Font(Res.font.Inter_Bold, FontWeight.Bold),
)
return MaterialTheme.typography.copy(
bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = fontFamily),
titleLarge = MaterialTheme.typography.titleLarge.copy(fontFamily = fontFamily, fontWeight = FontWeight.Bold),
)
}
```
## Raw Files and URIs
Place arbitrary files in `composeResources/files/` with any sub-hierarchy.
```kotlin
// Read bytes (suspend)
val bytes = Res.readBytes("files/data.json")
// Convert to images
val bitmap: ImageBitmap = bytes.decodeToImageBitmap()
val vector: ImageVector = bytes.decodeToImageVector(LocalDensity.current)
val painter: Painter = bytes.decodeToSvgPainter(LocalDensity.current) // all platforms except Android
// Get platform URI for external APIs (WebView, media players)
val uri: String = Res.getUri("files/intro.mp4")
```
Since CMP 1.7.0, multiplatform resources are packed into Android assets — enabling `@Preview` and `WebView`/media access via URI.
## Qualifiers Reference
| Qualifier | Format | Example |
|---|---|---|
| Language / region | ISO 639-1/2; optional `r` + ISO 3166-1-alpha-2 | `values-es/`, `values-fra/`, `values-es-rMX/` |
| Theme | `light` or `dark` | `drawable-dark/` |
| Density | `ldpi`/`mdpi`/`hdpi`/`xhdpi`/`xxhdpi`/`xxxhdpi` | `drawable-xxhdpi/` |
`stringResource()` automatically selects the correct locale at runtime — no code changes needed.
## Remote Images
For loading images from URLs, use a dedicated library — multiplatform resources are for bundled assets only. See [image-loading.md](image-loading.md).
## MVI Integration
**Rule: semantic keys in state, resource resolution in UI.** ViewModels use enums/semantic values — never resolved strings or resource IDs. UI maps semantic keys to `stringResource()`/`painterResource()` at render time.
```kotlin
enum class ErrorKey { NetworkError, InvalidInput, Unauthorized }
data class ProfileState(val userName: String = "", val error: ErrorKey? = null)
state.error?.let { key ->
Text(stringResource(when (key) {
ErrorKey.NetworkError -> Res.string.error_network
ErrorKey.InvalidInput -> Res.string.error_invalid_input
ErrorKey.Unauthorized -> Res.string.error_unauthorized
}))
}
```
For full MVI ViewModel collection pattern, see [architecture.md](architecture.md).
## Rules
- Use `composeResources/` for all shared strings, images, fonts, and raw files
- Use typed accessors (`Res.string.name`) for compile-time safety
- Use qualifiers for localization (`values-es/`), theme (`drawable-dark/`), density (`drawable-xxhdpi/`)
- Keep resource resolution in composables — call `stringResource()`/`painterResource()` at render time
- Use suspend variants (`getString()`, `getPluralString()`) for non-composable contexts
- Set `publicResClass = true` when sharing resources from a library module
- Use semantic keys/enums in state; map to resources in UI
- Never resolve strings or load resources inside reducers or ViewModels
- Never use Android `R.string`/`R.drawable` in `commonMain` — use `Res`
- Never place platform-only assets (Android adaptive icons, iOS asset catalogs) in `composeResources/`
- Rebuild after adding new resources — the `Res` class needs regeneration

View File

@@ -1,256 +0,0 @@
# Room Database
SQLite persistence via Room (KMP-ready since 2.7.0) for Compose Multiplatform and Android projects.
References:
- [Save data in a local database using Room](https://developer.android.com/training/data-storage/room)
- [Set up Room Database for KMP](https://developer.android.com/kotlin/multiplatform/room)
- [SQLite performance best practices](https://developer.android.com/topic/performance/sqlite-performance-best-practices)
## Setup
> **Always search online for the latest stable versions** of `androidx.room`, `androidx.sqlite`, and `com.google.devtools.ksp` before adding dependencies.
### Dependencies (version catalog)
```toml
[versions]
room = "<latest>" # search: "androidx.room latest version"
sqlite = "<latest>" # search: "androidx.sqlite latest version"
ksp = "<latest>" # must match your Kotlin version
[libraries]
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
androidx-room = { id = "androidx.room", version.ref = "room" }
```
### KMP Gradle
```kotlin
plugins {
alias(libs.plugins.ksp)
alias(libs.plugins.androidx.room)
}
kotlin {
sourceSets.commonMain.dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
}
}
dependencies {
add("kspAndroid", libs.androidx.room.compiler)
add("kspIosArm64", libs.androidx.room.compiler)
// ... add for every target
}
room { schemaDirectory("$projectDir/schemas") }
```
**Android-only:** use `ksp(libs.androidx.room.compiler)` directly.
### Database definition
```kotlin
@Database(entities = [ProjectEntity::class, TaskEntity::class], version = 1)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun projectDao(): ProjectDao
abstract fun taskDao(): TaskDao
}
@Suppress("KotlinNoActualForExpect")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
override fun initialize(): AppDatabase
}
```
Room generates `actual` implementations per platform. **Android-only:** skip `@ConstructedBy`, use `Room.databaseBuilder(context, AppDatabase::class.java, "app.db")`.
### Database instantiation
```kotlin
fun getRoomDatabase(builder: RoomDatabase.Builder<AppDatabase>): AppDatabase =
builder.setDriver(BundledSQLiteDriver()).setQueryCoroutineContext(Dispatchers.IO).build()
```
Each platform provides its own `getDatabaseBuilder`. See [KMP setup guide](https://developer.android.com/kotlin/multiplatform/room).
## Critical Performance Rules
| Rule | Why |
|------|-----|
| Index every column in `WHERE`, `ORDER BY`, `JOIN ON` | Avoids full table scan: O(n) → O(log n) |
| Batch writes inside `@Transaction` | Individual inserts each trigger separate disk sync |
| Select only needed columns (projection data classes) | Reduces memory and I/O vs `SELECT *` |
| `Flow` for reactive reads, `suspend` for writes | Auto-notify on changes; keep main thread free |
| Never `allowMainThreadQueries()` in production | Blocks UI, causes ANRs |
| Use `BundledSQLiteDriver` for KMP | Consistent SQLite version across platforms |
| Provide `RoomDatabase` as DI singleton | Each instance manages its own connection pool |
## Entity Design
```kotlin
@Entity(
tableName = "tasks",
indices = [Index("projectId"), Index("projectId", "dueDate")]
)
data class TaskEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val description: String,
val projectId: Long,
@ColumnInfo(name = "due_date") val dueDate: Long? = null,
@ColumnInfo(defaultValue = "0") val isCompleted: Boolean = false,
@Ignore val displayOrder: Int = 0
)
```
Composite key: `@Entity(primaryKeys = ["taskId", "labelId"])`. Full-text search: `@Fts4(contentEntity = ...)` with `MATCH` queries.
### Indexes
| Scenario | Index? | Reason |
|----------|--------|--------|
| Column in `WHERE`/`ORDER BY`/`JOIN ON` | Yes | Avoids full scan / sort pass |
| Foreign key column | Yes | Room warns if missing |
| Rarely queried column / tiny table | No | Wastes storage, slows writes |
Composite index `(a, b)` accelerates queries on `a` alone or both. Column order matters — most selective first.
## DAO Patterns
```kotlin
@Dao
interface TaskDao {
@Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insert(task: TaskEntity): Long
@Insert suspend fun insertAll(tasks: List<TaskEntity>): List<Long>
@Update suspend fun update(task: TaskEntity)
@Upsert suspend fun upsert(task: TaskEntity)
@Delete suspend fun delete(task: TaskEntity)
@Query("DELETE FROM tasks WHERE projectId = :projectId") suspend fun deleteByProject(projectId: Long)
@Query("SELECT * FROM tasks WHERE projectId = :projectId ORDER BY due_date ASC")
fun observeByProject(projectId: Long): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE id = :id") suspend fun getById(id: Long): TaskEntity?
}
```
`@Upsert` (Room 2.5+) inserts or updates by primary key. Prefer over `@Insert(onConflict = REPLACE)` which deletes then re-inserts, triggering cascading deletes. Room auto-invalidates `Flow` queries on table changes.
**KMP:** all DAO functions for non-Android must be `suspend` or return `Flow`.
### Performance-oriented queries
```kotlin
data class TaskSummary(val id: Long, val title: String, @ColumnInfo(name = "due_date") val dueDate: Long?)
@Query("SELECT id, title, due_date FROM tasks WHERE projectId = :projectId")
fun observeSummaries(projectId: Long): Flow<List<TaskSummary>>
@Query("SELECT projectId, COUNT(*) AS taskCount, SUM(CASE WHEN isCompleted = 1 THEN 1 ELSE 0 END) AS completedCount FROM tasks GROUP BY projectId")
suspend fun getProjectStats(): List<ProjectStats>
@Transaction
suspend fun replaceAllForProject(projectId: Long, tasks: List<TaskEntity>) {
deleteByProject(projectId); insertAll(tasks)
}
```
Always use `:paramName` bind parameters — never concatenate. Use `LIMIT` for bounded results. For unbounded scrolling, use [Paging](paging.md). For offline-first paging with Room, see [paging-offline.md](paging-offline.md).
## Relationships
### One-to-many
```kotlin
data class ProjectWithTasks(
@Embedded val project: ProjectEntity,
@Relation(parentColumn = "id", entityColumn = "projectId") val tasks: List<TaskEntity>
)
@Transaction @Query("SELECT * FROM projects WHERE id = :id")
suspend fun getWithTasks(id: Long): ProjectWithTasks?
```
Always `@Transaction` on relational queries — Room issues multiple queries internally.
### Many-to-many with Junction
```kotlin
@Entity(
tableName = "task_labels", primaryKeys = ["taskId", "labelId"],
foreignKeys = [
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["taskId"], onDelete = ForeignKey.CASCADE),
ForeignKey(entity = LabelEntity::class, parentColumns = ["id"], childColumns = ["labelId"], onDelete = ForeignKey.CASCADE)
],
indices = [Index("labelId")]
)
data class TaskLabelCrossRef(val taskId: Long, val labelId: Long)
data class TaskWithLabels(
@Embedded val task: TaskEntity,
@Relation(parentColumn = "id", entityColumn = "id",
associateBy = Junction(TaskLabelCrossRef::class, parentColumn = "taskId", entityColumn = "labelId"))
val labels: List<LabelEntity>
)
```
## TypeConverters
```kotlin
class Converters {
@TypeConverter fun fromInstant(value: Long?): Instant? = value?.let { Instant.fromEpochMilliseconds(it) }
@TypeConverter fun toInstant(instant: Instant?): Long? = instant?.toEpochMilliseconds()
}
```
**KMP:** use `kotlinx-datetime`. Reserve TypeConverters for simple mappings (timestamps, enums) — prefer normalized tables over JSON blobs.
## Transactions
- **KMP:** `database.useWriterConnection { it.immediateTransaction { } }` for writes, `database.useReaderConnection { it.deferredTransaction { } }` for consistent reads
- **Android-only:** `database.withTransaction { }` (not available in KMP `commonMain`)
- **DAO-level:** `@Transaction` to group multiple queries atomically
## Migrations
```kotlin
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0")
}
}
```
`.addMigrations(MIGRATION_1_2)`. **AutoMigration:** `autoMigrations = [AutoMigration(from = 1, to = 2)]` for simple changes. Export schema to VCS; `fallbackToDestructiveMigration()` only in early dev.
## MVI Integration
Map entities to domain models at the repository boundary (`TaskEntity.toDomain()` / `Task.toEntity()`). Never pass `@Entity` classes to the UI. Provide `RoomDatabase` and DAOs as DI singletons.
For the ViewModel collection pattern, see [architecture.md](architecture.md) — Reactive Data Collection.
## Testing
- **DAO tests:** `Room.inMemoryDatabaseBuilder<AppDatabase>()` with `BundledSQLiteDriver` + test dispatcher. Test `Flow` with Turbine.
- **Migration tests:** `MigrationTestHelper` — create at old version, run `runMigrationsAndValidate`, verify.
- **ViewModel tests:** Fake DAO backed by `MutableStateFlow<List<Entity>>`. See [testing.md](testing.md).
## Anti-Patterns
| Anti-pattern | Why it is harmful | Better replacement |
|---|---|---|
| `allowMainThreadQueries()` | Blocks UI, ANRs | `suspend` + `Flow` |
| `SELECT *` everywhere | Loads unused columns | Projection data classes |
| Missing indexes on queried columns | Full table scan | `@Entity(indices = [...])` |
| Destructive fallback only | Users lose data | `Migration` or `AutoMigration` |
| `@Insert(onConflict = REPLACE)` with FKs | Cascading deletes | `@Upsert` |
| Blocking DAO functions on KMP | Crashes non-Android | `suspend` or `Flow` |
| No `@Transaction` on relational queries | Inconsistent snapshot | Always `@Transaction` with `@Relation` |
| Multiple `RoomDatabase` instances | Breaks invalidation | DI singleton |
| Large blobs / nested JSON via TypeConverter | Bloats DB, opaque to SQL | File paths + normalized tables |

View File

@@ -1,222 +0,0 @@
# Testing Strategy
## What to Test in commonMain
### ViewModel Tests with Turbine (highest ROI)
Test the full event→state→effect cycle through the ViewModel. Use `kotlinx-coroutines-test` with the **Turbine** library:
```kotlin
@Test
fun `save with empty title shows validation error`() = runTest {
val viewModel = CreateItemViewModel(FakeItemRepository())
viewModel.state.test {
val initial = awaitItem()
assertTrue(initial.errors.isEmpty())
viewModel.onEvent(CreateItemEvent.OnSaveClick)
val afterSave = awaitItem()
assertEquals("Title is required", afterSave.errors["title"])
assertFalse(afterSave.isSaving)
}
}
@Test
fun `save with valid input transitions through saving to success`() = runTest {
val viewModel = CreateItemViewModel(FakeItemRepository())
viewModel.state.test {
awaitItem() // initial
viewModel.onEvent(CreateItemEvent.OnTitleChanged("New item"))
awaitItem()
viewModel.onEvent(CreateItemEvent.OnAmountChanged("42.5"))
awaitItem()
viewModel.onEvent(CreateItemEvent.OnSaveClick)
val saving = awaitItem()
assertTrue(saving.isSaving)
val done = awaitItem()
assertFalse(done.isSaving)
}
}
@Test
fun `title changed clears title validation error`() = runTest {
val viewModel = CreateItemViewModel(FakeItemRepository())
viewModel.state.test {
awaitItem() // initial
viewModel.onEvent(CreateItemEvent.OnSaveClick)
val withError = awaitItem()
assertTrue(withError.errors.containsKey("title"))
viewModel.onEvent(CreateItemEvent.OnTitleChanged("A"))
val cleared = awaitItem()
assertFalse(cleared.errors.containsKey("title"))
}
}
@Test
fun `save emits ShowMessage effect on success`() = runTest {
val viewModel = CreateItemViewModel(FakeItemRepository())
viewModel.onEvent(CreateItemEvent.OnTitleChanged("New item"))
viewModel.onEvent(CreateItemEvent.OnAmountChanged("10"))
viewModel.effect.test {
viewModel.onEvent(CreateItemEvent.OnSaveClick)
val effect = awaitItem()
assertTrue(effect is CreateItemEffect.ShowMessage)
}
}
```
**What to test:**
- Event→state transitions: field edits, validation triggers, loading states
- Event→effect emissions: navigation, snackbar, error messages
- Async flows: loading → success, loading → failure, retry
- Edge cases: empty input, duplicate detection, concurrent saves
- State preservation: old content kept during refresh, error doesn't wipe data
### Testing State and Effects Separately
When a single event produces both state changes and effects, test them independently for clarity:
```kotlin
@Test
fun `back click emits NavigateBack effect without changing state`() = runTest {
val viewModel = CreateItemViewModel(FakeItemRepository())
viewModel.effect.test {
viewModel.onEvent(CreateItemEvent.OnBackClick)
assertEquals(CreateItemEffect.NavigateBack, awaitItem())
}
viewModel.state.test {
val state = awaitItem()
assertEquals(CreateItemState(), state)
}
}
```
### Validation Tests
Test validation logic as pure functions when extracted into a dedicated validator:
```kotlin
@Test
fun `validator rejects blank title`() {
val errors = CreateItemValidator.validate(title = "", amount = "10")
assertEquals("Title is required", errors["title"])
}
@Test
fun `validator accepts valid input`() {
val errors = CreateItemValidator.validate(title = "Widget", amount = "25.0")
assertTrue(errors.isEmpty())
}
```
If validation is inline in the ViewModel (acceptable for simple cases), test it through ViewModel events as shown above.
### Calculation Engine Tests
Test pure calculation/domain services directly — no ViewModel needed:
```kotlin
@Test
fun `calculator computes correct monthly payment`() {
val result = LoanCalculator.monthlyPayment(amount = 100000.0, rate = 5.0, years = 30)
assertEquals(536.82, result, 0.01)
}
```
Test: edge cases, rounding policy, domain invariants, regression fixtures.
### Fake Repositories for ViewModel Tests
Use fakes (not mocks) for repositories and services:
```kotlin
class FakeItemRepository : ItemRepository {
private val items = mutableListOf<Item>()
var shouldThrow: Exception? = null
override suspend fun create(title: String, amount: Double) {
shouldThrow?.let { throw it }
items.add(Item(title = title, amount = amount))
}
override suspend fun getAll(): List<Item> = items.toList()
}
```
Fakes give you control over success/failure scenarios without mock framework complexity.
## Compose UI Tests
Compose Multiplatform common UI testing uses `runComposeUiTest` rather than Android's JUnit `TestRule` model.
Test:
- Critical field entry flows
- Submit enable/disable behavior
- Error visibility
- Loading placeholder/content swap
- Preserved content during refresh
- Accessibility labels on critical controls
## Platform Tests
### Android/iOS specific
Test:
- Platform shell wiring
- Deep-link entry
- Navigation host integration
- Share sheet / clipboard / haptic bindings
- Platform lifecycle edge cases
- Keyboard/safe-area regressions
## Snapshot Testing Caveats
Per-platform rendering, typography, and layout differ; shared Android/iOS goldens are brittle.
**Default:** prefer semantic assertions and interaction tests; use per-platform visual goldens only for a few high-value screens.
## Lean Default Test Matrix
1. ViewModel event→state→effect tests for every feature (via Turbine)
2. Validation/calculation tests for every rule-heavy feature (pure function tests)
3. UI tests for high-risk screens
4. Platform integration tests only for real platform behavior
Do not sink weeks into screenshot infrastructure before you have ViewModel test coverage.
## Anti-Patterns
| Anti-pattern | Why it hurts | Better replacement |
|---|---|---|
| No ViewModel tests, only UI tests | slow feedback, flaky, hard to isolate failures | ViewModel event→state→effect tests with Turbine first |
| Testing implementation details (private functions, internal state) | brittle tests that break on refactoring | test through public API: send event, assert state/effect |
| Mocking the DI framework | couples tests to DI internals | swap real implementations with fakes via constructor injection |
| Screenshot tests before ViewModel coverage | high maintenance, low defect yield | establish ViewModel + validator coverage first, then add screenshots selectively |
| Testing derived/computed properties in isolation from ViewModel | duplicates logic, drifts from real behavior | test derived values through ViewModel state assertions |
| Sharing mutable test fixtures across tests | hidden coupling, order-dependent failures | fresh state per test, explicit setup in each test function |
## Domain-Specific Testing
Some reference files contain their own testing sections with domain-specific patterns:
| Domain | Reference | What it covers |
|---|---|---|
| Paging 3 | [paging-mvi-testing.md](paging-mvi-testing.md) | PagingSource unit tests, `asSnapshot`, `TestPager` transformations |
| Room Database | [room-database.md](room-database.md) | In-memory DB tests, migration tests, fake DAOs |
| Networking | [networking-ktor-testing.md](networking-ktor-testing.md) | MockEngine, API response testing, DI integration |

View File

@@ -1,170 +0,0 @@
# UI/UX Patterns for Utility Apps
## Core Principles
Utility apps are trust products. The UI must feel: stable, immediate, precise, reversible, non-destructive.
## Loading States
### Decision Rule
| Situation | Best default |
|---|---|
| First load, known result card layout | skeleton |
| Small inline refresh of one section | keep content + small inline indicator |
| Whole-screen blocking startup with no known structure | spinner, but rare |
| Recalculating quote while old result exists | keep old result + "updating" affordance |
| Empty but idle state | empty-state hint, not spinner |
### Default Recommendation
- **Skeleton**: default for known layout with missing data
- **Subtle shimmer over skeleton**: optional polish, not the primary strategy
- **Spinner**: only for small unknown-layout operations or blocking tasks with no stable placeholder shape
### Stable Layout During Loading
Never wipe content during refresh. Never cause height jumps, flicker, or lost context.
## Inline Validation
Default behavior:
- Validate format/range as user edits for fields where feedback is obvious
- Avoid screaming errors on untouched fields
- Show errors inline, next to the field they belong to
- Do not collapse layout when error appears/disappears
- Disable submit when impossible, but also explain why
### Good inline validation behavior
- Field keeps its value during error
- Error appears under field
- Submit remains disabled only when necessary
- No modal dialog for every invalid keystroke
- No full-form red error wall
## Disabled States
Disabled is fine only when:
- The reason is obvious from nearby context
- The screen is still readable
- User input is preserved
Bad disabled state: button disabled with no visible reason, form cleared during loading, entire screen grayed out for a small refresh.
## Preserving User Input
Non-negotiable rules:
- **Never clear edited fields on refresh**
- **Never clear last good result while fetching a new one**
- **Never wipe the screen because one request failed**
## Progressive Disclosure
For dense forms:
- Hide advanced options by default
- Keep main path obvious
- Reveal secondary controls progressively
- Do not split trivial forms into too many steps
## Partial Results
Good pattern:
- Compute instant local estimate from current draft
- Show local estimate immediately
- Fetch remote refinement in background
- Keep old refined quote until new one arrives
- Label refreshed state clearly
## Perceived Performance
For form-heavy screens:
- Apply local field state changes instantly
- Recalculate cheap deterministic outputs immediately
- Debounce only expensive async work
- Keep layout stable
- Animate only meaningful content changes
## Accessibility
- Error messages must be text, not color only
- Loading indicators should not hide context unnecessarily
- Support logical keyboard/focus order
- Avoid rapid flashing/sweeping shimmer
- Keep controls large enough for data-entry reliability
## Code Examples
### BAD: disappearing content and layout jumps
```kotlin
@Composable
fun QuoteSection(quote: QuoteUi?, isLoading: Boolean) {
if (isLoading) {
CircularProgressIndicator()
} else if (quote != null) {
QuoteContent(quote = quote, refreshing = false)
}
}
```
### GOOD: stable layout with old content preserved
```kotlin
@Composable
fun QuoteSection(quote: QuoteUi?, isLoading: Boolean) {
ResultCardSlot {
when {
quote != null -> QuoteContent(quote = quote, refreshing = isLoading)
isLoading -> QuoteCardSkeleton()
else -> QuoteEmptyState()
}
}
}
```
### GOOD: stable placeholder slot
```kotlin
@Composable
fun ResultCardSlot(content: @Composable BoxScope.() -> Unit) {
Box(modifier = Modifier.fillMaxWidth().heightIn(min = 180.dp)) { content() }
}
```
### GOOD: skeleton with shimmer
```kotlin
@Composable
fun QuoteCardSkeleton(modifier: Modifier = Modifier) {
val alpha by rememberInfiniteTransition(label = "skeleton").animateFloat(
initialValue = 0.35f,
targetValue = 0.60f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800),
repeatMode = RepeatMode.Reverse,
),
label = "alpha",
)
Column(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 180.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.fillMaxWidth(0.4f).height(20.dp).clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
Box(Modifier.fillMaxWidth().height(36.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
Box(Modifier.fillMaxWidth(0.7f).height(20.dp).clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
}
}
```

View File

@@ -199,7 +199,7 @@ For UI and presentation work, **load and follow** the in-repo skill:
.codex/skills/compose-skill/SKILL.md
```
- Open at most one `references/*.md` file when the skills Quick Routing requires it.
- Open at most one `references/*.md` file when the skill links to it for the current task.
- Do not invent a second Compose style guide.
- VniDrop uses **MVVM-style** ViewModels (`*State` + `StateFlow` + named methods),
not a forced MVI `onEvent` base — adapt, do not rewrite.
@@ -279,7 +279,7 @@ branch from updated `master`.
| Task | Start here |
|------|------------|
| Share / multi-file / folders | `runtime/share.rs`, `filesystem.rs`, platform `FileSystemService.*` |
| Share / multi-file / folders | `runtime/share.rs`, `filesystem.rs`, platform `PickedShareSourceAdapter.*` |
| Receive / export / sinks | `runtime/receive.rs` |
| Cancel / delete / stop share | `runtime/lifecycle.rs`, `facade.rs` |
| Per-receiver send progress | `runtime/provider.rs`, `ui/state/AppUiModels.kt` |

View File

@@ -4,6 +4,10 @@ Local peer-to-peer file transfer. This glossary is the product/core ubiquitous l
## Transfers
**Transfer draft**:
A temporary, local selection of files or one folder, an editable transfer name, and a destination intent before a transfer is created. A draft may produce an Invitation transfer or a Targeted transfer; it is neither until creation succeeds.
_Avoid_: pending transfer, temporary transfer, share draft
**Invitation transfer**:
A share anyone with the ticket can request, subject to approval and access policy. Ordinary multi-recipient send/receive.
_Avoid_: contact send, held offer, reusable share offer

View File

@@ -1458,57 +1458,6 @@
"ru": "Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова."
}
},
"experimental_saved_devices_description": {
"context": "Settings > Experimental: toggle description for saved devices and targeted transfers.",
"targets": [
"kmp"
],
"translations": {
"en": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"fr": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"es": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"it": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"de": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"pt": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"pl": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"nl": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.",
"ru": "Remember devices after a transfer and send to them again without a new invitation. Experimental and may change."
}
},
"experimental_saved_devices_title": {
"context": "Settings > Experimental: toggle title for saved devices feature.",
"targets": [
"kmp"
],
"translations": {
"en": "Saved devices",
"fr": "Saved devices",
"es": "Saved devices",
"it": "Saved devices",
"de": "Saved devices",
"pt": "Saved devices",
"pl": "Saved devices",
"nl": "Saved devices",
"ru": "Saved devices"
}
},
"experimental_settings_title": {
"context": "Settings: experimental section title (Android).",
"targets": [
"kmp"
],
"translations": {
"en": "Experimental",
"fr": "Experimental",
"es": "Experimental",
"it": "Experimental",
"de": "Experimental",
"pt": "Experimental",
"pl": "Experimental",
"nl": "Experimental",
"ru": "Experimental"
}
},
"field_receiver_name": {
"context": "Text field label: the receiver's display name.",
"translations": {
@@ -1735,6 +1684,23 @@
"ru": "Получить"
}
},
"nav_saved_devices": {
"context": "Primary navigation: Saved devices destination label.",
"translations": {
"en": "Devices",
"fr": "Appareils",
"es": "Dispositivos",
"it": "Dispositivi",
"de": "Geräte",
"pt": "Dispositivos",
"pl": "Urządzenia",
"nl": "Apparaten",
"ru": "Устройства"
},
"targets": [
"kmp"
]
},
"nav_send": {
"context": "Bottom navigation: Send tab label.",
"translations": {
@@ -3293,6 +3259,29 @@
"ru": "Проверить передачу"
}
},
"send_default_transfer_name": {
"context": "Automatic transfer name when multiple files are selected. {count} = selected file count.",
"args": [
{
"name": "count",
"type": "int"
}
],
"translations": {
"en": "{count} files",
"fr": "{count} fichiers",
"es": "{count} archivos",
"it": "{count} file",
"de": "{count} Dateien",
"pt": "{count} ficheiros",
"pl": "Pliki: {count}",
"nl": "{count} bestanden",
"ru": "Файлов: {count}"
},
"targets": [
"kmp"
]
},
"send_selected_files_count": {
"context": "Send flow: count of files chosen. {count} = selected files. NOTE: singular case reads '1 files' — should become a plural.",
"args": [
@@ -4839,8 +4828,93 @@
"ru": "Отклонить"
}
},
"saved_devices_authenticated_name": {
"context": "Saved-device card: authenticated name shared by the remote app when a local label is also shown.",
"translations": {
"en": "Remote name: %1$s",
"fr": "Nom distant : %1$s",
"es": "Nombre remoto: %1$s",
"it": "Nome remoto: %1$s",
"de": "Remote-Name: %1$s",
"pt": "Nome remoto: %1$s",
"pl": "Nazwa zdalna: %1$s",
"nl": "Externe naam: %1$s",
"ru": "Имя устройства: %1$s"
},
"targets": [
"kmp"
]
},
"saved_devices_block_confirm_body": {
"context": "Confirmation message before blocking a saved device. Placeholder is the display name.",
"translations": {
"en": "Block %1$s and reject future saved-device transfers?",
"fr": "Bloquer %1$s et refuser les futurs transferts dappareil enregistré ?",
"es": "¿Bloquear a %1$s y rechazar futuros envíos de dispositivos guardados?",
"it": "Bloccare %1$s e rifiutare i futuri trasferimenti da dispositivi salvati?",
"de": "%1$s blockieren und künftige Übertragungen gespeicherter Geräte ablehnen?",
"pt": "Bloquear %1$s e rejeitar futuras transferências de dispositivos guardados?",
"pl": "Zablokować %1$s i odrzucać przyszłe transfery z zapisanych urządzeń?",
"nl": "%1$s blokkeren en toekomstige overdrachten van opgeslagen apparaten weigeren?",
"ru": "Заблокировать %1$s и отклонять будущие передачи с сохранённых устройств?"
},
"targets": [
"kmp"
]
},
"saved_devices_block_confirm_title": {
"context": "Confirmation dialog title before blocking a saved device.",
"translations": {
"en": "Block device?",
"fr": "Bloquer lappareil ?",
"es": "¿Bloquear dispositivo?",
"it": "Bloccare il dispositivo?",
"de": "Gerät blockieren?",
"pt": "Bloquear dispositivo?",
"pl": "Zablokować urządzenie?",
"nl": "Apparaat blokkeren?",
"ru": "Заблокировать устройство?"
},
"targets": [
"kmp"
]
},
"saved_devices_description": {
"context": "Saved devices screen: short explanation below the title.",
"translations": {
"en": "Send directly to devices you trust, without sharing another invitation.",
"fr": "Envoyez directement aux appareils de confiance, sans partager une nouvelle invitation.",
"es": "Envía directamente a dispositivos de confianza sin compartir otra invitación.",
"it": "Invia direttamente ai dispositivi attendibili senza condividere un altro invito.",
"de": "Direkt an vertrauenswürdige Geräte senden, ohne eine neue Einladung zu teilen.",
"pt": "Envie diretamente para dispositivos de confiança sem partilhar outro convite.",
"pl": "Wysyłaj bezpośrednio do zaufanych urządzeń bez udostępniania kolejnego zaproszenia.",
"nl": "Stuur rechtstreeks naar vertrouwde apparaten zonder opnieuw een uitnodiging te delen.",
"ru": "Отправляйте напрямую доверенным устройствам без новой ссылки-приглашения."
},
"targets": [
"kmp"
]
},
"saved_devices_endpoint": {
"context": "Saved-device screen: secondary diagnostic endpoint identity. Placeholder is a shortened endpoint ID.",
"translations": {
"en": "Device ID: %1$s",
"fr": "ID de lappareil : %1$s",
"es": "ID del dispositivo: %1$s",
"it": "ID dispositivo: %1$s",
"de": "Geräte-ID: %1$s",
"pt": "ID do dispositivo: %1$s",
"pl": "ID urządzenia: %1$s",
"nl": "Apparaat-ID: %1$s",
"ru": "ID устройства: %1$s"
},
"targets": [
"kmp"
]
},
"saved_devices_empty": {
"context": "Experimental saved devices: empty list when none are saved yet.",
"context": "Saved devices screen: empty list when none are saved yet.",
"translations": {
"en": "No saved devices yet. Finish a transfer, then remember a device.",
"fr": "No saved devices yet. Finish a transfer, then remember a device.",
@@ -4856,8 +4930,42 @@
"kmp"
]
},
"saved_devices_forget_confirm_body": {
"context": "Confirmation message before forgetting a saved device. Placeholder is the display name.",
"translations": {
"en": "Forget %1$s? You will both need to approve saving again before another direct transfer.",
"fr": "Oublier %1$s ? Vous devrez tous les deux approuver à nouveau lenregistrement avant un autre transfert direct.",
"es": "¿Olvidar a %1$s? Ambos deberán volver a aprobar el guardado antes de otra transferencia directa.",
"it": "Dimenticare %1$s? Entrambi dovrete approvare nuovamente il salvataggio prima di un altro trasferimento diretto.",
"de": "%1$s vergessen? Vor einer weiteren direkten Übertragung müssen beide das Speichern erneut bestätigen.",
"pt": "Esquecer %1$s? Ambos terão de voltar a aprovar antes de outra transferência direta.",
"pl": "Zapomnieć %1$s? Przed kolejnym transferem bezpośrednim obie strony muszą ponownie zatwierdzić zapisanie.",
"nl": "%1$s vergeten? Jullie moeten het opslaan allebei opnieuw goedkeuren voor een volgende directe overdracht.",
"ru": "Забыть %1$s? Перед следующей прямой передачей сохранение снова должны подтвердить обе стороны."
},
"targets": [
"kmp"
]
},
"saved_devices_forget_confirm_title": {
"context": "Confirmation dialog title before forgetting a saved device.",
"translations": {
"en": "Forget device?",
"fr": "Oublier lappareil ?",
"es": "¿Olvidar dispositivo?",
"it": "Dimenticare il dispositivo?",
"de": "Gerät vergessen?",
"pt": "Esquecer dispositivo?",
"pl": "Zapomnieć urządzenie?",
"nl": "Apparaat vergeten?",
"ru": "Забыть устройство?"
},
"targets": [
"kmp"
]
},
"saved_devices_eligibility_title": {
"context": "Experimental saved devices: section for peers eligible to pair after a transfer.",
"context": "Saved devices screen: section for peers eligible to pair after a transfer.",
"translations": {
"en": "Ready to remember",
"fr": "Ready to remember",
@@ -4874,7 +4982,7 @@
]
},
"saved_devices_pending_title": {
"context": "Experimental saved devices: section for in-progress pairing.",
"context": "Saved devices screen: section for in-progress pairing.",
"translations": {
"en": "Pending pairing",
"fr": "Pending pairing",
@@ -4891,7 +4999,7 @@
]
},
"saved_devices_list_title": {
"context": "Experimental saved devices: section listing saved devices.",
"context": "Saved devices screen title and saved-device list heading.",
"translations": {
"en": "Saved devices",
"fr": "Saved devices",
@@ -4907,6 +5015,74 @@
"kmp"
]
},
"saved_devices_load_failed": {
"context": "Saved devices screen: inline error when device state could not be loaded.",
"translations": {
"en": "Saved devices could not be loaded.",
"fr": "Impossible de charger les appareils enregistrés.",
"es": "No se pudieron cargar los dispositivos guardados.",
"it": "Impossibile caricare i dispositivi salvati.",
"de": "Gespeicherte Geräte konnten nicht geladen werden.",
"pt": "Não foi possível carregar os dispositivos guardados.",
"pl": "Nie udało się wczytać zapisanych urządzeń.",
"nl": "Opgeslagen apparaten konden niet worden geladen.",
"ru": "Не удалось загрузить сохранённые устройства."
},
"targets": [
"kmp"
]
},
"saved_devices_loading": {
"context": "Saved devices screen: loading state label.",
"translations": {
"en": "Loading saved devices…",
"fr": "Chargement des appareils enregistrés…",
"es": "Cargando dispositivos guardados…",
"it": "Caricamento dei dispositivi salvati…",
"de": "Gespeicherte Geräte werden geladen…",
"pt": "A carregar dispositivos guardados…",
"pl": "Wczytywanie zapisanych urządzeń…",
"nl": "Opgeslagen apparaten laden…",
"ru": "Загрузка сохранённых устройств…"
},
"targets": [
"kmp"
]
},
"saved_devices_more_actions": {
"context": "Accessibility label for the saved-device overflow menu. Placeholder is the display name.",
"translations": {
"en": "More actions for %1$s",
"fr": "Plus dactions pour %1$s",
"es": "Más acciones para %1$s",
"it": "Altre azioni per %1$s",
"de": "Weitere Aktionen für %1$s",
"pt": "Mais ações para %1$s",
"pl": "Więcej działań dla %1$s",
"nl": "Meer acties voor %1$s",
"ru": "Другие действия для %1$s"
},
"targets": [
"kmp"
]
},
"saved_devices_no_pending": {
"context": "Desktop Saved devices screen: status when no pairing decision is waiting.",
"translations": {
"en": "No pairing requests need your attention.",
"fr": "Aucune demande dassociation ne nécessite votre attention.",
"es": "No hay solicitudes de vinculación que requieran tu atención.",
"it": "Nessuna richiesta di associazione richiede attenzione.",
"de": "Keine Kopplungsanfragen benötigen Ihre Aufmerksamkeit.",
"pt": "Não existem pedidos de emparelhamento a aguardar atenção.",
"pl": "Żadne prośby o sparowanie nie wymagają uwagi.",
"nl": "Er zijn geen koppelverzoeken die aandacht nodig hebben.",
"ru": "Нет запросов на сопряжение, требующих внимания."
},
"targets": [
"kmp"
]
},
"saved_devices_unnamed": {
"context": "Fallback display when a saved device has no local label or remote name.",
"translations": {

View File

@@ -14,18 +14,18 @@ platforms use the native SwiftUI app under `apple/`.
---
## Compose skill (required for UI work)
## VniDrop KMP UI skill (required for UI work)
For screens, components, theme, navigation, resources, ViewModel↔UI wiring,
lists, animation, accessibility:
1. Load [`.codex/skills/compose-skill/SKILL.md`](../.codex/skills/compose-skill/SKILL.md).
2. Follow its workflow and defaults.
2. Follow its VniDrop-specific workflow and defaults.
3. Open **at most one** file under `.codex/skills/compose-skill/references/` when
the skills Quick Routing table says you need deeper guidance.
the skill links to it for the current task.
4. Do **not** invent a parallel Compose style guide.
### Project policy (overrides generic skill defaults)
### Project policy
| Topic | Do this |
|-------|---------|
@@ -37,7 +37,13 @@ lists, animation, accessibility:
| Platform | `androidMain` / `jvmMain` for pickers, SAF, NFC/QR, and desktop integration. |
| Dependencies | Before adding Jetpack/AndroidX to `commonMain`, verify multiplatform artifacts for all targets. |
compose-skill “Existing Project Policy”: adapt to this repo; do not force-migrate.
The skill is repository-specific. Do not substitute a generic Compose/MVI style guide.
Platform-native presentation is more important than maximizing shared UI code.
Use Material icons and conventions on Android, Fluent on Windows, and the existing
Lucide/desktop conventions on Linux. Repeated platform presentation code is
acceptable when sharing would make a platform feel foreign; domain behavior and
state machines must remain shared.
---
@@ -82,7 +88,8 @@ src/
- **Android share:** open content URIs as FDs; expand **folder trees** to per-file
documents with relative `displayName` paths before calling Rust
(`FileSystemService.android.kt` / `expandShareDirectory`).
(`PickedShareSourceAdapter.android.kt` / `expandShareDirectory`). Transfer creation
uses the focused `PickedShareSourceAdapter`; `FileSystemService` owns receive storage.
- **Android receive:** MediaStore Downloads sink and/or SAF tree write sink.
- **Apple:** lives outside this module under `apple/`; do not add Apple platform
behavior back to KMP.

View File

@@ -21,7 +21,10 @@ actual fun rememberShareFilePicker(
): ShareFilePicker {
val context = LocalContext.current
val filesLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
if (uris.isEmpty()) return@rememberLauncherForActivityResult
if (uris.isEmpty()) {
onFilesPicked(emptyList())
return@rememberLauncherForActivityResult
}
runCatching {
uris.map { uri -> context.pickedShareFile(uri) }
}.fold(
@@ -30,7 +33,10 @@ actual fun rememberShareFilePicker(
)
}
val folderLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
if (uri == null) {
onFilesPicked(emptyList())
return@rememberLauncherForActivityResult
}
runCatching {
// Read permission only — we expand the tree into file FDs at share time.
context.contentResolver.takePersistableUriPermission(

View File

@@ -55,9 +55,9 @@ private class AndroidFileSystemService(
override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus =
when (folder.kind) {
ReceiveFolderKind.FileSystemPath -> validatePath(folder.value)
ReceiveFolderKind.AndroidPublicDownloads -> validatePublicDownloads()
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
ReceiveFolderKind.FileSystemPath -> context.validatePath(folder.value)
ReceiveFolderKind.AndroidPublicDownloads -> context.validatePublicDownloads()
ReceiveFolderKind.AndroidTreeUri -> context.validateTreeUri(folder.value)
}
override suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): ReceivedStorageInspection {
@@ -135,63 +135,15 @@ private class AndroidFileSystemService(
ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri())
ReceiveFolderKind.FileSystemPath -> null
}
}
override suspend fun sharePickedFiles(
repository: CoreGateway,
files: List<PickedShareFile>,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> = withAndroidShareSources(files) { sources ->
repository.shareSources(sources, transferName, senderName, accessPolicy).getOrThrow()
}
override suspend fun createTargetedTransferFromPickedFiles(
repository: CoreGateway,
receiverEndpointId: String,
files: List<PickedShareFile>,
transferName: String?,
): Result<TargetedTransferModel> = withAndroidShareSources(files) { sources ->
repository.createTargetedTransfer(receiverEndpointId, sources, transferName).getOrThrow()
}
private suspend fun <T> withAndroidShareSources(
files: List<PickedShareFile>,
block: suspend (List<uniffi.vnidrop.ShareSource>) -> T,
): Result<T> = runCatching {
require(files.isNotEmpty()) { "Select at least one file to share" }
// Android cannot pass a directory as a single FD. Expand SAF trees into
// individual document files with relative collection paths, then open FDs.
val expanded = files.flatMap { file ->
if (file.isDirectory) context.expandShareDirectory(file) else listOf(file)
}
require(expanded.isNotEmpty()) { "No files found in the selected folder" }
val descriptors = expanded.map { file ->
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r")
?: error("Could not open selected file descriptor for ${file.displayName}")
}
try {
val sources = expanded.zip(descriptors) { file, descriptor ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR,
value = descriptor.fd.toString(),
displayName = file.displayName,
isDirectory = false,
)
}
block(sources)
} finally {
descriptors.forEach { it.close() }
}
}
/**
/**
* Probe a real create/write/delete instead of [File.canWrite].
*
* Scoped storage often reports public directories as writable even when
* the process cannot create files there. A probe matches what receive needs.
*/
private fun validatePath(path: String): FolderAccessStatus =
private fun Context.validatePath(path: String): FolderAccessStatus =
runCatching {
val directory = File(path)
if (!directory.exists() && !directory.mkdirs()) {
@@ -208,24 +160,24 @@ private class AndroidFileSystemService(
}
}.getOrDefault(FolderAccessStatus.Unavailable)
private fun validatePublicDownloads(): FolderAccessStatus =
private fun Context.validatePublicDownloads(): FolderAccessStatus =
runCatching {
val probeName = ".vnidrop-write-test-${UUID.randomUUID()}"
val sink = AndroidMediaStoreDownloadsSink(context)
val sink = AndroidMediaStoreDownloadsSink(this)
sink.startFile(probeName)
sink.writeChunk(probeName, byteArrayOf(1))
sink.abortFile(probeName, "write probe complete")
FolderAccessStatus.Writable
}.getOrDefault(FolderAccessStatus.Unavailable)
private fun validateTreeUri(value: String): FolderAccessStatus {
private fun Context.validateTreeUri(value: String): FolderAccessStatus {
val uri = Uri.parse(value)
val hasPermission = context.contentResolver.persistedUriPermissions.any { permission ->
val hasPermission = contentResolver.persistedUriPermissions.any { permission ->
permission.uri == uri && permission.isWritePermission
}
if (!hasPermission) return FolderAccessStatus.PermissionRequired
return runCatching {
val probe = AndroidTreeReceiveOutputSink(context, uri)
val probe = AndroidTreeReceiveOutputSink(this, uri)
val probeName = ".vnidrop-write-test-${UUID.randomUUID()}"
probe.startFile(probeName)
probe.writeChunk(probeName, byteArrayOf())
@@ -233,64 +185,6 @@ private class AndroidFileSystemService(
FolderAccessStatus.Writable
}.getOrDefault(FolderAccessStatus.Unavailable)
}
}
/**
* Expand a SAF document tree into individual file documents.
*
* Rust cannot accept a directory FD. Collection paths preserve the folder
* root name so receivers see `Folder/nested/file.txt`.
*/
private fun Context.expandShareDirectory(folder: PickedShareFile): List<PickedShareFile> {
val treeUri = Uri.parse(folder.value)
val rootId = DocumentsContract.getTreeDocumentId(treeUri)
val out = mutableListOf<PickedShareFile>()
fun walk(documentId: String, relativePath: String) {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId)
contentResolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
),
null,
null,
null,
)?.use { cursor ->
val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
val sizeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)
while (cursor.moveToNext()) {
val id = cursor.getString(idIndex) ?: continue
val name = cursor.getString(nameIndex) ?: continue
val mime = cursor.getString(mimeIndex)
val childRelative = if (relativePath.isEmpty()) name else "$relativePath/$name"
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
walk(id, childRelative)
} else {
val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id)
val size = if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
cursor.getLong(sizeIndex).takeIf { it >= 0L }?.toULong()
} else {
null
}
out += PickedShareFile(
value = documentUri.toString(),
displayName = childRelative,
sizeBytes = size,
isDirectory = false,
)
}
}
}
}
// Prefix paths with the folder display name so nested structure is preserved.
walk(rootId, folder.displayName)
return out
}
private inline fun <T> receiveSinkCall(block: () -> T): T =
try {

View File

@@ -0,0 +1,108 @@
package com.vnidrop.app.core
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@Composable
internal actual fun rememberPickedShareSourceAdapter(): PickedShareSourceAdapter {
val context = LocalContext.current.applicationContext
return remember(context) { AndroidPickedShareSourceAdapter(context) }
}
private class AndroidPickedShareSourceAdapter(
private val context: Context,
) : PickedShareSourceAdapter {
override suspend fun <T> withShareSources(
files: List<PickedShareFile>,
operation: suspend (List<uniffi.vnidrop.ShareSource>) -> T,
): Result<T> = runCatching {
require(files.isNotEmpty()) { "Select at least one file to share" }
// Android cannot pass a directory as a single FD. Expand SAF trees into
// individual document files with relative collection paths, then open FDs.
val expanded = files.flatMap { file ->
if (file.isDirectory) context.expandShareDirectory(file) else listOf(file)
}
require(expanded.isNotEmpty()) { "No files found in the selected folder" }
val descriptors = expanded.map { file ->
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r")
?: error("Could not open selected file descriptor for ${file.displayName}")
}
try {
val sources = expanded.zip(descriptors) { file, descriptor ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR,
value = descriptor.fd.toString(),
displayName = file.displayName,
isDirectory = false,
)
}
operation(sources)
} finally {
descriptors.forEach { it.close() }
}
}
// Android's picker returns content owned by the user, so there is no app copy to delete.
override suspend fun discardPickedFiles(files: List<PickedShareFile>) = Unit
}
/**
* Expand a SAF document tree into individual file documents.
*
* Rust cannot accept a directory FD. Collection paths preserve the folder
* root name so receivers see `Folder/nested/file.txt`.
*/
private fun Context.expandShareDirectory(folder: PickedShareFile): List<PickedShareFile> {
val treeUri = Uri.parse(folder.value)
val rootId = DocumentsContract.getTreeDocumentId(treeUri)
val out = mutableListOf<PickedShareFile>()
fun walk(documentId: String, relativePath: String) {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId)
contentResolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
),
null,
null,
null,
)?.use { cursor ->
val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
val sizeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)
while (cursor.moveToNext()) {
val id = cursor.getString(idIndex) ?: continue
val name = cursor.getString(nameIndex) ?: continue
val mime = cursor.getString(mimeIndex)
val childRelative = if (relativePath.isEmpty()) name else "$relativePath/$name"
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
walk(id, childRelative)
} else {
val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id)
val size = if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
cursor.getLong(sizeIndex).takeIf { it >= 0L }?.toULong()
} else {
null
}
out += PickedShareFile(
value = documentUri.toString(),
displayName = childRelative,
sizeBytes = size,
isDirectory = false,
)
}
}
}
}
// Prefix paths with the folder display name so nested structure is preserved.
walk(rootId, folder.displayName)
return out
}

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop startet noch. Öffnen Sie die Einladung gleich erneut.</string>
<string name="error_storage_full">Zum Speichern dieser Übertragung ist nicht genügend Speicherplatz vorhanden. Geben Sie Speicherplatz frei und versuchen Sie es erneut.</string>
<string name="error_transfer">Die Übertragungsdaten konnten nicht verarbeitet werden. Bitten Sie den Absender, sie erneut zu teilen.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Empfängername</string>
<string name="field_sender_name">Absendername</string>
<string name="field_transfer_name">Übertragungsname</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Größe</string>
<string name="metadata_status">Status</string>
<string name="nav_receive">Empfangen</string>
<string name="nav_saved_devices">Geräte</string>
<string name="nav_send">Senden</string>
<string name="nav_settings">Einstellungen</string>
<string name="network_title">Netzwerk</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Eine neue Übertragung erstellen</string>
<string name="send_new_transfer_title">Neue Übertragung</string>
<string name="send_review_title">Übertragung prüfen</string>
<string name="send_default_transfer_name">%1$d Dateien</string>
<string name="send_selected_files_count">%1$d Dateien ausgewählt</string>
<string name="send_stop_sharing">Freigabe beenden</string>
<string name="send_stop_sharing_description">Dies beendet die Übertragung und unterbricht alle, die sie gerade herunterladen. Sie verbleibt in Ihrem Verlauf als „Beendet“.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s möchte Ihnen „%2$s“ senden.</string>
<string name="offer_accept">Empfangen</string>
<string name="offer_decline">Ablehnen</string>
<string name="saved_devices_authenticated_name">Remote-Name: %1$s</string>
<string name="saved_devices_block_confirm_body">%1$s blockieren und künftige Übertragungen gespeicherter Geräte ablehnen?</string>
<string name="saved_devices_block_confirm_title">Gerät blockieren?</string>
<string name="saved_devices_description">Direkt an vertrauenswürdige Geräte senden, ohne eine neue Einladung zu teilen.</string>
<string name="saved_devices_endpoint">Geräte-ID: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">%1$s vergessen? Vor einer weiteren direkten Übertragung müssen beide das Speichern erneut bestätigen.</string>
<string name="saved_devices_forget_confirm_title">Gerät vergessen?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Gespeicherte Geräte konnten nicht geladen werden.</string>
<string name="saved_devices_loading">Gespeicherte Geräte werden geladen…</string>
<string name="saved_devices_more_actions">Weitere Aktionen für %1$s</string>
<string name="saved_devices_no_pending">Keine Kopplungsanfragen benötigen Ihre Aufmerksamkeit.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop todavía se está iniciando. Vuelva a abrir la invitación en un momento.</string>
<string name="error_storage_full">No hay suficiente espacio de almacenamiento para guardar esta transferencia. Libere espacio e inténtelo de nuevo.</string>
<string name="error_transfer">No se pudieron procesar los datos de la transferencia. Pida al remitente que vuelva a compartirlos.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nombre del destinatario</string>
<string name="field_sender_name">Nombre del remitente</string>
<string name="field_transfer_name">Nombre de la transferencia</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Tamaño</string>
<string name="metadata_status">Estado</string>
<string name="nav_receive">Recibir</string>
<string name="nav_saved_devices">Dispositivos</string>
<string name="nav_send">Enviar</string>
<string name="nav_settings">Ajustes</string>
<string name="network_title">Red</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Crear una nueva transferencia</string>
<string name="send_new_transfer_title">Nueva transferencia</string>
<string name="send_review_title">Revisar transferencia</string>
<string name="send_default_transfer_name">%1$d archivos</string>
<string name="send_selected_files_count">%1$d archivos seleccionados</string>
<string name="send_stop_sharing">Dejar de compartir</string>
<string name="send_stop_sharing_description">Esto detiene la transferencia e interrumpe a quien esté descargándola. Permanece en su historial como Detenida.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s quiere enviarte «%2$s».</string>
<string name="offer_accept">Recibir</string>
<string name="offer_decline">Rechazar</string>
<string name="saved_devices_authenticated_name">Nombre remoto: %1$s</string>
<string name="saved_devices_block_confirm_body">¿Bloquear a %1$s y rechazar futuros envíos de dispositivos guardados?</string>
<string name="saved_devices_block_confirm_title">¿Bloquear dispositivo?</string>
<string name="saved_devices_description">Envía directamente a dispositivos de confianza sin compartir otra invitación.</string>
<string name="saved_devices_endpoint">ID del dispositivo: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">¿Olvidar a %1$s? Ambos deberán volver a aprobar el guardado antes de otra transferencia directa.</string>
<string name="saved_devices_forget_confirm_title">¿Olvidar dispositivo?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">No se pudieron cargar los dispositivos guardados.</string>
<string name="saved_devices_loading">Cargando dispositivos guardados…</string>
<string name="saved_devices_more_actions">Más acciones para %1$s</string>
<string name="saved_devices_no_pending">No hay solicitudes de vinculación que requieran tu atención.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop démarre encore. Rouvrez linvitation dans un instant.</string>
<string name="error_storage_full">Lespace de stockage est insuffisant pour enregistrer ce transfert. Libérez de lespace et réessayez.</string>
<string name="error_transfer">Les données du transfert nont pas pu être traitées. Demandez à lexpéditeur de les partager à nouveau.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nom du destinataire</string>
<string name="field_sender_name">Nom de lexpéditeur</string>
<string name="field_transfer_name">Nom du transfert</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Taille</string>
<string name="metadata_status">Statut</string>
<string name="nav_receive">Recevoir</string>
<string name="nav_saved_devices">Appareils</string>
<string name="nav_send">Envoyer</string>
<string name="nav_settings">Réglages</string>
<string name="network_title">Réseau</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Créer un nouveau transfert</string>
<string name="send_new_transfer_title">Nouveau transfert</string>
<string name="send_review_title">Vérifier le transfert</string>
<string name="send_default_transfer_name">%1$d fichiers</string>
<string name="send_selected_files_count">%1$d fichiers sélectionnés</string>
<string name="send_stop_sharing">Arrêter le partage</string>
<string name="send_stop_sharing_description">Cela arrête le transfert et interrompt toute personne en train de le télécharger. Il reste dans votre historique en tant quArrêté.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s souhaite vous envoyer « %2$s ».</string>
<string name="offer_accept">Recevoir</string>
<string name="offer_decline">Refuser</string>
<string name="saved_devices_authenticated_name">Nom distant : %1$s</string>
<string name="saved_devices_block_confirm_body">Bloquer %1$s et refuser les futurs transferts dappareil enregistré ?</string>
<string name="saved_devices_block_confirm_title">Bloquer lappareil ?</string>
<string name="saved_devices_description">Envoyez directement aux appareils de confiance, sans partager une nouvelle invitation.</string>
<string name="saved_devices_endpoint">ID de lappareil : %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">Oublier %1$s ? Vous devrez tous les deux approuver à nouveau lenregistrement avant un autre transfert direct.</string>
<string name="saved_devices_forget_confirm_title">Oublier lappareil ?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Impossible de charger les appareils enregistrés.</string>
<string name="saved_devices_loading">Chargement des appareils enregistrés…</string>
<string name="saved_devices_more_actions">Plus dactions pour %1$s</string>
<string name="saved_devices_no_pending">Aucune demande dassociation ne nécessite votre attention.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop è ancora in fase di avvio. Riapra linvito tra un momento.</string>
<string name="error_storage_full">Lo spazio di archiviazione non è sufficiente per salvare il trasferimento. Liberi spazio e riprovi.</string>
<string name="error_transfer">Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nome del destinatario</string>
<string name="field_sender_name">Nome del mittente</string>
<string name="field_transfer_name">Nome del trasferimento</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Dimensione</string>
<string name="metadata_status">Stato</string>
<string name="nav_receive">Ricevi</string>
<string name="nav_saved_devices">Dispositivi</string>
<string name="nav_send">Invia</string>
<string name="nav_settings">Impostazioni</string>
<string name="network_title">Rete</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Crea un nuovo trasferimento</string>
<string name="send_new_transfer_title">Nuovo trasferimento</string>
<string name="send_review_title">Rivedi trasferimento</string>
<string name="send_default_transfer_name">%1$d file</string>
<string name="send_selected_files_count">%1$d file selezionati</string>
<string name="send_stop_sharing">Interrompi condivisione</string>
<string name="send_stop_sharing_description">Questo interrompe il trasferimento e blocca chiunque lo stia scaricando. Rimane nella sua cronologia come Interrotto.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s vuole inviarti «%2$s».</string>
<string name="offer_accept">Ricevi</string>
<string name="offer_decline">Rifiuta</string>
<string name="saved_devices_authenticated_name">Nome remoto: %1$s</string>
<string name="saved_devices_block_confirm_body">Bloccare %1$s e rifiutare i futuri trasferimenti da dispositivi salvati?</string>
<string name="saved_devices_block_confirm_title">Bloccare il dispositivo?</string>
<string name="saved_devices_description">Invia direttamente ai dispositivi attendibili senza condividere un altro invito.</string>
<string name="saved_devices_endpoint">ID dispositivo: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">Dimenticare %1$s? Entrambi dovrete approvare nuovamente il salvataggio prima di un altro trasferimento diretto.</string>
<string name="saved_devices_forget_confirm_title">Dimenticare il dispositivo?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Impossibile caricare i dispositivi salvati.</string>
<string name="saved_devices_loading">Caricamento dei dispositivi salvati…</string>
<string name="saved_devices_more_actions">Altre azioni per %1$s</string>
<string name="saved_devices_no_pending">Nessuna richiesta di associazione richiede attenzione.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop is nog aan het opstarten. Open de uitnodiging zo meteen opnieuw.</string>
<string name="error_storage_full">Er is onvoldoende opslagruimte om deze overdracht op te slaan. Maak ruimte vrij en probeer het opnieuw.</string>
<string name="error_transfer">De overdrachtsgegevens konden niet worden verwerkt. Vraag de afzender ze opnieuw te delen.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Naam van ontvanger</string>
<string name="field_sender_name">Naam van afzender</string>
<string name="field_transfer_name">Naam van overdracht</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Grootte</string>
<string name="metadata_status">Status</string>
<string name="nav_receive">Ontvangen</string>
<string name="nav_saved_devices">Apparaten</string>
<string name="nav_send">Versturen</string>
<string name="nav_settings">Instellingen</string>
<string name="network_title">Netwerk</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Een nieuwe overdracht aanmaken</string>
<string name="send_new_transfer_title">Nieuwe overdracht</string>
<string name="send_review_title">Overdracht controleren</string>
<string name="send_default_transfer_name">%1$d bestanden</string>
<string name="send_selected_files_count">%1$d bestanden geselecteerd</string>
<string name="send_stop_sharing">Stoppen met delen</string>
<string name="send_stop_sharing_description">Hiermee stopt de overdracht en wordt iedereen die deze op dit moment downloadt onderbroken. De overdracht blijft in uw geschiedenis staan als Gestopt.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s wil je “%2$s” sturen.</string>
<string name="offer_accept">Ontvangen</string>
<string name="offer_decline">Weigeren</string>
<string name="saved_devices_authenticated_name">Externe naam: %1$s</string>
<string name="saved_devices_block_confirm_body">%1$s blokkeren en toekomstige overdrachten van opgeslagen apparaten weigeren?</string>
<string name="saved_devices_block_confirm_title">Apparaat blokkeren?</string>
<string name="saved_devices_description">Stuur rechtstreeks naar vertrouwde apparaten zonder opnieuw een uitnodiging te delen.</string>
<string name="saved_devices_endpoint">Apparaat-ID: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">%1$s vergeten? Jullie moeten het opslaan allebei opnieuw goedkeuren voor een volgende directe overdracht.</string>
<string name="saved_devices_forget_confirm_title">Apparaat vergeten?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Opgeslagen apparaten konden niet worden geladen.</string>
<string name="saved_devices_loading">Opgeslagen apparaten laden…</string>
<string name="saved_devices_more_actions">Meer acties voor %1$s</string>
<string name="saved_devices_no_pending">Er zijn geen koppelverzoeken die aandacht nodig hebben.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop jeszcze się uruchamia. Otwórz zaproszenie ponownie za chwilę.</string>
<string name="error_storage_full">Brakuje miejsca na zapisanie tego transferu. Zwolnij miejsce i spróbuj ponownie.</string>
<string name="error_transfer">Nie udało się przetworzyć danych transferu. Poproś nadawcę o ponowne udostępnienie.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nazwa odbiorcy</string>
<string name="field_sender_name">Nazwa nadawcy</string>
<string name="field_transfer_name">Nazwa transferu</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Rozmiar</string>
<string name="metadata_status">Status</string>
<string name="nav_receive">Odbierz</string>
<string name="nav_saved_devices">Urządzenia</string>
<string name="nav_send">Wyślij</string>
<string name="nav_settings">Ustawienia</string>
<string name="network_title">Sieć</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Utwórz nowy transfer</string>
<string name="send_new_transfer_title">Nowy transfer</string>
<string name="send_review_title">Przejrzyj transfer</string>
<string name="send_default_transfer_name">Pliki: %1$d</string>
<string name="send_selected_files_count">Wybrane pliki: %1$d</string>
<string name="send_stop_sharing">Zatrzymaj udostępnianie</string>
<string name="send_stop_sharing_description">To zatrzymuje transfer i przerywa każdego, kto go właśnie pobiera. Pozostaje w historii jako Zatrzymany.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s chce wysłać Ci „%2$s”.</string>
<string name="offer_accept">Odbierz</string>
<string name="offer_decline">Odrzuć</string>
<string name="saved_devices_authenticated_name">Nazwa zdalna: %1$s</string>
<string name="saved_devices_block_confirm_body">Zablokować %1$s i odrzucać przyszłe transfery z zapisanych urządzeń?</string>
<string name="saved_devices_block_confirm_title">Zablokować urządzenie?</string>
<string name="saved_devices_description">Wysyłaj bezpośrednio do zaufanych urządzeń bez udostępniania kolejnego zaproszenia.</string>
<string name="saved_devices_endpoint">ID urządzenia: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">Zapomnieć %1$s? Przed kolejnym transferem bezpośrednim obie strony muszą ponownie zatwierdzić zapisanie.</string>
<string name="saved_devices_forget_confirm_title">Zapomnieć urządzenie?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Nie udało się wczytać zapisanych urządzeń.</string>
<string name="saved_devices_loading">Wczytywanie zapisanych urządzeń…</string>
<string name="saved_devices_more_actions">Więcej działań dla %1$s</string>
<string name="saved_devices_no_pending">Żadne prośby o sparowanie nie wymagają uwagi.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">O VniDrop ainda está a iniciar. Abra o convite novamente dentro de momentos.</string>
<string name="error_storage_full">Não existe espaço de armazenamento suficiente para guardar esta transferência. Liberte espaço e tente novamente.</string>
<string name="error_transfer">Não foi possível processar os dados da transferência. Peça ao remetente para os partilhar novamente.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Nome do destinatário</string>
<string name="field_sender_name">Nome do remetente</string>
<string name="field_transfer_name">Nome da transferência</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Tamanho</string>
<string name="metadata_status">Estado</string>
<string name="nav_receive">Receber</string>
<string name="nav_saved_devices">Dispositivos</string>
<string name="nav_send">Enviar</string>
<string name="nav_settings">Definições</string>
<string name="network_title">Rede</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Criar uma nova transferência</string>
<string name="send_new_transfer_title">Nova transferência</string>
<string name="send_review_title">Rever transferência</string>
<string name="send_default_transfer_name">%1$d ficheiros</string>
<string name="send_selected_files_count">%1$d ficheiros selecionados</string>
<string name="send_stop_sharing">Parar de partilhar</string>
<string name="send_stop_sharing_description">Isto para a transferência e interrompe quem estiver a descarregá-la. Permanece no seu histórico como Parada.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s quer enviar-lhe “%2$s”.</string>
<string name="offer_accept">Receber</string>
<string name="offer_decline">Recusar</string>
<string name="saved_devices_authenticated_name">Nome remoto: %1$s</string>
<string name="saved_devices_block_confirm_body">Bloquear %1$s e rejeitar futuras transferências de dispositivos guardados?</string>
<string name="saved_devices_block_confirm_title">Bloquear dispositivo?</string>
<string name="saved_devices_description">Envie diretamente para dispositivos de confiança sem partilhar outro convite.</string>
<string name="saved_devices_endpoint">ID do dispositivo: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">Esquecer %1$s? Ambos terão de voltar a aprovar antes de outra transferência direta.</string>
<string name="saved_devices_forget_confirm_title">Esquecer dispositivo?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Não foi possível carregar os dispositivos guardados.</string>
<string name="saved_devices_loading">A carregar dispositivos guardados…</string>
<string name="saved_devices_more_actions">Mais ações para %1$s</string>
<string name="saved_devices_no_pending">Não existem pedidos de emparelhamento a aguardar atenção.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop ещё запускается. Откройте приглашение снова через мгновение.</string>
<string name="error_storage_full">Недостаточно места для сохранения этой передачи. Освободите место и повторите попытку.</string>
<string name="error_transfer">Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Имя получателя</string>
<string name="field_sender_name">Имя отправителя</string>
<string name="field_transfer_name">Название передачи</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Размер</string>
<string name="metadata_status">Статус</string>
<string name="nav_receive">Получить</string>
<string name="nav_saved_devices">Устройства</string>
<string name="nav_send">Отправить</string>
<string name="nav_settings">Настройки</string>
<string name="network_title">Сеть</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Создать новую передачу</string>
<string name="send_new_transfer_title">Новая передача</string>
<string name="send_review_title">Проверить передачу</string>
<string name="send_default_transfer_name">Файлов: %1$d</string>
<string name="send_selected_files_count">Выбрано файлов: %1$d</string>
<string name="send_stop_sharing">Остановить общий доступ</string>
<string name="send_stop_sharing_description">Это остановит передачу и прервёт всех, кто сейчас её загружает. Она останется в вашей истории со статусом «Остановлена».</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s хочет отправить вам «%2$s».</string>
<string name="offer_accept">Получить</string>
<string name="offer_decline">Отклонить</string>
<string name="saved_devices_authenticated_name">Имя устройства: %1$s</string>
<string name="saved_devices_block_confirm_body">Заблокировать %1$s и отклонять будущие передачи с сохранённых устройств?</string>
<string name="saved_devices_block_confirm_title">Заблокировать устройство?</string>
<string name="saved_devices_description">Отправляйте напрямую доверенным устройствам без новой ссылки-приглашения.</string>
<string name="saved_devices_endpoint">ID устройства: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">Забыть %1$s? Перед следующей прямой передачей сохранение снова должны подтвердить обе стороны.</string>
<string name="saved_devices_forget_confirm_title">Забыть устройство?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Не удалось загрузить сохранённые устройства.</string>
<string name="saved_devices_loading">Загрузка сохранённых устройств…</string>
<string name="saved_devices_more_actions">Другие действия для %1$s</string>
<string name="saved_devices_no_pending">Нет запросов на сопряжение, требующих внимания.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -100,9 +100,6 @@
<string name="error_starting_up">VniDrop is still starting. Open the invitation again in a moment.</string>
<string name="error_storage_full">There is not enough storage space to save this transfer. Free up space and try again.</string>
<string name="error_transfer">The transfer data could not be processed. Ask the sender to share it again.</string>
<string name="experimental_saved_devices_description">Remember devices after a transfer and send to them again without a new invitation. Experimental and may change.</string>
<string name="experimental_saved_devices_title">Saved devices</string>
<string name="experimental_settings_title">Experimental</string>
<string name="field_receiver_name">Receiver name</string>
<string name="field_sender_name">Sender name</string>
<string name="field_transfer_name">Transfer name</string>
@@ -115,6 +112,7 @@
<string name="metadata_size">Size</string>
<string name="metadata_status">Status</string>
<string name="nav_receive">Receive</string>
<string name="nav_saved_devices">Devices</string>
<string name="nav_send">Send</string>
<string name="nav_settings">Settings</string>
<string name="network_title">Network</string>
@@ -221,6 +219,7 @@
<string name="send_new_transfer_description">Create a new transfer</string>
<string name="send_new_transfer_title">New transfer</string>
<string name="send_review_title">Review transfer</string>
<string name="send_default_transfer_name">%1$d files</string>
<string name="send_selected_files_count">%1$d files selected</string>
<string name="send_stop_sharing">Stop sharing</string>
<string name="send_stop_sharing_description">This stops the transfer and interrupts anyone currently downloading it. It stays in your history as Stopped.</string>
@@ -320,10 +319,21 @@
<string name="offer_body">%1$s wants to send you “%2$s”.</string>
<string name="offer_accept">Receive</string>
<string name="offer_decline">Decline</string>
<string name="saved_devices_authenticated_name">Remote name: %1$s</string>
<string name="saved_devices_block_confirm_body">Block %1$s and reject future saved-device transfers?</string>
<string name="saved_devices_block_confirm_title">Block device?</string>
<string name="saved_devices_description">Send directly to devices you trust, without sharing another invitation.</string>
<string name="saved_devices_endpoint">Device ID: %1$s</string>
<string name="saved_devices_empty">No saved devices yet. Finish a transfer, then remember a device.</string>
<string name="saved_devices_forget_confirm_body">Forget %1$s? You will both need to approve saving again before another direct transfer.</string>
<string name="saved_devices_forget_confirm_title">Forget device?</string>
<string name="saved_devices_eligibility_title">Ready to remember</string>
<string name="saved_devices_pending_title">Pending pairing</string>
<string name="saved_devices_list_title">Saved devices</string>
<string name="saved_devices_load_failed">Saved devices could not be loaded.</string>
<string name="saved_devices_loading">Loading saved devices…</string>
<string name="saved_devices_more_actions">More actions for %1$s</string>
<string name="saved_devices_no_pending">No pairing requests need your attention.</string>
<string name="saved_devices_unnamed">Saved device</string>
<string name="saved_devices_remember_action">Remember</string>
<string name="saved_devices_decline_action">Decline</string>

View File

@@ -38,11 +38,14 @@ import com.vnidrop.app.feature.receive.ReceiveFloatingAction
import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.receive.ReceiveMethod
import com.vnidrop.app.feature.saveddevices.PairingPromptHost
import com.vnidrop.app.feature.saveddevices.SavedDevicesRoute
import com.vnidrop.app.feature.saveddevices.SavedDevicesViewModel
import com.vnidrop.app.feature.saveddevices.TargetedOfferModalHost
import com.vnidrop.app.feature.send.SendRoute
import com.vnidrop.app.feature.send.SendFloatingAction
import com.vnidrop.app.feature.send.SendViewModel
import com.vnidrop.app.feature.send.TransferDraftViewModel
import com.vnidrop.app.feature.send.TransferDraftHost
import com.vnidrop.app.feature.settings.SettingsRoute
import com.vnidrop.app.feature.settings.SettingsViewModel
import com.vnidrop.app.platform.PlatformSystemAppearance
@@ -60,10 +63,12 @@ import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.app_starting
import com.vnidrop.app.core.rememberPickedShareSourceAdapter
@Composable
fun App(
@@ -76,6 +81,7 @@ fun App(
) {
val graphHolder = viewModel { AppGraphViewModel(dependencies) }
val graph = graphHolder.graph
val sourceAdapter = rememberPickedShareSourceAdapter()
val appViewModel = viewModel {
AppViewModel(
@@ -88,8 +94,22 @@ fun App(
val sendViewModel = viewModel {
SendViewModel(
graph.coreRepository,
dependencies.fileSystemService,
graph.preferencesRepository,
graph.filePreviewRepository,
graph.messages,
)
}
val invitationDraftViewModel = viewModel(key = "invitation-transfer-draft") {
TransferDraftViewModel(
graph.coreRepository,
sourceAdapter,
graph.filePreviewRepository,
graph.messages,
)
}
val targetedDraftViewModel = viewModel(key = "targeted-transfer-draft") {
TransferDraftViewModel(
graph.coreRepository,
sourceAdapter,
graph.filePreviewRepository,
graph.messages,
)
@@ -112,8 +132,6 @@ fun App(
val savedDevicesViewModel = viewModel {
SavedDevicesViewModel(
graph.coreRepository,
dependencies.fileSystemService,
graph.preferencesRepository,
graph.messages,
)
}
@@ -125,6 +143,9 @@ fun App(
val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle()
val pairingPromptState by graph.pairingPromptCoordinator.state.collectAsStateWithLifecycle()
val targetedOfferState by graph.targetedOfferCoordinator.state.collectAsStateWithLifecycle()
val username by graph.preferencesRepository.preferences
.map { it.username }
.collectAsStateWithLifecycle(initialValue = dependencies.environment.defaultUsername)
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(dependencies.externalInvitations, appViewModel, receiveViewModel) {
dependencies.externalInvitations.invitations.collect { invitation ->
@@ -212,7 +233,7 @@ fun App(
floatingAction = if (showSendAction) {
{
SendFloatingAction(
onClick = sendViewModel::openComposer,
onClick = { invitationDraftViewModel.openInvitation(username) },
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
@@ -228,10 +249,15 @@ fun App(
},
) {
when (appState.destination) {
AppDestination.Send -> SendRoute(sendViewModel, windowClass)
AppDestination.Send -> SendRoute(sendViewModel, invitationDraftViewModel, username, windowClass)
AppDestination.Receive -> ReceiveRoute(receiveViewModel, windowClass)
AppDestination.SavedDevices -> SavedDevicesRoute(
savedDevicesViewModel,
targetedDraftViewModel,
windowClass,
)
AppDestination.Settings -> ScreenScrollContainer {
SettingsRoute(settingsViewModel, savedDevicesViewModel, windowClass)
SettingsRoute(settingsViewModel, windowClass)
}
}
}
@@ -251,6 +277,7 @@ fun App(
onAccept = graph.targetedOfferCoordinator::accept,
onDecline = graph.targetedOfferCoordinator::decline,
)
TransferDraftHost(targetedDraftViewModel, windowClass, onCreated = {})
}
windowChrome?.invoke()
val startingLabel = stringResource(Res.string.app_starting)

View File

@@ -62,7 +62,6 @@ class AppGraph(
)
val pairingPromptCoordinator = PairingPromptCoordinator(
repository = coreRepository,
preferencesRepository = preferencesRepository,
messages = messages,
scope = applicationScope,
)

View File

@@ -14,14 +14,6 @@ enum class UiPlatform {
val UiPlatform.isDesktop: Boolean
get() = this != UiPlatform.Android
/** Experimental saved-devices Settings chrome for Android + Windows/Linux Compose. */
fun showsExperimentalSavedDevices(uiPlatform: UiPlatform): Boolean = when (uiPlatform) {
UiPlatform.Android,
UiPlatform.Windows,
UiPlatform.Linux -> true
UiPlatform.Desktop -> false
}
data class PlatformEnvironment(
val name: String,
val appVersion: String,

View File

@@ -197,7 +197,7 @@ interface CoreGateway {
suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null): Result<Unit>
suspend fun refresh(): Result<Unit>
// Experimental saved devices / targeted transfers
// Saved devices / targeted transfers
suspend fun listPairingEligibilities(): Result<List<PairingEligibilityModel>>
suspend fun declinePairingEligibility(peerEndpointId: String): Result<Unit>
suspend fun requestSavedDevicePairing(peerEndpointId: String): Result<Boolean>

View File

@@ -589,6 +589,7 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod
private fun PairingEligibilitySummary.toModel(): PairingEligibilityModel = PairingEligibilityModel(
peerEndpointId = peerEndpointId,
remoteDisplayName = remoteDisplayName,
sessionId = sessionId,
protocolVersion = protocolVersion,
createdAt = createdAt,
@@ -638,6 +639,7 @@ private fun TargetedTransfer.toModel(): TargetedTransferModel = TargetedTransfer
senderEndpointId = senderEndpointId,
receiverEndpointId = receiverEndpointId,
manifestId = manifestId,
transferName = transferName,
fileCount = fileCount,
totalSize = totalSize,
verifiedBytes = verifiedBytes,

View File

@@ -47,31 +47,6 @@ interface FileSystemService {
fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false
suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> =
Result.failure(UnsupportedOperationException("Revealing the receive folder is not supported"))
/** Releases only app-owned picker copies; implementations must never delete original user sources. */
suspend fun discardPickedFiles(files: List<PickedShareFile>) = Unit
suspend fun sharePickedFile(
repository: CoreGateway,
file: PickedShareFile,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> = sharePickedFiles(repository, listOf(file), transferName, senderName, accessPolicy)
suspend fun sharePickedFiles(
repository: CoreGateway,
files: List<PickedShareFile>,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share>
/** Builds platform share sources and creates a targeted transfer to a saved device. */
suspend fun createTargetedTransferFromPickedFiles(
repository: CoreGateway,
receiverEndpointId: String,
files: List<PickedShareFile>,
transferName: String?,
): Result<TargetedTransferModel>
}
@Composable

View File

@@ -0,0 +1,17 @@
package com.vnidrop.app.core
import androidx.compose.runtime.Composable
internal interface PickedShareSourceAdapter {
/** Keeps platform descriptors and leases valid until [operation] returns. */
suspend fun <T> withShareSources(
files: List<PickedShareFile>,
operation: suspend (List<uniffi.vnidrop.ShareSource>) -> T,
): Result<T>
/** Releases only app-owned picker copies; implementations must never delete original user sources. */
suspend fun discardPickedFiles(files: List<PickedShareFile>) = Unit
}
@Composable
internal expect fun rememberPickedShareSourceAdapter(): PickedShareSourceAdapter

View File

@@ -1,9 +1,9 @@
package com.vnidrop.app.core
/**
* App-facing models for experimental saved devices and targeted transfers.
* Maps UniFFI types; features must not depend on `uniffi.vnidrop` for these flows
* except share sources / output sinks already used by invitation receive.
* App-facing models for saved devices and targeted transfers. Maps UniFFI types;
* features must not depend on `uniffi.vnidrop` for these flows except share
* sources / output sinks already used by invitation receive.
*/
data class SavedDeviceModel(
@@ -33,6 +33,7 @@ data class DeviceRelationshipModel(
data class PairingEligibilityModel(
val peerEndpointId: String,
val remoteDisplayName: String?,
val sessionId: String,
val protocolVersion: UShort,
val createdAt: Long,
@@ -72,6 +73,7 @@ data class TargetedTransferModel(
val senderEndpointId: String,
val receiverEndpointId: String,
val manifestId: String,
val transferName: String,
val fileCount: ULong,
val totalSize: ULong,
val verifiedBytes: ULong,

View File

@@ -3,14 +3,11 @@ package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
@@ -18,14 +15,13 @@ import kotlinx.coroutines.launch
sealed interface PairingPrompt {
/** Local eligibility after a completed invitation transfer — user may remember the peer. */
data class Eligibility(val peerEndpointId: String) : PairingPrompt
data class Eligibility(val peerEndpointId: String, val remoteDisplayName: String?) : PairingPrompt
/** Peer requested pairing; user may accept or decline mutual consent. */
data class IncomingRequest(val peerEndpointId: String) : PairingPrompt
data class IncomingRequest(val peerEndpointId: String, val remoteDisplayName: String?) : PairingPrompt
}
data class PairingPromptState(
val enabled: Boolean = false,
val prompt: PairingPrompt? = null,
val busy: Boolean = false,
)
@@ -36,7 +32,6 @@ data class PairingPromptState(
*/
class PairingPromptCoordinator(
private val repository: CoreGateway,
private val preferencesRepository: PreferencesRepository,
private val messages: UiMessageController,
private val scope: CoroutineScope,
) {
@@ -48,26 +43,17 @@ class PairingPromptCoordinator(
init {
scope.launch {
// Preferences can emit before AppViewModel finishes core initialize.
// Hitting the gateway then surfaces "Initialize the core first" snackbars.
combine(
preferencesRepository.preferences.map { it.experimentalSavedDevicesEnabled },
repository.state.map { it.isInitialized },
) { enabled, initialized -> enabled to initialized }
repository.state.map { it.isInitialized }
.distinctUntilChanged()
.collectLatest { (enabled, initialized) ->
_state.update { it.copy(enabled = enabled) }
when {
enabled && initialized -> refresh()
!enabled -> _state.update { it.copy(prompt = null, busy = false) }
}
.collect { initialized ->
if (initialized) refresh()
}
}
scope.launch {
repository.signals.collect { signal ->
when (signal) {
CoreSignal.PairingChanged -> {
if (_state.value.enabled && repository.state.value.isInitialized) refresh()
if (repository.state.value.isInitialized) refresh()
}
is CoreSignal.ApprovalChanged,
is CoreSignal.ReceiverHistoryChanged,
@@ -124,15 +110,27 @@ class PairingPromptCoordinator(
}
private suspend fun refresh() {
if (!_state.value.enabled || _state.value.busy) return
if (_state.value.busy) return
val relationships = repository.listDeviceRelationships().getOrElse {
messages.error(it)
return
}
val incoming = relationships.firstOrNull { it.state == DeviceRelationshipStateModel.PendingIncoming }
if (incoming != null) {
val remoteDisplayName = repository.listPairingEligibilities()
.getOrElse {
messages.error(it)
emptyList()
}
.firstOrNull { eligibility -> eligibility.peerEndpointId == incoming.remoteEndpointId }
?.remoteDisplayName
_state.update {
it.copy(prompt = PairingPrompt.IncomingRequest(incoming.remoteEndpointId))
it.copy(
prompt = PairingPrompt.IncomingRequest(
incoming.remoteEndpointId,
remoteDisplayName,
),
)
}
return
}
@@ -142,7 +140,11 @@ class PairingPromptCoordinator(
}
val eligibility = eligibilities.firstOrNull { it.peerEndpointId !in dismissedEligibility }
_state.update {
it.copy(prompt = eligibility?.let { row -> PairingPrompt.Eligibility(row.peerEndpointId) })
it.copy(
prompt = eligibility?.let { row ->
PairingPrompt.Eligibility(row.peerEndpointId, row.remoteDisplayName)
},
)
}
}
}

View File

@@ -44,10 +44,10 @@ fun PairingPromptHost(
onDecline: () -> Unit,
onDismiss: () -> Unit,
) {
if (!state.enabled) return
val prompt = state.prompt ?: return
val colors = LocalVniDropColors.current
val deviceLabel = shortDeviceLabel(prompt.peerEndpointId())
val deviceLabel = prompt.remoteDisplayName()?.takeIf(String::isNotBlank)
?: shortDeviceLabel(prompt.peerEndpointId())
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
@@ -152,5 +152,10 @@ private fun PairingPrompt.peerEndpointId(): String = when (this) {
is PairingPrompt.IncomingRequest -> peerEndpointId
}
private fun PairingPrompt.remoteDisplayName(): String? = when (this) {
is PairingPrompt.Eligibility -> remoteDisplayName
is PairingPrompt.IncomingRequest -> remoteDisplayName
}
private fun shortDeviceLabel(endpointId: String): String =
if (endpointId.length <= 12) endpointId else endpointId.take(8) + ""

View File

@@ -1,250 +0,0 @@
package com.vnidrop.app.feature.saveddevices
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_cancel
import vnidrop.shared.generated.resources.saved_devices_accept_pairing_action
import vnidrop.shared.generated.resources.saved_devices_block_action
import vnidrop.shared.generated.resources.saved_devices_decline_action
import vnidrop.shared.generated.resources.saved_devices_eligibility_title
import vnidrop.shared.generated.resources.saved_devices_empty
import vnidrop.shared.generated.resources.saved_devices_forget_action
import vnidrop.shared.generated.resources.saved_devices_label_action
import vnidrop.shared.generated.resources.saved_devices_label_clear
import vnidrop.shared.generated.resources.saved_devices_label_placeholder
import vnidrop.shared.generated.resources.saved_devices_label_save
import vnidrop.shared.generated.resources.saved_devices_label_title
import vnidrop.shared.generated.resources.saved_devices_list_title
import vnidrop.shared.generated.resources.saved_devices_pending_incoming
import vnidrop.shared.generated.resources.saved_devices_pending_outgoing
import vnidrop.shared.generated.resources.saved_devices_pending_title
import vnidrop.shared.generated.resources.saved_devices_remember_action
import vnidrop.shared.generated.resources.saved_devices_send_action
import vnidrop.shared.generated.resources.saved_devices_unnamed
@Composable
fun SavedDevicesPanel(
state: SavedDevicesState,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
onLabelDraftChanged: (String) -> Unit,
onSaveLabel: () -> Unit,
onClearLabel: () -> Unit,
onDismissLabel: () -> Unit,
) {
if (!state.enabled) return
val colors = LocalVniDropColors.current
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (state.eligibilities.isNotEmpty()) {
Text(
stringResource(Res.string.saved_devices_eligibility_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
PanelGroup {
state.eligibilities.forEach { eligibility ->
val busy = eligibility.peerEndpointId in state.busyPeerIds
Column(
Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(shortEndpoint(eligibility.peerEndpointId), style = MaterialTheme.typography.bodyLarge)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PrimaryButton(
stringResource(Res.string.saved_devices_remember_action),
{ onRememberEligible(eligibility.peerEndpointId) },
enabled = !busy,
)
SecondaryButton(
stringResource(Res.string.saved_devices_decline_action),
{ onDeclineEligible(eligibility.peerEndpointId) },
enabled = !busy,
)
}
}
}
}
}
if (state.pendingRelationships.isNotEmpty()) {
Text(
stringResource(Res.string.saved_devices_pending_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
PanelGroup {
state.pendingRelationships.forEach { relationship ->
val busy = relationship.remoteEndpointId in state.busyPeerIds
Column(
Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(shortEndpoint(relationship.remoteEndpointId), style = MaterialTheme.typography.bodyLarge)
Text(
stringResource(
when (relationship.state) {
DeviceRelationshipStateModel.PendingIncoming ->
Res.string.saved_devices_pending_incoming
else -> Res.string.saved_devices_pending_outgoing
},
),
style = MaterialTheme.typography.bodySmall,
color = colors.foregroundLighter,
)
if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PrimaryButton(
stringResource(Res.string.saved_devices_accept_pairing_action),
{ onAcceptIncoming(relationship.remoteEndpointId) },
enabled = !busy,
)
SecondaryButton(
stringResource(Res.string.saved_devices_decline_action),
{ onDeclineIncoming(relationship.remoteEndpointId) },
enabled = !busy,
)
}
}
}
}
}
}
Text(
stringResource(Res.string.saved_devices_list_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
if (state.savedDevices.isEmpty() && state.eligibilities.isEmpty() && state.pendingRelationships.isEmpty()) {
Text(
stringResource(Res.string.saved_devices_empty),
style = MaterialTheme.typography.bodyMedium,
color = colors.foregroundLight,
)
} else if (state.savedDevices.isNotEmpty()) {
PanelGroup {
state.savedDevices.forEach { device ->
SavedDeviceRow(
device = device,
busy = device.endpointId in state.busyPeerIds || state.isSending,
onSend = { onSend(device.endpointId) },
onLabel = { onOpenLabel(device.endpointId) },
onForget = { onForget(device.endpointId) },
onBlock = { onBlock(device.endpointId) },
)
}
}
}
}
val labelingPeerId = state.labelingPeerId
if (labelingPeerId != null) {
AlertDialog(
onDismissRequest = onDismissLabel,
title = { Text(stringResource(Res.string.saved_devices_label_title)) },
text = {
OutlinedTextField(
value = state.labelDraft,
onValueChange = onLabelDraftChanged,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text(stringResource(Res.string.saved_devices_label_placeholder)) },
)
},
confirmButton = {
TextButton(onClick = onSaveLabel) {
Text(stringResource(Res.string.saved_devices_label_save))
}
},
dismissButton = {
Row {
TextButton(onClick = onClearLabel) {
Text(stringResource(Res.string.saved_devices_label_clear))
}
TextButton(onClick = onDismissLabel) {
Text(stringResource(Res.string.button_cancel))
}
}
},
)
}
}
@Composable
private fun PanelGroup(content: @Composable ColumnScope.() -> Unit) {
val colors = LocalVniDropColors.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
border = BorderStroke(1.dp, colors.borderDefault.copy(alpha = 0.72f)),
content = { Column(content = content) },
)
}
@Composable
private fun SavedDeviceRow(
device: SavedDeviceModel,
busy: Boolean,
onSend: () -> Unit,
onLabel: () -> Unit,
onForget: () -> Unit,
onBlock: () -> Unit,
) {
val colors = LocalVniDropColors.current
val title = device.localLabel?.takeIf { it.isNotBlank() }
?: device.remoteDisplayName?.takeIf { it.isNotBlank() }
?: stringResource(Res.string.saved_devices_unnamed)
Column(
Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
Text(
shortEndpoint(device.endpointId),
style = MaterialTheme.typography.bodySmall,
color = colors.foregroundLighter,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PrimaryButton(stringResource(Res.string.saved_devices_send_action), onSend, enabled = !busy)
SecondaryButton(stringResource(Res.string.saved_devices_label_action), onLabel, enabled = !busy)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SecondaryButton(stringResource(Res.string.saved_devices_forget_action), onForget, enabled = !busy)
SecondaryButton(stringResource(Res.string.saved_devices_block_action), onBlock, enabled = !busy)
}
}
}
private fun shortEndpoint(endpointId: String): String =
if (endpointId.length <= 16) endpointId else endpointId.take(12) + ""

View File

@@ -0,0 +1,40 @@
package com.vnidrop.app.feature.saveddevices
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.feature.send.TransferDraftViewModel
import com.vnidrop.app.ui.state.WindowClass
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.saved_devices_unnamed
@Composable
internal fun SavedDevicesRoute(
viewModel: SavedDevicesViewModel,
targetedDraftViewModel: TransferDraftViewModel,
windowClass: WindowClass,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val unnamedDeviceName = stringResource(Res.string.saved_devices_unnamed)
SavedDevicesScreen(
state = state,
windowClass = windowClass,
onRetry = viewModel::retry,
onRememberEligible = viewModel::rememberEligible,
onDeclineEligible = viewModel::declineEligible,
onAcceptIncoming = viewModel::acceptIncoming,
onDeclineIncoming = viewModel::declineIncoming,
onSend = { peerEndpointId ->
state.savedDevices.firstOrNull { it.endpointId == peerEndpointId }
?.let { targetedDraftViewModel.openTargeted(it, unnamedDeviceName) }
},
onOpenLabel = viewModel::openLabelEditor,
onForget = viewModel::forget,
onBlock = viewModel::block,
onLabelDraftChanged = viewModel::setLabelDraft,
onSaveLabel = viewModel::saveLabel,
onClearLabel = viewModel::clearLabel,
onDismissLabel = viewModel::dismissLabelEditor,
)
}

View File

@@ -0,0 +1,666 @@
package com.vnidrop.app.feature.saveddevices
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.DeviceRelationshipModel
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.icons.AppIcon
import com.vnidrop.app.ui.icons.PlatformIcon
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_cancel
import vnidrop.shared.generated.resources.button_retry
import vnidrop.shared.generated.resources.saved_devices_accept_pairing_action
import vnidrop.shared.generated.resources.saved_devices_authenticated_name
import vnidrop.shared.generated.resources.saved_devices_block_action
import vnidrop.shared.generated.resources.saved_devices_block_confirm_body
import vnidrop.shared.generated.resources.saved_devices_block_confirm_title
import vnidrop.shared.generated.resources.saved_devices_decline_action
import vnidrop.shared.generated.resources.saved_devices_description
import vnidrop.shared.generated.resources.saved_devices_eligibility_title
import vnidrop.shared.generated.resources.saved_devices_empty
import vnidrop.shared.generated.resources.saved_devices_endpoint
import vnidrop.shared.generated.resources.saved_devices_forget_action
import vnidrop.shared.generated.resources.saved_devices_forget_confirm_body
import vnidrop.shared.generated.resources.saved_devices_forget_confirm_title
import vnidrop.shared.generated.resources.saved_devices_label_action
import vnidrop.shared.generated.resources.saved_devices_label_clear
import vnidrop.shared.generated.resources.saved_devices_label_placeholder
import vnidrop.shared.generated.resources.saved_devices_label_save
import vnidrop.shared.generated.resources.saved_devices_label_title
import vnidrop.shared.generated.resources.saved_devices_list_title
import vnidrop.shared.generated.resources.saved_devices_load_failed
import vnidrop.shared.generated.resources.saved_devices_loading
import vnidrop.shared.generated.resources.saved_devices_more_actions
import vnidrop.shared.generated.resources.saved_devices_no_pending
import vnidrop.shared.generated.resources.saved_devices_pending_incoming
import vnidrop.shared.generated.resources.saved_devices_pending_outgoing
import vnidrop.shared.generated.resources.saved_devices_pending_title
import vnidrop.shared.generated.resources.saved_devices_remember_action
import vnidrop.shared.generated.resources.saved_devices_send_action
import vnidrop.shared.generated.resources.saved_devices_unnamed
@Composable
internal fun SavedDevicesScreen(
state: SavedDevicesState,
windowClass: WindowClass,
modifier: Modifier = Modifier,
onRetry: () -> Unit,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
onLabelDraftChanged: (String) -> Unit,
onSaveLabel: () -> Unit,
onClearLabel: () -> Unit,
onDismissLabel: () -> Unit,
) {
val hasContent = state.eligibilities.isNotEmpty() || state.pendingRelationships.isNotEmpty() || state.savedDevices.isNotEmpty()
Column(
modifier = modifier
.fillMaxSize()
.statusBarsPadding()
.padding(top = 20.dp),
) {
SavedDevicesHeader(Modifier.padding(horizontal = if (windowClass == WindowClass.Desktop) 24.dp else 16.dp))
Spacer(Modifier.height(16.dp))
when {
state.isLoading && !hasContent -> SavedDevicesLoading(Modifier.weight(1f))
state.loadFailed && !hasContent -> SavedDevicesLoadFailure(onRetry, Modifier.weight(1f))
windowClass == WindowClass.Desktop -> DesktopSavedDevicesContent(
state = state,
onRetry = onRetry,
onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming,
onSend = onSend,
onOpenLabel = onOpenLabel,
onForget = onForget,
onBlock = onBlock,
)
else -> CompactSavedDevicesContent(
state = state,
onRetry = onRetry,
onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming,
onSend = onSend,
onOpenLabel = onOpenLabel,
onForget = onForget,
onBlock = onBlock,
)
}
}
SavedDeviceLabelDialog(
visible = state.labelingPeerId != null,
label = state.labelDraft,
onLabelChanged = onLabelDraftChanged,
onSave = onSaveLabel,
onClear = onClearLabel,
onDismiss = onDismissLabel,
)
}
@Composable
private fun SavedDevicesHeader(modifier: Modifier = Modifier) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = stringResource(Res.string.saved_devices_list_title),
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.semantics { heading() },
)
Text(
text = stringResource(Res.string.saved_devices_description),
style = MaterialTheme.typography.bodyLarge,
color = LocalVniDropColors.current.foregroundLight,
)
}
}
@Composable
private fun CompactSavedDevicesContent(
state: SavedDevicesState,
onRetry: () -> Unit,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (state.isLoading) item(key = "loading") { LinearProgressIndicator(Modifier.fillMaxWidth()) }
if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) }
pairingItems(
state = state,
onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming,
)
item(key = "saved-title") { SectionTitle(stringResource(Res.string.saved_devices_list_title)) }
if (state.savedDevices.isEmpty()) {
item(key = "empty") { SavedDevicesEmptyCard() }
} else {
items(state.savedDevices, key = { "saved-${it.endpointId}" }) { device ->
SavedDeviceCard(
device = device,
busy = device.endpointId in state.busyPeerIds,
onSend = { onSend(device.endpointId) },
onLabel = { onOpenLabel(device.endpointId) },
onForget = { onForget(device.endpointId) },
onBlock = { onBlock(device.endpointId) },
)
}
}
}
}
@Composable
private fun DesktopSavedDevicesContent(
state: SavedDevicesState,
onRetry: () -> Unit,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onSend: (String) -> Unit,
onOpenLabel: (String) -> Unit,
onForget: (String) -> Unit,
onBlock: (String) -> Unit,
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
LazyColumn(
modifier = Modifier.weight(0.8f).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp),
) {
if (state.isLoading) item(key = "loading") { LinearProgressIndicator(Modifier.fillMaxWidth()) }
if (state.loadFailed) item(key = "load-failed") { InlineLoadFailure(onRetry) }
pairingItems(
state = state,
onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming,
)
if (state.eligibilities.isEmpty() && state.pendingRelationships.isEmpty()) {
item(key = "no-attention") {
DesktopStatusCard()
}
}
}
LazyColumn(
modifier = Modifier.weight(1.2f).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp),
) {
item(key = "saved-title") { SectionTitle(stringResource(Res.string.saved_devices_list_title)) }
if (state.savedDevices.isEmpty()) {
item(key = "empty") { SavedDevicesEmptyCard() }
} else {
items(state.savedDevices, key = { "saved-${it.endpointId}" }) { device ->
SavedDeviceCard(
device = device,
busy = device.endpointId in state.busyPeerIds,
onSend = { onSend(device.endpointId) },
onLabel = { onOpenLabel(device.endpointId) },
onForget = { onForget(device.endpointId) },
onBlock = { onBlock(device.endpointId) },
)
}
}
}
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.pairingItems(
state: SavedDevicesState,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
) {
if (state.eligibilities.isNotEmpty()) {
item(key = "eligibility-title") {
SectionTitle(stringResource(Res.string.saved_devices_eligibility_title))
}
items(state.eligibilities, key = { "eligibility-${it.peerEndpointId}" }) { eligibility ->
EligibilityCard(
eligibility = eligibility,
busy = eligibility.peerEndpointId in state.busyPeerIds,
onRemember = { onRememberEligible(eligibility.peerEndpointId) },
onDecline = { onDeclineEligible(eligibility.peerEndpointId) },
)
}
}
if (state.pendingRelationships.isNotEmpty()) {
item(key = "pending-title") {
SectionTitle(stringResource(Res.string.saved_devices_pending_title))
}
items(state.pendingRelationships, key = { "pending-${it.remoteEndpointId}" }) { relationship ->
PendingPairingCard(
relationship = relationship,
remoteDisplayName = state.eligibilities
.firstOrNull { it.peerEndpointId == relationship.remoteEndpointId }
?.remoteDisplayName,
busy = relationship.remoteEndpointId in state.busyPeerIds,
onAccept = { onAcceptIncoming(relationship.remoteEndpointId) },
onDecline = { onDeclineIncoming(relationship.remoteEndpointId) },
)
}
}
}
@Composable
private fun SectionTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp).semantics { heading() },
)
}
@Composable
private fun EligibilityCard(
eligibility: PairingEligibilityModel,
busy: Boolean,
onRemember: () -> Unit,
onDecline: () -> Unit,
) {
PairingCard(
name = eligibility.remoteDisplayName,
endpointId = eligibility.peerEndpointId,
status = stringResource(Res.string.saved_devices_eligibility_title),
busy = busy,
actions = {
PrimaryButton(stringResource(Res.string.saved_devices_remember_action), onRemember, enabled = !busy)
SecondaryButton(stringResource(Res.string.saved_devices_decline_action), onDecline, enabled = !busy)
},
)
}
@Composable
private fun PendingPairingCard(
relationship: DeviceRelationshipModel,
remoteDisplayName: String?,
busy: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
) {
PairingCard(
name = remoteDisplayName,
endpointId = relationship.remoteEndpointId,
status = stringResource(
when (relationship.state) {
DeviceRelationshipStateModel.PendingIncoming -> Res.string.saved_devices_pending_incoming
else -> Res.string.saved_devices_pending_outgoing
},
),
busy = busy,
actions = if (relationship.state == DeviceRelationshipStateModel.PendingIncoming) {
{
PrimaryButton(stringResource(Res.string.saved_devices_accept_pairing_action), onAccept, enabled = !busy)
SecondaryButton(stringResource(Res.string.saved_devices_decline_action), onDecline, enabled = !busy)
}
} else {
null
},
)
}
@Composable
private fun PairingCard(
name: String?,
endpointId: String,
status: String,
busy: Boolean,
actions: (@Composable RowScope.() -> Unit)?,
) {
val colors = LocalVniDropColors.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
border = BorderStroke(1.dp, colors.borderDefault.copy(alpha = 0.72f)),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
PlatformIcon(AppIcon.Shield, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(24.dp))
Column(Modifier.weight(1f)) {
Text(
name?.takeIf(String::isNotBlank) ?: stringResource(Res.string.saved_devices_unnamed),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(status, style = MaterialTheme.typography.bodyMedium, color = colors.foregroundLight)
}
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
}
DiagnosticEndpoint(endpointId)
if (actions != null) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), content = actions)
}
}
}
}
private enum class DeviceDestructiveAction { Forget, Block }
@Composable
private fun SavedDeviceCard(
device: SavedDeviceModel,
busy: Boolean,
onSend: () -> Unit,
onLabel: () -> Unit,
onForget: () -> Unit,
onBlock: () -> Unit,
) {
val colors = LocalVniDropColors.current
val title = device.displayName()
var menuExpanded by remember(device.endpointId) { mutableStateOf(false) }
var pendingAction by remember(device.endpointId) { mutableStateOf<DeviceDestructiveAction?>(null) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
border = BorderStroke(1.dp, colors.borderDefault.copy(alpha = 0.72f)),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(contentAlignment = Alignment.Center) {
Card(
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSelection),
) {
PlatformIcon(
AppIcon.ShieldCheck,
contentDescription = null,
tint = colors.brandLink,
modifier = Modifier.padding(10.dp).size(24.dp),
)
}
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
device.remoteDisplayName
?.takeIf { device.localLabel?.isNotBlank() == true && it.isNotBlank() }
?.let { authenticatedName ->
Text(
stringResource(Res.string.saved_devices_authenticated_name, authenticatedName),
style = MaterialTheme.typography.bodySmall,
color = colors.foregroundLight,
)
}
}
if (busy) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Box {
val moreLabel = stringResource(Res.string.saved_devices_more_actions, title)
IconButton(onClick = { menuExpanded = true }) {
PlatformIcon(AppIcon.MoreVertical, contentDescription = moreLabel)
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.saved_devices_label_action)) },
onClick = { menuExpanded = false; onLabel() },
leadingIcon = { PlatformIcon(AppIcon.User, contentDescription = null) },
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.saved_devices_forget_action)) },
onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Forget },
leadingIcon = { PlatformIcon(AppIcon.UserOff, contentDescription = null) },
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.saved_devices_block_action)) },
onClick = { menuExpanded = false; pendingAction = DeviceDestructiveAction.Block },
leadingIcon = { PlatformIcon(AppIcon.Lock, contentDescription = null) },
)
}
}
}
}
DiagnosticEndpoint(device.endpointId)
PrimaryButton(
text = stringResource(Res.string.saved_devices_send_action),
onClick = onSend,
modifier = Modifier.fillMaxWidth(),
enabled = !busy,
leadingIcon = { PlatformIcon(AppIcon.Send, contentDescription = null, modifier = Modifier.size(18.dp)) },
)
}
}
pendingAction?.let { action ->
val isBlock = action == DeviceDestructiveAction.Block
AlertDialog(
onDismissRequest = { pendingAction = null },
title = {
Text(stringResource(if (isBlock) Res.string.saved_devices_block_confirm_title else Res.string.saved_devices_forget_confirm_title))
},
text = {
Text(
stringResource(
if (isBlock) Res.string.saved_devices_block_confirm_body else Res.string.saved_devices_forget_confirm_body,
title,
),
)
},
confirmButton = {
TextButton(
onClick = {
pendingAction = null
if (isBlock) onBlock() else onForget()
},
) {
Text(stringResource(if (isBlock) Res.string.saved_devices_block_action else Res.string.saved_devices_forget_action))
}
},
dismissButton = {
TextButton(onClick = { pendingAction = null }) {
Text(stringResource(Res.string.button_cancel))
}
},
)
}
}
@Composable
private fun DiagnosticEndpoint(endpointId: String) {
Text(
text = stringResource(Res.string.saved_devices_endpoint, shortEndpoint(endpointId)),
style = MaterialTheme.typography.bodySmall,
color = LocalVniDropColors.current.foregroundLighter,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun SavedDevicesEmptyCard() {
val colors = LocalVniDropColors.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
PlatformIcon(AppIcon.ShieldCheck, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(32.dp))
Text(
stringResource(Res.string.saved_devices_empty),
style = MaterialTheme.typography.bodyLarge,
color = colors.foregroundLight,
)
}
}
}
@Composable
private fun DesktopStatusCard() {
val colors = LocalVniDropColors.current
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
shape = RoundedCornerShape(16.dp),
) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
PlatformIcon(AppIcon.Check, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(24.dp))
Text(stringResource(Res.string.saved_devices_pending_title), style = MaterialTheme.typography.titleMedium)
Text(stringResource(Res.string.saved_devices_no_pending), color = colors.foregroundLight)
}
}
}
@Composable
private fun SavedDevicesLoading(modifier: Modifier = Modifier) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
CircularProgressIndicator()
Text(stringResource(Res.string.saved_devices_loading), color = LocalVniDropColors.current.foregroundLight)
}
}
}
@Composable
private fun SavedDevicesLoadFailure(onRetry: () -> Unit, modifier: Modifier = Modifier) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
PlatformIcon(AppIcon.CloudOff, contentDescription = null, modifier = Modifier.size(32.dp))
Text(stringResource(Res.string.saved_devices_load_failed), color = LocalVniDropColors.current.foregroundLight)
SecondaryButton(stringResource(Res.string.button_retry), onRetry)
}
}
}
@Composable
private fun InlineLoadFailure(onRetry: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = LocalVniDropColors.current.backgroundSurface200),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringResource(Res.string.saved_devices_load_failed), Modifier.weight(1f))
TextButton(onClick = onRetry) { Text(stringResource(Res.string.button_retry)) }
}
}
}
@Composable
private fun SavedDeviceLabelDialog(
visible: Boolean,
label: String,
onLabelChanged: (String) -> Unit,
onSave: () -> Unit,
onClear: () -> Unit,
onDismiss: () -> Unit,
) {
if (!visible) return
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.saved_devices_label_title)) },
text = {
OutlinedTextField(
value = label,
onValueChange = onLabelChanged,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text(stringResource(Res.string.saved_devices_label_placeholder)) },
)
},
confirmButton = {
TextButton(onClick = onSave) { Text(stringResource(Res.string.saved_devices_label_save)) }
},
dismissButton = {
Row {
TextButton(onClick = onClear) { Text(stringResource(Res.string.saved_devices_label_clear)) }
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.button_cancel)) }
}
},
)
}
@Composable
private fun SavedDeviceModel.displayName(): String = localLabel?.takeIf(String::isNotBlank)
?: remoteDisplayName?.takeIf(String::isNotBlank)
?: stringResource(Res.string.saved_devices_unnamed)
private fun shortEndpoint(endpointId: String): String =
if (endpointId.length <= 20) endpointId else endpointId.take(16) + ""

View File

@@ -6,73 +6,48 @@ import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.DeviceRelationshipModel
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone
import com.vnidrop.app.ui.feedback.UiText
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.saved_devices_blocked
import vnidrop.shared.generated.resources.saved_devices_forgotten
import vnidrop.shared.generated.resources.saved_devices_labeled
import vnidrop.shared.generated.resources.saved_devices_send_started
data class SavedDevicesState(
val enabled: Boolean = false,
val isLoading: Boolean = true,
val loadFailed: Boolean = false,
val eligibilities: List<PairingEligibilityModel> = emptyList(),
val pendingRelationships: List<DeviceRelationshipModel> = emptyList(),
val savedDevices: List<SavedDeviceModel> = emptyList(),
val busyPeerIds: Set<String> = emptySet(),
val labelingPeerId: String? = null,
val labelDraft: String = "",
val sendTargetPeerId: String? = null,
val isSending: Boolean = false,
)
sealed interface SavedDevicesEffect {
data object OpenFilePicker : SavedDevicesEffect
}
class SavedDevicesViewModel(
private val repository: CoreGateway,
private val fileSystemService: FileSystemService,
preferencesRepository: PreferencesRepository,
private val messages: UiMessageController,
) : ViewModel() {
private val _state = MutableStateFlow(SavedDevicesState())
val state: StateFlow<SavedDevicesState> = _state.asStateFlow()
private val effects = Channel<SavedDevicesEffect>(Channel.BUFFERED)
val effectFlow = effects.receiveAsFlow()
init {
viewModelScope.launch {
combine(
preferencesRepository.preferences.map { it.experimentalSavedDevicesEnabled },
repository.state.map { it.isInitialized },
) { enabled, initialized -> enabled to initialized }
repository.state.map { it.isInitialized }
.distinctUntilChanged()
.collectLatest { (enabled, initialized) ->
_state.update { it.copy(enabled = enabled) }
when {
enabled && initialized -> refresh()
!enabled -> _state.update { SavedDevicesState(enabled = false) }
}
.collect { initialized ->
if (initialized) refresh()
}
}
viewModelScope.launch {
@@ -80,7 +55,7 @@ class SavedDevicesViewModel(
when (signal) {
CoreSignal.PairingChanged,
CoreSignal.TargetedTransferChanged -> {
if (_state.value.enabled && repository.state.value.isInitialized) refresh()
if (repository.state.value.isInitialized) refresh()
}
is CoreSignal.ApprovalChanged,
is CoreSignal.ReceiverHistoryChanged,
@@ -90,6 +65,11 @@ class SavedDevicesViewModel(
}
}
fun retry() {
if (!repository.state.value.isInitialized || _state.value.isLoading) return
viewModelScope.launch { refresh() }
}
fun rememberEligible(peerEndpointId: String) = mutatePeer(peerEndpointId) {
repository.requestSavedDevicePairing(peerEndpointId).map { }
}
@@ -154,46 +134,6 @@ class SavedDevicesViewModel(
}
}
fun startSend(peerEndpointId: String) {
if (_state.value.isSending) return
_state.update { it.copy(sendTargetPeerId = peerEndpointId) }
viewModelScope.launch { effects.send(SavedDevicesEffect.OpenFilePicker) }
}
fun onFilesPicked(files: List<PickedShareFile>) {
val peerId = _state.value.sendTargetPeerId ?: return
if (files.isEmpty() || _state.value.isSending) return
viewModelScope.launch {
_state.update { it.copy(isSending = true) }
val transferName = when {
files.size == 1 -> files.first().displayName
files.all { it.isDirectory } -> "${files.size} folders"
else -> "${files.size} files"
}
val result = fileSystemService.createTargetedTransferFromPickedFiles(
repository = repository,
receiverEndpointId = peerId,
files = files,
transferName = transferName,
)
if (result.isSuccess) fileSystemService.discardPickedFiles(files)
_state.update { it.copy(isSending = false, sendTargetPeerId = null) }
result.fold(
onSuccess = {
messages.tryShow(
UiMessage(UiText.Resource(Res.string.saved_devices_send_started), UiMessageTone.Success),
)
},
onFailure = messages::error,
)
}
}
fun onFilePickFailed(reason: String) {
_state.update { it.copy(sendTargetPeerId = null) }
messages.error(IllegalStateException(reason.ifBlank { "selection failed" }))
}
private fun mutatePeer(peerEndpointId: String, block: suspend () -> Result<*>) {
if (peerEndpointId in _state.value.busyPeerIds) return
_state.update { it.copy(busyPeerIds = it.busyPeerIds + peerEndpointId) }
@@ -207,21 +147,26 @@ class SavedDevicesViewModel(
}
private suspend fun refresh() {
if (!_state.value.enabled) return
_state.update { it.copy(isLoading = true, loadFailed = false) }
val eligibilities = repository.listPairingEligibilities().getOrElse {
_state.update { state -> state.copy(isLoading = false, loadFailed = true) }
messages.error(it)
return
}
val relationships = repository.listDeviceRelationships().getOrElse {
_state.update { state -> state.copy(isLoading = false, loadFailed = true) }
messages.error(it)
return
}
val saved = repository.listSavedDevices().getOrElse {
_state.update { state -> state.copy(isLoading = false, loadFailed = true) }
messages.error(it)
return
}
_state.update {
it.copy(
isLoading = false,
loadFailed = false,
eligibilities = eligibilities.sortedByDescending(PairingEligibilityModel::createdAt),
pendingRelationships = relationships.filter {
it.state == DeviceRelationshipStateModel.PendingIncoming ||

View File

@@ -25,12 +25,15 @@ import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.receive_completed
data class TargetedOfferState(
val enabled: Boolean = false,
val pending: List<PendingTargetedOfferModel> = emptyList(),
val senderDisplayNames: Map<String, String> = emptyMap(),
val respondingIds: Set<String> = emptySet(),
) {
val current: PendingTargetedOfferModel?
get() = pending.firstOrNull()
val currentSenderDisplayName: String?
get() = current?.senderEndpointId?.let(senderDisplayNames::get)
}
/**
@@ -61,19 +64,14 @@ class TargetedOfferCoordinator(
.distinctUntilChanged()
.collectLatest { (preferences, initialized) ->
receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder)
val enabled = preferences.experimentalSavedDevicesEnabled
_state.update { it.copy(enabled = enabled) }
when {
enabled && initialized -> refresh()
!enabled -> _state.update { it.copy(pending = emptyList()) }
}
if (initialized) refresh()
}
}
scope.launch {
repository.signals.collect { signal ->
when (signal) {
CoreSignal.TargetedTransferChanged -> {
if (_state.value.enabled && repository.state.value.isInitialized) refresh()
if (repository.state.value.isInitialized) refresh()
}
CoreSignal.PairingChanged,
is CoreSignal.ApprovalChanged,
@@ -125,14 +123,24 @@ class TargetedOfferCoordinator(
}
private suspend fun refresh() {
if (!_state.value.enabled) return
repository.listPendingTargetedOffers().fold(
onSuccess = { offers ->
_state.update {
it.copy(pending = offers.sortedBy(PendingTargetedOfferModel::receivedAt))
}
},
onFailure = messages::error,
)
val offers = repository.listPendingTargetedOffers().getOrElse {
messages.error(it)
return
}
val savedDevices = repository.listSavedDevices().getOrElse {
messages.error(it)
return
}
_state.update {
it.copy(
pending = offers.sortedBy(PendingTargetedOfferModel::receivedAt),
senderDisplayNames = savedDevices.associate { device ->
device.endpointId to (
device.localLabel?.takeIf(String::isNotBlank)
?: device.remoteDisplayName?.takeIf(String::isNotBlank)
).orEmpty()
}.filterValues(String::isNotBlank),
)
}
}
}

View File

@@ -41,11 +41,11 @@ fun TargetedOfferModalHost(
onAccept: (String) -> Unit,
onDecline: (String) -> Unit,
) {
if (!state.enabled) return
val offer = state.current ?: return
val busy = offer.transferId in state.respondingIds
val colors = LocalVniDropColors.current
val device = shortEndpoint(offer.senderEndpointId)
val device = state.currentSenderDisplayName?.takeIf(String::isNotBlank)
?: shortEndpoint(offer.senderEndpointId)
Dialog(
onDismissRequest = {},
properties = DialogProperties(

View File

@@ -6,25 +6,23 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.ui.state.WindowClass
@Composable
fun SendRoute(
internal fun SendRoute(
viewModel: SendViewModel,
draftViewModel: TransferDraftViewModel,
defaultSenderName: String,
windowClass: WindowClass,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current
val picker = rememberShareFilePicker(viewModel::onFilesPicked, viewModel::onFilePickFailed)
val shareActions = rememberTransferShareActions()
LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect ->
when (effect) {
SendEffect.OpenFilePicker -> picker.pickFiles()
SendEffect.OpenFolderPicker -> picker.pickFolder()
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
}
}
@@ -35,16 +33,7 @@ fun SendRoute(
state = state,
windowClass = windowClass,
shareActions = shareActions,
onOpenComposer = viewModel::openComposer,
onDismissComposer = viewModel::dismissComposer,
onSelectFile = viewModel::selectFile,
onSelectFolder = viewModel::selectFolder,
onClearFile = viewModel::clearSelectedSource,
onRemoveFile = viewModel::removeSelectedFile,
onTransferNameChanged = viewModel::setTransferName,
onSenderNameChanged = viewModel::setSenderName,
onAccessPolicyChanged = viewModel::setAccessPolicy,
onCreateShare = viewModel::createShare,
onOpenComposer = { draftViewModel.openInvitation(defaultSenderName) },
onTransferSelected = viewModel::openTransfer,
onShareTransfer = { transferId ->
viewModel.openTransfer(transferId)
@@ -63,4 +52,5 @@ fun SendRoute(
onDismissDelete = viewModel::dismissDeleteTransfer,
onConfirmDelete = viewModel::confirmDeleteTransfer,
)
TransferDraftHost(draftViewModel, windowClass, viewModel::onDraftCreated)
}

View File

@@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.ui.components.AdaptiveDrawer
@@ -23,15 +22,6 @@ fun SendScreen(
windowClass: WindowClass,
shareActions: TransferShareActions = UnavailableTransferShareActions,
onOpenComposer: () -> Unit,
onDismissComposer: () -> Unit,
onSelectFile: () -> Unit,
onSelectFolder: () -> Unit = {},
onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit = {},
onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit,
onTransferSelected: (ULong) -> Unit,
onShareTransfer: (ULong) -> Unit = {},
onStopSharing: (ULong) -> Unit = {},
@@ -87,24 +77,6 @@ fun SendScreen(
}
}
if (state.isComposerOpen) {
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissComposer) {
TransferComposer(
coreInitialized = coreState.isInitialized,
state = state,
windowClass = windowClass,
onSelectFile = onSelectFile,
onSelectFolder = onSelectFolder,
onClearFile = onClearFile,
onRemoveFile = onRemoveFile,
onTransferNameChanged = onTransferNameChanged,
onSenderNameChanged = onSenderNameChanged,
onAccessPolicyChanged = onAccessPolicyChanged,
onCreateShare = onCreateShare,
)
}
}
val canShowDetailPanel = selectedTransfer != null && when (state.detailPanel) {
TransferDetailPanel.Share -> selectedTransfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)
TransferDetailPanel.Activity, TransferDetailPanel.Receivers -> true

View File

@@ -4,13 +4,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone
@@ -25,18 +21,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.send_transfer_created
import vnidrop.shared.generated.resources.transfer_deleted
import vnidrop.shared.generated.resources.transfer_invitation_saved
import vnidrop.shared.generated.resources.transfer_nfc_written
data class SendState(
val isComposerOpen: Boolean = false,
val selectedFiles: List<PickedShareFile> = emptyList(),
val transferName: String = "",
val senderName: String = "",
val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval,
val isSharing: Boolean = false,
val selectedTransferId: ULong? = null,
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
val detailPanel: TransferDetailPanel? = null,
@@ -46,27 +35,16 @@ data class SendState(
val isDeleteConfirmationOpen: Boolean = false,
val deleteTargetTransferId: ULong? = null,
val isDeleting: Boolean = false,
) {
val selectedFile: PickedShareFile? get() = selectedFiles.singleOrNull()
val totalSelectedBytes: ULong
get() = selectedFiles.fold(0UL) { acc, file -> acc + (file.sizeBytes ?: 0UL) }
fun canCreateShare(coreInitialized: Boolean): Boolean =
coreInitialized && selectedFiles.isNotEmpty() && transferName.isNotBlank() && !isSharing
}
)
enum class TransferDetailPanel { Activity, Receivers, Share }
sealed interface SendEffect {
data object OpenFilePicker : SendEffect
data object OpenFolderPicker : SendEffect
data class CopyTicket(val ticket: String) : SendEffect
}
class SendViewModel(
private val repository: CoreGateway,
private val fileSystemService: FileSystemService,
preferencesRepository: PreferencesRepository,
private val filePreviewRepository: FilePreviewRepository,
private val messages: UiMessageController,
) : ViewModel() {
@@ -118,87 +96,18 @@ class SendViewModel(
if (activeIds != null) filePreviewRepository.restore(activeIds)
}
}
viewModelScope.launch {
preferencesRepository.preferences.collect { preferences ->
_state.update { current ->
if (current.senderName.isBlank()) current.copy(senderName = preferences.username) else current
}
}
}
}
fun openComposer() {
if (_state.value.isSharing) return
val discardedFiles = _state.value.selectedFiles
fun onDraftCreated(creation: TransferDraftCreation) {
if (creation !is TransferDraftCreation.Invitation) return
_state.update {
it.copy(
isComposerOpen = true,
selectedFiles = emptyList(),
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
selectedTransferId = creation.transferId,
detailPanel = TransferDetailPanel.Share,
)
}
discardPickedFiles(discardedFiles)
refreshReceivers(creation.transferId)
}
fun dismissComposer() {
if (_state.value.isSharing) return
val discardedFiles = _state.value.selectedFiles
_state.update {
it.copy(
isComposerOpen = false,
selectedFiles = emptyList(),
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
)
}
discardPickedFiles(discardedFiles)
}
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
fun selectFolder() = sendEffect(SendEffect.OpenFolderPicker)
fun onFilesPicked(files: List<PickedShareFile>) {
if (files.isEmpty()) return
val selectedValues = files.mapTo(mutableSetOf(), PickedShareFile::value)
val discardedFiles = _state.value.selectedFiles.filterNot { it.value in selectedValues }
_state.update {
it.copy(
isComposerOpen = true,
selectedFiles = files,
transferName = defaultTransferName(files),
)
}
discardPickedFiles(discardedFiles)
}
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason.takeIf(String::isNotBlank) ?: "selection failed"))
fun clearSelectedSource() {
val discardedFiles = _state.value.selectedFiles
_state.update { it.copy(selectedFiles = emptyList(), transferName = "") }
discardPickedFiles(discardedFiles)
}
fun removeSelectedFile(value: String) {
val discardedFiles = _state.value.selectedFiles.filter { it.value == value }
_state.update { current ->
val remaining = current.selectedFiles.filterNot { it.value == value }
current.copy(
selectedFiles = remaining,
transferName = when {
remaining.isEmpty() -> ""
current.transferName == defaultTransferName(current.selectedFiles) -> defaultTransferName(remaining)
else -> current.transferName
},
)
}
discardPickedFiles(discardedFiles)
}
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) }
fun openTransfer(transferId: ULong) {
_state.update { it.copy(selectedTransferId = transferId, detailPanel = null) }
refreshReceivers(transferId)
@@ -278,47 +187,6 @@ class SendViewModel(
)
}
fun createShare() {
val current = state.value
if (current.selectedFiles.isEmpty()) return
if (!current.canCreateShare(coreState.value.isInitialized)) return
viewModelScope.launch {
_state.update { it.copy(isSharing = true) }
val result = fileSystemService.sharePickedFiles(
repository = repository,
files = current.selectedFiles,
transferName = current.transferName.trim(),
senderName = current.senderName.trim(),
accessPolicy = current.accessPolicy,
)
if (result.isSuccess) fileSystemService.discardPickedFiles(current.selectedFiles)
result.fold(
onSuccess = { share ->
current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes }
?.let { filePreviewRepository.save(share.transferId, it) }
repository.refresh()
_state.update {
it.copy(
isComposerOpen = false,
selectedFiles = emptyList(),
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
isSharing = false,
selectedTransferId = share.transferId,
detailPanel = TransferDetailPanel.Share,
)
}
refreshReceivers(share.transferId)
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
},
onFailure = { error ->
_state.update { it.copy(isSharing = false) }
messages.error(error)
},
)
}
}
fun stopSharing(transferId: ULong) {
viewModelScope.launch {
repository.cancel(transferId).fold(
@@ -328,23 +196,10 @@ class SendViewModel(
}
}
private fun defaultTransferName(files: List<PickedShareFile>): String = when {
files.isEmpty() -> ""
files.size == 1 && files.first().isDirectory -> files.first().displayName
files.size == 1 -> files.first().displayName
files.all { it.isDirectory } -> "${files.size} folders"
else -> "${files.size} files"
}
private fun sendEffect(effect: SendEffect) {
viewModelScope.launch { effects.send(effect) }
}
private fun discardPickedFiles(files: List<PickedShareFile>) {
if (files.isEmpty()) return
viewModelScope.launch { fileSystemService.discardPickedFiles(files) }
}
private fun refreshReceivers(transferId: ULong) {
viewModelScope.launch {
_state.update { it.copy(isLoadingReceivers = true) }

View File

@@ -29,7 +29,6 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton
@@ -62,26 +61,27 @@ import vnidrop.shared.generated.resources.send_file_size_unknown
import vnidrop.shared.generated.resources.send_folder_label
import vnidrop.shared.generated.resources.send_review_title
import vnidrop.shared.generated.resources.send_selected_files_count
import vnidrop.shared.generated.resources.saved_devices_send_action
@Composable
internal fun TransferComposer(
coreInitialized: Boolean,
state: SendState,
state: TransferDraftState,
windowClass: WindowClass,
onSelectFile: () -> Unit,
onSelectFolder: () -> Unit,
onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit,
onRemoveFile: (DraftSourceId) -> Unit,
onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit,
onSubmit: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (state.selectedFiles.isEmpty()) {
if (state.sources.isEmpty()) {
ChooseFileStep(onSelectFile, onSelectFolder)
} else {
ReviewFileStep(
@@ -94,7 +94,7 @@ internal fun TransferComposer(
onTransferNameChanged = onTransferNameChanged,
onSenderNameChanged = onSenderNameChanged,
onAccessPolicyChanged = onAccessPolicyChanged,
onCreateShare = onCreateShare,
onSubmit = onSubmit,
coreInitialized = coreInitialized,
)
}
@@ -124,73 +124,83 @@ private fun ChooseFileStep(onSelectFile: () -> Unit, onSelectFolder: () -> Unit)
@Composable
private fun ReviewFileStep(
state: SendState,
state: TransferDraftState,
windowClass: WindowClass,
onSelectFile: () -> Unit,
onSelectFolder: () -> Unit,
onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit,
onRemoveFile: (DraftSourceId) -> Unit,
onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit,
onSubmit: () -> Unit,
coreInitialized: Boolean,
) {
Text(stringResource(Res.string.send_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
if (state.selectedFiles.size > 1) {
if (state.sources.size > 1) {
Text(
stringResource(Res.string.send_selected_files_count, state.selectedFiles.size),
stringResource(Res.string.send_selected_files_count, state.sources.size),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
}
state.selectedFiles.forEach { file ->
state.sources.forEach { file ->
SelectedFileCard(
file = file,
canRemove = state.selectedFiles.size > 1 && !state.isSharing,
onRemove = { onRemoveFile(file.value) },
canRemove = state.sources.size > 1 && !state.isSubmitting,
onRemove = { onRemoveFile(file.id) },
)
}
Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name))
Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name))
Text(stringResource(Res.string.send_access_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
PolicyOption(
icon = AppIcon.Shield,
title = stringResource(Res.string.send_access_approval),
description = stringResource(Res.string.send_access_approval_description),
selected = state.accessPolicy == ShareAccessPolicy.RequireApproval,
onClick = { onAccessPolicyChanged(ShareAccessPolicy.RequireApproval) },
)
PolicyOption(
icon = AppIcon.Globe,
title = stringResource(Res.string.send_access_anyone),
description = stringResource(Res.string.send_access_anyone_description),
selected = state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer,
onClick = { onAccessPolicyChanged(ShareAccessPolicy.AnyoneWithTransfer) },
)
if (state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer) {
Text(
stringResource(Res.string.send_access_anyone_warning),
color = LocalVniDropColors.current.destructiveDefault,
style = MaterialTheme.typography.bodySmall,
Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name), enabled = !state.isSubmitting)
when (val destination = state.destination) {
TransferDraftDestination.Invitation -> {
Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name), enabled = !state.isSubmitting)
Text(stringResource(Res.string.send_access_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
PolicyOption(
icon = AppIcon.Shield,
title = stringResource(Res.string.send_access_approval),
description = stringResource(Res.string.send_access_approval_description),
selected = state.accessPolicy == ShareAccessPolicy.RequireApproval,
onClick = { onAccessPolicyChanged(ShareAccessPolicy.RequireApproval) },
)
PolicyOption(
icon = AppIcon.Globe,
title = stringResource(Res.string.send_access_anyone),
description = stringResource(Res.string.send_access_anyone_description),
selected = state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer,
onClick = { onAccessPolicyChanged(ShareAccessPolicy.AnyoneWithTransfer) },
)
if (state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer) {
Text(
stringResource(Res.string.send_access_anyone_warning),
color = LocalVniDropColors.current.destructiveDefault,
style = MaterialTheme.typography.bodySmall,
)
}
}
is TransferDraftDestination.Targeted -> Text(
destination.receiver.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
null -> Unit
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
SubmitButton(state, coreInitialized, onSubmit, Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SourceButton(
text = stringResource(Res.string.button_change_files),
icon = AppIcon.File,
onClick = onSelectFile,
modifier = Modifier.weight(1f),
enabled = !state.isSharing,
enabled = !state.isSubmitting,
)
SourceButton(
text = stringResource(Res.string.button_choose_folder),
icon = AppIcon.Folder,
onClick = onSelectFolder,
modifier = Modifier.weight(1f),
enabled = !state.isSharing,
enabled = !state.isSubmitting,
)
if (windowClass != WindowClass.Phone) {
SourceButton(
@@ -198,7 +208,7 @@ private fun ReviewFileStep(
icon = AppIcon.Close,
onClick = onClearFile,
modifier = Modifier.weight(1f),
enabled = !state.isSharing,
enabled = !state.isSubmitting,
)
}
}
@@ -221,18 +231,23 @@ private fun SourceButton(
}
@Composable
private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShare: () -> Unit, modifier: Modifier = Modifier) {
private fun SubmitButton(state: TransferDraftState, coreInitialized: Boolean, onSubmit: () -> Unit, modifier: Modifier = Modifier) {
val targeted = state.destination is TransferDraftDestination.Targeted
PrimaryButton(
if (state.isSharing) stringResource(Res.string.button_sharing_file) else stringResource(Res.string.button_share_file),
onClick = onCreateShare,
when {
state.isSubmitting -> stringResource(Res.string.button_sharing_file)
targeted -> stringResource(Res.string.saved_devices_send_action)
else -> stringResource(Res.string.button_share_file)
},
onClick = onSubmit,
modifier = modifier,
enabled = state.canCreateShare(coreInitialized),
enabled = state.canSubmit(coreInitialized),
)
}
@Composable
private fun SelectedFileCard(
file: PickedShareFile,
file: TransferDraftSource,
canRemove: Boolean,
onRemove: () -> Unit,
) {

View File

@@ -0,0 +1,60 @@
package com.vnidrop.app.feature.send
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.ui.components.AdaptiveDrawer
import com.vnidrop.app.ui.state.WindowClass
@Composable
internal fun TransferDraftHost(
viewModel: TransferDraftViewModel,
windowClass: WindowClass,
onCreated: (TransferDraftCreation) -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
var activePickerRequestId by remember { mutableStateOf<Long?>(null) }
val picker = rememberShareFilePicker(
onFilesPicked = { files -> activePickerRequestId?.let { viewModel.onFilesPicked(it, files) } },
onError = { reason -> activePickerRequestId?.let { viewModel.onFilePickFailed(it, reason) } },
)
LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect ->
when (effect) {
is TransferDraftEffect.OpenPicker -> {
activePickerRequestId = effect.requestId
when (effect.kind) {
TransferDraftPickKind.Files -> picker.pickFiles()
TransferDraftPickKind.Folder -> picker.pickFolder()
}
}
is TransferDraftEffect.Created -> onCreated(effect.creation)
}
}
}
if (state.isOpen) {
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = viewModel::dismiss) {
TransferComposer(
coreInitialized = coreState.isInitialized,
state = state,
windowClass = windowClass,
onSelectFile = viewModel::chooseFiles,
onSelectFolder = viewModel::chooseFolder,
onClearFile = viewModel::clearSources,
onRemoveFile = viewModel::removeSource,
onTransferNameChanged = viewModel::changeTransferName,
onSenderNameChanged = viewModel::changeSenderName,
onAccessPolicyChanged = viewModel::changeAccessPolicy,
onSubmit = viewModel::submit,
)
}
}
}

View File

@@ -0,0 +1,367 @@
package com.vnidrop.app.feature.send
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.PickedShareSourceAdapter
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone
import com.vnidrop.app.ui.feedback.UiText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.saved_devices_send_started
import vnidrop.shared.generated.resources.send_default_transfer_name
import vnidrop.shared.generated.resources.send_transfer_created
sealed interface TransferDraftDestination {
data object Invitation : TransferDraftDestination
data class Targeted(
val receiver: LockedSavedDevice,
) : TransferDraftDestination
}
data class LockedSavedDevice(
val endpointId: String,
val displayName: String,
)
@JvmInline
value class DraftSourceId(val value: String)
data class TransferDraftSource(
val id: DraftSourceId,
val displayName: String,
val sizeBytes: ULong?,
val thumbnailBytes: ByteArray?,
val isDirectory: Boolean,
)
enum class TransferDraftPickKind { Files, Folder }
data class TransferDraftState(
val destination: TransferDraftDestination? = null,
val sources: List<TransferDraftSource> = emptyList(),
val transferName: String = "",
val senderName: String = "",
val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval,
val pickerRequestId: Long? = null,
val isPreparingSources: Boolean = false,
val isSubmitting: Boolean = false,
) {
val isOpen: Boolean get() = destination != null
val isPicking: Boolean get() = pickerRequestId != null || isPreparingSources
val totalSelectedBytes: ULong
get() = sources.fold(0UL) { total, source -> total + (source.sizeBytes ?: 0UL) }
fun canSubmit(coreInitialized: Boolean): Boolean =
coreInitialized && sources.isNotEmpty() && transferName.isNotBlank() && !isPicking && !isSubmitting
}
sealed interface TransferDraftCreation {
data class Invitation(val transferId: ULong) : TransferDraftCreation
data class Targeted(val transferId: String, val receiverEndpointId: String) : TransferDraftCreation
}
sealed interface TransferDraftEffect {
data class OpenPicker(val requestId: Long, val kind: TransferDraftPickKind) : TransferDraftEffect
data class Created(val creation: TransferDraftCreation) : TransferDraftEffect
}
internal class TransferDraftViewModel(
private val repository: CoreGateway,
private val sourceAdapter: PickedShareSourceAdapter,
private val filePreviewRepository: FilePreviewRepository,
private val messages: UiMessageController,
private val multipleFilesName: suspend (Int) -> String = { count ->
getString(Res.string.send_default_transfer_name, count)
},
) : ViewModel() {
private data class SelectedSource(
val source: TransferDraftSource,
val picked: PickedShareFile,
)
private val _state = MutableStateFlow(TransferDraftState())
val state: StateFlow<TransferDraftState> = _state.asStateFlow()
val coreState = repository.state
private val effects = Channel<TransferDraftEffect>(Channel.BUFFERED)
val effectFlow = effects.receiveAsFlow()
private var selectedSources = emptyList<SelectedSource>()
private var nextPickerRequestId = 1L
private var nextSourceId = 1L
private var automaticName = true
fun openInvitation(defaultSenderName: String) {
if (_state.value.isOpen) return
reset(
TransferDraftState(
destination = TransferDraftDestination.Invitation,
senderName = defaultSenderName,
),
)
}
fun openTargeted(device: SavedDeviceModel, unnamedDeviceName: String) {
if (_state.value.isOpen) return
val displayName = device.localLabel?.takeIf(String::isNotBlank)
?: device.remoteDisplayName?.takeIf(String::isNotBlank)
?: unnamedDeviceName
reset(
TransferDraftState(
destination = TransferDraftDestination.Targeted(
LockedSavedDevice(device.endpointId, displayName),
),
),
)
}
fun chooseFiles() = requestPicker(TransferDraftPickKind.Files)
fun chooseFolder() = requestPicker(TransferDraftPickKind.Folder)
fun onFilesPicked(requestId: Long, files: List<PickedShareFile>) {
if (_state.value.pickerRequestId != requestId) {
discard(files)
return
}
if (files.isEmpty()) {
_state.update { it.copy(pickerRequestId = null) }
return
}
val validSelection = files.none(PickedShareFile::isDirectory) ||
(files.size == 1 && files.single().isDirectory)
if (!validSelection) {
_state.update { it.copy(pickerRequestId = null) }
discard(files)
messages.error(IllegalArgumentException("Choose multiple files or one folder"))
return
}
_state.update { it.copy(isPreparingSources = true) }
viewModelScope.launch {
val newName = try {
when {
files.size == 1 -> files.single().displayName
else -> multipleFilesName(files.size)
}
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
_state.update { it.copy(pickerRequestId = null, isPreparingSources = false) }
discardOwned(files)
messages.error(error)
return@launch
}
if (_state.value.pickerRequestId != requestId) {
discardOwned(files)
return@launch
}
val previous = selectedSources
selectedSources = files.map { file ->
SelectedSource(
source = TransferDraftSource(
id = DraftSourceId("source-${nextSourceId++}"),
displayName = file.displayName,
sizeBytes = file.sizeBytes,
thumbnailBytes = file.thumbnailBytes,
isDirectory = file.isDirectory,
),
picked = file,
)
}
automaticName = true
_state.update {
it.copy(
sources = selectedSources.map(SelectedSource::source),
transferName = newName,
pickerRequestId = null,
isPreparingSources = false,
)
}
val replacementValues = files.mapTo(mutableSetOf(), PickedShareFile::value)
discardOwned(previous.map(SelectedSource::picked).filterNot { it.value in replacementValues })
}
}
fun onFilePickFailed(requestId: Long, reason: String) {
if (_state.value.pickerRequestId != requestId) return
_state.update { it.copy(pickerRequestId = null) }
messages.error(IllegalStateException(reason.ifBlank { "selection failed" }))
}
fun clearSources() {
if (!editable()) return
val discarded = selectedSources.map(SelectedSource::picked)
selectedSources = emptyList()
automaticName = true
_state.update { it.copy(sources = emptyList(), transferName = "") }
discard(discarded)
}
fun removeSource(id: DraftSourceId) {
if (!editable()) return
val discarded = selectedSources.filter { it.source.id == id }.map(SelectedSource::picked)
if (discarded.isEmpty()) return
selectedSources = selectedSources.filterNot { it.source.id == id }
_state.update {
it.copy(
sources = selectedSources.map(SelectedSource::source),
isPreparingSources = true,
)
}
viewModelScope.launch {
val replacementName = try {
when {
!automaticName -> _state.value.transferName
selectedSources.isEmpty() -> ""
selectedSources.size == 1 -> selectedSources.single().source.displayName
else -> multipleFilesName(selectedSources.size)
}
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
_state.update { it.copy(isPreparingSources = false) }
discardOwned(discarded)
messages.error(error)
return@launch
}
_state.update {
it.copy(
transferName = replacementName,
isPreparingSources = false,
)
}
discardOwned(discarded)
}
}
fun changeTransferName(value: String) {
if (!editable()) return
automaticName = false
_state.update { it.copy(transferName = value) }
}
fun changeSenderName(value: String) {
if (!editable() || _state.value.destination !is TransferDraftDestination.Invitation) return
_state.update { it.copy(senderName = value) }
}
fun changeAccessPolicy(value: ShareAccessPolicy) {
if (!editable() || _state.value.destination !is TransferDraftDestination.Invitation) return
_state.update { it.copy(accessPolicy = value) }
}
fun submit() {
val current = _state.value
if (!current.canSubmit(repository.state.value.isInitialized)) return
val files = selectedSources.map(SelectedSource::picked)
val thumbnail = selectedSources.firstNotNullOfOrNull { it.source.thumbnailBytes }
viewModelScope.launch {
_state.update { it.copy(isSubmitting = true) }
val result = runCatching {
val destination = current.destination ?: error("Transfer draft is closed")
if (destination is TransferDraftDestination.Targeted) {
val stillSaved = repository.listSavedDevices().getOrThrow()
.any { it.endpointId == destination.receiver.endpointId }
check(stillSaved) { "Saved device is no longer available" }
}
sourceAdapter.withShareSources(files) { sources ->
when (destination) {
TransferDraftDestination.Invitation -> repository.shareSources(
sources = sources,
transferName = current.transferName.trim(),
senderName = current.senderName.trim(),
accessPolicy = current.accessPolicy,
).getOrThrow().let { share -> TransferDraftCreation.Invitation(share.transferId) }
is TransferDraftDestination.Targeted -> repository.createTargetedTransfer(
receiverEndpointId = destination.receiver.endpointId,
sources = sources,
transferName = current.transferName.trim(),
).getOrThrow().let { transfer ->
TransferDraftCreation.Targeted(transfer.id, destination.receiver.endpointId)
}
}
}.getOrThrow()
}
result.fold(
onSuccess = { creation ->
if (creation is TransferDraftCreation.Invitation) {
runCatching {
thumbnail?.let { filePreviewRepository.save(creation.transferId, it) }
repository.refresh().getOrThrow()
}.onFailure(messages::error)
}
discardOwned(files)
selectedSources = emptyList()
automaticName = true
_state.value = TransferDraftState()
effects.send(TransferDraftEffect.Created(creation))
val message = when (creation) {
is TransferDraftCreation.Invitation -> Res.string.send_transfer_created
is TransferDraftCreation.Targeted -> Res.string.saved_devices_send_started
}
messages.show(UiMessage(UiText.Resource(message), UiMessageTone.Success))
},
onFailure = { error ->
if (error is CancellationException) throw error
_state.update { it.copy(isSubmitting = false) }
messages.error(error)
},
)
}
}
fun dismiss() {
if (_state.value.isSubmitting) return
val discarded = selectedSources.map(SelectedSource::picked)
selectedSources = emptyList()
automaticName = true
_state.value = TransferDraftState()
discard(discarded)
}
private fun requestPicker(kind: TransferDraftPickKind) {
if (!editable() || _state.value.isPicking) return
val requestId = nextPickerRequestId++
_state.update { it.copy(pickerRequestId = requestId) }
viewModelScope.launch { effects.send(TransferDraftEffect.OpenPicker(requestId, kind)) }
}
private fun editable(): Boolean = _state.value.isOpen && !_state.value.isPicking && !_state.value.isSubmitting
private fun reset(state: TransferDraftState) {
selectedSources = emptyList()
automaticName = true
_state.value = state
}
private fun discard(files: List<PickedShareFile>) {
if (files.isEmpty()) return
viewModelScope.launch { discardOwned(files) }
}
private suspend fun discardOwned(files: List<PickedShareFile>) {
if (files.isEmpty()) return
try {
sourceAdapter.discardPickedFiles(files)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
messages.error(error)
}
}
}

View File

@@ -1,66 +0,0 @@
package com.vnidrop.app.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import com.vnidrop.app.feature.saveddevices.SavedDevicesPanel
import com.vnidrop.app.feature.saveddevices.SavedDevicesState
import com.vnidrop.app.ui.icons.AppIcon
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.experimental_saved_devices_description
import vnidrop.shared.generated.resources.experimental_saved_devices_title
import vnidrop.shared.generated.resources.experimental_settings_title
@Composable
internal fun ExperimentalSettings(
state: SettingsState,
savedDevicesState: SavedDevicesState,
onSavedDevicesEnabledChanged: (Boolean) -> Unit,
onRememberEligible: (String) -> Unit,
onDeclineEligible: (String) -> Unit,
onAcceptIncoming: (String) -> Unit,
onDeclineIncoming: (String) -> Unit,
onSendToDevice: (String) -> Unit,
onOpenDeviceLabel: (String) -> Unit,
onForgetDevice: (String) -> Unit,
onBlockDevice: (String) -> Unit,
onLabelDraftChanged: (String) -> Unit,
onSaveDeviceLabel: () -> Unit,
onClearDeviceLabel: () -> Unit,
onDismissDeviceLabel: () -> Unit,
onBack: () -> Unit,
showBack: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.experimental_settings_title), onBack, showBack)
SettingsGroup {
SettingsToggleRow(
icon = AppIcon.Lock,
title = stringResource(Res.string.experimental_saved_devices_title),
description = stringResource(Res.string.experimental_saved_devices_description),
checked = state.experimentalSavedDevicesEnabled,
enabled = true,
onCheckedChange = onSavedDevicesEnabledChanged,
)
}
if (state.experimentalSavedDevicesEnabled) {
SavedDevicesPanel(
state = savedDevicesState,
onRememberEligible = onRememberEligible,
onDeclineEligible = onDeclineEligible,
onAcceptIncoming = onAcceptIncoming,
onDeclineIncoming = onDeclineIncoming,
onSend = onSendToDevice,
onOpenLabel = onOpenDeviceLabel,
onForget = onForgetDevice,
onBlock = onBlockDevice,
onLabelDraftChanged = onLabelDraftChanged,
onSaveLabel = onSaveDeviceLabel,
onClearLabel = onClearDeviceLabel,
onDismissLabel = onDismissDeviceLabel,
)
}
}
}

View File

@@ -12,7 +12,6 @@ import com.vnidrop.app.ui.icons.AppIcon
import com.vnidrop.app.ui.theme.ThemeMode
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.experimental_settings_title
import vnidrop.shared.generated.resources.notifications_title
import vnidrop.shared.generated.resources.preferences_title
import vnidrop.shared.generated.resources.relay_mode_automatic
@@ -33,7 +32,6 @@ internal fun SettingsOverview(
state: SettingsState,
onSectionSelected: (SettingsSection) -> Unit,
largeTitle: Boolean,
showExperimental: Boolean = false,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
@@ -82,16 +80,6 @@ internal fun SettingsOverview(
onClick = { onSectionSelected(SettingsSection.Network) },
)
}
if (showExperimental) {
SettingsGroup {
SettingsRow(
icon = AppIcon.Lock,
title = stringResource(Res.string.experimental_settings_title),
selected = state.selectedSection == SettingsSection.Experimental,
onClick = { onSectionSelected(SettingsSection.Experimental) },
)
}
}
SettingsGroup {
SettingsRow(
icon = AppIcon.Info,

View File

@@ -4,28 +4,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.showsExperimentalSavedDevices
import com.vnidrop.app.core.rememberReceiveFolderPicker
import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.feature.saveddevices.SavedDevicesEffect
import com.vnidrop.app.feature.saveddevices.SavedDevicesViewModel
import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.state.WindowClass
@Composable
fun SettingsRoute(
internal fun SettingsRoute(
viewModel: SettingsViewModel,
savedDevicesViewModel: SavedDevicesViewModel,
windowClass: WindowClass,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val savedDevicesState by savedDevicesViewModel.state.collectAsStateWithLifecycle()
val showExperimental = showsExperimentalSavedDevices(LocalUiPlatform.current)
val folderPicker = rememberReceiveFolderPicker(viewModel::onReceiveFolderPicked, viewModel::onReceiveFolderPickFailed)
val sharePicker = rememberShareFilePicker(
savedDevicesViewModel::onFilesPicked,
savedDevicesViewModel::onFilePickFailed,
)
LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect ->
when (effect) {
@@ -33,13 +21,6 @@ fun SettingsRoute(
}
}
}
LaunchedEffect(savedDevicesViewModel) {
savedDevicesViewModel.effectFlow.collect { effect ->
when (effect) {
SavedDevicesEffect.OpenFilePicker -> sharePicker.pickFiles()
}
}
}
SettingsScreen(
state = state,
windowClass = windowClass,
@@ -54,21 +35,6 @@ fun SettingsRoute(
onChooseFolder = viewModel::chooseReceiveFolder,
onResetFolder = viewModel::resetReceiveFolder,
onNotificationsChanged = viewModel::setNotificationsEnabled,
onExperimentalSavedDevicesChanged = viewModel::setExperimentalSavedDevicesEnabled,
showExperimental = showExperimental,
savedDevicesState = savedDevicesState,
onRememberEligibleDevice = savedDevicesViewModel::rememberEligible,
onDeclineEligibleDevice = savedDevicesViewModel::declineEligible,
onAcceptIncomingPairing = savedDevicesViewModel::acceptIncoming,
onDeclineIncomingPairing = savedDevicesViewModel::declineIncoming,
onSendToSavedDevice = savedDevicesViewModel::startSend,
onOpenSavedDeviceLabel = savedDevicesViewModel::openLabelEditor,
onForgetSavedDevice = savedDevicesViewModel::forget,
onBlockSavedDevice = savedDevicesViewModel::block,
onSavedDeviceLabelDraftChanged = savedDevicesViewModel::setLabelDraft,
onSaveSavedDeviceLabel = savedDevicesViewModel::saveLabel,
onClearSavedDeviceLabel = savedDevicesViewModel::clearLabel,
onDismissSavedDeviceLabel = savedDevicesViewModel::dismissLabelEditor,
onOpenNotificationSettings = viewModel::openNotificationSettings,
onBugWhatChanged = viewModel::setBugWhatHappened,
onBugExpectedChanged = viewModel::setBugExpected,

View File

@@ -9,7 +9,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.RelayMode
import com.vnidrop.app.feature.saveddevices.SavedDevicesState
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.ThemeMode
@@ -24,21 +23,6 @@ fun SettingsScreen(
onChooseFolder: () -> Unit,
onResetFolder: () -> Unit,
onNotificationsChanged: (Boolean) -> Unit,
onExperimentalSavedDevicesChanged: (Boolean) -> Unit = {},
showExperimental: Boolean = false,
savedDevicesState: SavedDevicesState = SavedDevicesState(),
onRememberEligibleDevice: (String) -> Unit = {},
onDeclineEligibleDevice: (String) -> Unit = {},
onAcceptIncomingPairing: (String) -> Unit = {},
onDeclineIncomingPairing: (String) -> Unit = {},
onSendToSavedDevice: (String) -> Unit = {},
onOpenSavedDeviceLabel: (String) -> Unit = {},
onForgetSavedDevice: (String) -> Unit = {},
onBlockSavedDevice: (String) -> Unit = {},
onSavedDeviceLabelDraftChanged: (String) -> Unit = {},
onSaveSavedDeviceLabel: () -> Unit = {},
onClearSavedDeviceLabel: () -> Unit = {},
onDismissSavedDeviceLabel: () -> Unit = {},
onOpenNotificationSettings: () -> Unit,
onBugWhatChanged: (String) -> Unit,
onBugExpectedChanged: (String) -> Unit,
@@ -66,7 +50,6 @@ fun SettingsScreen(
state,
onSectionSelected,
largeTitle = false,
showExperimental = showExperimental,
)
}
Column(Modifier.weight(1f)) {
@@ -83,20 +66,6 @@ fun SettingsScreen(
onChooseFolder = onChooseFolder,
onResetFolder = onResetFolder,
onNotificationsChanged = onNotificationsChanged,
onExperimentalSavedDevicesChanged = onExperimentalSavedDevicesChanged,
savedDevicesState = savedDevicesState,
onRememberEligibleDevice = onRememberEligibleDevice,
onDeclineEligibleDevice = onDeclineEligibleDevice,
onAcceptIncomingPairing = onAcceptIncomingPairing,
onDeclineIncomingPairing = onDeclineIncomingPairing,
onSendToSavedDevice = onSendToSavedDevice,
onOpenSavedDeviceLabel = onOpenSavedDeviceLabel,
onForgetSavedDevice = onForgetSavedDevice,
onBlockSavedDevice = onBlockSavedDevice,
onSavedDeviceLabelDraftChanged = onSavedDeviceLabelDraftChanged,
onSaveSavedDeviceLabel = onSaveSavedDeviceLabel,
onClearSavedDeviceLabel = onClearSavedDeviceLabel,
onDismissSavedDeviceLabel = onDismissSavedDeviceLabel,
onOpenNotificationSettings = onOpenNotificationSettings,
onBugWhatChanged = onBugWhatChanged,
onBugExpectedChanged = onBugExpectedChanged,
@@ -122,7 +91,6 @@ fun SettingsScreen(
state,
onSectionSelected,
largeTitle = true,
showExperimental = showExperimental,
)
else -> SettingsSectionContent(
state = state,
@@ -144,20 +112,6 @@ fun SettingsScreen(
onChooseFolder = onChooseFolder,
onResetFolder = onResetFolder,
onNotificationsChanged = onNotificationsChanged,
onExperimentalSavedDevicesChanged = onExperimentalSavedDevicesChanged,
savedDevicesState = savedDevicesState,
onRememberEligibleDevice = onRememberEligibleDevice,
onDeclineEligibleDevice = onDeclineEligibleDevice,
onAcceptIncomingPairing = onAcceptIncomingPairing,
onDeclineIncomingPairing = onDeclineIncomingPairing,
onSendToSavedDevice = onSendToSavedDevice,
onOpenSavedDeviceLabel = onOpenSavedDeviceLabel,
onForgetSavedDevice = onForgetSavedDevice,
onBlockSavedDevice = onBlockSavedDevice,
onSavedDeviceLabelDraftChanged = onSavedDeviceLabelDraftChanged,
onSaveSavedDeviceLabel = onSaveSavedDeviceLabel,
onClearSavedDeviceLabel = onClearSavedDeviceLabel,
onDismissSavedDeviceLabel = onDismissSavedDeviceLabel,
onOpenNotificationSettings = onOpenNotificationSettings,
onBugWhatChanged = onBugWhatChanged,
onBugExpectedChanged = onBugExpectedChanged,
@@ -192,20 +146,6 @@ private fun SettingsSectionContent(
onChooseFolder: () -> Unit,
onResetFolder: () -> Unit,
onNotificationsChanged: (Boolean) -> Unit,
onExperimentalSavedDevicesChanged: (Boolean) -> Unit,
savedDevicesState: SavedDevicesState,
onRememberEligibleDevice: (String) -> Unit,
onDeclineEligibleDevice: (String) -> Unit,
onAcceptIncomingPairing: (String) -> Unit,
onDeclineIncomingPairing: (String) -> Unit,
onSendToSavedDevice: (String) -> Unit,
onOpenSavedDeviceLabel: (String) -> Unit,
onForgetSavedDevice: (String) -> Unit,
onBlockSavedDevice: (String) -> Unit,
onSavedDeviceLabelDraftChanged: (String) -> Unit,
onSaveSavedDeviceLabel: () -> Unit,
onClearSavedDeviceLabel: () -> Unit,
onDismissSavedDeviceLabel: () -> Unit,
onOpenNotificationSettings: () -> Unit,
onBugWhatChanged: (String) -> Unit,
onBugExpectedChanged: (String) -> Unit,
@@ -238,25 +178,6 @@ private fun SettingsSectionContent(
showBack = showBack,
)
SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack)
SettingsSection.Experimental -> ExperimentalSettings(
state = state,
savedDevicesState = savedDevicesState,
onSavedDevicesEnabledChanged = onExperimentalSavedDevicesChanged,
onRememberEligible = onRememberEligibleDevice,
onDeclineEligible = onDeclineEligibleDevice,
onAcceptIncoming = onAcceptIncomingPairing,
onDeclineIncoming = onDeclineIncomingPairing,
onSendToDevice = onSendToSavedDevice,
onOpenDeviceLabel = onOpenSavedDeviceLabel,
onForgetDevice = onForgetSavedDevice,
onBlockDevice = onBlockSavedDevice,
onLabelDraftChanged = onSavedDeviceLabelDraftChanged,
onSaveDeviceLabel = onSaveSavedDeviceLabel,
onClearDeviceLabel = onClearSavedDeviceLabel,
onDismissDeviceLabel = onDismissSavedDeviceLabel,
onBack = onBack,
showBack = showBack,
)
SettingsSection.Storage -> StorageSettings(
state,
windowClass,

View File

@@ -58,7 +58,6 @@ enum class SettingsSection {
Network,
Notifications,
Storage,
Experimental,
About,
BugReport,
}
@@ -98,7 +97,6 @@ data class SettingsState(
val hasActiveNetworkWork: Boolean = false,
val endpointId: String? = null,
val notificationsEnabled: Boolean = false,
val experimentalSavedDevicesEnabled: Boolean = false,
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
val deviceInfo: DeviceInfo? = null,
val appVersion: String = "",
@@ -162,7 +160,6 @@ class SettingsViewModel(
receiveFolder = receiveFolder,
themeMode = preferences.themeMode,
notificationsEnabled = preferences.notificationsEnabled,
experimentalSavedDevicesEnabled = preferences.experimentalSavedDevicesEnabled,
savedRelaySettings = preferences.relaySettings,
relayMode = if (hasLocalRelayDraft) current.relayMode else preferences.relaySettings.mode,
relayUrls = if (hasLocalRelayDraft) {
@@ -571,12 +568,6 @@ class SettingsViewModel(
}
}
fun setExperimentalSavedDevicesEnabled(enabled: Boolean) {
viewModelScope.launch {
preferencesRepository.setExperimentalSavedDevicesEnabled(enabled)
}
}
fun openNotificationSettings() {
viewModelScope.launch {
enableNotificationsAfterSettings = true

View File

@@ -27,8 +27,6 @@ data class AppPreferences(
/** Stable anonymous install id for bug-report correlation; never an account or advertising id. */
val diagnosticsInstallId: String = "",
val relaySettings: RelaySettings = RelaySettings(),
/** Experimental saved-devices / targeted-transfer UI (Android). Default off. */
val experimentalSavedDevicesEnabled: Boolean = false,
)
class AppPreferencesDefaults(
@@ -36,7 +34,6 @@ class AppPreferencesDefaults(
val receiveFolder: ReceiveFolder,
val themeMode: ThemeMode,
val notificationsEnabled: Boolean = false,
val experimentalSavedDevicesEnabled: Boolean = false,
)
interface PreferencesRepository {
@@ -47,7 +44,6 @@ interface PreferencesRepository {
suspend fun setThemeMode(mode: ThemeMode)
suspend fun setNotificationsEnabled(enabled: Boolean)
suspend fun setRelaySettings(settings: RelaySettings)
suspend fun setExperimentalSavedDevicesEnabled(enabled: Boolean)
/** Ensures a durable install id exists and returns it. */
suspend fun ensureDiagnosticsInstallId(): String
}
@@ -88,8 +84,6 @@ class AppPreferencesRepository(
mode = relayMode,
relayUrls = relayUrls,
),
experimentalSavedDevicesEnabled = prefs[PreferenceKeys.ExperimentalSavedDevicesEnabled]
?: defaults.experimentalSavedDevicesEnabled,
)
}
@@ -123,12 +117,6 @@ class AppPreferencesRepository(
}
}
override suspend fun setExperimentalSavedDevicesEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.ExperimentalSavedDevicesEnabled] = enabled
}
}
override suspend fun setRelaySettings(settings: RelaySettings) {
dataStore.edit { prefs ->
prefs[PreferenceKeys.RelayMode] = settings.mode.name
@@ -161,7 +149,6 @@ private object PreferenceKeys {
val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name")
val ThemeMode = stringPreferencesKey("theme_mode")
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
val ExperimentalSavedDevicesEnabled = booleanPreferencesKey("experimental_saved_devices_enabled")
val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id")
val RelayMode = stringPreferencesKey("relay_mode")
val RelayUrls = stringPreferencesKey("relay_urls")

View File

@@ -4,12 +4,14 @@ import com.vnidrop.app.ui.icons.AppIcon
import org.jetbrains.compose.resources.StringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.nav_receive
import vnidrop.shared.generated.resources.nav_saved_devices
import vnidrop.shared.generated.resources.nav_send
import vnidrop.shared.generated.resources.nav_settings
enum class AppDestination {
Send,
Receive,
SavedDevices,
Settings,
}
@@ -19,11 +21,9 @@ internal data class NavigationItem(
val icon: AppIcon,
)
// The route list is intentionally tiny for this phase. Activity, receiver
// requests, and diagnostics remain available inside screens instead of being
// promoted to top-level navigation.
internal val primaryNavigationItems = listOf(
NavigationItem(AppDestination.Send, Res.string.nav_send, AppIcon.Send),
NavigationItem(AppDestination.Receive, Res.string.nav_receive, AppIcon.Download),
NavigationItem(AppDestination.SavedDevices, Res.string.nav_saved_devices, AppIcon.ShieldCheck),
NavigationItem(AppDestination.Settings, Res.string.nav_settings, AppIcon.Settings),
)

View File

@@ -1,15 +0,0 @@
package com.vnidrop.app
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ExperimentalSavedDevicesGateTest {
@Test
fun experimentalChromeIsShownOnAndroidWindowsAndLinuxOnly() {
assertTrue(showsExperimentalSavedDevices(UiPlatform.Android))
assertTrue(showsExperimentalSavedDevices(UiPlatform.Windows))
assertTrue(showsExperimentalSavedDevices(UiPlatform.Linux))
assertFalse(showsExperimentalSavedDevices(UiPlatform.Desktop))
}
}

View File

@@ -6,14 +6,12 @@ import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.CoreStatus
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.CoreStorageUsageModel
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.RelayMode
import com.vnidrop.app.core.RelaySettings
import com.vnidrop.app.core.Share
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection
@@ -26,7 +24,6 @@ import com.vnidrop.app.feature.app.AppViewModel
import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget
import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.send.SendViewModel
import com.vnidrop.app.feature.send.TransferDetailPanel
import com.vnidrop.app.feature.settings.SettingsSection
import com.vnidrop.app.feature.settings.RelaySettingsApplyError
import com.vnidrop.app.feature.settings.RelaySettingsInputError
@@ -55,7 +52,6 @@ import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertContentEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -472,22 +468,6 @@ class ViewModelsTest {
assertEquals(null, withTimeoutOrNull(1) { viewModel.effectFlow.first() })
}
@Test
fun sendViewModelOwnsSelectedFileState() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val fileSystem = FakeFileSystemService(folder)
val viewModel = SendViewModel(FakeCoreGateway(), fileSystem, preferences(), FakeFilePreviewRepository(), UiMessageController())
viewModel.openComposer()
val selected = PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, isTemporaryCopy = true)
viewModel.onFilesPicked(listOf(selected))
assertEquals("photo.jpg", viewModel.state.value.transferName)
assertEquals(42UL, viewModel.state.value.selectedFile?.sizeBytes)
viewModel.clearSelectedSource()
advanceUntilIdle()
assertEquals(null, viewModel.state.value.selectedFile)
assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
}
@Test
fun sendViewModelTracksReceiverCompletionForCatalogProgress() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -509,7 +489,7 @@ class ViewModelsTest {
mutableState.value = CoreState(isInitialized = true, transfers = listOf(sentTransfer(7UL)))
requests[7UL] = listOf(accepted)
}
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
val viewModel = SendViewModel(core, FakeFilePreviewRepository(), UiMessageController())
advanceUntilIdle()
assertEquals(ReceiverDeliveryStatus.Accepted, viewModel.state.value.receiversByTransfer.getValue(7UL).single().status)
@@ -520,37 +500,6 @@ class ViewModelsTest {
assertEquals(ReceiverDeliveryStatus.Completed, viewModel.state.value.receiversByTransfer.getValue(7UL).single().status)
}
@Test
fun sendComposerClosesAfterSuccessfulAtomicShareCreation() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = CoreState(isInitialized = true)
shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL))
}
val previews = FakeFilePreviewRepository()
val fileSystem = FakeFileSystemService(folder)
val viewModel = SendViewModel(core, fileSystem, preferences(), previews, UiMessageController())
advanceUntilIdle()
viewModel.openComposer()
val thumbnail = ByteArray(12).also {
it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte()
}
val selected = PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail, isTemporaryCopy = true)
viewModel.onFilesPicked(listOf(selected))
viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
viewModel.createShare()
advanceUntilIdle()
assertFalse(viewModel.state.value.isComposerOpen)
assertEquals(null, viewModel.state.value.selectedFile)
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
assertEquals(7UL, core.state.value.transfers.first().transferId)
assertEquals(7UL, viewModel.state.value.selectedTransferId)
assertEquals(TransferDetailPanel.Share, viewModel.state.value.detailPanel)
assertContentEquals(thumbnail, previews.previews.value.getValue(7UL))
assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
}
@Test
fun sendViewModelStopsSharingFromCatalogAction() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -559,8 +508,6 @@ class ViewModelsTest {
}
val viewModel = SendViewModel(
core,
FakeFileSystemService(folder),
preferences(),
FakeFilePreviewRepository(),
UiMessageController(),
)
@@ -572,63 +519,6 @@ class ViewModelsTest {
assertEquals(listOf(7UL), core.cancelledTransfers)
}
@Test
fun sendComposerStaysOpenWhenShareCreationFails() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply { mutableState.value = CoreState(isInitialized = true) }
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
advanceUntilIdle()
viewModel.openComposer()
viewModel.onFilesPicked(listOf(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)))
viewModel.createShare()
advanceUntilIdle()
assertTrue(viewModel.state.value.isComposerOpen)
assertEquals("photo.jpg", viewModel.state.value.selectedFile?.displayName)
assertFalse(viewModel.state.value.isSharing)
}
@Test
fun sendViewModelSupportsMultipleFilesAndDefaultName() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = CoreState(isInitialized = true)
shareResult = Result.success(Share(9UL, "ticket", "2 files", "hash", 2UL, 84UL))
}
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
advanceUntilIdle()
viewModel.onFilesPicked(
listOf(
com.vnidrop.app.core.PickedShareFile("/tmp/a.jpg", "a.jpg", 40UL),
com.vnidrop.app.core.PickedShareFile("/tmp/b.jpg", "b.jpg", 44UL),
),
)
assertEquals("2 files", viewModel.state.value.transferName)
assertEquals(2, viewModel.state.value.selectedFiles.size)
viewModel.createShare()
advanceUntilIdle()
assertEquals(2, core.lastShareSourceCount)
assertTrue(viewModel.state.value.selectedFiles.isEmpty())
}
@Test
fun sendViewModelNamesFolderSelectionAfterFolderDisplayName() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
advanceUntilIdle()
viewModel.onFilesPicked(
listOf(
com.vnidrop.app.core.PickedShareFile(
value = "/tmp/photos",
displayName = "photos",
isDirectory = true,
),
),
)
assertEquals("photos", viewModel.state.value.transferName)
assertTrue(viewModel.state.value.selectedFiles.single().isDirectory)
}
@Test
fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -646,7 +536,7 @@ class ViewModelsTest {
}
val previews = FakeFilePreviewRepository()
previews.save(7UL, byteArrayOf(1, 2, 3))
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), previews, UiMessageController())
val viewModel = SendViewModel(core, previews, UiMessageController())
advanceUntilIdle()
viewModel.openTransfer(7UL)
viewModel.requestDeleteTransfer()

View File

@@ -3,13 +3,8 @@ package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.DeviceRelationshipModel
import com.vnidrop.app.core.DeviceRelationshipStateModel
import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.support.FakeCoreGateway
import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
@@ -23,25 +18,9 @@ import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class PairingPromptCoordinatorTest {
@Test
fun experimentalOffDoesNotPromptOnEligibility() = runTest {
val core = initializedCore().apply {
pairingEligibilities = listOf(eligibility("peer-a"))
}
val coordinator = PairingPromptCoordinator(
core,
preferences(enabled = false),
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertNull(coordinator.state.value.prompt)
}
@Test
fun waitsForCoreInitializeBeforeRefreshing() = runTest {
// Regression: experimental prefs emit before AppViewModel initialize finishes.
// The coordinator must not query domain state before AppViewModel initializes the core.
val core = FakeCoreGateway().apply {
pairingEligibilities = listOf(eligibility("peer-a"))
}
@@ -52,7 +31,6 @@ class PairingPromptCoordinatorTest {
}
val coordinator = PairingPromptCoordinator(
core,
preferences(enabled = true),
messages,
backgroundScope,
)
@@ -66,7 +44,7 @@ class PairingPromptCoordinatorTest {
runCurrent()
advanceUntilIdle()
assertEquals(1, core.listDeviceRelationshipsCount)
assertEquals(PairingPrompt.Eligibility("peer-a"), coordinator.state.value.prompt)
assertEquals(PairingPrompt.Eligibility("peer-a", "Remote device"), coordinator.state.value.prompt)
assertTrue(seen.isEmpty())
}
@@ -77,13 +55,12 @@ class PairingPromptCoordinatorTest {
}
val coordinator = PairingPromptCoordinator(
core,
preferences(enabled = true),
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertEquals(PairingPrompt.Eligibility("peer-a"), coordinator.state.value.prompt)
assertEquals(PairingPrompt.Eligibility("peer-a", "Remote device"), coordinator.state.value.prompt)
coordinator.accept()
runCurrent()
@@ -98,7 +75,6 @@ class PairingPromptCoordinatorTest {
}
val coordinator = PairingPromptCoordinator(
core,
preferences(enabled = true),
UiMessageController(),
backgroundScope,
)
@@ -119,7 +95,6 @@ class PairingPromptCoordinatorTest {
}
val coordinator = PairingPromptCoordinator(
core,
preferences(enabled = true),
UiMessageController(),
backgroundScope,
)
@@ -139,7 +114,6 @@ class PairingPromptCoordinatorTest {
}
val coordinator = PairingPromptCoordinator(
core,
preferences(enabled = true),
UiMessageController(),
backgroundScope,
)
@@ -159,6 +133,7 @@ class PairingPromptCoordinatorTest {
private fun eligibility(peer: String) = PairingEligibilityModel(
peerEndpointId = peer,
remoteDisplayName = "Remote device",
sessionId = "session",
protocolVersion = 1u,
createdAt = 1L,
@@ -173,14 +148,4 @@ class PairingPromptCoordinatorTest {
createdAt = 1L,
updatedAt = 1L,
)
private fun preferences(enabled: Boolean) = FakePreferencesRepository(
AppPreferences(
username = "User",
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
themeMode = ThemeMode.System,
notificationsEnabled = false,
experimentalSavedDevicesEnabled = enabled,
),
)
}

View File

@@ -1,17 +1,8 @@
package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.core.TargetedTransferModel
import com.vnidrop.app.core.TargetedTransferStateModel
import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.support.FakeCoreGateway
import com.vnidrop.app.support.FakeFileSystemService
import com.vnidrop.app.support.FakePreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -33,6 +24,23 @@ class SavedDevicesViewModelTest {
Dispatchers.resetMain()
}
@Test
fun loadsSavedDevicesWithoutAnExperimentalPreferenceGate() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
savedDevices = listOf(device("peer-always-visible", label = null))
}
val viewModel = SavedDevicesViewModel(core, UiMessageController())
runCurrent()
advanceUntilIdle()
assertEquals("peer-always-visible", viewModel.state.value.savedDevices.single().endpointId)
assertEquals(false, viewModel.state.value.isLoading)
assertEquals(false, viewModel.state.value.loadFailed)
}
@Test
fun labelForgetAndBlockUpdateGateway() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -40,11 +48,8 @@ class SavedDevicesViewModelTest {
mutableState.value = mutableState.value.copy(isInitialized = true)
savedDevices = listOf(device("peer-1", label = null))
}
val preferences = preferences(enabled = true)
val viewModel = SavedDevicesViewModel(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences,
UiMessageController(),
)
runCurrent()
@@ -79,46 +84,6 @@ class SavedDevicesViewModelTest {
assertEquals(listOf("peer-2"), core.blockedPeers.toList())
}
@Test
fun sendFromSavedDeviceCreatesTargetedTransfer() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = mutableState.value.copy(isInitialized = true)
savedDevices = listOf(device("peer-3", label = "Kitchen"))
createTargetedResult = Result.success(
TargetedTransferModel(
id = "t1",
senderEndpointId = "me",
receiverEndpointId = "peer-3",
manifestId = "m",
fileCount = 1u,
totalSize = 1u,
verifiedBytes = 0u,
state = TargetedTransferStateModel.Offering,
createdAt = 1L,
updatedAt = 1L,
),
)
}
val viewModel = SavedDevicesViewModel(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(enabled = true),
UiMessageController(),
)
runCurrent()
advanceUntilIdle()
viewModel.startSend("peer-3")
viewModel.onFilesPicked(
listOf(PickedShareFile(value = "/tmp/a.txt", displayName = "a.txt", sizeBytes = 1u)),
)
runCurrent()
advanceUntilIdle()
assertEquals(1, core.createdTargetedTransfers.size)
assertEquals("peer-3", core.createdTargetedTransfers.single().first)
assertNull(viewModel.state.value.sendTargetPeerId)
}
private fun device(id: String, label: String?) = SavedDeviceModel(
endpointId = id,
localLabel = label,
@@ -126,14 +91,4 @@ class SavedDevicesViewModelTest {
createdAt = 1L,
lastAuthenticatedAt = null,
)
private fun preferences(enabled: Boolean) = FakePreferencesRepository(
AppPreferences(
username = "User",
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
themeMode = ThemeMode.System,
notificationsEnabled = false,
experimentalSavedDevicesEnabled = enabled,
),
)
}

View File

@@ -3,6 +3,7 @@ package com.vnidrop.app.feature.saveddevices
import com.vnidrop.app.core.PendingTargetedOfferModel
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.core.TargetedOfferResponseModel
import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.support.FakeCoreGateway
@@ -21,6 +22,34 @@ import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class TargetedOfferCoordinatorTest {
@Test
fun pendingOfferUsesTheSavedDevicesDisplayNamePolicy() = runTest {
val core = initializedCore().apply {
pendingTargetedOffers = listOf(offer("named-transfer"))
savedDevices = listOf(
SavedDeviceModel(
endpointId = "sender",
localLabel = "Office PC",
remoteDisplayName = "Amira's laptop",
createdAt = 1,
lastAuthenticatedAt = 2,
),
)
}
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(),
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertEquals("Office PC", coordinator.state.value.currentSenderDisplayName)
}
@Test
fun acceptApprovesAndPullsByTransferId() = runTest {
val core = initializedCore().apply {
@@ -31,7 +60,7 @@ class TargetedOfferCoordinatorTest {
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(enabled = true),
preferences(),
UiMessageController(),
backgroundScope,
)
@@ -67,7 +96,7 @@ class TargetedOfferCoordinatorTest {
ReceiveFolder(ReceiveFolderKind.AndroidPublicDownloads, "downloads", "Downloads"),
receiveOutputSink = sink,
),
preferences(enabled = true),
preferences(),
UiMessageController(),
backgroundScope,
)
@@ -89,7 +118,7 @@ class TargetedOfferCoordinatorTest {
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(enabled = true),
preferences(),
UiMessageController(),
backgroundScope,
)
@@ -102,23 +131,6 @@ class TargetedOfferCoordinatorTest {
assertTrue(core.receivedTargetedTransferIds.isEmpty())
}
@Test
fun experimentalOffIgnoresPendingOffers() = runTest {
val core = initializedCore().apply {
pendingTargetedOffers = listOf(offer("transfer-3"))
}
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(enabled = false),
UiMessageController(),
backgroundScope,
)
runCurrent()
advanceUntilIdle()
assertTrue(coordinator.state.value.pending.isEmpty())
}
@Test
fun waitsForCoreInitializeBeforeListingOffers() = runTest {
val core = FakeCoreGateway().apply {
@@ -127,7 +139,7 @@ class TargetedOfferCoordinatorTest {
val coordinator = TargetedOfferCoordinator(
core,
FakeFileSystemService(ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp")),
preferences(enabled = true),
preferences(),
UiMessageController(),
backgroundScope,
)
@@ -160,13 +172,12 @@ class TargetedOfferCoordinatorTest {
receivedAt = 1L,
)
private fun preferences(enabled: Boolean) = FakePreferencesRepository(
private fun preferences() = FakePreferencesRepository(
AppPreferences(
username = "User",
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
themeMode = ThemeMode.System,
notificationsEnabled = false,
experimentalSavedDevicesEnabled = enabled,
),
)
}

View File

@@ -0,0 +1,249 @@
package com.vnidrop.app.feature.send
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.core.Share
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TargetedTransferModel
import com.vnidrop.app.core.TargetedTransferStateModel
import com.vnidrop.app.support.FakeCoreGateway
import com.vnidrop.app.support.FakeFilePreviewRepository
import com.vnidrop.app.support.FakePickedShareSourceAdapter
import com.vnidrop.app.ui.feedback.UiMessageController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
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.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class TransferDraftViewModelTest {
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun invitationAndTargetedCreationShareOneDraftBehavior() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = readyCore().apply {
shareResult = Result.success(Share(7UL, "ticket", "Holiday", "hash", 2UL, 3UL))
createTargetedResult = Result.success(targeted("target-9", "peer-9"))
savedDevices = listOf(device("peer-9", "Kitchen"))
}
val adapter = FakePickedShareSourceAdapter()
val invitation = draft(core, adapter)
invitation.openInvitation("Alice")
invitation.chooseFiles()
advanceUntilIdle()
assertIs<TransferDraftEffect.OpenPicker>(invitation.effectFlow.first())
val invitationRequest = invitation.state.value.pickerRequestId!!
invitation.onFilesPicked(
invitationRequest,
listOf(file("/a", "a.txt"), file("/b", "b.txt")),
)
advanceUntilIdle()
assertEquals("2 localized files", invitation.state.value.transferName)
invitation.changeTransferName("Holiday")
invitation.changeAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
invitation.submit()
advanceUntilIdle()
val invitationCreated = assertIs<TransferDraftEffect.Created>(invitation.effectFlow.first()).creation
assertIs<TransferDraftCreation.Invitation>(invitationCreated)
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
assertFalse(invitation.state.value.isOpen)
val targeted = draft(core, adapter)
targeted.openTargeted(core.savedDevices.single(), "Saved device")
targeted.chooseFolder()
advanceUntilIdle()
assertIs<TransferDraftEffect.OpenPicker>(targeted.effectFlow.first())
val targetedRequest = targeted.state.value.pickerRequestId!!
targeted.onFilesPicked(
targetedRequest,
listOf(file("/photos", "Photos", directory = true)),
)
advanceUntilIdle()
assertEquals("Photos", targeted.state.value.transferName)
assertEquals(
"Kitchen",
assertIs<TransferDraftDestination.Targeted>(targeted.state.value.destination).receiver.displayName,
)
targeted.submit()
advanceUntilIdle()
val created = assertIs<TransferDraftEffect.Created>(targeted.effectFlow.first()).creation
assertEquals(TransferDraftCreation.Targeted("target-9", "peer-9"), created)
assertTrue(core.createdTargetedTransfers.single().second.single().isDirectory)
}
@Test
fun replacementAndDismissalReleaseOnlyOwnedCopiesExactlyOnce() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val adapter = FakePickedShareSourceAdapter()
val viewModel = draft(readyCore(), adapter)
val first = file("/owned-a", "a.txt", temporary = true)
val second = file("/owned-b", "b.txt", temporary = true)
viewModel.openInvitation("Alice")
viewModel.chooseFiles()
viewModel.onFilesPicked(viewModel.state.value.pickerRequestId!!, listOf(first))
advanceUntilIdle()
viewModel.chooseFiles()
viewModel.onFilesPicked(viewModel.state.value.pickerRequestId!!, listOf(second))
advanceUntilIdle()
viewModel.dismiss()
advanceUntilIdle()
assertEquals(listOf(first, second), adapter.discardedPickedFiles)
}
@Test
fun failedCreationPreservesEditableDraftAndOwnedSources() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = readyCore()
val adapter = FakePickedShareSourceAdapter()
val viewModel = draft(core, adapter)
val selected = file("/owned", "report.pdf", temporary = true)
viewModel.openInvitation("Alice")
viewModel.chooseFiles()
viewModel.onFilesPicked(viewModel.state.value.pickerRequestId!!, listOf(selected))
advanceUntilIdle()
viewModel.changeTransferName("Report")
viewModel.submit()
advanceUntilIdle()
assertTrue(viewModel.state.value.isOpen)
assertFalse(viewModel.state.value.isSubmitting)
assertEquals("Report", viewModel.state.value.transferName)
assertEquals(emptyList(), adapter.discardedPickedFiles)
}
@Test
fun targetedSubmitRevalidatesReceiverWithoutFallingBackToInvitation() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = readyCore().apply { savedDevices = listOf(device("peer-3", "Desk")) }
val viewModel = draft(core, FakePickedShareSourceAdapter())
viewModel.openTargeted(core.savedDevices.single(), "Saved device")
viewModel.chooseFiles()
viewModel.onFilesPicked(viewModel.state.value.pickerRequestId!!, listOf(file("/a", "a.txt")))
advanceUntilIdle()
core.savedDevices = emptyList()
viewModel.submit()
advanceUntilIdle()
assertTrue(viewModel.state.value.isOpen)
assertIs<TransferDraftDestination.Targeted>(viewModel.state.value.destination)
assertTrue(core.createdTargetedTransfers.isEmpty())
}
@Test
fun stalePickerResultCannotReopenDismissedDraftAndIsReleased() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val adapter = FakePickedShareSourceAdapter()
val viewModel = draft(readyCore(), adapter)
val stale = file("/owned", "late.txt", temporary = true)
viewModel.openInvitation("Alice")
viewModel.chooseFiles()
val requestId = viewModel.state.value.pickerRequestId!!
viewModel.dismiss()
viewModel.onFilesPicked(requestId, listOf(stale))
advanceUntilIdle()
assertFalse(viewModel.state.value.isOpen)
assertEquals(listOf(stale), adapter.discardedPickedFiles)
}
@Test
fun pickerCancellationPreservesTheExistingDraft() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = draft(readyCore(), FakePickedShareSourceAdapter())
viewModel.openInvitation("Alice")
viewModel.chooseFiles()
val firstRequest = viewModel.state.value.pickerRequestId!!
viewModel.onFilesPicked(firstRequest, listOf(file("/a", "a.txt")))
advanceUntilIdle()
viewModel.chooseFiles()
val cancelledRequest = viewModel.state.value.pickerRequestId!!
viewModel.onFilesPicked(cancelledRequest, emptyList())
assertEquals("a.txt", viewModel.state.value.transferName)
assertEquals(listOf("a.txt"), viewModel.state.value.sources.map(TransferDraftSource::displayName))
assertFalse(viewModel.state.value.isPicking)
}
@Test
fun removingAFileKeepsAUserEditedTransferName() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = draft(readyCore(), FakePickedShareSourceAdapter())
viewModel.openInvitation("Alice")
viewModel.chooseFiles()
viewModel.onFilesPicked(
viewModel.state.value.pickerRequestId!!,
listOf(file("/a", "a.txt"), file("/b", "b.txt")),
)
advanceUntilIdle()
viewModel.changeTransferName("My documents")
viewModel.removeSource(viewModel.state.value.sources.first().id)
advanceUntilIdle()
assertEquals("My documents", viewModel.state.value.transferName)
assertEquals(listOf("b.txt"), viewModel.state.value.sources.map(TransferDraftSource::displayName))
}
private fun draft(core: FakeCoreGateway, adapter: FakePickedShareSourceAdapter) =
TransferDraftViewModel(
repository = core,
sourceAdapter = adapter,
filePreviewRepository = FakeFilePreviewRepository(),
messages = UiMessageController(),
multipleFilesName = { "$it localized files" },
)
private fun readyCore() = FakeCoreGateway().apply {
mutableState.value = CoreState(isInitialized = true)
}
private fun file(
value: String,
name: String,
directory: Boolean = false,
temporary: Boolean = false,
) = PickedShareFile(
value = value,
displayName = name,
sizeBytes = 1UL,
isTemporaryCopy = temporary,
isDirectory = directory,
)
private fun device(id: String, label: String?) = SavedDeviceModel(
endpointId = id,
localLabel = label,
remoteDisplayName = "Remote",
createdAt = 1L,
lastAuthenticatedAt = 1L,
)
private fun targeted(id: String, peerId: String) = TargetedTransferModel(
id = id,
senderEndpointId = "me",
receiverEndpointId = peerId,
manifestId = "manifest",
transferName = "Transfer",
fileCount = 1UL,
totalSize = 1UL,
verifiedBytes = 0UL,
state = TargetedTransferStateModel.Offering,
createdAt = 1L,
updatedAt = 1L,
)
}

View File

@@ -10,6 +10,7 @@ import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.PairingEligibilityModel
import com.vnidrop.app.core.PendingTargetedOfferModel
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.PickedShareSourceAdapter
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceivedArtifactModel
import com.vnidrop.app.core.ReceivedStorageInspection
@@ -334,9 +335,6 @@ class FakePreferencesRepository(
override suspend fun resetReceiveFolder() = Unit
override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) }
override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) }
override suspend fun setExperimentalSavedDevicesEnabled(enabled: Boolean) {
mutablePreferences.value = mutablePreferences.value.copy(experimentalSavedDevicesEnabled = enabled)
}
override suspend fun setRelaySettings(settings: RelaySettings) {
mutablePreferences.value = mutablePreferences.value.copy(relaySettings = settings)
}
@@ -378,7 +376,6 @@ class FakeFileSystemService(
var reclaimTemporaryStorageCount = 0
var revealFolderResult: Result<Unit> = Result.success(Unit)
val revealedFolders = mutableListOf<ReceiveFolder>()
val discardedPickedFiles = mutableListOf<PickedShareFile>()
override val supportsCustomReceiveFolders: Boolean get() = supportsCustomFolders
override fun defaultReceiveFolder() = folder
override fun effectiveReceiveFolder(configuredFolder: ReceiveFolder) =
@@ -397,42 +394,33 @@ class FakeFileSystemService(
revealedFolders += folder
return revealFolderResult
}
override suspend fun discardPickedFiles(files: List<PickedShareFile>) {
discardedPickedFiles += files
}
override suspend fun sharePickedFiles(
repository: CoreGateway,
}
internal class FakePickedShareSourceAdapter : PickedShareSourceAdapter {
val discardedPickedFiles = mutableListOf<PickedShareFile>()
var adaptResult: Result<Unit> = Result.success(Unit)
var beforeOperation: suspend () -> Unit = {}
override suspend fun <T> withShareSources(
files: List<PickedShareFile>,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> {
val sources = files.map { file ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.PATH,
value = file.value,
displayName = file.displayName,
isDirectory = false,
)
}
return repository.shareSources(sources, transferName, senderName, accessPolicy)
operation: suspend (List<uniffi.vnidrop.ShareSource>) -> T,
): Result<T> = runCatching {
adaptResult.getOrThrow()
beforeOperation()
operation(
files.map { file ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.PATH,
value = file.value,
displayName = file.displayName,
isDirectory = file.isDirectory,
)
},
)
}
override suspend fun createTargetedTransferFromPickedFiles(
repository: CoreGateway,
receiverEndpointId: String,
files: List<PickedShareFile>,
transferName: String?,
): Result<TargetedTransferModel> {
val sources = files.map { file ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.PATH,
value = file.value,
displayName = file.displayName,
isDirectory = false,
)
}
return repository.createTargetedTransfer(receiverEndpointId, sources, transferName)
override suspend fun discardPickedFiles(files: List<PickedShareFile>) {
discardedPickedFiles += files.filter(PickedShareFile::isTemporaryCopy)
}
}

View File

@@ -9,10 +9,10 @@ class NavigationModelTest {
@Test
fun primaryNavigationContainsOnlyProductDestinations() {
assertEquals(
listOf(AppDestination.Send, AppDestination.Receive, AppDestination.Settings),
listOf(AppDestination.Send, AppDestination.Receive, AppDestination.SavedDevices, AppDestination.Settings),
primaryNavigationItems.map { it.destination },
)
assertEquals(3, primaryNavigationItems.map { it.label }.distinct().size)
assertEquals(4, primaryNavigationItems.map { it.label }.distinct().size)
}
@Test

View File

@@ -1,11 +1,13 @@
package com.vnidrop.app.ui.state
import com.vnidrop.app.feature.receive.ReceiveState
import com.vnidrop.app.feature.send.SendState
import com.vnidrop.app.feature.send.TransferDraftDestination
import com.vnidrop.app.feature.send.TransferDraftSource
import com.vnidrop.app.feature.send.TransferDraftState
import com.vnidrop.app.feature.send.DraftSourceId
import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.ui.theme.ThemeMode
@@ -52,16 +54,17 @@ class AppUiModelsTest {
}
@Test
fun sendStateExposesShareEligibility() {
val ready = SendState(
selectedFiles = listOf(PickedShareFile("/tmp/payload.txt", "payload.txt", 128UL)),
fun transferDraftStateExposesSubmitEligibility() {
val ready = TransferDraftState(
destination = TransferDraftDestination.Invitation,
sources = listOf(TransferDraftSource(DraftSourceId("source-1"), "payload.txt", 128UL, null, false)),
transferName = "payload.txt",
)
assertTrue(ready.canCreateShare(coreInitialized = true))
assertFalse(ready.canCreateShare(coreInitialized = false))
assertFalse(SendState().canCreateShare(coreInitialized = true))
assertFalse(ready.copy(isSharing = true).canCreateShare(coreInitialized = true))
assertTrue(ready.canSubmit(coreInitialized = true))
assertFalse(ready.canSubmit(coreInitialized = false))
assertFalse(TransferDraftState().canSubmit(coreInitialized = true))
assertFalse(ready.copy(isSubmitting = true).canSubmit(coreInitialized = true))
}
@Test

View File

@@ -36,7 +36,7 @@ actual fun rememberShareFilePicker(
JvmFilePickerBackend.XdgPortal -> scope.launch {
try {
val selected = withContext(Dispatchers.IO) { pickShareFilesWithPortal() }
if (selected.isNotEmpty()) onFilesPicked(selected)
onFilesPicked(selected)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
@@ -48,7 +48,7 @@ actual fun rememberShareFilePicker(
scope.launch {
try {
val selected = withContext(Dispatchers.IO) { pickWindowsFiles(owner) }
if (selected.isNotEmpty()) onFilesPicked(selected)
onFilesPicked(selected)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
@@ -58,7 +58,7 @@ actual fun rememberShareFilePicker(
}
JvmFilePickerBackend.AwtSwing -> openPicker(onError) {
val selected = pickShareFiles()
if (selected.isNotEmpty()) onFilesPicked(selected)
onFilesPicked(selected)
}
}
}
@@ -69,8 +69,8 @@ actual fun rememberShareFilePicker(
try {
val selected = withContext(Dispatchers.IO) {
pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true)
} ?: return@launch
onFilesPicked(listOf(selected))
}
onFilesPicked(selected?.let(::listOf).orEmpty())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
@@ -83,8 +83,8 @@ actual fun rememberShareFilePicker(
try {
val selected = withContext(Dispatchers.IO) {
pickWindowsFolder("Select folder to share", owner)
} ?: return@launch
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
}
onFilesPicked(selected?.let { listOf(it.toPickedShareFile(isDirectory = true)) }.orEmpty())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
@@ -93,8 +93,8 @@ actual fun rememberShareFilePicker(
}
}
JvmFilePickerBackend.AwtSwing -> openPicker(onError) {
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
val selected = pickDirectory(title = "Select folder to share")
onFilesPicked(selected?.let { listOf(it.toPickedShareFile(isDirectory = true)) }.orEmpty())
}
}
}

View File

@@ -53,35 +53,7 @@ private class JvmFileSystemService : FileSystemService {
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null
override suspend fun sharePickedFiles(
repository: CoreGateway,
files: List<PickedShareFile>,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> {
require(files.isNotEmpty()) { "Select at least one file to share" }
return repository.shareSources(pathShareSources(files), transferName, senderName, accessPolicy)
}
override suspend fun createTargetedTransferFromPickedFiles(
repository: CoreGateway,
receiverEndpointId: String,
files: List<PickedShareFile>,
transferName: String?,
): Result<TargetedTransferModel> {
require(files.isNotEmpty()) { "Select at least one file to share" }
return repository.createTargetedTransfer(receiverEndpointId, pathShareSources(files), transferName)
}
private fun pathShareSources(files: List<PickedShareFile>) = files.map { file ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.PATH,
value = file.value,
displayName = file.displayName,
isDirectory = file.isDirectory || File(file.value).isDirectory,
)
}
}
internal fun desktopTemporaryUsage(receiveFolder: ReceiveFolder): ULong {

Some files were not shown because too many files have changed in this diff Show More