Merge pull request #1 from vnidrop/feat/ui-phase

UI phase
This commit is contained in:
Hammed Abass
2026-07-07 17:39:42 +02:00
committed by GitHub
87 changed files with 13172 additions and 801 deletions

View File

@@ -0,0 +1,118 @@
---
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

@@ -0,0 +1,204 @@
---
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.
---
# Jetpack Compose & Compose Multiplatform
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.
## Existing Project Policy
**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.).
## Workflow
When helping with Jetpack Compose or Compose Multiplatform code, follow this process:
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.
## Dependency Verification Rule
**Before recommending any new dependency or version upgrade, verify:**
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.
**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.
**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.
## Fetching Up-to-Date Documentation
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.
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.
**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.
## Core Architecture: MVI or MVVM
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.
- **MVI**: `sealed interface Event` + single `onEvent()` entry point
- **MVVM**: Named public functions (`onTitleChanged()`, `save()`)
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`
**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.
### UI Rendering Boundary
These boundaries apply to both MVI and MVVM:
- **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)
## Decision Heuristics
- 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
## State Modeling
For calculator/form screens, split state into four buckets:
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
| 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 |
## Recommended Defaults
Apply these unless the project already follows a different coherent pattern.
| 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.

View File

@@ -0,0 +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."

View File

@@ -0,0 +1,195 @@
# 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

@@ -0,0 +1,238 @@
# 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

@@ -0,0 +1,191 @@
# 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

@@ -0,0 +1,109 @@
# 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

@@ -0,0 +1,204 @@
# Architecture & State Management
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.
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
```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
```
| Forbidden | Why |
|---|---|
| `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 |
## State Modeling for Forms and Calculators
Split into four buckets:
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
| 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 |
Use computed properties for trivial derivations:
```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()
}
```
**Avoid duplicated state:** don't store `total` + `formattedTotal` + `totalText`, or `showErrorDialog` + `pendingError` when one implies the other.
## Where Logic Belongs
| Logic | Where |
|---|---|
| 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

View File

@@ -0,0 +1,290 @@
# 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

@@ -0,0 +1,210 @@
# 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

@@ -0,0 +1,232 @@
# 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

@@ -0,0 +1,119 @@
# 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

@@ -0,0 +1,188 @@
# 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

@@ -0,0 +1,232 @@
# 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

@@ -0,0 +1,197 @@
# 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

@@ -0,0 +1,82 @@
# 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

@@ -0,0 +1,298 @@
# 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

@@ -0,0 +1,278 @@
# 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

@@ -0,0 +1,198 @@
# 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

@@ -0,0 +1,208 @@
# 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

@@ -0,0 +1,260 @@
# 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

@@ -0,0 +1,161 @@
# 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

@@ -0,0 +1,246 @@
# 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

@@ -0,0 +1,220 @@
# 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

@@ -0,0 +1,236 @@
# 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

@@ -0,0 +1,139 @@
# 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

@@ -0,0 +1,251 @@
# 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

@@ -0,0 +1,174 @@
# 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

@@ -0,0 +1,229 @@
# 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

@@ -0,0 +1,120 @@
# 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

@@ -0,0 +1,91 @@
# 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

@@ -0,0 +1,237 @@
# 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

@@ -0,0 +1,204 @@
# 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

@@ -0,0 +1,153 @@
# 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

@@ -0,0 +1,270 @@
# 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

@@ -0,0 +1,150 @@
# 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

@@ -0,0 +1,130 @@
# 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

@@ -0,0 +1,219 @@
# 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

@@ -0,0 +1,166 @@
# 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,206 @@
# 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

@@ -0,0 +1,256 @@
# 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

@@ -0,0 +1,222 @@
# 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

@@ -0,0 +1,170 @@
# 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)))
}
}
```

File diff suppressed because it is too large Load Diff

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ captures
**/xcshareddata/WorkspaceSettings.xcsettings **/xcshareddata/WorkspaceSettings.xcsettings
node_modules/ node_modules/
target/ target/
.junie

View File

@@ -10,7 +10,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:theme="@android:style/Theme.Material.NoActionBar">
<activity <activity
android:exported="true" android:exported="true"
android:name=".MainActivity"> android:name=".MainActivity">

View File

@@ -13,6 +13,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
attachAndroidFilePickerContext(this) attachAndroidFilePickerContext(this)
attachAndroidPlatformContext(this)
setContent { setContent {
App() App()

View File

@@ -1,8 +1,17 @@
use std::{path::Path, sync::OnceLock}; use std::{
fs::{self, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::OnceLock,
};
use anyhow::Result; use anyhow::Result;
use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter}; use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter};
const LOG_FILE: &str = "vnidrop.log";
const MAX_LOG_BYTES: u64 = 1_048_576;
const MAX_LOG_FILES: usize = 5;
static LOG_GUARD: OnceLock<tracing_appender::non_blocking::WorkerGuard> = OnceLock::new(); static LOG_GUARD: OnceLock<tracing_appender::non_blocking::WorkerGuard> = OnceLock::new();
pub(crate) fn init_logging(app_data_dir: &Path) -> Result<()> { pub(crate) fn init_logging(app_data_dir: &Path) -> Result<()> {
@@ -11,9 +20,9 @@ pub(crate) fn init_logging(app_data_dir: &Path) -> Result<()> {
} }
let log_dir = app_data_dir.join("logs"); let log_dir = app_data_dir.join("logs");
std::fs::create_dir_all(&log_dir)?; fs::create_dir_all(&log_dir)?;
let file_appender = tracing_appender::rolling::daily(log_dir, "vnidrop.log"); let writer = SizeRotatingWriter::new(log_dir, MAX_LOG_BYTES, MAX_LOG_FILES);
let (writer, guard) = tracing_appender::non_blocking(file_appender); let (writer, guard) = tracing_appender::non_blocking(writer);
let filter = EnvFilter::try_from_default_env() let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("vnidrop=debug,iroh=info,iroh_blobs=info,warn")); .unwrap_or_else(|_| EnvFilter::new("vnidrop=debug,iroh=info,iroh_blobs=info,warn"));
@@ -27,3 +36,71 @@ pub(crate) fn init_logging(app_data_dir: &Path) -> Result<()> {
Ok(()) Ok(())
} }
struct SizeRotatingWriter {
log_dir: PathBuf,
max_bytes: u64,
max_files: usize,
}
impl SizeRotatingWriter {
fn new(log_dir: PathBuf, max_bytes: u64, max_files: usize) -> Self {
Self {
log_dir,
max_bytes,
max_files,
}
}
fn active_path(&self) -> PathBuf {
self.log_dir.join(LOG_FILE)
}
fn rotated_path(&self, index: usize) -> PathBuf {
self.log_dir.join(format!("vnidrop.{index}.log"))
}
fn rotate_if_needed(&self, incoming_bytes: usize) -> io::Result<()> {
fs::create_dir_all(&self.log_dir)?;
let active = self.active_path();
let current_size = active
.metadata()
.map(|metadata| metadata.len())
.unwrap_or(0);
if current_size == 0 || current_size + incoming_bytes as u64 <= self.max_bytes {
return Ok(());
}
if self.max_files == 0 {
let _ = fs::remove_file(active);
return Ok(());
}
let _ = fs::remove_file(self.rotated_path(self.max_files));
for index in (1..self.max_files).rev() {
let source = self.rotated_path(index);
if source.exists() {
let _ = fs::rename(source, self.rotated_path(index + 1));
}
}
if active.exists() {
let _ = fs::rename(active, self.rotated_path(1));
}
Ok(())
}
}
impl Write for SizeRotatingWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.rotate_if_needed(buf.len())?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(self.active_path())?;
file.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}

View File

@@ -11,6 +11,7 @@ dependencies {
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing) implementation(libs.kotlinx.coroutinesSwing)
implementation(libs.jna)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
} }

View File

@@ -0,0 +1,51 @@
package com.vnidrop.app
import com.sun.jna.Library
import com.sun.jna.Native
import com.sun.jna.NativeLibrary
import com.sun.jna.Pointer
internal object MacOsAppKitAppearance {
private val objc: ObjCRuntime? by lazy {
runCatching {
NativeLibrary.getInstance("AppKit")
Native.load("objc", ObjCRuntime::class.java)
}.getOrNull()
}
fun apply(isDarkTheme: Boolean) {
if (!isMacOs()) return
runCatching {
val runtime = objc ?: return
val applicationClass = runtime.objc_getClass("NSApplication") ?: return
val appearanceClass = runtime.objc_getClass("NSAppearance") ?: return
val application = runtime.objc_msgSend(applicationClass, runtime.sel_registerName("sharedApplication")) ?: return
val appearanceName = nsString(runtime, macOsAppearanceName(isDarkTheme)) ?: return
val appearance = runtime.objc_msgSend(
appearanceClass,
runtime.sel_registerName("appearanceNamed:"),
appearanceName,
) ?: return
runtime.objc_msgSend(application, runtime.sel_registerName("setAppearance:"), appearance)
}
}
private fun macOsAppearanceName(isDarkTheme: Boolean): String =
if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
private fun nsString(runtime: ObjCRuntime, value: String): Pointer? {
val stringClass = runtime.objc_getClass("NSString") ?: return null
return runtime.objc_msgSend(stringClass, runtime.sel_registerName("stringWithUTF8String:"), value)
}
private fun isMacOs(): Boolean =
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
}
private interface ObjCRuntime : Library {
fun objc_getClass(name: String): Pointer?
fun sel_registerName(name: String): Pointer
fun objc_msgSend(receiver: Pointer?, selector: Pointer?): Pointer?
fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: Pointer?): Pointer?
fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: String): Pointer?
}

View File

@@ -2,8 +2,12 @@ package com.vnidrop.app
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import com.vnidrop.app.platform.DesktopAppearanceBridge
fun main() = application { fun main() {
configureMacOsNativeAppearance()
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
application {
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "vnidrop", title = "vnidrop",
@@ -11,3 +15,11 @@ fun main() = application {
App() App()
} }
} }
}
private fun configureMacOsNativeAppearance() {
if (!System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) return
// AWT reads this before creating the first native window. Runtime theme
// changes are handled in the JVM platform appearance hook.
System.setProperty("apple.awt.application.appearance", "system")
}

View File

@@ -15,6 +15,7 @@ junit = "4.13.2"
kotlin = "2.4.0" kotlin = "2.4.0"
kotlinx-coroutines = "1.11.0" kotlinx-coroutines = "1.11.0"
material3 = "1.11.0-alpha07" material3 = "1.11.0-alpha07"
jna = "5.17.0"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -36,6 +37,7 @@ compose-components-resources = { module = "org.jetbrains.compose.components:comp
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
[plugins] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }

View File

@@ -2,9 +2,64 @@ import Shared
import SwiftUI import SwiftUI
import UIKit import UIKit
// Compose renders the app content, but UIKit owns the status bar style. This
// host listens for theme changes from shared Kotlin code and asks iOS to
// recompute the status bar contrast.
final class VniDropHostViewController: UIViewController {
private let composeController: UIViewController
private var usesDarkTheme: Bool
init(composeController: UIViewController) {
self.composeController = composeController
self.usesDarkTheme = UITraitCollection.current.userInterfaceStyle == .dark
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var preferredStatusBarStyle: UIStatusBarStyle {
usesDarkTheme ? .lightContent : .darkContent
}
override func viewDidLoad() {
super.viewDidLoad()
addChild(composeController)
view.addSubview(composeController.view)
composeController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
composeController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
composeController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
composeController.view.topAnchor.constraint(equalTo: view.topAnchor),
composeController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
composeController.didMove(toParent: self)
NotificationCenter.default.addObserver(
self,
selector: #selector(themeDidChange(_:)),
name: Notification.Name("VniDropThemeChanged"),
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc private func themeDidChange(_ notification: Notification) {
guard let isDark = notification.userInfo?["isDark"] as? String else { return }
usesDarkTheme = isDark == "true"
setNeedsStatusBarAppearanceUpdate()
}
}
struct ComposeView: UIViewControllerRepresentable { struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Self.Context) -> UIViewController { func makeUIViewController(context: Self.Context) -> UIViewController {
MainViewControllerKt.MainViewController() VniDropHostViewController(composeController: MainViewControllerKt.MainViewController())
} }
func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {} func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {}

View File

@@ -4,5 +4,7 @@
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -1,6 +1,11 @@
package com.vnidrop.app package com.vnidrop.app
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build import android.os.Build
import java.net.NetworkInterface
class AndroidPlatform : Platform { class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}" override val name: String = "Android ${Build.VERSION.SDK_INT}"
@@ -8,6 +13,56 @@ class AndroidPlatform : Platform {
System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop" System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop"
override val defaultReceiveDir: String = override val defaultReceiveDir: String =
System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive" System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive"
override val deviceInfo: DeviceInfo = DeviceInfo(
deviceName = Build.DEVICE,
deviceModel = listOf(Build.MANUFACTURER, Build.MODEL)
.filter { it.isNotBlank() }
.joinToString(" ")
.ifBlank { null },
operatingSystem = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})",
network = activeNetworkSummary(),
batteryLevel = batteryLevel(),
)
} }
actual fun getPlatform(): Platform = AndroidPlatform() actual fun getPlatform(): Platform = AndroidPlatform()
fun attachAndroidPlatformContext(context: Context) {
AndroidPlatformContextHolder.context = context.applicationContext
}
private object AndroidPlatformContextHolder {
var context: Context? = null
}
private fun activeNetworkSummary(): String? =
runCatching {
val context = AndroidPlatformContextHolder.context ?: return@runCatching networkInterfaceName()
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return@runCatching networkInterfaceName()
val activeNetwork = manager.activeNetwork ?: return@runCatching networkInterfaceName()
val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return@runCatching networkInterfaceName()
when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "Mobile"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "Ethernet"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN"
else -> networkInterfaceName()
}
}.getOrNull()
private fun batteryLevel(): String? =
runCatching {
val context = AndroidPlatformContextHolder.context ?: return@runCatching null
val manager = context.getSystemService(BatteryManager::class.java)
val level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
level.takeIf { it >= 0 }?.let { "$it%" }
}.getOrNull()
private fun networkInterfaceName(): String? =
NetworkInterface.getNetworkInterfaces()
.asSequence()
.filter { it.isUp && !it.isLoopback }
.map { it.displayName }
.firstOrNull()

View File

@@ -0,0 +1,56 @@
package com.vnidrop.app.logging
import java.io.File
import java.nio.charset.StandardCharsets
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
AndroidPlatformLogStore(appDataDir, policy)
actual fun platformNowMillis(): Long = System.currentTimeMillis()
private class AndroidPlatformLogStore(
appDataDir: String,
private val policy: LogRotationPolicy,
) : PlatformLogStore {
private val directory = File(appDataDir, "logs")
private val activeFile = File(directory, "app.log")
override val logDirectory: String = directory.absolutePath
@Synchronized
override fun append(line: String) {
directory.mkdirs()
val bytes = line.toByteArray(StandardCharsets.UTF_8)
if (policy.shouldRotate(activeFile.length(), bytes.size.toLong())) {
rotate()
}
activeFile.appendBytes(bytes)
}
@Synchronized
override fun listLogFiles(): List<LogFileInfo> {
directory.mkdirs()
return directory
.listFiles { file -> file.isFile && file.name.startsWith("app") && file.name.endsWith(".log") }
.orEmpty()
.sortedByDescending { it.lastModified() }
.map { file -> LogFileInfo(file.name, file.absolutePath, file.length(), file.lastModified()) }
}
private fun rotate() {
if (policy.maxFiles == 0) {
activeFile.delete()
return
}
File(directory, "app.${policy.maxFiles}.log").delete()
for (index in policy.maxFiles - 1 downTo 1) {
val source = File(directory, "app.$index.log")
if (source.exists()) {
source.renameTo(File(directory, "app.${index + 1}.log"))
}
}
if (activeFile.exists()) {
activeFile.renameTo(File(directory, "app.1.log"))
}
}
}

View File

@@ -0,0 +1,40 @@
package com.vnidrop.app.platform
import android.app.Activity
import android.os.Build
import android.view.View
import android.view.WindowInsetsController
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
@Composable
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
val view = LocalView.current
if (view.isInEditMode) return
SideEffect {
val window = (view.context as? Activity)?.window ?: return@SideEffect
window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb()
val useDarkIcons = systemBarIconModeForTheme(isDarkTheme) == SystemBarIconMode.DarkIcons
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val lightBars = WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
window.insetsController?.setSystemBarsAppearance(if (useDarkIcons) lightBars else 0, lightBars)
} else {
var flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
if (useDarkIcons) {
flags = flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
flags = flags or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
}
}
window.decorView.systemUiVisibility = flags
}
}
}

View File

@@ -0,0 +1,73 @@
<resources>
<string name="nav_send">Send</string>
<string name="nav_receive">Receive</string>
<string name="nav_settings">Settings</string>
<string name="send_title">Send</string>
<string name="send_subtitle">Create a VniDrop ticket and approve receivers when required.</string>
<string name="source_title">Source</string>
<string name="send_source_empty">Select a file to start a share. The app keeps bytes in Rust and platform file handles.</string>
<string name="button_select_file">Select file</string>
<string name="button_clear">Clear</string>
<string name="transfer_details_title">Transfer details</string>
<string name="field_transfer_name">Transfer name</string>
<string name="field_sender_name">Sender name</string>
<string name="button_create_share_ticket">Create share ticket</string>
<string name="button_creating_ticket">Creating ticket...</string>
<string name="share_ticket_title">Share ticket</string>
<string name="receiver_requests_title">Receiver requests</string>
<string name="button_copy">Copy</string>
<string name="button_use_locally">Use locally</string>
<string name="button_refresh">Refresh</string>
<string name="button_refuse">Refuse</string>
<string name="button_approve">Approve</string>
<string name="receive_title">Receive</string>
<string name="receive_subtitle">Inspect a ticket, request access, and stream files into the output directory.</string>
<string name="ticket_card_title">Ticket</string>
<string name="field_ticket">Ticket</string>
<string name="field_output_directory">Output directory</string>
<string name="field_receiver_name">Receiver name</string>
<string name="button_inspect_ticket">Inspect ticket</string>
<string name="button_receive">Receive</string>
<string name="button_receiving">Receiving...</string>
<string name="ticket_details_title">Ticket details</string>
<string name="ticket_no_metadata">This ticket does not include VniDrop metadata.</string>
<string name="settings_title">Settings</string>
<string name="settings_subtitle">Configure the local node and app appearance.</string>
<string name="node_title">Node</string>
<string name="appearance_title">Appearance</string>
<string name="appearance_mode_title">Display mode</string>
<string name="appearance_system_mode">System</string>
<string name="appearance_dark_mode">Dark mode</string>
<string name="appearance_light_mode">Light mode</string>
<string name="appearance_auto_description">VniDrop follows the theme selected on this device.</string>
<string name="about_title">About</string>
<string name="about_privacy">Privacy policy</string>
<string name="about_bug_report">Report a bug</string>
<string name="version_title">App version</string>
<string name="device_name_title">Device name</string>
<string name="device_model_title">Device model</string>
<string name="os_version_title">Operating system</string>
<string name="network_title">Network</string>
<string name="battery_level_title">Battery level</string>
<string name="value_unavailable">Not available</string>
<string name="core_status_ready">Ready</string>
<string name="event_log_title">Event log</string>
<string name="no_events">No events have been emitted yet.</string>
<string name="progress_title">Progress</string>
<string name="not_initialized">Not initialized</string>
<string name="unknown_sender">Unknown</string>
<string name="metadata_name">Name</string>
<string name="metadata_source">Source</string>
<string name="metadata_transfer">Transfer</string>
<string name="metadata_size">Size</string>
<string name="metadata_kind">Kind</string>
<string name="metadata_sender">Sender</string>
<string name="metadata_files">Files</string>
<string name="metadata_hash">Hash</string>
<string name="metadata_platform">Platform</string>
<string name="metadata_status">Status</string>
<string name="error_invalid_ticket">The ticket could not be read. Check that the full ticket was copied.</string>
<string name="error_permission">The transfer is waiting for approval or was refused by the sender.</string>
<string name="error_socket_bind">VniDrop could not open its network sockets on this device.</string>
<string name="error_missing_native_library">The native VniDrop library is missing from this build.</string>
</resources>

View File

@@ -1,35 +1,6 @@
package com.vnidrop.app package com.vnidrop.app
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.safeContentPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -38,51 +9,27 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreRepository import com.vnidrop.app.core.CoreRepository
import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.rememberShareFilePicker import com.vnidrop.app.core.rememberShareFilePicker
import com.vnidrop.app.core.sharePickedFile import com.vnidrop.app.core.sharePickedFile
import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.ui.components.ErrorBanner import com.vnidrop.app.platform.PlatformSystemAppearance
import com.vnidrop.app.ui.components.Field import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.components.MetadataRow import com.vnidrop.app.ui.screens.ReceiveScreen
import com.vnidrop.app.ui.components.PillTone import com.vnidrop.app.ui.screens.SendScreen
import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.screens.SettingsScreen
import com.vnidrop.app.ui.components.ProgressRow import com.vnidrop.app.ui.shell.AppShell
import com.vnidrop.app.ui.components.QuietButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.components.StatusPill
import com.vnidrop.app.ui.state.AppDestination
import com.vnidrop.app.ui.state.AppUiState import com.vnidrop.app.ui.state.AppUiState
import com.vnidrop.app.ui.state.ReceiveUiState import com.vnidrop.app.ui.state.ReceiveUiState
import com.vnidrop.app.ui.state.SendUiState import com.vnidrop.app.ui.state.SendUiState
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.activeReceiverRequests
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.state.friendlyCoreError
import com.vnidrop.app.ui.state.summarizeProgress
import com.vnidrop.app.ui.state.transferSubtitle
import com.vnidrop.app.ui.state.windowClassFor import com.vnidrop.app.ui.state.windowClassFor
import com.vnidrop.app.ui.theme.LocalVniDropColors
import com.vnidrop.app.ui.theme.ThemeMode
import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.ShareResult
import uniffi.vnidrop.StoredTransfer
import uniffi.vnidrop.TicketInspection
@Composable @Composable
@Preview @Preview
@@ -91,7 +38,7 @@ fun App() {
val repository = remember { CoreRepository() } val repository = remember { CoreRepository() }
val coreState by repository.state.collectAsState() val coreState by repository.state.collectAsState()
var appState by remember { mutableStateOf(AppUiState()) } var appState by remember { mutableStateOf(AppUiState()) }
var appDataDir by remember { mutableStateOf(platform.defaultCoreDataDir) } val appDataDir = platform.defaultCoreDataDir
var sendState by remember { mutableStateOf(SendUiState()) } var sendState by remember { mutableStateOf(SendUiState()) }
var receiveState by remember { mutableStateOf(ReceiveUiState(outputDirectory = platform.defaultReceiveDir)) } var receiveState by remember { mutableStateOf(ReceiveUiState(outputDirectory = platform.defaultReceiveDir)) }
var selectedFile by remember { mutableStateOf<PickedShareFile?>(null) } var selectedFile by remember { mutableStateOf<PickedShareFile?>(null) }
@@ -99,59 +46,80 @@ fun App() {
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val picker = rememberShareFilePicker( val picker = rememberShareFilePicker(
onFilePicked = { file -> onFilePicked = { file ->
AppLogger.info("file-picker", "file selected", mapOf("name" to file.displayName))
selectedFile = file selectedFile = file
sendState = sendState.copy( sendState = sendState.withSelectedFile(file)
selectedSource = file.value, },
selectedDisplayName = file.displayName, onError = { error ->
transferName = if (sendState.transferName == "VniDrop transfer" || sendState.transferName.isBlank()) { AppLogger.warn("file-picker", "file picker error", mapOf("reason" to error))
file.displayName scope.launch { repository.setError(error) }
} else {
sendState.transferName
}, },
) )
},
onError = { error -> scope.launch { repository.setError(error) } }, LaunchedEffect(Unit) {
) AppLogger.initialize(appDataDir)
AppLogger.info("lifecycle", "app started", mapOf("platform" to platform.name))
AppLogger.info("core", "automatic initialize requested", mapOf("appDataDir" to appDataDir))
repository.initialize(appDataDir)
}
LaunchedEffect(coreState.lastShare?.transferId) { LaunchedEffect(coreState.lastShare?.transferId) {
coreState.lastShare?.let { share -> repository.refreshReceiverRequests(share.transferId) } coreState.lastShare?.let { share -> repository.refreshReceiverRequests(share.transferId) }
} }
VniDropTheme(mode = appState.themeMode) { val isDarkTheme = rememberResolvedDarkTheme(appState.themeMode)
PlatformSystemAppearance(isDarkTheme)
LaunchedEffect(isDarkTheme) {
AppLogger.info("appearance", "system appearance synchronized", mapOf("dark" to isDarkTheme.toString()))
}
VniDropTheme(isDarkTheme = isDarkTheme) {
BoxWithConstraints { BoxWithConstraints {
val windowClass = windowClassFor(maxWidth.value) val windowClass = windowClassFor(maxWidth.value)
AppFrame( AppShell(
appState = appState, selectedDestination = appState.destination,
coreState = coreState,
windowClass = windowClass, windowClass = windowClass,
onDestinationChange = { appState = appState.copy(destination = it) }, onDestinationSelected = { appState = appState.copy(destination = it) },
) { ) {
when (appState.destination) { when (appState.destination) {
AppDestination.Send -> SendScreen( AppDestination.Send -> SendScreen(
coreState = coreState, coreState = coreState,
sendState = sendState, sendState = sendState,
onSendStateChange = { sendState = it }, onSendStateChange = { sendState = it },
onSelectFile = { picker.pickFile() }, onSelectFile = {
AppLogger.info("file-picker", "open share file picker")
picker.pickFile()
},
onCreateShare = { onCreateShare = {
scope.launch { scope.launch {
AppLogger.info("send", "create share requested", mapOf("source" to sendState.selectedSource))
sendState = sendState.copy(isSharing = true) sendState = sendState.copy(isSharing = true)
val file = selectedFile val file = selectedFile
if (file != null) { if (file == null) {
sharePickedFile(repository, file, sendState.transferName, sendState.senderName)
} else {
repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName) repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName)
} else {
sharePickedFile(repository, file, sendState.transferName, sendState.senderName)
} }
sendState = sendState.copy(isSharing = false) sendState = sendState.copy(isSharing = false)
} }
}, },
onCopyTicket = { ticket -> clipboard.setText(AnnotatedString(ticket)) }, onCopyTicket = { ticket ->
AppLogger.info("send", "ticket copied")
clipboard.setText(AnnotatedString(ticket))
},
onUseLocally = { ticket -> onUseLocally = { ticket ->
receiveState = receiveState.copy(ticket = ticket) receiveState = receiveState.copy(ticket = ticket)
appState = appState.copy(destination = AppDestination.Receive) appState = appState.copy(destination = AppDestination.Receive)
}, },
onRefreshRequests = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } }, onRefreshRequests = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } },
onRespondRequest = { requestId, accepted -> onRespondRequest = { requestId, accepted ->
scope.launch { repository.respondReceiverRequest(requestId, accepted, reason = if (accepted) null else "sender-refused") } scope.launch {
repository.respondReceiverRequest(
requestId = requestId,
accepted = accepted,
reason = if (accepted) null else "sender-refused",
)
}
}, },
) )
AppDestination.Receive -> ReceiveScreen( AppDestination.Receive -> ReceiveScreen(
@@ -161,598 +129,32 @@ fun App() {
onInspect = { scope.launch { repository.inspectTicket(receiveState.ticket) } }, onInspect = { scope.launch { repository.inspectTicket(receiveState.ticket) } },
onReceive = { onReceive = {
scope.launch { scope.launch {
AppLogger.info("receive", "receive requested")
receiveState = receiveState.copy(isReceiving = true) receiveState = receiveState.copy(isReceiving = true)
repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName) repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName)
receiveState = receiveState.copy(isReceiving = false) receiveState = receiveState.copy(isReceiving = false)
} }
}, },
) )
AppDestination.Activity -> ActivityScreen(
coreState = coreState,
onRefresh = {
scope.launch {
repository.refreshTransfers()
repository.refreshEvents()
}
},
onCancel = { transferId -> scope.launch { repository.cancel(transferId) } },
)
AppDestination.Requests -> RequestsScreen(
requests = coreState.receiverRequests,
lastShare = coreState.lastShare,
onRefresh = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } },
onRespond = { requestId, accepted ->
scope.launch { repository.respondReceiverRequest(requestId, accepted, reason = if (accepted) null else "sender-refused") }
},
)
AppDestination.Settings -> SettingsScreen( AppDestination.Settings -> SettingsScreen(
platformName = platform.name, deviceInfo = platform.deviceInfo,
appDataDir = appDataDir,
onAppDataDirChange = { appDataDir = it },
coreState = coreState, coreState = coreState,
themeMode = appState.themeMode, themeMode = appState.themeMode,
onThemeModeChange = { appState = appState.copy(themeMode = it) }, windowClass = windowClass,
diagnosticsVisible = appState.diagnosticsVisible, onThemeModeChange = {
onDiagnosticsVisibleChange = { appState = appState.copy(diagnosticsVisible = it) }, AppLogger.info("appearance", "theme mode changed", mapOf("mode" to it.name))
onInitialize = { scope.launch { repository.initialize(appDataDir) } }, appState = appState.copy(themeMode = it)
) },
}
if (appState.diagnosticsVisible) {
DiagnosticsPanel(events = coreState.events)
}
}
}
}
}
@Composable
private fun AppFrame(
appState: AppUiState,
coreState: CoreUiState,
windowClass: WindowClass,
onDestinationChange: (AppDestination) -> Unit,
content: @Composable () -> Unit,
) {
val colors = LocalVniDropColors.current
Surface(
modifier = Modifier
.fillMaxSize()
.background(colors.canvas)
.safeContentPadding(),
color = colors.canvas,
) {
if (windowClass == WindowClass.Compact) {
Column(modifier = Modifier.fillMaxSize()) {
TopBar(coreState = coreState)
Box(modifier = Modifier.weight(1f)) {
ScreenContent(content = content)
}
BottomNav(selected = appState.destination, onDestinationChange = onDestinationChange)
}
} else {
Row(modifier = Modifier.fillMaxSize()) {
SideNav(
selected = appState.destination,
coreState = coreState,
onDestinationChange = onDestinationChange,
)
Box(modifier = Modifier.weight(1f)) {
ScreenContent(content = content)
}
}
}
}
}
@Composable
private fun ScreenContent(content: @Composable () -> Unit) {
val colors = LocalVniDropColors.current
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(colors.canvas)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
content()
}
}
}
@Composable
private fun TopBar(coreState: CoreUiState) {
val colors = LocalVniDropColors.current
Row(
modifier = Modifier
.fillMaxWidth()
.background(colors.sidebar)
.border(BorderStroke(1.dp, colors.border))
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("VniDrop", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
NodeStatus(coreState)
}
}
@Composable
private fun SideNav(
selected: AppDestination,
coreState: CoreUiState,
onDestinationChange: (AppDestination) -> Unit,
) {
val colors = LocalVniDropColors.current
Column(
modifier = Modifier
.width(220.dp)
.fillMaxHeight()
.background(colors.sidebar)
.border(BorderStroke(1.dp, colors.border))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("VniDrop", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text("Private file transfer", color = colors.textMuted, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(12.dp))
AppDestination.entries.forEach { destination ->
NavItem(
destination = destination,
selected = destination == selected,
onClick = { onDestinationChange(destination) },
)
}
Spacer(Modifier.weight(1f))
NodeStatus(coreState)
}
}
@Composable
private fun BottomNav(
selected: AppDestination,
onDestinationChange: (AppDestination) -> Unit,
) {
val colors = LocalVniDropColors.current
Row(
modifier = Modifier
.fillMaxWidth()
.background(colors.sidebar)
.border(BorderStroke(1.dp, colors.border))
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
AppDestination.entries.forEach { destination ->
NavItem(
destination = destination,
selected = destination == selected,
onClick = { onDestinationChange(destination) },
modifier = Modifier.weight(1f),
compact = true,
) )
} }
} }
} }
}
}
@Composable private fun SendUiState.withSelectedFile(file: PickedShareFile): SendUiState =
private fun NavItem( copy(
destination: AppDestination, selectedSource = file.value,
selected: Boolean, selectedDisplayName = file.displayName,
onClick: () -> Unit, transferName = if (transferName == "VniDrop transfer" || transferName.isBlank()) file.displayName else transferName,
modifier: Modifier = Modifier,
compact: Boolean = false,
) {
val colors = LocalVniDropColors.current
val background = if (selected) colors.surfaceMuted else colors.sidebar
val border = if (selected) colors.brand.copy(alpha = 0.55f) else colors.border.copy(alpha = 0f)
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(background)
.border(1.dp, border, RoundedCornerShape(8.dp))
.selectable(selected = selected, onClick = onClick)
.padding(horizontal = if (compact) 6.dp else 12.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Text(
destination.label,
style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.bodyMedium,
color = if (selected) colors.textPrimary else colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
}
}
@Composable
private fun NodeStatus(coreState: CoreUiState) {
StatusPill(
label = if (coreState.isInitialized) "Online" else "Offline",
tone = if (coreState.isInitialized) PillTone.Success else PillTone.Neutral,
)
}
@Composable
private fun SendScreen(
coreState: CoreUiState,
sendState: SendUiState,
onSendStateChange: (SendUiState) -> Unit,
onSelectFile: () -> Unit,
onCreateShare: () -> Unit,
onCopyTicket: (String) -> Unit,
onUseLocally: (String) -> Unit,
onRefreshRequests: (ULong) -> Unit,
onRespondRequest: (String, Boolean) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader("Send", "Create a VniDrop ticket and approve receivers when required.")
ErrorSection(coreState)
AppCard(title = "Source") {
if (sendState.selectedSource.isBlank()) {
EmptyText("Select a file to start a share. The app keeps bytes in Rust and platform file handles.")
} else {
MetadataRow("Name", sendState.selectedDisplayName.ifBlank { sendState.selectedSource.substringAfterLast('/') })
MetadataRow("Source", sendState.selectedSource)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton("Select file", onClick = onSelectFile)
SecondaryButton(
text = "Clear",
onClick = { onSendStateChange(sendState.copy(selectedSource = "", selectedDisplayName = "")) },
enabled = sendState.selectedSource.isNotBlank(),
)
}
}
AppCard(title = "Transfer details") {
Field(
value = sendState.transferName,
onValueChange = { onSendStateChange(sendState.copy(transferName = it)) },
label = "Transfer name",
)
Field(
value = sendState.senderName,
onValueChange = { onSendStateChange(sendState.copy(senderName = it)) },
label = "Sender name",
)
PrimaryButton(
text = if (sendState.isSharing) "Creating ticket..." else "Create share ticket",
onClick = onCreateShare,
enabled = coreState.isInitialized && sendState.selectedSource.isNotBlank() && !sendState.isSharing,
)
}
coreState.lastShare?.let { share ->
ShareResultCard(
share = share,
requests = coreState.receiverRequests,
onCopyTicket = onCopyTicket,
onUseLocally = onUseLocally,
onRefreshRequests = onRefreshRequests,
onRespondRequest = onRespondRequest,
)
}
ProgressSection(coreState)
}
}
@Composable
private fun ShareResultCard(
share: ShareResult,
requests: List<ReceiverRequest>,
onCopyTicket: (String) -> Unit,
onUseLocally: (String) -> Unit,
onRefreshRequests: (ULong) -> Unit,
onRespondRequest: (String, Boolean) -> Unit,
) {
AppCard(title = "Share ticket", trailing = {
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
}) {
MetadataRow("Transfer", share.transferName)
MetadataRow("Size", formatBytes(share.totalSize))
SelectionContainer {
Text(
text = share.ticket,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(LocalVniDropColors.current.surfaceMuted)
.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton("Copy", onClick = { onCopyTicket(share.ticket) })
SecondaryButton("Use locally", onClick = { onUseLocally(share.ticket) })
SecondaryButton("Refresh", onClick = { onRefreshRequests(share.transferId) })
}
if (requests.isNotEmpty()) {
HorizontalDivider(color = LocalVniDropColors.current.border)
ReceiverRequestList(requests = requests, onRespondRequest = onRespondRequest)
}
}
}
@Composable
private fun ReceiveScreen(
coreState: CoreUiState,
receiveState: ReceiveUiState,
onReceiveStateChange: (ReceiveUiState) -> Unit,
onInspect: () -> Unit,
onReceive: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader("Receive", "Inspect a ticket, request access, and stream files into the output directory.")
ErrorSection(coreState)
AppCard(title = "Ticket") {
Field(
value = receiveState.ticket,
onValueChange = { onReceiveStateChange(receiveState.copy(ticket = it)) },
label = "Ticket",
minLines = 4,
)
Field(
value = receiveState.outputDirectory,
onValueChange = { onReceiveStateChange(receiveState.copy(outputDirectory = it)) },
label = "Output directory",
)
Field(
value = receiveState.receiverName,
onValueChange = { onReceiveStateChange(receiveState.copy(receiverName = it)) },
label = "Receiver name",
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SecondaryButton(
text = "Inspect ticket",
onClick = onInspect,
enabled = coreState.isInitialized && receiveState.ticket.isNotBlank(),
)
PrimaryButton(
text = if (receiveState.isReceiving) "Receiving..." else "Receive",
onClick = onReceive,
enabled = coreState.isInitialized &&
receiveState.ticket.isNotBlank() &&
receiveState.outputDirectory.isNotBlank() &&
!receiveState.isReceiving,
)
}
}
coreState.lastInspection?.let { TicketInspectionCard(it) }
ProgressSection(coreState)
}
}
@Composable
private fun TicketInspectionCard(inspection: TicketInspection) {
AppCard(title = "Ticket details") {
MetadataRow("Kind", inspection.kind)
inspection.metadata?.let { metadata ->
MetadataRow("Transfer", metadata.transferName)
MetadataRow("Sender", metadata.senderName ?: "Unknown")
MetadataRow("Files", metadata.fileCount.toString())
MetadataRow("Size", formatBytes(metadata.totalSize))
MetadataRow("Hash", metadata.contentHash)
} ?: EmptyText("This ticket does not include VniDrop metadata.")
}
}
@Composable
private fun ActivityScreen(
coreState: CoreUiState,
onRefresh: () -> Unit,
onCancel: (ULong) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader("Activity", "Follow current and recent transfers from the Rust core.")
ErrorSection(coreState)
AppCard(title = "Transfers", trailing = { SecondaryButton("Refresh", onClick = onRefresh) }) {
if (coreState.transfers.isEmpty()) {
EmptyText("No transfers yet.")
} else {
coreState.transfers.forEach { transfer ->
TransferRow(transfer = transfer, onCancel = onCancel)
}
}
}
ProgressSection(coreState)
}
}
@Composable
private fun TransferRow(transfer: StoredTransfer, onCancel: (ULong) -> Unit) {
val status = displayNameForStatus(transfer.status)
val tone = when (transfer.status.lowercase()) {
"done" -> PillTone.Success
"failed" -> PillTone.Destructive
"cancelled", "stopped" -> PillTone.Warning
"sharing", "receiving" -> PillTone.Brand
else -> PillTone.Neutral
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(LocalVniDropColors.current.surfaceRaised)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(transfer.transferName ?: "Transfer ${transfer.transferId}", fontWeight = FontWeight.SemiBold)
Text(transferSubtitle(transfer), color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall)
}
StatusPill(status, tone = tone)
}
if (transfer.status == "sharing" || transfer.status == "receiving") {
QuietButton("Cancel", onClick = { onCancel(transfer.transferId) })
}
}
}
@Composable
private fun RequestsScreen(
requests: List<ReceiverRequest>,
lastShare: ShareResult?,
onRefresh: (ULong) -> Unit,
onRespond: (String, Boolean) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader("Requests", "Approve or refuse receivers for the current share.")
AppCard(title = "Receiver requests", trailing = {
lastShare?.let { SecondaryButton("Refresh", onClick = { onRefresh(it.transferId) }) }
}) {
if (lastShare == null) {
EmptyText("Create a share ticket first.")
} else if (requests.isEmpty()) {
EmptyText("No receiver requests yet.")
} else {
ReceiverRequestList(requests = requests, onRespondRequest = onRespond)
}
}
}
}
@Composable
private fun ReceiverRequestList(
requests: List<ReceiverRequest>,
onRespondRequest: (String, Boolean) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
requests.forEach { request ->
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(LocalVniDropColors.current.surfaceRaised)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(request.receiverName ?: "Receiver", fontWeight = FontWeight.SemiBold)
Text(request.remoteEndpointId.take(28), color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall)
}
StatusPill(displayNameForStatus(request.status), tone = if (request.status == "requested") PillTone.Warning else PillTone.Neutral)
}
request.reason?.let { Text(it, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall) }
if (request.status == "requested") {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SecondaryButton("Refuse", onClick = { onRespondRequest(request.id, false) })
PrimaryButton("Approve", onClick = { onRespondRequest(request.id, true) })
}
}
}
}
}
}
@Composable
private fun SettingsScreen(
platformName: String,
appDataDir: String,
onAppDataDirChange: (String) -> Unit,
coreState: CoreUiState,
themeMode: ThemeMode,
onThemeModeChange: (ThemeMode) -> Unit,
diagnosticsVisible: Boolean,
onDiagnosticsVisibleChange: (Boolean) -> Unit,
onInitialize: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader("Settings", "Configure the local node and app appearance.")
ErrorSection(coreState)
AppCard(title = "Node") {
MetadataRow("Platform", platformName)
MetadataRow("Status", coreState.status)
Field(value = appDataDir, onValueChange = onAppDataDirChange, label = "Core data directory")
PrimaryButton("Initialize core", onClick = onInitialize)
}
AppCard(title = "Appearance") {
ThemeMode.entries.forEach { mode ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.selectable(selected = themeMode == mode, onClick = { onThemeModeChange(mode) })
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = themeMode == mode, onClick = { onThemeModeChange(mode) })
Text(mode.name)
}
}
}
AppCard(title = "Diagnostics") {
SecondaryButton(
text = if (diagnosticsVisible) "Hide event log" else "Show event log",
onClick = { onDiagnosticsVisibleChange(!diagnosticsVisible) },
)
EmptyText("Diagnostics are intentionally separate from the primary flow so transfer state stays readable.")
}
}
}
@Composable
private fun DiagnosticsPanel(events: List<CoreEvent>) {
AppCard(title = "Event log") {
if (events.isEmpty()) {
EmptyText("No events have been emitted yet.")
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.height(280.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
events.forEach { event ->
EventRow(event)
}
}
}
}
}
@Composable
private fun EventRow(event: CoreEvent) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(LocalVniDropColors.current.surfaceRaised)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("${event.scope}/${event.direction ?: "-"} ${event.phase}:${event.kind}", style = MaterialTheme.typography.bodySmall)
Text(event.dataJson, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall)
}
}
@Composable
private fun ProgressSection(coreState: CoreUiState) {
val progress = summarizeProgress(coreState.events)
if (progress.isNotEmpty()) {
AppCard(title = "Progress") {
progress.forEach { item ->
ProgressRow(label = item.label, progress = item.progress)
}
}
}
}
@Composable
private fun ScreenHeader(title: String, subtitle: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(subtitle, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun ErrorSection(coreState: CoreUiState) {
friendlyCoreError(coreState.error)?.let { ErrorBanner(it) }
}
@Composable
private fun EmptyText(text: String) {
Text(text, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodyMedium)
}

View File

@@ -4,6 +4,15 @@ interface Platform {
val name: String val name: String
val defaultCoreDataDir: String val defaultCoreDataDir: String
val defaultReceiveDir: String val defaultReceiveDir: String
val deviceInfo: DeviceInfo
} }
data class DeviceInfo(
val deviceName: String?,
val deviceModel: String?,
val operatingSystem: String,
val network: String?,
val batteryLevel: String?,
)
expect fun getPlatform(): Platform expect fun getPlatform(): Platform

View File

@@ -0,0 +1,95 @@
package com.vnidrop.app.logging
data class LogRotationPolicy(
val maxBytes: Long = 1_048_576,
val maxFiles: Int = 5,
) {
init {
require(maxBytes > 0) { "maxBytes must be positive" }
require(maxFiles >= 0) { "maxFiles must be zero or positive" }
}
fun shouldRotate(currentBytes: Long, incomingBytes: Long): Boolean =
currentBytes > 0 && currentBytes + incomingBytes > maxBytes
}
enum class AppLogLevel {
Debug,
Info,
Warn,
Error,
}
data class LogFileInfo(
val name: String,
val path: String,
val sizeBytes: Long,
val modifiedAtMillis: Long,
)
interface PlatformLogStore {
val logDirectory: String
fun append(line: String)
fun listLogFiles(): List<LogFileInfo>
}
expect fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore
expect fun platformNowMillis(): Long
object AppLogger {
private var store: PlatformLogStore? = null
private var activeDirectory: String? = null
val logDirectory: String?
get() = store?.logDirectory
fun initialize(appDataDir: String, policy: LogRotationPolicy = LogRotationPolicy()) {
if (activeDirectory == appDataDir && store != null) return
store = createPlatformLogStore(appDataDir, policy)
activeDirectory = appDataDir
info("logging", "app logger initialized", mapOf("directory" to (store?.logDirectory ?: "")))
}
fun debug(scope: String, message: String, fields: Map<String, String> = emptyMap()) =
write(AppLogLevel.Debug, scope, message, fields)
fun info(scope: String, message: String, fields: Map<String, String> = emptyMap()) =
write(AppLogLevel.Info, scope, message, fields)
fun warn(scope: String, message: String, fields: Map<String, String> = emptyMap()) =
write(AppLogLevel.Warn, scope, message, fields)
fun error(scope: String, message: String, throwable: Throwable? = null, fields: Map<String, String> = emptyMap()) {
val allFields = if (throwable == null) {
fields
} else {
fields + ("error" to (throwable.message ?: throwable.toString()))
}
write(AppLogLevel.Error, scope, message, allFields)
}
fun listLogFiles(): List<LogFileInfo> =
store?.listLogFiles().orEmpty()
private fun write(level: AppLogLevel, scope: String, message: String, fields: Map<String, String>) {
val line = buildString {
append(platformNowMillis())
append(" ")
append(level.name.uppercase())
append(" [")
append(scope)
append("] ")
append(message)
if (fields.isNotEmpty()) {
append(" ")
append(fields.entries.joinToString(" ") { (key, value) -> "$key=${value.sanitizeLogValue()}" })
}
append("\n")
}
store?.append(line)
}
}
private fun String.sanitizeLogValue(): String =
replace('\n', ' ').replace('\r', ' ')

View File

@@ -0,0 +1,6 @@
package com.vnidrop.app.platform
import androidx.compose.runtime.Composable
@Composable
expect fun PlatformSystemAppearance(isDarkTheme: Boolean)

View File

@@ -0,0 +1,9 @@
package com.vnidrop.app.platform
enum class SystemBarIconMode {
LightIcons,
DarkIcons,
}
fun systemBarIconModeForTheme(isDarkTheme: Boolean): SystemBarIconMode =
if (isDarkTheme) SystemBarIconMode.LightIcons else SystemBarIconMode.DarkIcons

View File

@@ -46,8 +46,8 @@ fun AppCard(
Card( Card(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colors.surface), colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface75),
border = BorderStroke(1.dp, colors.border), border = BorderStroke(1.dp, colors.borderDefault),
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@@ -61,7 +61,7 @@ fun AppCard(
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
trailing?.invoke() trailing?.invoke()
} }
HorizontalDivider(color = colors.border) HorizontalDivider(color = colors.borderDefault)
content() content()
} }
} }
@@ -100,7 +100,7 @@ fun PrimaryButton(
enabled = enabled, enabled = enabled,
modifier = modifier.heightIn(min = 44.dp), modifier = modifier.heightIn(min = 44.dp),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = colors.brand, contentColor = Color.White), colors = ButtonDefaults.buttonColors(containerColor = colors.brandButton, contentColor = Color.White),
) { ) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
@@ -143,11 +143,11 @@ fun StatusPill(
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
val color = when (tone) { val color = when (tone) {
PillTone.Neutral -> colors.textMuted PillTone.Neutral -> colors.foregroundLighter
PillTone.Success -> colors.success PillTone.Success -> colors.brandLink
PillTone.Warning -> colors.warning PillTone.Warning -> colors.warningDefault
PillTone.Destructive -> colors.destructive PillTone.Destructive -> colors.destructiveDefault
PillTone.Brand -> colors.brand PillTone.Brand -> colors.brandLink
} }
Row( Row(
modifier = modifier modifier = modifier
@@ -182,8 +182,8 @@ fun ErrorBanner(message: String, modifier: Modifier = Modifier) {
Card( Card(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colors.destructive.copy(alpha = 0.14f)), colors = CardDefaults.cardColors(containerColor = colors.destructive200),
border = BorderStroke(1.dp, colors.destructive.copy(alpha = 0.28f)), border = BorderStroke(1.dp, colors.destructive400),
) { ) {
Text( Text(
text = message, text = message,
@@ -220,7 +220,7 @@ fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) {
Text( Text(
text = label, text = label,
modifier = Modifier.weight(0.35f), modifier = Modifier.weight(0.35f),
color = LocalVniDropColors.current.textMuted, color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Text( Text(

View File

@@ -0,0 +1,29 @@
package com.vnidrop.app.ui.navigation
import androidx.compose.ui.graphics.vector.ImageVector
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_send
import vnidrop.shared.generated.resources.nav_settings
enum class AppDestination {
Send,
Receive,
Settings,
}
data class NavigationItem(
val destination: AppDestination,
val label: StringResource,
val icon: ImageVector,
)
// 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.
val primaryNavigationItems = listOf(
NavigationItem(AppDestination.Send, Res.string.nav_send, VniDropIcons.Send),
NavigationItem(AppDestination.Receive, Res.string.nav_receive, VniDropIcons.Receive),
NavigationItem(AppDestination.Settings, Res.string.nav_settings, VniDropIcons.Settings),
)

View File

@@ -0,0 +1,168 @@
package com.vnidrop.app.ui.navigation
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
@Composable
fun AppSidebarNavigation(
selected: AppDestination,
onDestinationSelected: (AppDestination) -> Unit,
modifier: Modifier = Modifier,
) {
val colors = LocalVniDropColors.current
Box(
modifier = modifier
.width(88.dp)
.fillMaxHeight()
.background(colors.backgroundSurface200),
) {
Column(
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
primaryNavigationItems.forEach { item ->
SidebarNavigationItem(
item = item,
selected = item.destination == selected,
onClick = { onDestinationSelected(item.destination) },
)
}
}
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.width(1.dp)
.fillMaxHeight()
.background(colors.borderDefault),
)
}
}
@Composable
fun AppBottomNavigation(
selected: AppDestination,
onDestinationSelected: (AppDestination) -> Unit,
modifier: Modifier = Modifier,
) {
val colors = LocalVniDropColors.current
Column(
modifier = modifier
.fillMaxWidth()
.background(colors.backgroundSurface200),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
primaryNavigationItems.forEach { item ->
BottomNavigationItem(
item = item,
selected = item.destination == selected,
onClick = { onDestinationSelected(item.destination) },
modifier = Modifier.weight(1f),
)
}
}
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
@Composable
private fun SidebarNavigationItem(
item: NavigationItem,
selected: Boolean,
onClick: () -> Unit,
) {
val colors = LocalVniDropColors.current
val foreground = if (selected) colors.brandLink else colors.foregroundLight
val label = stringResource(item.label)
Box(
modifier = Modifier
.fillMaxWidth()
.selectable(selected = selected, onClick = onClick)
.padding(vertical = 13.dp),
) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
Icon(imageVector = item.icon, contentDescription = label, tint = foreground, modifier = Modifier.size(24.dp))
Text(
text = label,
color = foreground,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun BottomNavigationItem(
item: NavigationItem,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = LocalVniDropColors.current
val foreground = if (selected) colors.brandLink else colors.foregroundLight
val label = stringResource(item.label)
Column(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.selectable(selected = selected, onClick = onClick)
.fillMaxHeight()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
) {
Icon(imageVector = item.icon, contentDescription = label, tint = foreground, modifier = Modifier.size(24.dp))
Text(
text = label,
color = foreground,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View File

@@ -0,0 +1,84 @@
package com.vnidrop.app.ui.navigation
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
object VniDropIcons {
val Send: ImageVector by lazy {
ImageVector.Builder("Send", 24.dp, 24.dp, 24f, 24f).apply {
path(
fill = SolidColor(Color.Transparent),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
pathFillType = PathFillType.NonZero,
) {
moveTo(22f, 2f)
lineTo(11f, 13f)
moveTo(22f, 2f)
lineTo(15f, 22f)
lineTo(11f, 13f)
lineTo(2f, 9f)
lineTo(22f, 2f)
}
}.build()
}
val Receive: ImageVector by lazy {
ImageVector.Builder("Receive", 24.dp, 24.dp, 24f, 24f).apply {
path(
fill = SolidColor(Color.Transparent),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
pathFillType = PathFillType.NonZero,
) {
moveTo(12f, 17f)
lineTo(12f, 3f)
moveTo(6f, 11f)
lineTo(12f, 17f)
lineTo(18f, 11f)
moveTo(19f, 21f)
lineTo(5f, 21f)
}
}.build()
}
val Settings: ImageVector by lazy {
ImageVector.Builder("Settings", 24.dp, 24.dp, 24f, 24f).apply {
path(
fill = SolidColor(Color.Transparent),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
pathFillType = PathFillType.NonZero,
) {
moveTo(9.671f, 4.136f)
arcToRelative(2.34f, 2.34f, 0f, false, true, 4.659f, 0f)
arcToRelative(2.34f, 2.34f, 0f, false, false, 3.319f, 1.915f)
arcToRelative(2.34f, 2.34f, 0f, false, true, 2.33f, 4.033f)
arcToRelative(2.34f, 2.34f, 0f, false, false, 0f, 3.831f)
arcToRelative(2.34f, 2.34f, 0f, false, true, -2.33f, 4.033f)
arcToRelative(2.34f, 2.34f, 0f, false, false, -3.319f, 1.915f)
arcToRelative(2.34f, 2.34f, 0f, false, true, -4.659f, 0f)
arcToRelative(2.34f, 2.34f, 0f, false, false, -3.32f, -1.915f)
arcToRelative(2.34f, 2.34f, 0f, false, true, -2.33f, -4.033f)
arcToRelative(2.34f, 2.34f, 0f, false, false, 0f, -3.831f)
arcTo(2.34f, 2.34f, 0f, false, true, 6.35f, 6.051f)
arcToRelative(2.34f, 2.34f, 0f, false, false, 3.319f, -1.915f)
moveTo(12f, 15f)
arcTo(3f, 3f, 0f, false, true, 12f, 9f)
arcTo(3f, 3f, 0f, false, true, 12f, 15f)
}
}.build()
}
}

View File

@@ -0,0 +1,73 @@
package com.vnidrop.app.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.state.ReceiveUiState
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_inspect_ticket
import vnidrop.shared.generated.resources.button_receive
import vnidrop.shared.generated.resources.button_receiving
import vnidrop.shared.generated.resources.field_output_directory
import vnidrop.shared.generated.resources.field_receiver_name
import vnidrop.shared.generated.resources.field_ticket
import vnidrop.shared.generated.resources.receive_subtitle
import vnidrop.shared.generated.resources.receive_title
import vnidrop.shared.generated.resources.ticket_card_title
@Composable
fun ReceiveScreen(
coreState: CoreUiState,
receiveState: ReceiveUiState,
onReceiveStateChange: (ReceiveUiState) -> Unit,
onInspect: () -> Unit,
onReceive: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle))
ErrorSection(coreState)
AppCard(title = stringResource(Res.string.ticket_card_title)) {
Field(
value = receiveState.ticket,
onValueChange = { onReceiveStateChange(receiveState.copy(ticket = it)) },
label = stringResource(Res.string.field_ticket),
minLines = 4,
)
Field(
value = receiveState.outputDirectory,
onValueChange = { onReceiveStateChange(receiveState.copy(outputDirectory = it)) },
label = stringResource(Res.string.field_output_directory),
)
Field(
value = receiveState.receiverName,
onValueChange = { onReceiveStateChange(receiveState.copy(receiverName = it)) },
label = stringResource(Res.string.field_receiver_name),
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SecondaryButton(
text = stringResource(Res.string.button_inspect_ticket),
onClick = onInspect,
enabled = coreState.isInitialized && receiveState.ticket.isNotBlank(),
)
PrimaryButton(
text = if (receiveState.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
onClick = onReceive,
enabled = coreState.isInitialized &&
receiveState.ticket.isNotBlank() &&
receiveState.outputDirectory.isNotBlank() &&
!receiveState.isReceiving,
)
}
}
coreState.lastInspection?.let { TicketInspectionCard(it) }
ProgressSection(coreState)
}
}

View File

@@ -0,0 +1,186 @@
package com.vnidrop.app.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.ErrorBanner
import com.vnidrop.app.ui.components.MetadataRow
import com.vnidrop.app.ui.components.PillTone
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.components.StatusPill
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.state.friendlyCoreError
import com.vnidrop.app.ui.state.summarizeProgress
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.TicketInspection
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_approve
import vnidrop.shared.generated.resources.button_refuse
import vnidrop.shared.generated.resources.event_log_title
import vnidrop.shared.generated.resources.metadata_files
import vnidrop.shared.generated.resources.metadata_hash
import vnidrop.shared.generated.resources.metadata_kind
import vnidrop.shared.generated.resources.metadata_sender
import vnidrop.shared.generated.resources.metadata_size
import vnidrop.shared.generated.resources.metadata_transfer
import vnidrop.shared.generated.resources.no_events
import vnidrop.shared.generated.resources.progress_title
import vnidrop.shared.generated.resources.ticket_details_title
import vnidrop.shared.generated.resources.ticket_no_metadata
import vnidrop.shared.generated.resources.unknown_sender
@Composable
fun ScreenHeader(title: String, subtitle: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(subtitle, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
fun ErrorSection(coreState: CoreUiState) {
friendlyCoreError(coreState.error)?.let { ErrorBanner(it) }
}
@Composable
fun EmptyText(text: String) {
Text(text, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodyMedium)
}
@Composable
fun ProgressSection(coreState: CoreUiState) {
val progress = summarizeProgress(coreState.events)
if (progress.isNotEmpty()) {
AppCard(title = stringResource(Res.string.progress_title)) {
progress.forEach { item ->
ProgressRow(label = item.label, progress = item.progress)
}
}
}
}
@Composable
fun TicketInspectionCard(inspection: TicketInspection) {
AppCard(title = stringResource(Res.string.ticket_details_title)) {
MetadataRow(stringResource(Res.string.metadata_kind), inspection.kind)
inspection.metadata?.let { metadata ->
MetadataRow(stringResource(Res.string.metadata_transfer), metadata.transferName)
MetadataRow(stringResource(Res.string.metadata_sender), metadata.senderName ?: stringResource(Res.string.unknown_sender))
MetadataRow(stringResource(Res.string.metadata_files), metadata.fileCount.toString())
MetadataRow(stringResource(Res.string.metadata_size), formatBytes(metadata.totalSize))
MetadataRow(stringResource(Res.string.metadata_hash), metadata.contentHash)
} ?: EmptyText(stringResource(Res.string.ticket_no_metadata))
}
}
@Composable
fun ReceiverRequestList(
requests: List<ReceiverRequest>,
onRespondRequest: (String, Boolean) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
requests.forEach { request ->
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(LocalVniDropColors.current.backgroundSurface100)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(request.receiverName ?: "Receiver", fontWeight = FontWeight.SemiBold)
Text(request.remoteEndpointId.take(28), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
StatusPill(displayNameForStatus(request.status), tone = if (request.status == "requested") PillTone.Warning else PillTone.Neutral)
}
request.reason?.let { Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) }
if (request.status == "requested") {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SecondaryButton(stringResource(Res.string.button_refuse), onClick = { onRespondRequest(request.id, false) })
PrimaryButton(stringResource(Res.string.button_approve), onClick = { onRespondRequest(request.id, true) })
}
}
}
}
}
}
@Composable
fun TicketText(ticket: String) {
SelectionContainer {
Text(
text = ticket,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(LocalVniDropColors.current.backgroundSurface200)
.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
@Composable
fun DiagnosticsPanel(events: List<CoreEvent>) {
AppCard(title = stringResource(Res.string.event_log_title)) {
if (events.isEmpty()) {
EmptyText(stringResource(Res.string.no_events))
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.height(280.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
events.forEach { event -> EventRow(event) }
}
}
}
}
@Composable
private fun EventRow(event: CoreEvent) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(LocalVniDropColors.current.backgroundSurface100)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("${event.scope}/${event.direction ?: "-"} ${event.phase}:${event.kind}", style = MaterialTheme.typography.bodySmall)
Text(event.dataJson, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
}
@Composable
fun SectionDivider() {
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
}

View File

@@ -0,0 +1,163 @@
package com.vnidrop.app.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.MetadataRow
import com.vnidrop.app.ui.components.PillTone
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.components.StatusPill
import com.vnidrop.app.ui.state.SendUiState
import com.vnidrop.app.ui.state.formatBytes
import org.jetbrains.compose.resources.stringResource
import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.ShareResult
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_approve
import vnidrop.shared.generated.resources.button_clear
import vnidrop.shared.generated.resources.button_copy
import vnidrop.shared.generated.resources.button_create_share_ticket
import vnidrop.shared.generated.resources.button_creating_ticket
import vnidrop.shared.generated.resources.button_refresh
import vnidrop.shared.generated.resources.button_refuse
import vnidrop.shared.generated.resources.button_select_file
import vnidrop.shared.generated.resources.button_use_locally
import vnidrop.shared.generated.resources.field_sender_name
import vnidrop.shared.generated.resources.field_transfer_name
import vnidrop.shared.generated.resources.metadata_name
import vnidrop.shared.generated.resources.metadata_size
import vnidrop.shared.generated.resources.metadata_source
import vnidrop.shared.generated.resources.metadata_transfer
import vnidrop.shared.generated.resources.receiver_requests_title
import vnidrop.shared.generated.resources.send_source_empty
import vnidrop.shared.generated.resources.send_subtitle
import vnidrop.shared.generated.resources.send_title
import vnidrop.shared.generated.resources.share_ticket_title
import vnidrop.shared.generated.resources.source_title
import vnidrop.shared.generated.resources.transfer_details_title
@Composable
fun SendScreen(
coreState: CoreUiState,
sendState: SendUiState,
onSendStateChange: (SendUiState) -> Unit,
onSelectFile: () -> Unit,
onCreateShare: () -> Unit,
onCopyTicket: (String) -> Unit,
onUseLocally: (String) -> Unit,
onRefreshRequests: (ULong) -> Unit,
onRespondRequest: (String, Boolean) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
ErrorSection(coreState)
SendSourceCard(
sendState = sendState,
onSendStateChange = onSendStateChange,
onSelectFile = onSelectFile,
)
SendDetailsCard(
coreState = coreState,
sendState = sendState,
onSendStateChange = onSendStateChange,
onCreateShare = onCreateShare,
)
coreState.lastShare?.let { share ->
ShareResultCard(
share = share,
requests = coreState.receiverRequests,
onCopyTicket = onCopyTicket,
onUseLocally = onUseLocally,
onRefreshRequests = onRefreshRequests,
onRespondRequest = onRespondRequest,
)
}
ProgressSection(coreState)
}
}
@Composable
private fun SendSourceCard(
sendState: SendUiState,
onSendStateChange: (SendUiState) -> Unit,
onSelectFile: () -> Unit,
) {
AppCard(title = stringResource(Res.string.source_title)) {
if (sendState.selectedSource.isBlank()) {
EmptyText(stringResource(Res.string.send_source_empty))
} else {
MetadataRow(stringResource(Res.string.metadata_name), sendState.selectedDisplayName.ifBlank { sendState.selectedSource.substringAfterLast('/') })
MetadataRow(stringResource(Res.string.metadata_source), sendState.selectedSource)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton(stringResource(Res.string.button_select_file), onClick = onSelectFile)
SecondaryButton(
text = stringResource(Res.string.button_clear),
onClick = { onSendStateChange(sendState.copy(selectedSource = "", selectedDisplayName = "")) },
enabled = sendState.selectedSource.isNotBlank(),
)
}
}
}
@Composable
private fun SendDetailsCard(
coreState: CoreUiState,
sendState: SendUiState,
onSendStateChange: (SendUiState) -> Unit,
onCreateShare: () -> Unit,
) {
AppCard(title = stringResource(Res.string.transfer_details_title)) {
Field(
value = sendState.transferName,
onValueChange = { onSendStateChange(sendState.copy(transferName = it)) },
label = stringResource(Res.string.field_transfer_name),
)
Field(
value = sendState.senderName,
onValueChange = { onSendStateChange(sendState.copy(senderName = it)) },
label = stringResource(Res.string.field_sender_name),
)
PrimaryButton(
text = if (sendState.isSharing) stringResource(Res.string.button_creating_ticket) else stringResource(Res.string.button_create_share_ticket),
onClick = onCreateShare,
enabled = coreState.isInitialized && sendState.selectedSource.isNotBlank() && !sendState.isSharing,
)
}
}
@Composable
private fun ShareResultCard(
share: ShareResult,
requests: List<ReceiverRequest>,
onCopyTicket: (String) -> Unit,
onUseLocally: (String) -> Unit,
onRefreshRequests: (ULong) -> Unit,
onRespondRequest: (String, Boolean) -> Unit,
) {
AppCard(title = stringResource(Res.string.share_ticket_title), trailing = {
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
}) {
MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName)
MetadataRow(stringResource(Res.string.metadata_size), formatBytes(share.totalSize))
TicketText(share.ticket)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton(stringResource(Res.string.button_copy), onClick = { onCopyTicket(share.ticket) })
SecondaryButton(stringResource(Res.string.button_use_locally), onClick = { onUseLocally(share.ticket) })
SecondaryButton(stringResource(Res.string.button_refresh), onClick = { onRefreshRequests(share.transferId) })
}
if (requests.isNotEmpty()) {
SectionDivider()
Text(stringResource(Res.string.receiver_requests_title), fontWeight = FontWeight.SemiBold)
ReceiverRequestList(requests = requests, onRespondRequest = onRespondRequest)
}
}
}

View File

@@ -0,0 +1,614 @@
package com.vnidrop.app.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.Spacer
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.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.core.CoreUiState
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.LocalVniDropColors
import com.vnidrop.app.ui.theme.ThemeMode
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.about_bug_report
import vnidrop.shared.generated.resources.about_privacy
import vnidrop.shared.generated.resources.about_title
import vnidrop.shared.generated.resources.appearance_auto_description
import vnidrop.shared.generated.resources.appearance_dark_mode
import vnidrop.shared.generated.resources.appearance_light_mode
import vnidrop.shared.generated.resources.appearance_mode_title
import vnidrop.shared.generated.resources.appearance_system_mode
import vnidrop.shared.generated.resources.appearance_title
import vnidrop.shared.generated.resources.battery_level_title
import vnidrop.shared.generated.resources.core_status_ready
import vnidrop.shared.generated.resources.device_model_title
import vnidrop.shared.generated.resources.device_name_title
import vnidrop.shared.generated.resources.network_title
import vnidrop.shared.generated.resources.node_title
import vnidrop.shared.generated.resources.not_initialized
import vnidrop.shared.generated.resources.os_version_title
import vnidrop.shared.generated.resources.settings_title
import vnidrop.shared.generated.resources.value_unavailable
import vnidrop.shared.generated.resources.version_title
private enum class SettingsPane {
Overview,
Appearance,
About,
}
@Composable
fun SettingsScreen(
deviceInfo: DeviceInfo,
coreState: CoreUiState,
themeMode: ThemeMode,
windowClass: WindowClass,
onThemeModeChange: (ThemeMode) -> Unit,
) {
var pane by remember { mutableStateOf(SettingsPane.Overview) }
when (windowClass) {
WindowClass.Desktop -> DesktopSettings(
selectedPane = pane,
onPaneSelected = { pane = it },
deviceInfo = deviceInfo,
coreState = coreState,
themeMode = themeMode,
onThemeModeChange = onThemeModeChange,
)
else -> MobileSettings(
pane = pane,
onPaneSelected = { pane = it },
deviceInfo = deviceInfo,
coreState = coreState,
themeMode = themeMode,
onThemeModeChange = onThemeModeChange,
)
}
}
@Composable
private fun MobileSettings(
pane: SettingsPane,
onPaneSelected: (SettingsPane) -> Unit,
deviceInfo: DeviceInfo,
coreState: CoreUiState,
themeMode: ThemeMode,
onThemeModeChange: (ThemeMode) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ErrorSection(coreState)
when (pane) {
SettingsPane.Overview -> SettingsOverview(
coreState = coreState,
themeMode = themeMode,
onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) },
onOpenAbout = { onPaneSelected(SettingsPane.About) },
largeTitle = true,
)
SettingsPane.Appearance -> AppearanceSettings(
themeMode = themeMode,
onThemeModeChange = onThemeModeChange,
onBack = { onPaneSelected(SettingsPane.Overview) },
showBack = true,
)
SettingsPane.About -> AboutSettings(
deviceInfo = deviceInfo,
coreState = coreState,
onBack = { onPaneSelected(SettingsPane.Overview) },
showBack = true,
)
}
}
}
@Composable
private fun DesktopSettings(
selectedPane: SettingsPane,
onPaneSelected: (SettingsPane) -> Unit,
deviceInfo: DeviceInfo,
coreState: CoreUiState,
themeMode: ThemeMode,
onThemeModeChange: (ThemeMode) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.Top,
) {
Column(
modifier = Modifier.widthIn(min = 280.dp, max = 340.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
SettingsOverview(
coreState = coreState,
themeMode = themeMode,
onOpenAppearance = { onPaneSelected(SettingsPane.Appearance) },
onOpenAbout = { onPaneSelected(SettingsPane.About) },
largeTitle = false,
selectedPane = selectedPane,
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
when (selectedPane) {
SettingsPane.Overview,
SettingsPane.Appearance -> AppearanceSettings(
themeMode = themeMode,
onThemeModeChange = onThemeModeChange,
onBack = {},
showBack = false,
)
SettingsPane.About -> AboutSettings(
deviceInfo = deviceInfo,
coreState = coreState,
onBack = {},
showBack = false,
)
}
}
}
}
@Composable
private fun SettingsOverview(
coreState: CoreUiState,
themeMode: ThemeMode,
onOpenAppearance: () -> Unit,
onOpenAbout: () -> Unit,
largeTitle: Boolean,
selectedPane: SettingsPane = SettingsPane.Overview,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (largeTitle) {
SettingsLargeTitle(stringResource(Res.string.settings_title))
} else {
Text(stringResource(Res.string.settings_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
}
SettingsGroup {
SettingsRow(
icon = SettingsIcons.Sun,
title = stringResource(Res.string.appearance_title),
value = themeMode.displayName(),
selected = selectedPane == SettingsPane.Appearance,
onClick = onOpenAppearance,
)
}
SettingsGroup {
SettingsRow(
icon = SettingsIcons.Node,
title = stringResource(Res.string.node_title),
value = if (coreState.isInitialized) stringResource(Res.string.core_status_ready) else stringResource(Res.string.not_initialized),
iconTone = IconTone.Neutral,
)
SettingsRow(
icon = SettingsIcons.Info,
title = stringResource(Res.string.about_title),
selected = selectedPane == SettingsPane.About,
onClick = onOpenAbout,
)
}
}
}
@Composable
private fun AppearanceSettings(
themeMode: ThemeMode,
onThemeModeChange: (ThemeMode) -> Unit,
onBack: () -> Unit,
showBack: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(title = stringResource(Res.string.appearance_title), onBack = onBack, showBack = showBack)
Text(
text = stringResource(Res.string.appearance_mode_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
ThemeChoice(
icon = SettingsIcons.Device,
title = stringResource(Res.string.appearance_system_mode),
description = stringResource(Res.string.appearance_auto_description),
selected = themeMode == ThemeMode.System,
onClick = { onThemeModeChange(ThemeMode.System) },
)
ThemeChoice(
icon = SettingsIcons.Moon,
title = stringResource(Res.string.appearance_dark_mode),
selected = themeMode == ThemeMode.Dark,
onClick = { onThemeModeChange(ThemeMode.Dark) },
)
ThemeChoice(
icon = SettingsIcons.Sun,
title = stringResource(Res.string.appearance_light_mode),
selected = themeMode == ThemeMode.Light,
onClick = { onThemeModeChange(ThemeMode.Light) },
)
}
}
@Composable
private fun AboutSettings(
deviceInfo: DeviceInfo,
coreState: CoreUiState,
onBack: () -> Unit,
showBack: Boolean,
) {
val unavailable = stringResource(Res.string.value_unavailable)
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(title = stringResource(Res.string.about_title), onBack = onBack, showBack = showBack)
SettingsGroup {
SettingsRow(icon = SettingsIcons.Document, title = stringResource(Res.string.about_privacy), iconTone = IconTone.Neutral)
SettingsRow(icon = SettingsIcons.Bug, title = stringResource(Res.string.about_bug_report), iconTone = IconTone.Neutral)
}
PlainInfoSection {
PlainInfoItem(stringResource(Res.string.version_title), "0.1.0")
PlainInfoItem(stringResource(Res.string.device_name_title), deviceInfo.deviceName.orUnavailable(unavailable))
PlainInfoItem(stringResource(Res.string.device_model_title), deviceInfo.deviceModel.orUnavailable(unavailable))
PlainInfoItem(stringResource(Res.string.os_version_title), deviceInfo.operatingSystem)
PlainInfoItem(stringResource(Res.string.network_title), deviceInfo.network.orUnavailable(unavailable))
PlainInfoItem(stringResource(Res.string.battery_level_title), deviceInfo.batteryLevel.orUnavailable(unavailable))
PlainInfoItem(
title = stringResource(Res.string.node_title),
value = if (coreState.isInitialized) {
stringResource(Res.string.core_status_ready)
} else {
stringResource(Res.string.not_initialized)
},
)
}
}
}
@Composable
private fun SettingsLargeTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
)
}
@Composable
private fun SettingsTopBar(title: String, onBack: () -> Unit, showBack: Boolean) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (showBack) {
Icon(
imageVector = SettingsIcons.Back,
contentDescription = null,
tint = LocalVniDropColors.current.foregroundDefault,
modifier = Modifier
.size(30.dp)
.clickable(onClick = onBack)
.padding(3.dp),
)
}
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun SettingsGroup(content: @Composable ColumnScope.() -> Unit) {
val colors = LocalVniDropColors.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = colors.backgroundSurface200),
) {
Column(modifier = Modifier.padding(vertical = 4.dp), content = content)
}
}
@Composable
private fun PlainInfoSection(content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
content = content,
)
}
@Composable
private fun PlainInfoItem(title: String, value: String) {
val colors = LocalVniDropColors.current
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title,
color = colors.foregroundLighter,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium,
)
Text(
text = value,
color = colors.foregroundDefault,
style = MaterialTheme.typography.bodyLarge,
)
}
}
private fun String?.orUnavailable(fallback: String): String =
this?.takeIf { it.isNotBlank() } ?: fallback
@Composable
private fun SettingsRow(
icon: ImageVector,
title: String,
value: String? = null,
selected: Boolean = false,
iconTone: IconTone = IconTone.Brand,
onClick: (() -> Unit)? = null,
) {
val colors = LocalVniDropColors.current
val iconColor = when (iconTone) {
IconTone.Brand -> colors.brandLink
IconTone.Neutral -> colors.foregroundLighter
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.background(if (selected) colors.backgroundSurface300 else Color.Transparent)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(14.dp))
Text(
text = title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
value?.let {
Text(
text = it,
color = colors.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(10.dp))
}
if (onClick != null) {
Icon(SettingsIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(20.dp))
}
}
}
@Composable
private fun ThemeChoice(
icon: ImageVector,
title: String,
selected: Boolean,
onClick: () -> Unit,
description: String? = null,
) {
val colors = LocalVniDropColors.current
val shape = RoundedCornerShape(18.dp)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(62.dp)
.background(colors.backgroundSurface200, shape)
.border(
border = if (selected) BorderStroke(1.5.dp, colors.brandLink) else BorderStroke(1.dp, Color.Transparent),
shape = shape,
)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = null, tint = colors.foregroundLight, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(14.dp))
Text(
text = title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
if (selected) {
Icon(SettingsIcons.Check, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(24.dp))
}
}
description?.let {
Text(
text = it,
color = colors.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@Composable
private fun ThemeMode.displayName(): String =
when (this) {
ThemeMode.System -> stringResource(Res.string.appearance_system_mode)
ThemeMode.Light -> stringResource(Res.string.appearance_light_mode)
ThemeMode.Dark -> stringResource(Res.string.appearance_dark_mode)
}
private enum class IconTone {
Brand,
Neutral,
}
private object SettingsIcons {
val ChevronRight = lineIcon("ChevronRight") {
moveTo(9f, 18f)
lineTo(15f, 12f)
lineTo(9f, 6f)
}
val Back = lineIcon("Back") {
moveTo(19f, 12f)
lineTo(5f, 12f)
moveTo(12f, 19f)
lineTo(5f, 12f)
lineTo(12f, 5f)
}
val Check = lineIcon("Check") {
moveTo(20f, 6f)
lineTo(9f, 17f)
lineTo(4f, 12f)
}
val Sun = lineIcon("Sun") {
moveTo(12f, 4f)
lineTo(12f, 2f)
moveTo(12f, 22f)
lineTo(12f, 20f)
moveTo(4.93f, 4.93f)
lineTo(6.34f, 6.34f)
moveTo(17.66f, 17.66f)
lineTo(19.07f, 19.07f)
moveTo(2f, 12f)
lineTo(4f, 12f)
moveTo(20f, 12f)
lineTo(22f, 12f)
moveTo(4.93f, 19.07f)
lineTo(6.34f, 17.66f)
moveTo(17.66f, 6.34f)
lineTo(19.07f, 4.93f)
moveTo(16f, 12f)
arcTo(4f, 4f, 0f, true, true, 8f, 12f)
arcTo(4f, 4f, 0f, true, true, 16f, 12f)
}
val Moon = lineIcon("Moon") {
moveTo(21f, 12.79f)
arcTo(9f, 9f, 0f, true, true, 11.21f, 3f)
arcTo(7f, 7f, 0f, false, false, 21f, 12.79f)
}
val Device = lineIcon("Device") {
roundRect(7f, 2f, 10f, 20f, 2.5f)
moveTo(11f, 18f)
lineTo(13f, 18f)
}
val Info = lineIcon("Info") {
moveTo(12f, 16f)
lineTo(12f, 12f)
moveTo(12f, 8f)
lineTo(12.01f, 8f)
moveTo(21f, 12f)
arcTo(9f, 9f, 0f, true, true, 3f, 12f)
arcTo(9f, 9f, 0f, true, true, 21f, 12f)
}
val Bug = lineIcon("Bug") {
moveTo(8f, 2f)
lineTo(9.88f, 3.88f)
moveTo(16f, 2f)
lineTo(14.12f, 3.88f)
roundRect(7f, 6f, 10f, 14f, 5f)
moveTo(3f, 10f)
lineTo(7f, 10f)
moveTo(17f, 10f)
lineTo(21f, 10f)
moveTo(3f, 16f)
lineTo(7f, 16f)
moveTo(17f, 16f)
lineTo(21f, 16f)
moveTo(12f, 6f)
lineTo(12f, 20f)
}
val Document = lineIcon("Document") {
moveTo(14f, 2f)
lineTo(6f, 2f)
arcTo(2f, 2f, 0f, false, false, 4f, 4f)
lineTo(4f, 20f)
arcTo(2f, 2f, 0f, false, false, 6f, 22f)
lineTo(18f, 22f)
arcTo(2f, 2f, 0f, false, false, 20f, 20f)
lineTo(20f, 8f)
lineTo(14f, 2f)
moveTo(14f, 2f)
lineTo(14f, 8f)
lineTo(20f, 8f)
moveTo(8f, 13f)
lineTo(16f, 13f)
moveTo(8f, 17f)
lineTo(16f, 17f)
}
val Node = lineIcon("Node") {
roundRect(4f, 4f, 16f, 16f, 3f)
moveTo(9f, 9f)
lineTo(15f, 9f)
moveTo(9f, 13f)
lineTo(15f, 13f)
moveTo(9f, 17f)
lineTo(12f, 17f)
}
}
private fun lineIcon(name: String, block: androidx.compose.ui.graphics.vector.PathBuilder.() -> Unit): ImageVector =
ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply {
path(
fill = SolidColor(Color.Transparent),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
pathFillType = PathFillType.NonZero,
pathBuilder = block,
)
}.build()
private fun androidx.compose.ui.graphics.vector.PathBuilder.roundRect(x: Float, y: Float, width: Float, height: Float, radius: Float) {
moveTo(x + radius, y)
lineTo(x + width - radius, y)
arcTo(radius, radius, 0f, false, true, x + width, y + radius)
lineTo(x + width, y + height - radius)
arcTo(radius, radius, 0f, false, true, x + width - radius, y + height)
lineTo(x + radius, y + height)
arcTo(radius, radius, 0f, false, true, x, y + height - radius)
lineTo(x, y + radius)
arcTo(radius, radius, 0f, false, true, x + radius, y)
}

View File

@@ -0,0 +1,99 @@
package com.vnidrop.app.ui.shell
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.navigation.AppBottomNavigation
import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.navigation.AppSidebarNavigation
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.useBottomNavigation
import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable
fun AppShell(
selectedDestination: AppDestination,
windowClass: WindowClass,
onDestinationSelected: (AppDestination) -> Unit,
content: @Composable () -> Unit,
) {
val colors = LocalVniDropColors.current
Surface(
modifier = Modifier
.fillMaxSize()
.background(colors.backgroundDashCanvas),
color = colors.backgroundDashCanvas,
) {
if (useBottomNavigation(windowClass)) {
PhoneShell(
selectedDestination = selectedDestination,
onDestinationSelected = onDestinationSelected,
content = content,
)
} else {
WideShell(
selectedDestination = selectedDestination,
onDestinationSelected = onDestinationSelected,
content = content,
)
}
}
}
@Composable
private fun WideShell(
selectedDestination: AppDestination,
onDestinationSelected: (AppDestination) -> Unit,
content: @Composable () -> Unit,
) {
Row(modifier = Modifier.fillMaxSize()) {
AppSidebarNavigation(
selected = selectedDestination,
onDestinationSelected = onDestinationSelected,
)
ScreenScrollContainer(modifier = Modifier.weight(1f), content = content)
}
}
@Composable
private fun PhoneShell(
selectedDestination: AppDestination,
onDestinationSelected: (AppDestination) -> Unit,
content: @Composable () -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
ScreenScrollContainer(modifier = Modifier.weight(1f), content = content)
AppBottomNavigation(
selected = selectedDestination,
onDestinationSelected = onDestinationSelected,
)
}
}
@Composable
fun ScreenScrollContainer(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
LazyColumn(
modifier = modifier
.fillMaxSize()
.statusBarsPadding()
.padding(16.dp),
contentPadding = PaddingValues(bottom = 16.dp),
) {
item {
content()
}
}
}

View File

@@ -1,38 +1,30 @@
package com.vnidrop.app.ui.state package com.vnidrop.app.ui.state
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import com.vnidrop.app.ui.navigation.AppDestination
import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.ReceiverRequest
import uniffi.vnidrop.StoredTransfer import uniffi.vnidrop.StoredTransfer
import kotlin.math.roundToInt import kotlin.math.roundToInt
enum class AppDestination(
val label: String,
) {
Send("Send"),
Receive("Receive"),
Activity("Activity"),
Requests("Requests"),
Settings("Settings"),
}
enum class WindowClass { enum class WindowClass {
Compact, Phone,
Medium, Tablet,
Expanded, Desktop,
} }
fun windowClassFor(widthDp: Float): WindowClass = fun windowClassFor(widthDp: Float): WindowClass =
when { when {
widthDp >= 920f -> WindowClass.Expanded widthDp >= 920f -> WindowClass.Desktop
widthDp >= 640f -> WindowClass.Medium widthDp >= 600f -> WindowClass.Tablet
else -> WindowClass.Compact else -> WindowClass.Phone
} }
fun useBottomNavigation(windowClass: WindowClass): Boolean =
windowClass == WindowClass.Phone
data class AppUiState( data class AppUiState(
val destination: AppDestination = AppDestination.Send, val destination: AppDestination = AppDestination.Send,
val themeMode: ThemeMode = ThemeMode.System, val themeMode: ThemeMode = ThemeMode.System,
val diagnosticsVisible: Boolean = false,
) )
data class SendUiState( data class SendUiState(
@@ -68,9 +60,6 @@ fun displayNameForStatus(status: String): String =
else -> status.replaceFirstChar { it.uppercase() } else -> status.replaceFirstChar { it.uppercase() }
} }
fun activeReceiverRequests(requests: List<ReceiverRequest>): List<ReceiverRequest> =
requests.filter { it.status == "requested" }
fun summarizeProgress(events: List<CoreEvent>): List<TransferProgress> = fun summarizeProgress(events: List<CoreEvent>): List<TransferProgress> =
events events
.filter { event -> event.transferId != null && event.phase in progressPhases } .filter { event -> event.transferId != null && event.phase in progressPhases }

View File

@@ -9,7 +9,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@@ -26,85 +25,156 @@ fun resolveDarkTheme(mode: ThemeMode, systemDark: Boolean): Boolean =
ThemeMode.Dark -> true ThemeMode.Dark -> true
} }
@Composable
fun rememberResolvedDarkTheme(mode: ThemeMode): Boolean =
resolveDarkTheme(mode, isSystemInDarkTheme())
@Immutable @Immutable
data class VniDropColors( data class VniDropColors(
val canvas: Color, val backgroundDefault: Color,
val sidebar: Color, val backgroundDashCanvas: Color,
val surface: Color, val backgroundDashSidebar: Color,
val surfaceRaised: Color, val backgroundSurface75: Color,
val surfaceMuted: Color, val backgroundSurface100: Color,
val border: Color, val backgroundSurface200: Color,
val backgroundSurface300: Color,
val backgroundSurface400: Color,
val backgroundMuted: Color,
val backgroundControl: Color,
val backgroundSelection: Color,
val backgroundButton: Color,
val backgroundOverlayHover: Color,
val backgroundDialog: Color,
val borderDefault: Color,
val borderStrong: Color, val borderStrong: Color,
val textPrimary: Color, val borderStronger: Color,
val textSecondary: Color, val borderMuted: Color,
val textMuted: Color, val borderControl: Color,
val brand: Color, val foregroundDefault: Color,
val brandPressed: Color, val foregroundLight: Color,
val warning: Color, val foregroundLighter: Color,
val destructive: Color, val foregroundMuted: Color,
val success: Color, val foregroundContrast: Color,
val brandLink: Color,
val brandButton: Color,
val brandDefault: Color,
val brand600: Color,
val brand500: Color,
val brand400: Color,
val brand300: Color,
val brand200: Color,
val warningDefault: Color,
val warning200: Color,
val warning300: Color,
val warning400: Color,
val warning500: Color,
val warning600: Color,
val destructiveDefault: Color,
val destructive200: Color,
val destructive300: Color,
val destructive400: Color,
val destructive500: Color,
val destructive600: Color,
) )
val LocalVniDropColors = staticCompositionLocalOf { lightVniDropColors } val LocalVniDropColors = staticCompositionLocalOf { VniDropThemeTokens.light }
private val lightVniDropColors = VniDropColors( object VniDropThemeTokens {
canvas = hsl(0f, 0f, 97.3f), // These values are a direct Compose port of the legacy Tauri theme tokens.
sidebar = hsl(0f, 0f, 98.8f), // The app uses these semantic tokens directly because Material3's ColorScheme
surface = hsl(0f, 0f, 100f), // cannot represent the full surface, border, and foreground stack.
surfaceRaised = hsl(0f, 0f, 98.8f), val light = VniDropColors(
surfaceMuted = hsl(0f, 0f, 95.3f), backgroundDefault = hsl(0f, 0f, 98.8f),
border = hsl(0f, 0f, 85.9f), backgroundDashCanvas = hsl(0f, 0f, 97.3f),
borderStrong = hsl(0f, 0f, 78f), backgroundDashSidebar = hsl(0f, 0f, 98.8f),
textPrimary = hsl(0f, 0f, 9f), backgroundSurface75 = hsl(0f, 0f, 100f),
textSecondary = hsl(0f, 0f, 32.2f), backgroundSurface100 = hsl(0f, 0f, 98.8f),
textMuted = hsl(0f, 0f, 43.9f), backgroundSurface200 = hsl(0f, 0f, 95.3f),
brand = hsl(153.1f, 60.2f, 52.7f), backgroundSurface300 = hsl(0f, 0f, 92.9f),
brandPressed = hsl(152.9f, 56.1f, 46.5f), backgroundSurface400 = hsl(0f, 0f, 89.8f),
warning = hsl(38.9f, 100f, 57.1f), backgroundMuted = hsl(0f, 0f, 96.9f),
destructive = hsl(10.2f, 77.9f, 53.9f), backgroundControl = hsl(0f, 0f, 95.3f),
success = hsl(153.1f, 60.2f, 40f), backgroundSelection = hsl(0f, 0f, 92.9f),
backgroundButton = hsl(0f, 0f, 91f),
backgroundOverlayHover = hsl(0f, 0f, 95.3f),
backgroundDialog = hsl(0f, 0f, 100f),
borderDefault = hsl(0f, 0f, 87.5f),
borderStrong = hsl(0f, 0f, 83.1f),
borderStronger = hsl(0f, 0f, 56.1f),
borderMuted = hsl(0f, 0f, 92.9f),
borderControl = hsl(0f, 0f, 78f),
foregroundDefault = hsl(0f, 0f, 9f),
foregroundLight = hsl(0f, 0f, 32.2f),
foregroundLighter = hsl(0f, 0f, 43.9f),
foregroundMuted = hsl(0f, 0f, 69.8f),
foregroundContrast = hsl(0f, 0f, 98.4f),
brandLink = hsl(271f, 91f, 65f),
brandButton = hsl(270f, 95f, 75f),
brandDefault = hsl(271f, 91f, 65f),
brand600 = hsl(271f, 81f, 56f),
brand500 = hsl(271f, 91f, 65f),
brand400 = hsl(270f, 95f, 75f),
brand300 = hsl(269f, 97f, 85f),
brand200 = hsl(269f, 100f, 92f),
warningDefault = hsl(38.9f, 100f, 57.1f),
warning600 = hsl(30.3f, 80.3f, 47.8f),
warning500 = hsl(36.3f, 85.7f, 67.1f),
warning400 = hsl(41.9f, 100f, 81.8f),
warning300 = hsl(44.3f, 100f, 91.8f),
warning200 = hsl(40f, 81.8f, 97.8f),
destructiveDefault = hsl(10.2f, 77.9f, 53.9f),
destructive600 = hsl(9.9f, 82f, 43.5f),
destructive500 = hsl(10.4f, 77.1f, 79.4f),
destructive400 = hsl(7.1f, 91.3f, 91f),
destructive300 = hsl(7.1f, 100f, 96.7f),
destructive200 = hsl(0f, 100f, 99.4f),
) )
private val darkVniDropColors = VniDropColors( val dark = VniDropColors(
canvas = hsl(0f, 0f, 7.1f), backgroundDefault = hsl(0f, 0f, 7.1f),
sidebar = hsl(0f, 0f, 9f), backgroundDashCanvas = hsl(0f, 0f, 7.1f),
surface = hsl(0f, 0f, 12.2f), backgroundDashSidebar = hsl(0f, 0f, 9f),
surfaceRaised = hsl(0f, 0f, 14.1f), backgroundSurface75 = hsl(0f, 0f, 9f),
surfaceMuted = hsl(0f, 0f, 16.1f), backgroundSurface100 = hsl(0f, 0f, 12.2f),
border = hsl(0f, 0f, 24.3f), backgroundSurface200 = hsl(0f, 0f, 12.9f),
borderStrong = hsl(0f, 0f, 31.4f), backgroundSurface300 = hsl(0f, 0f, 16.1f),
textPrimary = hsl(0f, 0f, 98f), backgroundSurface400 = hsl(0f, 0f, 16.1f),
textSecondary = hsl(0f, 0f, 70.6f), backgroundMuted = hsl(0f, 0f, 14.1f),
textMuted = hsl(0f, 0f, 53.7f), backgroundControl = hsl(0f, 0f, 14.1f),
brand = hsl(153.1f, 60.2f, 52.7f), backgroundSelection = hsl(0f, 0f, 19.2f),
brandPressed = hsl(152.9f, 56.1f, 46.5f), backgroundButton = hsl(0f, 0f, 18f),
warning = hsl(38.9f, 100f, 42.9f), backgroundOverlayHover = hsl(0f, 0f, 18f),
destructive = hsl(10.2f, 77.9f, 53.9f), backgroundDialog = hsl(0f, 0f, 7.1f),
success = hsl(153.1f, 60.2f, 52.7f), borderDefault = hsl(0f, 0f, 18f),
) borderStrong = hsl(0f, 0f, 21.2f),
borderStronger = hsl(0f, 0f, 27.1f),
private fun materialScheme(tokens: VniDropColors, dark: Boolean): ColorScheme { borderMuted = hsl(0f, 0f, 14.1f),
val base = if (dark) { borderControl = hsl(0f, 0f, 22.4f),
darkColorScheme() foregroundDefault = hsl(0f, 0f, 98f),
} else { foregroundLight = hsl(0f, 0f, 70.6f),
lightColorScheme() foregroundLighter = hsl(0f, 0f, 53.7f),
} foregroundMuted = hsl(0f, 0f, 30.2f),
return base.copy( foregroundContrast = hsl(0f, 0f, 8.6f),
primary = tokens.brand, brandLink = hsl(270f, 95f, 75f),
onPrimary = if (dark) Color.Black else Color.White, brandButton = hsl(271f, 81f, 56f),
primaryContainer = tokens.surfaceMuted, brandDefault = hsl(270f, 95f, 75f),
onPrimaryContainer = tokens.textPrimary, brand600 = hsl(271f, 91f, 65f),
background = tokens.canvas, brand500 = hsl(271f, 81f, 56f),
onBackground = tokens.textPrimary, brand400 = hsl(273f, 67f, 39f),
surface = tokens.surface, brand300 = hsl(274f, 66f, 32f),
onSurface = tokens.textPrimary, brand200 = hsl(274f, 87f, 21f),
surfaceVariant = tokens.surfaceMuted, warningDefault = hsl(38.9f, 100f, 42.9f),
onSurfaceVariant = tokens.textSecondary, warning600 = hsl(38.9f, 100f, 42.9f),
outline = tokens.border, warning500 = hsl(34.8f, 90.9f, 21.6f),
outlineVariant = tokens.border, warning400 = hsl(33.2f, 100f, 14.5f),
error = tokens.destructive, warning300 = hsl(32.3f, 100f, 10.2f),
errorContainer = tokens.destructive.copy(alpha = if (dark) 0.22f else 0.16f), warning200 = hsl(36.6f, 100f, 8f),
onErrorContainer = tokens.textPrimary, destructiveDefault = hsl(10.2f, 77.9f, 53.9f),
destructive600 = hsl(9.7f, 85.2f, 62.9f),
destructive500 = hsl(7.9f, 71.6f, 29f),
destructive400 = hsl(6.7f, 60f, 20.6f),
destructive300 = hsl(7.5f, 51.3f, 15.3f),
destructive200 = hsl(10.9f, 23.4f, 9.2f),
) )
} }
@@ -113,23 +183,51 @@ fun VniDropTheme(
mode: ThemeMode, mode: ThemeMode,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val dark = resolveDarkTheme(mode, isSystemInDarkTheme()) VniDropTheme(isDarkTheme = rememberResolvedDarkTheme(mode), content = content)
val tokens = if (dark) darkVniDropColors else lightVniDropColors }
@Composable
fun VniDropTheme(
isDarkTheme: Boolean,
content: @Composable () -> Unit,
) {
val tokens = if (isDarkTheme) VniDropThemeTokens.dark else VniDropThemeTokens.light
androidx.compose.runtime.CompositionLocalProvider(LocalVniDropColors provides tokens) { androidx.compose.runtime.CompositionLocalProvider(LocalVniDropColors provides tokens) {
MaterialTheme( MaterialTheme(
colorScheme = materialScheme(tokens, dark), colorScheme = tokens.toMaterialColorScheme(isDarkTheme),
content = content, content = content,
) )
} }
} }
private fun VniDropColors.toMaterialColorScheme(isDark: Boolean): ColorScheme {
val base = if (isDark) darkColorScheme() else lightColorScheme()
return base.copy(
primary = brandDefault,
onPrimary = if (isDark) Color.Black else Color.White,
secondary = brandLink,
background = backgroundDashCanvas,
onBackground = foregroundDefault,
surface = backgroundSurface75,
onSurface = foregroundDefault,
surfaceVariant = backgroundSurface200,
onSurfaceVariant = foregroundLight,
outline = borderDefault,
outlineVariant = borderMuted,
error = destructiveDefault,
errorContainer = destructive200,
onErrorContainer = if (isDark) destructive600 else destructiveDefault,
)
}
fun hslColorForTest(hue: Float, saturation: Float, lightness: Float): Color =
hsl(hue, saturation, lightness)
private fun hsl(hue: Float, saturation: Float, lightness: Float): Color { private fun hsl(hue: Float, saturation: Float, lightness: Float): Color {
val h = ((hue % 360f) + 360f) % 360f / 360f val h = ((hue % 360f) + 360f) % 360f / 360f
val s = saturation.coerceIn(0f, 100f) / 100f val s = saturation.coerceIn(0f, 100f) / 100f
val l = lightness.coerceIn(0f, 100f) / 100f val l = lightness.coerceIn(0f, 100f) / 100f
if (s == 0f) { if (s == 0f) return Color(l, l, l)
return Color(l, l, l)
}
val q = if (l < 0.5f) l * (1 + s) else l + s - l * s val q = if (l < 0.5f) l * (1 + s) else l + s - l * s
val p = 2 * l - q val p = 2 * l - q
return Color( return Color(
@@ -150,9 +248,3 @@ private fun hueToRgb(p: Float, q: Float, input: Float): Float {
else -> p else -> p
}.let { min(1f, max(0f, it)) } }.let { min(1f, max(0f, it)) }
} }
fun Color.contrastAgainst(other: Color): Float =
abs(luminanceApproximation() - other.luminanceApproximation())
private fun Color.luminanceApproximation(): Float =
(red * 0.2126f) + (green * 0.7152f) + (blue * 0.0722f)

View File

@@ -0,0 +1,16 @@
package com.vnidrop.app.logging
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AppLoggerTest {
@Test
fun rotationPolicyRotatesOnlyWhenActiveFileWouldExceedLimit() {
val policy = LogRotationPolicy(maxBytes = 10, maxFiles = 2)
assertFalse(policy.shouldRotate(currentBytes = 0, incomingBytes = 20))
assertFalse(policy.shouldRotate(currentBytes = 4, incomingBytes = 6))
assertTrue(policy.shouldRotate(currentBytes = 5, incomingBytes = 6))
}
}

View File

@@ -0,0 +1,12 @@
package com.vnidrop.app.platform
import kotlin.test.Test
import kotlin.test.assertEquals
class SystemBarIconModeTest {
@Test
fun darkThemeUsesLightSystemBarIcons() {
assertEquals(SystemBarIconMode.LightIcons, systemBarIconModeForTheme(isDarkTheme = true))
assertEquals(SystemBarIconMode.DarkIcons, systemBarIconModeForTheme(isDarkTheme = false))
}
}

View File

@@ -0,0 +1,15 @@
package com.vnidrop.app.ui.navigation
import kotlin.test.Test
import kotlin.test.assertEquals
class NavigationModelTest {
@Test
fun primaryNavigationContainsOnlyProductDestinations() {
assertEquals(
listOf(AppDestination.Send, AppDestination.Receive, AppDestination.Settings),
primaryNavigationItems.map { it.destination },
)
assertEquals(3, primaryNavigationItems.map { it.label }.distinct().size)
}
}

View File

@@ -9,10 +9,17 @@ import kotlin.test.assertTrue
class AppUiModelsTest { class AppUiModelsTest {
@Test @Test
fun windowClassUsesCompactMediumExpandedBreakpoints() { fun windowClassUsesPhoneTabletDesktopBreakpoints() {
assertEquals(WindowClass.Compact, windowClassFor(390f)) assertEquals(WindowClass.Phone, windowClassFor(390f))
assertEquals(WindowClass.Medium, windowClassFor(700f)) assertEquals(WindowClass.Tablet, windowClassFor(700f))
assertEquals(WindowClass.Expanded, windowClassFor(1200f)) assertEquals(WindowClass.Desktop, windowClassFor(1200f))
}
@Test
fun bottomNavigationIsReservedForPhoneWidth() {
assertTrue(useBottomNavigation(WindowClass.Phone))
assertFalse(useBottomNavigation(WindowClass.Tablet))
assertFalse(useBottomNavigation(WindowClass.Desktop))
} }
@Test @Test

View File

@@ -0,0 +1,48 @@
package com.vnidrop.app.ui.theme
import kotlin.test.Test
import kotlin.test.assertEquals
import org.jetbrains.compose.resources.StringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.about_title
import vnidrop.shared.generated.resources.appearance_dark_mode
import vnidrop.shared.generated.resources.appearance_light_mode
import vnidrop.shared.generated.resources.appearance_system_mode
import vnidrop.shared.generated.resources.battery_level_title
import vnidrop.shared.generated.resources.device_model_title
import vnidrop.shared.generated.resources.device_name_title
import vnidrop.shared.generated.resources.error_invalid_ticket
import vnidrop.shared.generated.resources.nav_receive
import vnidrop.shared.generated.resources.nav_send
import vnidrop.shared.generated.resources.nav_settings
import vnidrop.shared.generated.resources.network_title
import vnidrop.shared.generated.resources.os_version_title
import vnidrop.shared.generated.resources.receive_title
import vnidrop.shared.generated.resources.send_title
import vnidrop.shared.generated.resources.settings_title
class I18nResourceTest {
@Test
fun primaryNavigationScreenAndErrorKeysExist() {
val resources: List<StringResource> = listOf(
Res.string.nav_send,
Res.string.nav_receive,
Res.string.nav_settings,
Res.string.send_title,
Res.string.receive_title,
Res.string.settings_title,
Res.string.error_invalid_ticket,
Res.string.appearance_system_mode,
Res.string.appearance_dark_mode,
Res.string.appearance_light_mode,
Res.string.about_title,
Res.string.device_name_title,
Res.string.device_model_title,
Res.string.os_version_title,
Res.string.network_title,
Res.string.battery_level_title,
)
assertEquals(16, resources.size)
}
}

View File

@@ -0,0 +1,20 @@
package com.vnidrop.app.ui.theme
import kotlin.test.Test
import kotlin.test.assertEquals
class VniDropThemeTokenTest {
@Test
fun lightPaletteUsesTauriPurpleBrandAndSurfaceTokens() {
assertEquals(hslColorForTest(271f, 91f, 65f), VniDropThemeTokens.light.brandLink)
assertEquals(hslColorForTest(0f, 0f, 97.3f), VniDropThemeTokens.light.backgroundDashCanvas)
assertEquals(hslColorForTest(0f, 0f, 87.5f), VniDropThemeTokens.light.borderDefault)
}
@Test
fun darkPaletteUsesTauriPurpleBrandAndSurfaceTokens() {
assertEquals(hslColorForTest(270f, 95f, 75f), VniDropThemeTokens.dark.brandLink)
assertEquals(hslColorForTest(0f, 0f, 7.1f), VniDropThemeTokens.dark.backgroundDashCanvas)
assertEquals(hslColorForTest(0f, 0f, 18f), VniDropThemeTokens.dark.borderDefault)
}
}

View File

@@ -1,12 +1,28 @@
package com.vnidrop.app package com.vnidrop.app
import platform.UIKit.UIDevice
import platform.Foundation.NSTemporaryDirectory import platform.Foundation.NSTemporaryDirectory
import platform.UIKit.UIDevice
class IOSPlatform : Platform { class IOSPlatform : Platform {
override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion private val device: UIDevice = UIDevice.currentDevice
override val name: String = device.systemName() + " " + device.systemVersion
override val defaultCoreDataDir: String = NSTemporaryDirectory() + "vnidrop" override val defaultCoreDataDir: String = NSTemporaryDirectory() + "vnidrop"
override val defaultReceiveDir: String = NSTemporaryDirectory() + "vnidrop-receive" override val defaultReceiveDir: String = NSTemporaryDirectory() + "vnidrop-receive"
override val deviceInfo: DeviceInfo = DeviceInfo(
deviceName = device.name,
deviceModel = device.model,
operatingSystem = device.systemName() + " " + device.systemVersion,
network = null,
batteryLevel = batteryLevel(device),
)
} }
actual fun getPlatform(): Platform = IOSPlatform() actual fun getPlatform(): Platform = IOSPlatform()
private fun batteryLevel(device: UIDevice): String? =
runCatching {
device.batteryMonitoringEnabled = true
val level = device.batteryLevel
if (level >= 0.0) "${(level * 100).toInt()}%" else null
}.getOrNull()

View File

@@ -0,0 +1,95 @@
package com.vnidrop.app.logging
import platform.Foundation.NSData
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import platform.Foundation.NSDate
import platform.Foundation.NSFileManager
import platform.Foundation.NSFileModificationDate
import platform.Foundation.NSFileSize
import platform.Foundation.NSNumber
import platform.Foundation.timeIntervalSince1970
import platform.posix.fclose
import platform.posix.fopen
import platform.posix.fwrite
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
IosPlatformLogStore(appDataDir, policy)
actual fun platformNowMillis(): Long =
(NSDate().timeIntervalSince1970 * 1000.0).toLong()
@OptIn(ExperimentalForeignApi::class)
private class IosPlatformLogStore(
appDataDir: String,
private val policy: LogRotationPolicy,
) : PlatformLogStore {
private val fileManager = NSFileManager.defaultManager
private val directory = appDataDir.trimEnd('/') + "/logs"
private val activePath = "$directory/app.log"
override val logDirectory: String = directory
override fun append(line: String) {
ensureDirectory()
val bytes = line.encodeToByteArray()
if (policy.shouldRotate(fileSize(activePath), bytes.size.toLong())) {
rotate()
}
val file = fopen(activePath, "ab") ?: return
try {
bytes.usePinned { pinned ->
fwrite(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file)
}
} finally {
fclose(file)
}
}
override fun listLogFiles(): List<LogFileInfo> {
ensureDirectory()
val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
.filterIsInstance<String>()
.filter { it.startsWith("app") && it.endsWith(".log") }
return names
.map { name ->
val path = "$directory/$name"
LogFileInfo(name, path, fileSize(path), modifiedAt(path))
}
.sortedByDescending { it.modifiedAtMillis }
}
private fun rotate() {
if (policy.maxFiles == 0) {
fileManager.removeItemAtPath(activePath, null)
return
}
fileManager.removeItemAtPath("$directory/app.${policy.maxFiles}.log", null)
for (index in policy.maxFiles - 1 downTo 1) {
val source = "$directory/app.$index.log"
if (fileManager.fileExistsAtPath(source)) {
fileManager.moveItemAtPath(source, "$directory/app.${index + 1}.log", null)
}
}
if (fileManager.fileExistsAtPath(activePath)) {
fileManager.moveItemAtPath(activePath, "$directory/app.1.log", null)
}
}
private fun ensureDirectory() {
fileManager.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
}
private fun fileSize(path: String): Long {
val attributes = fileManager.attributesOfItemAtPath(path, null) ?: return 0L
return (attributes[NSFileSize] as? NSNumber)?.longLongValue ?: 0L
}
private fun modifiedAt(path: String): Long {
val attributes = fileManager.attributesOfItemAtPath(path, null) ?: return 0L
val date = attributes[NSFileModificationDate] as? NSDate ?: return 0L
return (date.timeIntervalSince1970 * 1000.0).toLong()
}
}

View File

@@ -0,0 +1,19 @@
package com.vnidrop.app.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import platform.Foundation.NSNotificationCenter
@Composable
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
SideEffect {
// The Swift host owns the actual UIKit status bar style. Compose publishes
// the resolved theme here so the wrapper can update without coupling common
// UI code to iOS-specific view controller APIs.
NSNotificationCenter.defaultCenter.postNotificationName(
aName = "VniDropThemeChanged",
`object` = null,
userInfo = mapOf("isDark" to if (isDarkTheme) "true" else "false"),
)
}
}

View File

@@ -1,11 +1,37 @@
package com.vnidrop.app package com.vnidrop.app
import java.net.NetworkInterface
class JVMPlatform : Platform { class JVMPlatform : Platform {
override val name: String = "Java ${System.getProperty("java.version")}" override val name: String = "Java ${System.getProperty("java.version")}"
override val defaultCoreDataDir: String = override val defaultCoreDataDir: String =
System.getProperty("user.home") + "/.vnidrop" System.getProperty("user.home") + "/.vnidrop"
override val defaultReceiveDir: String = override val defaultReceiveDir: String =
System.getProperty("user.home") + "/Downloads" System.getProperty("user.home") + "/Downloads"
override val deviceInfo: DeviceInfo = DeviceInfo(
deviceName = System.getenv("COMPUTERNAME")
?: System.getenv("HOSTNAME")
?: System.getProperty("user.name"),
deviceModel = listOfNotNull(
System.getProperty("os.arch"),
System.getProperty("java.vm.name"),
).joinToString(" | ").ifBlank { null },
operatingSystem = listOfNotNull(
System.getProperty("os.name"),
System.getProperty("os.version"),
).joinToString(" ").ifBlank { name },
network = activeNetworkSummary(),
batteryLevel = null,
)
} }
actual fun getPlatform(): Platform = JVMPlatform() actual fun getPlatform(): Platform = JVMPlatform()
private fun activeNetworkSummary(): String? =
runCatching {
NetworkInterface.getNetworkInterfaces()
.asSequence()
.filter { it.isUp && !it.isLoopback }
.map { it.displayName }
.firstOrNull()
}.getOrNull()

View File

@@ -0,0 +1,56 @@
package com.vnidrop.app.logging
import java.io.File
import java.nio.charset.StandardCharsets
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
JvmPlatformLogStore(appDataDir, policy)
actual fun platformNowMillis(): Long = System.currentTimeMillis()
private class JvmPlatformLogStore(
appDataDir: String,
private val policy: LogRotationPolicy,
) : PlatformLogStore {
private val directory = File(appDataDir, "logs")
private val activeFile = File(directory, "app.log")
override val logDirectory: String = directory.absolutePath
@Synchronized
override fun append(line: String) {
directory.mkdirs()
val bytes = line.toByteArray(StandardCharsets.UTF_8)
if (policy.shouldRotate(activeFile.length(), bytes.size.toLong())) {
rotate()
}
activeFile.appendBytes(bytes)
}
@Synchronized
override fun listLogFiles(): List<LogFileInfo> {
directory.mkdirs()
return directory
.listFiles { file -> file.isFile && file.name.startsWith("app") && file.name.endsWith(".log") }
.orEmpty()
.sortedByDescending { it.lastModified() }
.map { file -> LogFileInfo(file.name, file.absolutePath, file.length(), file.lastModified()) }
}
private fun rotate() {
if (policy.maxFiles == 0) {
activeFile.delete()
return
}
File(directory, "app.${policy.maxFiles}.log").delete()
for (index in policy.maxFiles - 1 downTo 1) {
val source = File(directory, "app.$index.log")
if (source.exists()) {
source.renameTo(File(directory, "app.${index + 1}.log"))
}
}
if (activeFile.exists()) {
activeFile.renameTo(File(directory, "app.1.log"))
}
}
}

View File

@@ -0,0 +1,59 @@
package com.vnidrop.app.platform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import java.awt.Color
import java.awt.EventQueue
import java.awt.Window
import javax.swing.JFrame
@Composable
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
SideEffect {
DesktopSystemAppearance.apply(isDarkTheme)
}
}
internal object DesktopSystemAppearance {
private const val MAC_APPEARANCE_PROPERTY = "apple.awt.application.appearance"
private const val TRANSPARENT_TITLE_BAR_PROPERTY = "apple.awt.transparentTitleBar"
fun apply(isDarkTheme: Boolean) {
if (!isMacOs()) return
System.setProperty(MAC_APPEARANCE_PROPERTY, macOsAppearanceName(isDarkTheme))
EventQueue.invokeLater {
DesktopAppearanceBridge.applyNativeAppearance?.invoke(isDarkTheme)
Window.getWindows().forEach { window ->
applyWindowChrome(window, isDarkTheme)
}
}
}
internal fun macOsAppearanceName(isDarkTheme: Boolean): String =
if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
internal fun usesTransparentTitlebar(): Boolean = true
internal fun titlebarBackground(isDarkTheme: Boolean): Color =
if (isDarkTheme) Color(0x12, 0x12, 0x12) else Color(0xF8, 0xF8, 0xF8)
private fun applyWindowChrome(window: Window, isDarkTheme: Boolean) {
val background = titlebarBackground(isDarkTheme)
window.background = background
(window as? JFrame)?.rootPane?.let { rootPane ->
// The titlebar stays native, but AppKit receives the resolved app
// appearance so title text and controls switch contrast at runtime.
rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, usesTransparentTitlebar())
rootPane.background = background
rootPane.contentPane.background = background
}
}
private fun isMacOs(): Boolean =
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
}
object DesktopAppearanceBridge {
@Volatile
var applyNativeAppearance: ((Boolean) -> Unit)? = null
}

View File

@@ -0,0 +1,29 @@
package com.vnidrop.app.platform
import kotlin.test.Test
import kotlin.test.assertEquals
class DesktopSystemAppearanceTest {
@Test
fun macOsAppearanceNamesMatchResolvedTheme() {
assertEquals("NSAppearanceNameDarkAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = true))
assertEquals("NSAppearanceNameAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = false))
}
@Test
fun titlebarBackgroundUsesLightAndDarkSurfaces() {
assertEquals(0x121212, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF)
assertEquals(0xF8F8F8, DesktopSystemAppearance.titlebarBackground(isDarkTheme = false).rgb and 0xFFFFFF)
}
@Test
fun transparentTitlebarIsAlwaysUsedWithAppKitAppearance() {
assertEquals(true, DesktopSystemAppearance.usesTransparentTitlebar())
}
@Test
fun runtimeAppearanceCallIsFailSoft() {
DesktopSystemAppearance.apply(isDarkTheme = true)
DesktopSystemAppearance.apply(isDarkTheme = false)
}
}