feat(shared): graduate saved-device transfer experience

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

View File

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