mirror of
https://github.com/openMF/mifos-mobile.git
synced 2026-02-06 11:26:51 +00:00
feat(docs): add comprehensive feature layer instructions and component registry
- Add feature-layer/instructions/ with modular implementation guides: - VIEWMODEL.md: MVI patterns (State, Event, Action, Internal Actions) - COMPOSE.md: Screen patterns, theming, component hierarchy - NAVIGATION.md: Type-safe routes, NavGraph, transitions - DI.md: Koin module patterns and registration - UPDATING_FEATURE.md: v2.0 UI redesign and improvement workflow - Add core-layer/COMPONENTS.md with hybrid lookup strategy: - Static registry for fast O(1) component lookup - Dynamic fallback for discovering new components - Auto-update rules to keep registry current - Naming conventions (Kpt*, Mifos*, [Feature]*, [Screen]*) - Update LAYER_GUIDE.md: - Add Table of Contents for quick navigation - Add "Creating New Feature" step-by-step guide - Add component hierarchy and placement rules - Add Component Creation cross-update rules - Reference modular instruction files
This commit is contained in:
parent
c4bcc50352
commit
ee27d4aa95
352
claude-product-cycle/core-layer/COMPONENTS.md
Normal file
352
claude-product-cycle/core-layer/COMPONENTS.md
Normal file
@ -0,0 +1,352 @@
|
||||
# Component Discovery Guide
|
||||
|
||||
> **Purpose**: Fast component lookup with automatic updates
|
||||
> **Pattern**: Static first → Dynamic fallback → Auto-update
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
1. [Lookup Strategy](#lookup-strategy)
|
||||
2. [Static Component Registry](#static-component-registry)
|
||||
3. [Dynamic Discovery](#dynamic-discovery)
|
||||
4. [Naming Conventions](#naming-conventions)
|
||||
5. [Auto-Update Rules](#auto-update-rules)
|
||||
6. [Component Placement](#component-placement)
|
||||
|
||||
---
|
||||
|
||||
## Lookup Strategy
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ STEP 1: Check Static Registry (Fast) │
|
||||
│ → Look in tables below for existing component │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ STEP 2: If Not Found → Dynamic Search (Fallback) │
|
||||
│ → Run discovery commands to find in source │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ STEP 3: If Found Dynamically → Update Static Registry │
|
||||
│ → Add new component to appropriate table below │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Why This Pattern:**
|
||||
- Static lookup is instant (read from file)
|
||||
- Dynamic search catches new components
|
||||
- Auto-update keeps registry current
|
||||
|
||||
---
|
||||
|
||||
## Static Component Registry
|
||||
|
||||
### Foundation Components (core-base/designsystem)
|
||||
|
||||
**Prefix: `Kpt*`**
|
||||
|
||||
#### Components (`component/`)
|
||||
| Component | Purpose | Usage |
|
||||
|-----------|---------|-------|
|
||||
| `KptTopAppBar` | Configurable app bar | Standard/Large/Medium variants |
|
||||
| `KptShimmerLoadingBox` | Skeleton loading | Loading placeholders |
|
||||
| `KptSnackbarHost` | Snackbar container | Toast messages |
|
||||
| `KptAnimationSpecs` | Animation specifications | Standard animations |
|
||||
| `BounceAnimation` | Bounce effect | Button press feedback |
|
||||
| `SlideTransition` | Slide animation | Screen transitions |
|
||||
|
||||
#### Layouts (`layout/`)
|
||||
| Layout | Purpose | Usage |
|
||||
|--------|---------|-------|
|
||||
| `KptGrid` | Responsive grid | Card grids |
|
||||
| `KptFlowRow` | Horizontal flow | Tag/chip layouts |
|
||||
| `KptFlowColumn` | Vertical flow | Wrapping columns |
|
||||
| `KptStack` | Z-axis stacking | Overlays |
|
||||
| `KptMasonryGrid` | Masonry layout | Pinterest-style |
|
||||
| `KptResponsiveLayout` | Adaptive layout | Screen size adaptation |
|
||||
| `KptSidebarLayout` | Sidebar with content | Navigation drawer |
|
||||
| `KptSplitPane` | Resizable split | Two-panel layout |
|
||||
| `AdaptiveListDetailPaneScaffold` | List-detail adaptive | Master-detail |
|
||||
| `AdaptiveNavigableListDetailScaffold` | Navigable list-detail | Navigable master-detail |
|
||||
| `AdaptiveNavigableSupportingPaneScaffold` | Supporting pane | Three-pane layout |
|
||||
| `AdaptiveNavigationSuiteScaffold` | Navigation suite | Adaptive navigation |
|
||||
|
||||
#### Theme Tokens
|
||||
| Token | Access | Values |
|
||||
|-------|--------|--------|
|
||||
| Spacing | `KptTheme.spacing.*` | `xs`(4dp), `sm`(8dp), `md`(16dp), `lg`(24dp), `xl`(32dp) |
|
||||
| Shapes | `KptTheme.shapes.*` | `small`, `medium`, `large` |
|
||||
| Colors | `KptTheme.colorScheme.*` | Material3 color scheme |
|
||||
|
||||
---
|
||||
|
||||
### Design System Components (core/designsystem)
|
||||
|
||||
**Prefix: `Mifos*`**
|
||||
|
||||
| Component | Purpose | Usage |
|
||||
|-----------|---------|-------|
|
||||
| `MifosScaffold` | Screen scaffold | Top bar, bottom bar, content |
|
||||
| `MifosTopAppBar` | App bar | Navigation icon, title, actions |
|
||||
| `MifosTopBar` | Simple title bar | Title only |
|
||||
| `MifosButton` | Primary button | Main actions |
|
||||
| `MifosTextField` | Text input | Form fields |
|
||||
| `MifosPasswordField` | Password input | Visibility toggle |
|
||||
| `MifosOtpTextField` | OTP input | Verification codes |
|
||||
| `MifosSearchTextField` | Search input | Search bars |
|
||||
| `MifosCard` | Card container | Content cards |
|
||||
| `MifosBottomSheet` | Bottom sheet | Modal content |
|
||||
| `MifosAlertDialog` | Alert dialog | Confirmations |
|
||||
| `MifosBasicDialog` | Basic dialog | Simple messages |
|
||||
| `MifosLoadingDialog` | Loading dialog | Blocking loader |
|
||||
| `MifosTab` | Tab item | Tab navigation |
|
||||
| `MifosTabPager` | Tab pager | Swipeable tabs |
|
||||
| `MifosDropDownMenu` | Dropdown menu | Selection menu |
|
||||
| `MifosRadioButton` | Radio button | Single selection |
|
||||
| `MifosNavigation` | Navigation | Nav components |
|
||||
|
||||
---
|
||||
|
||||
### Business Components (core/ui)
|
||||
|
||||
**Prefix: `Mifos*` or descriptive name**
|
||||
|
||||
#### Cards
|
||||
| Component | Purpose | Usage |
|
||||
|-----------|---------|-------|
|
||||
| `MifosAccountCard` | Account display | Account list items |
|
||||
| `MifosDetailsCard` | Detail display | Information cards |
|
||||
| `MifosDashboardCard` | Dashboard item | Home dashboard |
|
||||
| `MifosActionCard` | Action card | Clickable actions |
|
||||
| `MifosItemCard` | Generic item | List items |
|
||||
| `MifosLabelValueCard` | Key-value | Detail rows |
|
||||
| `MifosPoweredCard` | Footer card | "Powered by" |
|
||||
| `MifosTitleSearchCard` | Title + search | Searchable headers |
|
||||
|
||||
#### Lists & Items
|
||||
| Component | Purpose | Usage |
|
||||
|-----------|---------|-------|
|
||||
| `BeneficiaryCard` | Beneficiary item | Beneficiary list |
|
||||
| `BeneficiariesListing` | Beneficiary list | Full list view |
|
||||
| `TransactionScreenItem` | Transaction item | Transaction list |
|
||||
| `FaqItemHolder` | FAQ item | Expandable FAQ |
|
||||
| `AboutUsItemCard` | About item | About section |
|
||||
| `MonitorListItemWithIcon` | Icon list item | Settings list |
|
||||
|
||||
#### States
|
||||
| Component | Purpose | Usage |
|
||||
|-----------|---------|-------|
|
||||
| `MifosErrorComponent` | Error state | Error display |
|
||||
| `EmptyDataView` | Empty state | No data |
|
||||
| `NoInternet` | Network error | Offline state |
|
||||
| `MifosProgressIndicator` | Loading spinner | Inline loading |
|
||||
| `MifosStatusComponent` | Status badge | Status display |
|
||||
| `MifosSuccessDialog` | Success dialog | Confirmation |
|
||||
|
||||
#### Forms
|
||||
| Component | Purpose | Usage |
|
||||
|-----------|---------|-------|
|
||||
| `MifosDropDownTextField` | Dropdown field | Form dropdowns |
|
||||
| `MifosOutlineDropDown` | Outlined dropdown | Outlined variant |
|
||||
| `MifosDropDownPayFromComponent` | Pay from selector | Transfer forms |
|
||||
| `MifosCheckBox` | Checkbox | Multi-select |
|
||||
| `MFStepProcess` | Step indicator | Multi-step forms |
|
||||
| `FilterTopSection` | Filter header | List filters |
|
||||
|
||||
#### User/Profile
|
||||
| Component | Purpose | Usage |
|
||||
|-----------|---------|-------|
|
||||
| `MifosUserImage` | User avatar | Profile images |
|
||||
| `MifosTextUserImage` | Text avatar | Initials avatar |
|
||||
| `UserProfileField` | Profile field | Profile display |
|
||||
| `MifosHiddenTextRow` | Hidden text | Sensitive data |
|
||||
|
||||
#### Other
|
||||
| Component | Purpose | Usage |
|
||||
|-----------|---------|-------|
|
||||
| `MifosRoundIcon` | Round icon button | FAB-like |
|
||||
| `MifosLinkText` | Link text | Clickable links |
|
||||
| `MifosTextButtonWithTopDrawable` | Text button + icon | Icon buttons |
|
||||
| `MifosMobileIcon` | App icon | Branding |
|
||||
| `MifosRadioButtonAlertDialog` | Radio dialog | Selection dialog |
|
||||
| `MifosAlertDialog` | Alert dialog | Confirmations |
|
||||
| `MifosTexts` | Text styles | Styled text |
|
||||
|
||||
---
|
||||
|
||||
## Dynamic Discovery
|
||||
|
||||
### When to Use
|
||||
|
||||
Use dynamic discovery when:
|
||||
1. Component not found in static registry above
|
||||
2. Searching for recently added components
|
||||
3. Unsure if component exists
|
||||
|
||||
### Discovery Commands
|
||||
|
||||
```bash
|
||||
# Foundation components (Kpt*)
|
||||
ls core-base/designsystem/src/commonMain/kotlin/**/component/
|
||||
ls core-base/designsystem/src/commonMain/kotlin/**/layout/
|
||||
|
||||
# Design system components (Mifos* in designsystem)
|
||||
ls core/designsystem/src/commonMain/kotlin/**/component/
|
||||
|
||||
# Business components (core/ui)
|
||||
ls core/ui/src/commonMain/kotlin/**/component/
|
||||
```
|
||||
|
||||
### Search by Type
|
||||
|
||||
```bash
|
||||
# Find all Button components
|
||||
grep -r "@Composable" core/ core-base/ | grep -i "button"
|
||||
|
||||
# Find all Card components
|
||||
grep -r "@Composable" core/ core-base/ | grep -i "card"
|
||||
|
||||
# Find all Dialog components
|
||||
grep -r "@Composable" core/ core-base/ | grep -i "dialog"
|
||||
|
||||
# Find all TextField/Input components
|
||||
grep -r "@Composable" core/ core-base/ | grep -iE "(textfield|input|field)"
|
||||
|
||||
# Find loading/progress components
|
||||
grep -r "@Composable" core/ core-base/ | grep -iE "(loading|progress|shimmer)"
|
||||
|
||||
# Find error/empty state components
|
||||
grep -r "@Composable" core/ core-base/ | grep -iE "(error|empty|nodata)"
|
||||
```
|
||||
|
||||
### Claude Glob Patterns
|
||||
|
||||
```
|
||||
core-base/designsystem/**/component/*.kt
|
||||
core-base/designsystem/**/layout/*.kt
|
||||
core/designsystem/**/component/*.kt
|
||||
core/ui/**/component/*.kt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Prefix Rules
|
||||
|
||||
| Prefix | Location | Purpose |
|
||||
|--------|----------|---------|
|
||||
| `Kpt*` | core-base/designsystem | Foundation/Theme |
|
||||
| `Mifos*` | core/designsystem | UI primitives |
|
||||
| `Mifos*` | core/ui | Business components |
|
||||
| `[Feature]*` | feature/[name]/components | Feature-shared |
|
||||
| `[Screen]*` | feature/[name]/[screen]/components | Screen-specific |
|
||||
|
||||
### Component Type by Name Pattern
|
||||
|
||||
| Pattern | Type | Look In |
|
||||
|---------|------|---------|
|
||||
| `*Button` | Action | core/designsystem |
|
||||
| `*TextField`, `*Field` | Input | core/designsystem |
|
||||
| `*Dialog`, `*Sheet` | Modal | core/designsystem |
|
||||
| `*Card` | Container | core/ui |
|
||||
| `*Item` | List item | core/ui |
|
||||
| `*Component`, `*View` | Composite | core/ui |
|
||||
| `*Indicator` | Feedback | core/ui |
|
||||
| `*Grid`, `*Row`, `*Column` | Layout | core-base |
|
||||
| `*Scaffold`, `*Layout` | Structure | core-base |
|
||||
|
||||
---
|
||||
|
||||
## Auto-Update Rules
|
||||
|
||||
### When to Update This File
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| Found in static registry | No update needed |
|
||||
| Found via dynamic search | ADD to static registry |
|
||||
| Created new component in core/ | ADD to static registry |
|
||||
| Created feature component | No update (not in registry) |
|
||||
|
||||
### How to Update
|
||||
|
||||
When you find a component dynamically that's not in the static registry:
|
||||
|
||||
1. Identify the correct table (Foundation/Design System/Business)
|
||||
2. Add a new row with: Component | Purpose | Usage
|
||||
3. Keep tables alphabetically sorted within categories
|
||||
|
||||
**Example:**
|
||||
```markdown
|
||||
| `NewMifosComponent` | Brief purpose | When to use |
|
||||
```
|
||||
|
||||
### What NOT to Update
|
||||
|
||||
- Feature-specific components (`feature/*/components/`)
|
||||
- Screen-specific components (`feature/*/[screen]/components/`)
|
||||
- Temporary or experimental components
|
||||
|
||||
---
|
||||
|
||||
## Component Placement
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```
|
||||
Creating a new component?
|
||||
│
|
||||
├── Is it a theme/layout primitive?
|
||||
│ └── YES → core-base/designsystem (Kpt*)
|
||||
│
|
||||
├── Is it a UI primitive (Button, TextField)?
|
||||
│ └── YES → core/designsystem (Mifos*)
|
||||
│
|
||||
├── Used in 2+ features?
|
||||
│ └── YES → core/ui (Mifos*)
|
||||
│
|
||||
├── Used across screens in same feature?
|
||||
│ └── YES → feature/[name]/components/
|
||||
│
|
||||
└── Used only in one screen?
|
||||
└── YES → feature/[name]/[screen]/components/
|
||||
```
|
||||
|
||||
### After Creating in core/
|
||||
|
||||
If you create a new component in `core/` or `core-base/`:
|
||||
1. **ADD it to the static registry above**
|
||||
2. Follow the naming convention
|
||||
3. Include Purpose and Usage columns
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### I need a...
|
||||
|
||||
| Need | Check Static Table | If Not Found |
|
||||
|------|-------------------|--------------|
|
||||
| Button | Design System Components | `grep -i button core/` |
|
||||
| Card | Business Components → Cards | `grep -i card core/` |
|
||||
| List item | Business Components → Lists | `grep -i item core/` |
|
||||
| Loading | Foundation Components | Already exists: `KptShimmerLoadingBox` |
|
||||
| Error | Business Components → States | Already exists: `MifosErrorComponent` |
|
||||
| Empty | Business Components → States | Already exists: `EmptyDataView` |
|
||||
| Dialog | Design System Components | `grep -i dialog core/` |
|
||||
| Layout | Foundation Layouts | `grep -i layout core-base/` |
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
|
||||
- Feature Layer Guide: `feature-layer/LAYER_GUIDE.md`
|
||||
- Compose Patterns: `feature-layer/instructions/COMPOSE.md`
|
||||
- Design Spec Patterns: `design-spec-layer/_shared/PATTERNS.md`
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2025-01-05 | Created with hybrid static + dynamic approach |
|
||||
@ -3,11 +3,20 @@
|
||||
> **Location**: `feature/`
|
||||
> **Command**: `/feature [Feature]`
|
||||
|
||||
## Table of Contents
|
||||
1. [Overview](#overview)
|
||||
2. [Creating New Feature](#creating-new-feature)
|
||||
3. [Directory Structure](#directory-structure)
|
||||
4. [Component Organization](#component-organization)
|
||||
5. [Build Commands](#build-commands)
|
||||
6. [Cross-Update Rules](#cross-update-rules)
|
||||
7. [Instructions Reference](#instructions-reference)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The feature layer contains UI modules with ViewModel + Screen (MVI pattern):
|
||||
The feature layer contains UI modules following MVI (Model-View-Intent) architecture:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
@ -15,236 +24,269 @@ The feature layer contains UI modules with ViewModel + Screen (MVI pattern):
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ feature/[name]/ │
|
||||
│ ├── [Name]ViewModel.kt → MVI (State, Event, Action) │
|
||||
│ ├── [Name]Screen.kt → Compose UI │
|
||||
│ ├── navigation/ → Navigation routes │
|
||||
│ ├── components/ → Feature-specific components │
|
||||
│ └── di/[Name]Module.kt → Koin registration │
|
||||
│ ├── [screen]/ → Screen package │
|
||||
│ │ ├── [Screen]Screen.kt → Compose UI │
|
||||
│ │ ├── [Screen]ViewModel.kt → MVI (State, Event, Action) │
|
||||
│ │ ├── [Screen].kt → State/Event/Action definitions │
|
||||
│ │ └── components/ → Screen-specific components │
|
||||
│ ├── components/ → Feature-shared components │
|
||||
│ ├── navigation/ → Navigation routes │
|
||||
│ └── di/[Name]Module.kt → Koin registration │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
## Creating New Feature
|
||||
|
||||
```
|
||||
feature/
|
||||
├── home/
|
||||
│ └── src/commonMain/kotlin/.../feature/home/
|
||||
│ ├── HomeScreen.kt
|
||||
│ ├── HomeViewModel.kt
|
||||
│ ├── navigation/
|
||||
│ │ └── HomeNavigation.kt
|
||||
│ ├── components/
|
||||
│ │ └── BottomSheetContent.kt
|
||||
│ └── di/
|
||||
│ └── HomeModule.kt
|
||||
│
|
||||
├── accounts/
|
||||
├── auth/
|
||||
├── beneficiary/
|
||||
├── client-charge/
|
||||
├── guarantor/
|
||||
├── loan-account/
|
||||
├── loan-application/
|
||||
├── location/
|
||||
├── notification/
|
||||
├── onboarding-language/
|
||||
├── passcode/
|
||||
├── qr/
|
||||
├── recent-transaction/
|
||||
├── savings-account/
|
||||
├── savings-application/
|
||||
├── settings/
|
||||
├── share-account/
|
||||
├── share-application/
|
||||
├── status/
|
||||
├── third-party-transfer/
|
||||
└── transfer-process/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MVI Architecture
|
||||
|
||||
```kotlin
|
||||
// State - UI state (immutable)
|
||||
@Immutable
|
||||
data class [Feature]State(
|
||||
val isLoading: Boolean = false,
|
||||
val items: List<Item> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
// ScreenState - Loading/Success/Error states
|
||||
sealed interface [Feature]ScreenState {
|
||||
data object Loading : [Feature]ScreenState
|
||||
data object Success : [Feature]ScreenState
|
||||
data class Error(val message: StringResource) : [Feature]ScreenState
|
||||
}
|
||||
|
||||
// Event - One-shot navigation/effects
|
||||
sealed interface [Feature]Event {
|
||||
data class NavigateToDetail(val id: Long) : [Feature]Event
|
||||
data object NavigateBack : [Feature]Event
|
||||
}
|
||||
|
||||
// Action - User interactions
|
||||
sealed interface [Feature]Action {
|
||||
data class OnItemClick(val id: Long) : [Feature]Action
|
||||
data object OnRefresh : [Feature]Action
|
||||
|
||||
// Internal actions for handling async results
|
||||
sealed interface Internal : [Feature]Action {
|
||||
data class ReceiveData(val dataState: DataState<Data>) : Internal
|
||||
}
|
||||
}
|
||||
|
||||
// ViewModel
|
||||
internal class [Feature]ViewModel(
|
||||
private val repository: [Feature]Repository,
|
||||
) : BaseViewModel<[Feature]State, [Feature]Event, [Feature]Action>(
|
||||
initialState = [Feature]State(),
|
||||
) {
|
||||
override fun handleAction(action: [Feature]Action) {
|
||||
when (action) {
|
||||
is [Feature]Action.OnItemClick ->
|
||||
sendEvent([Feature]Event.NavigateToDetail(action.id))
|
||||
is [Feature]Action.Internal.ReceiveData ->
|
||||
handleDataResult(action.dataState)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen Pattern
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
internal fun [Feature]Screen(
|
||||
navigateToDetail: (Long) -> Unit,
|
||||
navigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: [Feature]ViewModel = koinViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel) { event ->
|
||||
when (event) {
|
||||
is [Feature]Event.NavigateToDetail -> navigateToDetail(event.id)
|
||||
[Feature]Event.NavigateBack -> navigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
[Feature]ScreenContent(
|
||||
state = state,
|
||||
onAction = viewModel::trySendAction,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun [Feature]ScreenContent(
|
||||
state: [Feature]State,
|
||||
onAction: ([Feature]Action) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// UI implementation
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation Pattern
|
||||
|
||||
```kotlin
|
||||
// feature/[name]/navigation/[Feature]Navigation.kt
|
||||
|
||||
const val [FEATURE]_NAVIGATION_ROUTE = "[feature]_route"
|
||||
const val [FEATURE]_SCREEN_ROUTE = "[feature]_screen"
|
||||
|
||||
fun NavController.navigateTo[Feature]Screen() {
|
||||
navigate([FEATURE]_SCREEN_ROUTE)
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.[feature]NavGraph(
|
||||
navigateBack: () -> Unit,
|
||||
navigateToDetail: (Long) -> Unit,
|
||||
) {
|
||||
navigation(
|
||||
startDestination = [FEATURE]_SCREEN_ROUTE,
|
||||
route = [FEATURE]_NAVIGATION_ROUTE,
|
||||
) {
|
||||
composable(route = [FEATURE]_SCREEN_ROUTE) {
|
||||
[Feature]Screen(
|
||||
navigateBack = navigateBack,
|
||||
navigateToDetail = navigateToDetail,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Flow
|
||||
|
||||
```
|
||||
/feature [Feature]
|
||||
│
|
||||
├── 1. Create State, ScreenState, Event, Action
|
||||
│
|
||||
├── 2. Create ViewModel
|
||||
│ └── Inject Repositories from data layer
|
||||
│
|
||||
├── 3. Create Screen
|
||||
│ ├── Use EventsEffect for navigation events
|
||||
│ └── Use collectAsStateWithLifecycle for state
|
||||
│
|
||||
├── 4. Create Components (if needed)
|
||||
│
|
||||
├── 5. Create [Feature]Module.kt
|
||||
│ └── viewModelOf(::[Feature]ViewModel)
|
||||
│
|
||||
├── 6. Create Navigation
|
||||
│ └── NavGraphBuilder extension functions
|
||||
│
|
||||
└── 7. Register in cmp-navigation
|
||||
├── Add to KoinModules.kt
|
||||
└── Add to navigation graph
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Koin Module Pattern
|
||||
|
||||
```kotlin
|
||||
// feature/[name]/di/[Feature]Module.kt
|
||||
val [Feature]Module = module {
|
||||
viewModelOf(::[Feature]ViewModel)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build Command
|
||||
### Step 1: Create Module Directory
|
||||
|
||||
```bash
|
||||
./gradlew :feature:[name]:build
|
||||
mkdir -p feature/[name]/src/commonMain/kotlin/org/mifos/mobile/feature/[name]
|
||||
mkdir -p feature/[name]/src/commonMain/composeResources/values
|
||||
```
|
||||
|
||||
### Step 2: Create build.gradle.kts
|
||||
|
||||
```kotlin
|
||||
// feature/[name]/build.gradle.kts
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.mifos.cmp.feature)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.mifos.mobile.feature.[name]"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.core.designsystem)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Register in settings.gradle.kts
|
||||
|
||||
```kotlin
|
||||
// settings.gradle.kts
|
||||
include(":feature:[name]")
|
||||
```
|
||||
|
||||
### Step 4: Create Directory Structure
|
||||
|
||||
```
|
||||
feature/[name]/src/commonMain/kotlin/org/mifos/mobile/feature/[name]/
|
||||
├── [screen]/
|
||||
│ ├── [Screen]Screen.kt
|
||||
│ ├── [Screen]ViewModel.kt
|
||||
│ ├── [Screen].kt
|
||||
│ └── components/
|
||||
├── components/
|
||||
├── navigation/
|
||||
│ └── [Feature]Navigation.kt
|
||||
└── di/
|
||||
└── [Feature]Module.kt
|
||||
```
|
||||
|
||||
### Step 5: Create strings.xml
|
||||
|
||||
```xml
|
||||
<!-- feature/[name]/src/commonMain/composeResources/values/strings.xml -->
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="feature_[name]_title">Feature Title</string>
|
||||
</resources>
|
||||
```
|
||||
|
||||
### New Feature Checklist
|
||||
|
||||
- [ ] Module directory created
|
||||
- [ ] build.gradle.kts configured
|
||||
- [ ] Registered in settings.gradle.kts
|
||||
- [ ] Screen package created with Screen, ViewModel, State/Event/Action
|
||||
- [ ] Navigation setup in navigation/
|
||||
- [ ] DI module created in di/
|
||||
- [ ] DI module registered in KoinModules.kt
|
||||
- [ ] strings.xml created for string resources
|
||||
- [ ] Build passes: `./gradlew :feature:[name]:build`
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
### Single-Screen Feature
|
||||
|
||||
For features with one main screen:
|
||||
|
||||
```
|
||||
feature/[name]/
|
||||
├── [Feature]Screen.kt
|
||||
├── [Feature]ViewModel.kt
|
||||
├── [Feature].kt # State/Event/Action
|
||||
├── components/
|
||||
│ └── [Feature]Component.kt
|
||||
├── navigation/
|
||||
│ └── [Feature]Navigation.kt
|
||||
└── di/
|
||||
└── [Feature]Module.kt
|
||||
```
|
||||
|
||||
**Example - Notification:**
|
||||
```
|
||||
feature/notification/
|
||||
├── NotificationScreen.kt
|
||||
├── NotificationViewModel.kt
|
||||
├── Notification.kt
|
||||
├── components/
|
||||
│ └── NotificationItem.kt
|
||||
├── navigation/
|
||||
│ └── NotificationNavigation.kt
|
||||
└── di/
|
||||
└── NotificationModule.kt
|
||||
```
|
||||
|
||||
### Multi-Screen Feature
|
||||
|
||||
For features with multiple screens:
|
||||
|
||||
```
|
||||
feature/[name]/
|
||||
├── [screen1]/
|
||||
│ ├── [Screen1]Screen.kt
|
||||
│ ├── [Screen1]ViewModel.kt
|
||||
│ ├── [Screen1].kt
|
||||
│ └── components/
|
||||
│ └── [Screen1]Header.kt
|
||||
├── [screen2]/
|
||||
│ ├── [Screen2]Screen.kt
|
||||
│ ├── [Screen2]ViewModel.kt
|
||||
│ ├── [Screen2].kt
|
||||
│ └── components/
|
||||
│ └── [Screen2]Form.kt
|
||||
├── components/ # Shared across screens
|
||||
│ └── [Feature]SharedComponent.kt
|
||||
├── navigation/
|
||||
│ └── [Feature]Navigation.kt
|
||||
└── di/
|
||||
└── [Feature]Module.kt
|
||||
```
|
||||
|
||||
**Example - Auth:**
|
||||
```
|
||||
feature/auth/
|
||||
├── login/
|
||||
│ ├── LoginScreen.kt
|
||||
│ ├── LoginViewModel.kt
|
||||
│ ├── Login.kt
|
||||
│ └── components/
|
||||
│ ├── LoginHeader.kt
|
||||
│ └── LoginForm.kt
|
||||
├── registration/
|
||||
│ ├── RegistrationScreen.kt
|
||||
│ ├── RegistrationViewModel.kt
|
||||
│ ├── Registration.kt
|
||||
│ └── components/
|
||||
│ └── RegistrationSteps.kt
|
||||
├── otpAuthentication/
|
||||
│ ├── OtpAuthenticationScreen.kt
|
||||
│ ├── OtpAuthenticationViewModel.kt
|
||||
│ └── components/
|
||||
│ └── OtpInput.kt
|
||||
├── components/ # Shared auth components
|
||||
│ ├── AuthHeader.kt
|
||||
│ └── AuthFooter.kt
|
||||
├── navigation/
|
||||
│ └── AuthNavigation.kt
|
||||
└── di/
|
||||
└── AuthModule.kt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-Update Rules (MANDATORY)
|
||||
## Component Organization
|
||||
|
||||
### String Resources - No Hardcoded Strings
|
||||
### Component Hierarchy
|
||||
|
||||
**NEVER use hardcoded strings or `String.format()` in feature code.**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Screen-Specific: feature/[name]/[screen]/components/ │
|
||||
│ → Used only in that screen │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Feature-Shared: feature/[name]/components/ │
|
||||
│ → Used across screens in same feature │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ App-Wide: core/ui/component/ │
|
||||
│ → Used in 2+ features │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Design System: core/designsystem/component/ │
|
||||
│ → UI primitives (Button, TextField, Card) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Foundation: core-base/designsystem/ │
|
||||
│ → Theme, layouts (KptTheme, KptGrid) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
All user-facing strings MUST be defined in `composeResources/values/strings.xml` and accessed via `stringResource()`.
|
||||
### Component Placement Decision
|
||||
|
||||
```
|
||||
Creating a new component?
|
||||
│
|
||||
├── Used only in this screen?
|
||||
│ └── feature/[name]/[screen]/components/
|
||||
│
|
||||
├── Used across screens in same feature?
|
||||
│ └── feature/[name]/components/
|
||||
│
|
||||
├── Used in 2+ features?
|
||||
│ └── core/ui/component/
|
||||
│
|
||||
├── UI primitive (Button, TextField variant)?
|
||||
│ └── core/designsystem/component/
|
||||
│
|
||||
└── Theme/Layout component?
|
||||
└── core-base/designsystem/
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
| Component | Location | Reason |
|
||||
|-----------|----------|--------|
|
||||
| `LoginForm` | `feature/auth/login/components/` | Only in login screen |
|
||||
| `AuthHeader` | `feature/auth/components/` | Shared across auth screens |
|
||||
| `MifosAccountCard` | `core/ui/component/` | Used in accounts, home |
|
||||
| `MifosButton` | `core/designsystem/component/` | UI primitive |
|
||||
| `KptTheme` | `core-base/designsystem/` | Foundation |
|
||||
|
||||
---
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Build specific feature
|
||||
./gradlew :feature:[name]:build
|
||||
|
||||
# Build all features
|
||||
./gradlew build
|
||||
|
||||
# Lint check
|
||||
./gradlew :feature:[name]:detekt
|
||||
|
||||
# Format code
|
||||
./gradlew spotlessApply
|
||||
|
||||
# Run tests
|
||||
./gradlew :feature:[name]:test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-Update Rules
|
||||
|
||||
### String Resources
|
||||
|
||||
**NEVER use hardcoded strings.** All user-facing strings MUST be in `strings.xml`:
|
||||
|
||||
```kotlin
|
||||
// WRONG - Hardcoded string
|
||||
@ -256,11 +298,11 @@ Text(text = String.format("Hello, %s!", userName))
|
||||
// CORRECT - Use stringResource
|
||||
Text(text = stringResource(Res.string.welcome_message))
|
||||
|
||||
// CORRECT - Use stringResource with arguments
|
||||
// CORRECT - With arguments
|
||||
Text(text = stringResource(Res.string.hello_user, userName))
|
||||
```
|
||||
|
||||
**strings.xml example:**
|
||||
**strings.xml:**
|
||||
```xml
|
||||
<!-- feature/[name]/src/commonMain/composeResources/values/strings.xml -->
|
||||
<resources>
|
||||
@ -269,10 +311,112 @@ Text(text = stringResource(Res.string.hello_user, userName))
|
||||
</resources>
|
||||
```
|
||||
|
||||
### Status Updates
|
||||
|
||||
After implementing a feature, update:
|
||||
1. `feature-layer/LAYER_STATUS.md` - Feature layer status
|
||||
2. `design-spec-layer/features/[feature]/STATUS.md` - Feature design status
|
||||
|
||||
### Component Creation
|
||||
|
||||
**ALWAYS check existing components before creating new ones.**
|
||||
|
||||
See [core-layer/COMPONENTS.md](../core-layer/COMPONENTS.md) for complete registry.
|
||||
|
||||
**Lookup Strategy:**
|
||||
```
|
||||
Step 1: Check Static Registry (Fast)
|
||||
→ Read core-layer/COMPONENTS.md tables
|
||||
|
||||
Step 2: If Not Found → Dynamic Search
|
||||
→ grep -r "@Composable" core/ | grep -i "[type]"
|
||||
|
||||
Step 3: If Found Dynamically → Update Registry
|
||||
→ Add to core-layer/COMPONENTS.md static tables
|
||||
```
|
||||
|
||||
**Naming Convention:**
|
||||
| Location | Prefix | Example |
|
||||
|----------|--------|---------|
|
||||
| core-base/designsystem | `Kpt*` | `KptGrid`, `KptShimmerLoadingBox` |
|
||||
| core/designsystem | `Mifos*` | `MifosButton`, `MifosTextField` |
|
||||
| core/ui | `Mifos*` | `MifosAccountCard`, `MifosErrorComponent` |
|
||||
| feature/[name]/components | `[Feature]*` | `AuthHeader` |
|
||||
| feature/[name]/[screen]/components | `[Screen]*` | `LoginForm` |
|
||||
|
||||
**Update Rules:**
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| Found in static registry | No update needed |
|
||||
| Found via dynamic search | ADD to static registry |
|
||||
| Created new component in core/ | ADD to static registry |
|
||||
| Created feature component | No update needed |
|
||||
|
||||
---
|
||||
|
||||
## Instructions Reference
|
||||
|
||||
For detailed implementation patterns, see:
|
||||
|
||||
| Pattern | File | When to Use |
|
||||
|---------|------|-------------|
|
||||
| **ViewModel** | [instructions/VIEWMODEL.md](instructions/VIEWMODEL.md) | Creating/updating ViewModel, State, Event, Action |
|
||||
| **Compose Screen** | [instructions/COMPOSE.md](instructions/COMPOSE.md) | Creating screens, components, UI patterns |
|
||||
| **Navigation** | [instructions/NAVIGATION.md](instructions/NAVIGATION.md) | Setting up routes, NavGraph |
|
||||
| **Dependency Injection** | [instructions/DI.md](instructions/DI.md) | Koin module registration |
|
||||
| **Updating Feature** | [instructions/UPDATING_FEATURE.md](instructions/UPDATING_FEATURE.md) | v2.0 UI redesign, improving existing features |
|
||||
|
||||
### Quick Pattern Reference
|
||||
|
||||
**ViewModel Pattern:**
|
||||
```kotlin
|
||||
internal class [Feature]ViewModel(
|
||||
private val repository: [Feature]Repository,
|
||||
) : BaseViewModel<[Feature]State, [Feature]Event, [Feature]Action>(
|
||||
initialState = [Feature]State()
|
||||
) {
|
||||
override fun handleAction(action: [Feature]Action) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Screen Pattern:**
|
||||
```kotlin
|
||||
@Composable
|
||||
internal fun [Feature]Screen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: [Feature]ViewModel = koinViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
EventsEffect(viewModel.eventFlow) { event -> ... }
|
||||
[Feature]Content(state = state, onAction = viewModel::trySendAction)
|
||||
}
|
||||
```
|
||||
|
||||
**Navigation Pattern:**
|
||||
```kotlin
|
||||
@Serializable
|
||||
data object [Feature]Route
|
||||
|
||||
fun NavGraphBuilder.[feature]Screen(onNavigateBack: () -> Unit) {
|
||||
composableWithStayTransitions<[Feature]Route> {
|
||||
[Feature]Screen(onNavigateBack = onNavigateBack)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**DI Pattern:**
|
||||
```kotlin
|
||||
val [Feature]Module = module {
|
||||
viewModelOf(::[Feature]ViewModel)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
|
||||
- UI Spec: `claude-product-cycle/design-spec-layer/features/[feature]/SPEC.md`
|
||||
- Design Specs: `claude-product-cycle/design-spec-layer/features/[feature]/SPEC.md`
|
||||
- Mockups: `claude-product-cycle/design-spec-layer/features/[feature]/MOCKUP.md`
|
||||
- Patterns: `claude-product-cycle/design-spec-layer/_shared/PATTERNS.md`
|
||||
- Navigation: `cmp-navigation/src/commonMain/kotlin/cmp/navigation/`
|
||||
- Feature Status: `claude-product-cycle/feature-layer/LAYER_STATUS.md`
|
||||
|
||||
746
claude-product-cycle/feature-layer/instructions/COMPOSE.md
Normal file
746
claude-product-cycle/feature-layer/instructions/COMPOSE.md
Normal file
@ -0,0 +1,746 @@
|
||||
# Compose Screen Patterns
|
||||
|
||||
## Table of Contents
|
||||
1. [Component Hierarchy](#component-hierarchy)
|
||||
2. [Available Components](#available-components)
|
||||
3. [Screen Structure](#screen-structure)
|
||||
4. [Container + Content Pattern](#container--content-pattern)
|
||||
5. [Section-Based Design](#section-based-design)
|
||||
6. [Component Placement Rules](#component-placement-rules)
|
||||
7. [Reusability Rules](#reusability-rules)
|
||||
8. [Loading States](#loading-states)
|
||||
9. [Error States](#error-states)
|
||||
10. [Empty States](#empty-states)
|
||||
11. [Dialog Management](#dialog-management)
|
||||
12. [Theming Guidelines](#theming-guidelines)
|
||||
13. [Preview Patterns](#preview-patterns)
|
||||
|
||||
---
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ LEVEL 5: Foundation (core-base/designsystem) │
|
||||
│ KptTheme, KptTopAppBar, KptShimmerLoadingBox, KptGrid │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ LEVEL 4: Design System (core/designsystem) │
|
||||
│ MifosButton, MifosTextField, MifosCard, MifosScaffold │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ LEVEL 3: App-Wide (core/ui) │
|
||||
│ MifosAccountCard, TransactionScreenItem, EmptyDataView │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ LEVEL 2: Feature-Shared (feature/[name]/components/) │
|
||||
│ AuthHeader, TransferAmountInput │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ LEVEL 1: Screen-Specific (feature/[name]/[screen]/comp/) │
|
||||
│ LoginForm, AccountDetailsHeader │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Rule**: Always check higher levels before creating new components.
|
||||
|
||||
---
|
||||
|
||||
## Available Components
|
||||
|
||||
> **Lookup Strategy**: Static registry first → Dynamic search if not found → Auto-update registry
|
||||
> See [core-layer/COMPONENTS.md](../../core-layer/COMPONENTS.md) for complete component registry.
|
||||
|
||||
### Component Lookup (ALWAYS DO FIRST)
|
||||
|
||||
**Step 1: Check Static Registry (Fast)**
|
||||
```
|
||||
Read: core-layer/COMPONENTS.md → Static Component Registry section
|
||||
```
|
||||
|
||||
**Step 2: If Not Found → Dynamic Search**
|
||||
```bash
|
||||
# Search by component type
|
||||
grep -r "@Composable" core/ core-base/ | grep -i "[type]"
|
||||
|
||||
# Or list directories
|
||||
ls core-base/designsystem/**/component/
|
||||
ls core/designsystem/**/component/
|
||||
ls core/ui/**/component/
|
||||
```
|
||||
|
||||
**Step 3: If Found Dynamically → Update Registry**
|
||||
```
|
||||
Add to: core-layer/COMPONENTS.md → appropriate static table
|
||||
```
|
||||
|
||||
### Source Directories
|
||||
|
||||
| Layer | Path | Prefix | Purpose |
|
||||
|-------|------|--------|---------|
|
||||
| Foundation | `core-base/designsystem/.../component/` | `Kpt*` | Theme, loading, animations |
|
||||
| Foundation | `core-base/designsystem/.../layout/` | `Kpt*` | Grid, flow, responsive layouts |
|
||||
| Design System | `core/designsystem/.../component/` | `Mifos*` | UI primitives (button, textfield) |
|
||||
| Business | `core/ui/.../component/` | `Mifos*` | App-wide (cards, lists, states) |
|
||||
|
||||
### Naming Convention Rules
|
||||
|
||||
| Prefix | Location | When to Use |
|
||||
|--------|----------|-------------|
|
||||
| `Kpt*` | core-base/designsystem | Theme, layout, animation components |
|
||||
| `Mifos*` | core/designsystem | UI primitives (Button, TextField, Dialog) |
|
||||
| `Mifos*` | core/ui | Business components (Card, Item, State) |
|
||||
| `[Feature]*` | feature/[name]/components | Feature-shared components |
|
||||
| `[Screen]*` | feature/[name]/[screen]/components | Screen-specific components |
|
||||
|
||||
### Component Type by Name Pattern
|
||||
|
||||
| Pattern | Type | Look In |
|
||||
|---------|------|---------|
|
||||
| `*Button` | Action | `core/designsystem` |
|
||||
| `*TextField`, `*Field` | Input | `core/designsystem` |
|
||||
| `*Dialog`, `*Sheet` | Modal | `core/designsystem` |
|
||||
| `*Card` | Container | `core/ui` |
|
||||
| `*Item` | List item | `core/ui` |
|
||||
| `*Component`, `*View` | Composite | `core/ui` |
|
||||
| `*Grid`, `*Row`, `*Column` | Layout | `core-base/designsystem` |
|
||||
| `*Scaffold`, `*Layout` | Structure | `core-base/designsystem` |
|
||||
|
||||
### Theme Tokens (Always Available)
|
||||
|
||||
```kotlin
|
||||
// Spacing
|
||||
KptTheme.spacing.xs // 4.dp
|
||||
KptTheme.spacing.sm // 8.dp
|
||||
KptTheme.spacing.md // 16.dp
|
||||
KptTheme.spacing.lg // 24.dp
|
||||
KptTheme.spacing.xl // 32.dp
|
||||
|
||||
// Shapes
|
||||
KptTheme.shapes.small // 4.dp rounded
|
||||
KptTheme.shapes.medium // 8.dp rounded
|
||||
KptTheme.shapes.large // 16.dp rounded
|
||||
|
||||
// Colors
|
||||
KptTheme.colorScheme.primary
|
||||
KptTheme.colorScheme.onPrimary
|
||||
KptTheme.colorScheme.surface
|
||||
KptTheme.colorScheme.onSurface
|
||||
KptTheme.colorScheme.error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen Structure
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```
|
||||
feature/[name]/
|
||||
├── [screen]/
|
||||
│ ├── [Screen]Screen.kt # Container composable
|
||||
│ ├── [Screen]ViewModel.kt # ViewModel
|
||||
│ ├── [Screen].kt # State/Event/Action
|
||||
│ └── components/ # Screen-specific components
|
||||
│ ├── [Screen]Header.kt
|
||||
│ ├── [Screen]Content.kt
|
||||
│ └── [Screen]Card.kt
|
||||
├── components/ # Feature-shared components
|
||||
│ └── Shared[Feature]Component.kt
|
||||
├── navigation/
|
||||
└── di/
|
||||
```
|
||||
|
||||
### File Naming
|
||||
|
||||
| File | Naming |
|
||||
|------|--------|
|
||||
| Screen | `[Screen]Screen.kt` |
|
||||
| ViewModel | `[Screen]ViewModel.kt` |
|
||||
| State/Event/Action | `[Screen].kt` |
|
||||
| Components | `[Screen][Purpose].kt` |
|
||||
|
||||
---
|
||||
|
||||
## Container + Content Pattern
|
||||
|
||||
### Container Composable
|
||||
|
||||
Handles ViewModel integration, events, and dialogs:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
internal fun [Screen]Screen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToDetail: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: [Screen]ViewModel = koinViewModel(),
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
// Handle one-time events
|
||||
EventsEffect(viewModel.eventFlow) { event ->
|
||||
when (event) {
|
||||
is [Screen]Event.NavigateBack -> onNavigateBack()
|
||||
is [Screen]Event.NavigateToDetail -> onNavigateToDetail(event.id)
|
||||
is [Screen]Event.ShowToast -> {
|
||||
scope.launch { snackbarHostState.showSnackbar(event.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render dialogs
|
||||
[Screen]Dialogs(
|
||||
dialogState = state.dialogState,
|
||||
onDismiss = remember(viewModel) {
|
||||
{ viewModel.trySendAction([Screen]Action.OnDismissDialog) }
|
||||
},
|
||||
)
|
||||
|
||||
// Render content
|
||||
[Screen]Content(
|
||||
state = state,
|
||||
snackbarHostState = snackbarHostState,
|
||||
onAction = remember(viewModel) { { viewModel.trySendAction(it) } },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Content Composable
|
||||
|
||||
Pure UI, testable:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun [Screen]Content(
|
||||
state: [Screen]State,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
onAction: ([Screen]Action) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
MifosScaffold(
|
||||
topBar = {
|
||||
MifosTopAppBar(
|
||||
title = stringResource(Res.string.screen_title),
|
||||
onNavigationClick = { onAction([Screen]Action.OnNavigateBack) },
|
||||
)
|
||||
},
|
||||
snackbarHost = { KptSnackbarHost(snackbarHostState) },
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
when (state.uiState) {
|
||||
is [Screen]ScreenState.Loading -> {
|
||||
[Screen]LoadingContent(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
is [Screen]ScreenState.Success -> {
|
||||
[Screen]SuccessContent(
|
||||
state = state,
|
||||
onAction = onAction,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
is [Screen]ScreenState.Error -> {
|
||||
MifosErrorComponent(
|
||||
message = stringResource(state.uiState.message),
|
||||
onRetry = { onAction([Screen]Action.OnRetry) },
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
is [Screen]ScreenState.Empty -> {
|
||||
EmptyDataView(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
is [Screen]ScreenState.Network -> {
|
||||
NoInternet(
|
||||
onRetry = { onAction([Screen]Action.OnRetry) },
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Patterns
|
||||
|
||||
| Pattern | Purpose |
|
||||
|---------|---------|
|
||||
| `remember(viewModel)` | Memoize callbacks to prevent recomposition |
|
||||
| `EventsEffect` | Lifecycle-aware event handling |
|
||||
| `collectAsStateWithLifecycle` | Lifecycle-aware state collection |
|
||||
| Separate dialogs | Keep dialog logic isolated |
|
||||
|
||||
---
|
||||
|
||||
## Section-Based Design
|
||||
|
||||
### Rule: Break Screens into Sections
|
||||
|
||||
**Bad** - Monolithic screen:
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun AccountContent(state: AccountState) {
|
||||
Column {
|
||||
// 200+ lines of mixed UI code
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Good** - Section-based:
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun [Screen]SuccessContent(
|
||||
state: [Screen]State,
|
||||
onAction: ([Screen]Action) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
// Header Section
|
||||
item {
|
||||
[Screen]HeaderSection(
|
||||
title = state.title,
|
||||
subtitle = state.subtitle,
|
||||
)
|
||||
}
|
||||
|
||||
// Summary Section
|
||||
item {
|
||||
[Screen]SummarySection(
|
||||
balance = state.balance,
|
||||
currency = state.currency,
|
||||
)
|
||||
}
|
||||
|
||||
// Actions Section
|
||||
item {
|
||||
[Screen]ActionsSection(
|
||||
onTransfer = { onAction([Screen]Action.OnTransfer) },
|
||||
onWithdraw = { onAction([Screen]Action.OnWithdraw) },
|
||||
)
|
||||
}
|
||||
|
||||
// List Header
|
||||
item {
|
||||
SectionHeader(title = stringResource(Res.string.transactions))
|
||||
}
|
||||
|
||||
// List Items
|
||||
items(
|
||||
items = state.transactions,
|
||||
key = { it.id }
|
||||
) { transaction ->
|
||||
TransactionScreenItem(
|
||||
transaction = transaction,
|
||||
onClick = { onAction([Screen]Action.OnTransactionClick(it.id)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Section Component Template
|
||||
|
||||
```kotlin
|
||||
// feature/[name]/[screen]/components/[Screen]HeaderSection.kt
|
||||
|
||||
@Composable
|
||||
internal fun [Screen]HeaderSection(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(KptTheme.spacing.md),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MifosTypography.titleLarge,
|
||||
color = KptTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(KptTheme.spacing.xs))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MifosTypography.bodyMedium,
|
||||
color = KptTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Placement Rules
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```
|
||||
Creating a new component?
|
||||
│
|
||||
├── Is it a UI primitive (Button variant, TextField variant)?
|
||||
│ └── YES → core/designsystem/component/
|
||||
│
|
||||
├── Used in 2+ features?
|
||||
│ └── YES → core/ui/component/
|
||||
│
|
||||
├── Used across screens in same feature?
|
||||
│ └── YES → feature/[name]/components/
|
||||
│
|
||||
└── Used only in this screen?
|
||||
└── YES → feature/[name]/[screen]/components/
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
| Component | Location | Reason |
|
||||
|-----------|----------|--------|
|
||||
| `MifosOutlinedButton` | `core/designsystem/` | UI primitive |
|
||||
| `TransactionScreenItem` | `core/ui/` | Used in accounts, home, recent |
|
||||
| `AuthHeader` | `feature/auth/components/` | Used in login, register, otp |
|
||||
| `LoginForm` | `feature/auth/login/components/` | Only in login screen |
|
||||
|
||||
---
|
||||
|
||||
## Reusability Rules
|
||||
|
||||
### When to Move to core/ui
|
||||
|
||||
Move a component when:
|
||||
1. **Used in 2+ features**
|
||||
2. **Represents a business concept** (Account, Transaction, Beneficiary)
|
||||
3. **Has consistent behavior** across uses
|
||||
|
||||
```kotlin
|
||||
// Step 1: Identify repeated component
|
||||
// feature/accounts/components/AccountCard.kt
|
||||
// feature/home/components/AccountCard.kt <- Duplication!
|
||||
|
||||
// Step 2: Move to core/ui
|
||||
// core/ui/component/MifosAccountCard.kt
|
||||
|
||||
// Step 3: Update imports everywhere
|
||||
import org.mifos.mobile.core.ui.component.MifosAccountCard
|
||||
```
|
||||
|
||||
### When to Move to Feature components/
|
||||
|
||||
Move from screen-specific when:
|
||||
1. Used in 2+ screens within same feature
|
||||
2. Shared styling/behavior within feature
|
||||
|
||||
---
|
||||
|
||||
## Loading States
|
||||
|
||||
### Shimmer Loading
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun [Screen]LoadingContent(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(KptTheme.spacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm),
|
||||
) {
|
||||
// Header skeleton
|
||||
KptShimmerLoadingBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp)
|
||||
.clip(KptTheme.shapes.medium),
|
||||
)
|
||||
|
||||
// Card skeletons
|
||||
repeat(3) {
|
||||
KptShimmerLoadingBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.clip(KptTheme.shapes.medium),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Inline Loading
|
||||
|
||||
```kotlin
|
||||
if (state.isLoading) {
|
||||
MifosProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Overlay Loading
|
||||
|
||||
```kotlin
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Content
|
||||
[Screen]SuccessContent(...)
|
||||
|
||||
// Loading overlay
|
||||
if (state.showOverlay) {
|
||||
MifosLoadingDialog()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error States
|
||||
|
||||
### Full Screen Error
|
||||
|
||||
```kotlin
|
||||
MifosErrorComponent(
|
||||
message = stringResource(state.uiState.message),
|
||||
onRetry = { onAction([Screen]Action.OnRetry) },
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
```
|
||||
|
||||
### Inline Error
|
||||
|
||||
```kotlin
|
||||
if (state.error != null) {
|
||||
Text(
|
||||
text = state.error,
|
||||
color = KptTheme.colorScheme.error,
|
||||
style = MifosTypography.bodySmall,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Empty States
|
||||
|
||||
```kotlin
|
||||
EmptyDataView(
|
||||
icon = Icons.Default.Inbox,
|
||||
title = stringResource(Res.string.no_data_title),
|
||||
message = stringResource(Res.string.no_data_message),
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dialog Management
|
||||
|
||||
### Dialog State in ViewModel State
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class [Screen]State(
|
||||
// ...
|
||||
val dialogState: DialogState? = null,
|
||||
) {
|
||||
sealed interface DialogState {
|
||||
data class Error(val message: String) : DialogState
|
||||
data class ConfirmDelete(val item: Item) : DialogState
|
||||
data object Success : DialogState
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dialog Composable
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun [Screen]Dialogs(
|
||||
dialogState: [Screen]State.DialogState?,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirmDelete: () -> Unit = {},
|
||||
) {
|
||||
when (dialogState) {
|
||||
is [Screen]State.DialogState.Error -> {
|
||||
MifosBasicDialog(
|
||||
visibilityState = BasicDialogState.Shown(
|
||||
message = dialogState.message,
|
||||
),
|
||||
onDismissRequest = onDismiss,
|
||||
)
|
||||
}
|
||||
is [Screen]State.DialogState.ConfirmDelete -> {
|
||||
MifosAlertDialog(
|
||||
title = stringResource(Res.string.confirm_delete),
|
||||
message = stringResource(Res.string.delete_message),
|
||||
confirmText = stringResource(Res.string.delete),
|
||||
onConfirm = onConfirmDelete,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
is [Screen]State.DialogState.Success -> {
|
||||
MifosSuccessDialog(
|
||||
message = stringResource(Res.string.success_message),
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Theming Guidelines
|
||||
|
||||
### NEVER Use Hardcoded Values
|
||||
|
||||
```kotlin
|
||||
// WRONG - Hardcoded values
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp) // Hardcoded
|
||||
) {
|
||||
Text(
|
||||
text = "Title",
|
||||
fontSize = 24.sp, // Hardcoded
|
||||
color = Color(0xFF1A1A1A), // Hardcoded
|
||||
)
|
||||
}
|
||||
|
||||
// CORRECT - Theme tokens
|
||||
Column(
|
||||
modifier = Modifier.padding(KptTheme.spacing.md)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.title), // String resource
|
||||
style = MifosTypography.titleLarge, // Typography token
|
||||
color = KptTheme.colorScheme.onSurface, // Color token
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Theme Token Quick Reference
|
||||
|
||||
> Full theme tokens defined in [Available Components](#theme-tokens-always-available) section.
|
||||
|
||||
| Token Type | Access | Examples |
|
||||
|------------|--------|----------|
|
||||
| Spacing | `KptTheme.spacing.*` | `xs`, `sm`, `md`, `lg`, `xl` |
|
||||
| Shapes | `KptTheme.shapes.*` | `small`, `medium`, `large` |
|
||||
| Colors | `KptTheme.colorScheme.*` | `primary`, `surface`, `error` |
|
||||
| Typography | `MifosTypography.*` | `titleLarge`, `bodyMedium` |
|
||||
|
||||
### Theming Rules
|
||||
|
||||
1. **NEVER** use hardcoded `dp`, `sp`, or `Color()` values
|
||||
2. **ALWAYS** use `KptTheme.spacing.*` for padding/margin
|
||||
3. **ALWAYS** use `KptTheme.shapes.*` for corner radius
|
||||
4. **ALWAYS** use `KptTheme.colorScheme.*` for colors
|
||||
5. **ALWAYS** use `MifosTypography.*` for text styles
|
||||
6. **ALWAYS** use `stringResource()` for user-facing text
|
||||
|
||||
---
|
||||
|
||||
## Preview Patterns
|
||||
|
||||
### Content Preview
|
||||
|
||||
```kotlin
|
||||
@Preview
|
||||
@Composable
|
||||
private fun [Screen]ContentPreview() {
|
||||
MifosMobileTheme {
|
||||
[Screen]Content(
|
||||
state = [Screen]State(
|
||||
uiState = [Screen]ScreenState.Success,
|
||||
items = listOf(
|
||||
Item(id = 1, name = "Item 1"),
|
||||
Item(id = 2, name = "Item 2"),
|
||||
),
|
||||
),
|
||||
snackbarHostState = remember { SnackbarHostState() },
|
||||
onAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### State Previews
|
||||
|
||||
```kotlin
|
||||
@Preview
|
||||
@Composable
|
||||
private fun [Screen]LoadingPreview() {
|
||||
MifosMobileTheme {
|
||||
[Screen]LoadingContent()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun [Screen]ErrorPreview() {
|
||||
MifosMobileTheme {
|
||||
MifosErrorComponent(
|
||||
message = "Failed to load data",
|
||||
onRetry = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun [Screen]EmptyPreview() {
|
||||
MifosMobileTheme {
|
||||
EmptyDataView()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Component Preview
|
||||
|
||||
```kotlin
|
||||
@Preview
|
||||
@Composable
|
||||
private fun [Screen]HeaderSectionPreview() {
|
||||
MifosMobileTheme {
|
||||
[Screen]HeaderSection(
|
||||
title = "Account Details",
|
||||
subtitle = "Savings Account",
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist for New Screens
|
||||
|
||||
Before creating a new screen:
|
||||
|
||||
### Component Lookup (REQUIRED)
|
||||
- [ ] **Step 1**: Check static registry in [core-layer/COMPONENTS.md](../../core-layer/COMPONENTS.md)
|
||||
- [ ] **Step 2**: If not found, run dynamic search commands
|
||||
- [ ] **Step 3**: If found dynamically, update the static registry
|
||||
- [ ] Verify no duplicate exists before creating new component
|
||||
|
||||
### Screen Design
|
||||
- [ ] Plan sections (Header, Content, Actions, List)
|
||||
- [ ] Identify reusable components (don't create duplicates!)
|
||||
- [ ] Follow naming conventions (`Kpt*`, `Mifos*`, `[Feature]*`)
|
||||
|
||||
### Implementation
|
||||
- [ ] Follow Container + Content pattern
|
||||
- [ ] Use theme tokens ONLY (no hardcoded values)
|
||||
- [ ] Use `stringResource()` for all text
|
||||
- [ ] Use `remember(viewModel)` for callbacks
|
||||
|
||||
### States
|
||||
- [ ] Add loading state with `KptShimmerLoadingBox`
|
||||
- [ ] Add error state with `MifosErrorComponent`
|
||||
- [ ] Add empty state with `EmptyDataView`
|
||||
- [ ] Add network error state with `NoInternet`
|
||||
- [ ] Handle dialogs separately
|
||||
|
||||
### Verification
|
||||
- [ ] Create previews for each state
|
||||
- [ ] No hardcoded `dp`, `sp`, `Color()` values
|
||||
- [ ] All strings in `strings.xml`
|
||||
282
claude-product-cycle/feature-layer/instructions/DI.md
Normal file
282
claude-product-cycle/feature-layer/instructions/DI.md
Normal file
@ -0,0 +1,282 @@
|
||||
# Dependency Injection Patterns
|
||||
|
||||
## Table of Contents
|
||||
1. [Koin Module](#koin-module)
|
||||
2. [ViewModel Registration](#viewmodel-registration)
|
||||
3. [Multiple ViewModels](#multiple-viewmodels)
|
||||
4. [Module Registration](#module-registration)
|
||||
5. [Accessing Dependencies](#accessing-dependencies)
|
||||
6. [Complete Example](#complete-example)
|
||||
|
||||
---
|
||||
|
||||
## Koin Module
|
||||
|
||||
Create a Koin module for each feature:
|
||||
|
||||
```kotlin
|
||||
// feature/[name]/di/[Feature]Module.kt
|
||||
|
||||
package org.mifos.mobile.feature.[name].di
|
||||
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
import org.mifos.mobile.feature.[name].[Feature]ViewModel
|
||||
|
||||
val [Feature]Module = module {
|
||||
viewModelOf(::[Feature]ViewModel)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ViewModel Registration
|
||||
|
||||
### Single ViewModel
|
||||
|
||||
```kotlin
|
||||
val [Feature]Module = module {
|
||||
viewModelOf(::[Feature]ViewModel)
|
||||
}
|
||||
```
|
||||
|
||||
### ViewModel with SavedStateHandle
|
||||
|
||||
SavedStateHandle is automatically injected:
|
||||
|
||||
```kotlin
|
||||
// ViewModel constructor
|
||||
internal class [Feature]ViewModel(
|
||||
private val repository: [Feature]Repository,
|
||||
savedStateHandle: SavedStateHandle, // Auto-injected
|
||||
) : BaseViewModel<...>(...) {
|
||||
|
||||
init {
|
||||
// Access navigation arguments
|
||||
val id = savedStateHandle.get<Long>("id")
|
||||
}
|
||||
}
|
||||
|
||||
// Module - no special registration needed
|
||||
val [Feature]Module = module {
|
||||
viewModelOf(::[Feature]ViewModel)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multiple ViewModels
|
||||
|
||||
Register all ViewModels in a feature module:
|
||||
|
||||
```kotlin
|
||||
val AuthModule = module {
|
||||
viewModelOf(::LoginViewModel)
|
||||
viewModelOf(::RegistrationViewModel)
|
||||
viewModelOf(::OtpAuthenticationViewModel)
|
||||
viewModelOf(::RecoverPasswordViewModel)
|
||||
viewModelOf(::SetPasswordViewModel)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module Registration
|
||||
|
||||
### Step 1: Export Module
|
||||
|
||||
```kotlin
|
||||
// feature/[name]/di/[Feature]Module.kt
|
||||
val [Feature]Module = module {
|
||||
viewModelOf(::[Feature]ViewModel)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Register in KoinModules
|
||||
|
||||
```kotlin
|
||||
// cmp-navigation/src/.../di/KoinModules.kt
|
||||
|
||||
val featureModules = module {
|
||||
includes(
|
||||
// Core modules
|
||||
AuthModule,
|
||||
HomeModule,
|
||||
AccountsModule,
|
||||
|
||||
// Account modules
|
||||
SavingsAccountModule,
|
||||
LoanModule,
|
||||
ShareAccountModule,
|
||||
|
||||
// Transaction modules
|
||||
BeneficiaryModule,
|
||||
TransferProcessModule,
|
||||
ThirdPartyTransferModule,
|
||||
|
||||
// Utility modules
|
||||
NotificationModule,
|
||||
SettingsModule,
|
||||
QrModule,
|
||||
|
||||
// Add your module here
|
||||
[Feature]Module,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessing Dependencies
|
||||
|
||||
### In ViewModel
|
||||
|
||||
Dependencies are constructor-injected:
|
||||
|
||||
```kotlin
|
||||
internal class [Feature]ViewModel(
|
||||
private val repository: [Feature]Repository,
|
||||
private val userPreferences: UserPreferencesRepository,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
) : BaseViewModel<[Feature]State, [Feature]Event, [Feature]Action>(
|
||||
initialState = [Feature]State()
|
||||
) {
|
||||
// Use injected dependencies
|
||||
private fun loadData() {
|
||||
viewModelScope.launch {
|
||||
repository.getData().collect { ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### In Composable
|
||||
|
||||
Use `koinViewModel()`:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
internal fun [Feature]Screen(
|
||||
onNavigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: [Feature]ViewModel = koinViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### With ViewModel Key
|
||||
|
||||
For multiple instances of same ViewModel:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun [Feature]Screen(
|
||||
accountId: Long,
|
||||
viewModel: [Feature]ViewModel = koinViewModel(
|
||||
key = "account_$accountId"
|
||||
),
|
||||
) {
|
||||
// Unique ViewModel instance per accountId
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
### Module Definition
|
||||
|
||||
```kotlin
|
||||
// feature/beneficiary/di/BeneficiaryModule.kt
|
||||
|
||||
package org.mifos.mobile.feature.beneficiary.di
|
||||
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
import org.mifos.mobile.feature.beneficiary.list.BeneficiaryListViewModel
|
||||
import org.mifos.mobile.feature.beneficiary.detail.BeneficiaryDetailViewModel
|
||||
import org.mifos.mobile.feature.beneficiary.add.AddBeneficiaryViewModel
|
||||
import org.mifos.mobile.feature.beneficiary.edit.EditBeneficiaryViewModel
|
||||
|
||||
val BeneficiaryModule = module {
|
||||
viewModelOf(::BeneficiaryListViewModel)
|
||||
viewModelOf(::BeneficiaryDetailViewModel)
|
||||
viewModelOf(::AddBeneficiaryViewModel)
|
||||
viewModelOf(::EditBeneficiaryViewModel)
|
||||
}
|
||||
```
|
||||
|
||||
### ViewModel with Dependencies
|
||||
|
||||
```kotlin
|
||||
// feature/beneficiary/list/BeneficiaryListViewModel.kt
|
||||
|
||||
internal class BeneficiaryListViewModel(
|
||||
private val beneficiaryRepository: BeneficiaryRepository,
|
||||
) : BaseViewModel<BeneficiaryListState, BeneficiaryListEvent, BeneficiaryListAction>(
|
||||
initialState = BeneficiaryListState()
|
||||
) {
|
||||
init {
|
||||
loadBeneficiaries()
|
||||
}
|
||||
|
||||
override fun handleAction(action: BeneficiaryListAction) {
|
||||
// ...
|
||||
}
|
||||
|
||||
private fun loadBeneficiaries() {
|
||||
viewModelScope.launch {
|
||||
beneficiaryRepository.getBeneficiaries().collect { result ->
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Screen with ViewModel
|
||||
|
||||
```kotlin
|
||||
// feature/beneficiary/list/BeneficiaryListScreen.kt
|
||||
|
||||
@Composable
|
||||
internal fun BeneficiaryListScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToDetail: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: BeneficiaryListViewModel = koinViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel.eventFlow) { event ->
|
||||
when (event) {
|
||||
is BeneficiaryListEvent.NavigateBack -> onNavigateBack()
|
||||
is BeneficiaryListEvent.NavigateToDetail -> onNavigateToDetail(event.id)
|
||||
}
|
||||
}
|
||||
|
||||
BeneficiaryListContent(
|
||||
state = state,
|
||||
onAction = remember(viewModel) { { viewModel.trySendAction(it) } },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Registration in KoinModules
|
||||
|
||||
```kotlin
|
||||
// cmp-navigation/src/.../di/KoinModules.kt
|
||||
|
||||
val featureModules = module {
|
||||
includes(
|
||||
AuthModule,
|
||||
HomeModule,
|
||||
AccountsModule,
|
||||
BeneficiaryModule, // Registered here
|
||||
// ...
|
||||
)
|
||||
}
|
||||
```
|
||||
438
claude-product-cycle/feature-layer/instructions/NAVIGATION.md
Normal file
438
claude-product-cycle/feature-layer/instructions/NAVIGATION.md
Normal file
@ -0,0 +1,438 @@
|
||||
# Navigation Patterns
|
||||
|
||||
## Table of Contents
|
||||
1. [Route Definition](#route-definition)
|
||||
2. [NavController Extensions](#navcontroller-extensions)
|
||||
3. [NavGraphBuilder Extensions](#navgraphbuilder-extensions)
|
||||
4. [Transitions](#transitions)
|
||||
5. [Arguments](#arguments)
|
||||
6. [Type-Safe Destinations](#type-safe-destinations)
|
||||
7. [Navigation Graph](#navigation-graph)
|
||||
8. [Complete Example](#complete-example)
|
||||
|
||||
---
|
||||
|
||||
## Route Definition
|
||||
|
||||
Use `@Serializable` data objects for type-safe navigation:
|
||||
|
||||
### Simple Route
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
data object [Feature]Route
|
||||
```
|
||||
|
||||
### Route with Arguments
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class [Feature]DetailRoute(
|
||||
val id: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class [Feature]EditRoute(
|
||||
val id: Long,
|
||||
val title: String? = null, // Optional argument
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NavController Extensions
|
||||
|
||||
Create extension functions for navigation:
|
||||
|
||||
### Simple Navigation
|
||||
|
||||
```kotlin
|
||||
fun NavController.navigateTo[Feature]Screen(navOptions: NavOptions? = null) {
|
||||
navigate([Feature]Route, navOptions)
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation with Arguments
|
||||
|
||||
```kotlin
|
||||
fun NavController.navigateTo[Feature]Detail(id: Long) {
|
||||
navigate([Feature]DetailRoute(id))
|
||||
}
|
||||
|
||||
fun NavController.navigateTo[Feature]Edit(id: Long, title: String? = null) {
|
||||
navigate([Feature]EditRoute(id, title))
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation with Pop Behavior
|
||||
|
||||
```kotlin
|
||||
fun NavController.navigateTo[Feature]Screen() {
|
||||
navigate([Feature]Route) {
|
||||
popUpTo([Previous]Route) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NavGraphBuilder Extensions
|
||||
|
||||
### Simple Screen
|
||||
|
||||
```kotlin
|
||||
fun NavGraphBuilder.[feature]Screen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToDetail: (Long) -> Unit,
|
||||
) {
|
||||
composableWithStayTransitions<[Feature]Route> {
|
||||
[Feature]Screen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToDetail = onNavigateToDetail,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Screen with Arguments
|
||||
|
||||
```kotlin
|
||||
fun NavGraphBuilder.[feature]DetailScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithStayTransitions<[Feature]DetailRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<[Feature]DetailRoute>()
|
||||
[Feature]DetailScreen(
|
||||
id = route.id,
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Screen with Optional Arguments
|
||||
|
||||
```kotlin
|
||||
fun NavGraphBuilder.[feature]EditScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onSaveComplete: () -> Unit,
|
||||
) {
|
||||
composableWithStayTransitions<[Feature]EditRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<[Feature]EditRoute>()
|
||||
[Feature]EditScreen(
|
||||
id = route.id,
|
||||
title = route.title,
|
||||
onNavigateBack = onNavigateBack,
|
||||
onSaveComplete = onSaveComplete,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Transitions
|
||||
|
||||
### Standard Transitions
|
||||
|
||||
```kotlin
|
||||
// Default stay transitions (recommended)
|
||||
composableWithStayTransitions<[Feature]Route> {
|
||||
[Feature]Screen(...)
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Transitions
|
||||
|
||||
```kotlin
|
||||
composable<[Feature]Route>(
|
||||
enterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { fullWidth -> fullWidth },
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { fullWidth -> -fullWidth },
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { fullWidth -> -fullWidth },
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { fullWidth -> fullWidth },
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
},
|
||||
) {
|
||||
[Feature]Screen(...)
|
||||
}
|
||||
```
|
||||
|
||||
### Fade Transitions
|
||||
|
||||
```kotlin
|
||||
composable<[Feature]Route>(
|
||||
enterTransition = { fadeIn(animationSpec = tween(300)) },
|
||||
exitTransition = { fadeOut(animationSpec = tween(300)) },
|
||||
) {
|
||||
[Feature]Screen(...)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Arguments
|
||||
|
||||
### Primitive Types
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class [Feature]Route(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val isEnabled: Boolean = true,
|
||||
val count: Int = 0,
|
||||
)
|
||||
```
|
||||
|
||||
### Nullable Arguments
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class [Feature]Route(
|
||||
val id: Long,
|
||||
val title: String? = null, // Optional
|
||||
)
|
||||
```
|
||||
|
||||
### Enum Arguments
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
enum class AccountType {
|
||||
SAVINGS, LOAN, SHARE
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AccountRoute(
|
||||
val accountType: AccountType,
|
||||
)
|
||||
```
|
||||
|
||||
### Accessing Arguments
|
||||
|
||||
```kotlin
|
||||
composableWithStayTransitions<[Feature]DetailRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<[Feature]DetailRoute>()
|
||||
|
||||
[Feature]DetailScreen(
|
||||
id = route.id,
|
||||
title = route.title ?: "Default Title",
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type-Safe Destinations
|
||||
|
||||
### Sealed Class Pattern
|
||||
|
||||
```kotlin
|
||||
sealed class [Feature]Destination {
|
||||
data object List : [Feature]Destination()
|
||||
data class Detail(val id: Long) : [Feature]Destination()
|
||||
data class Edit(val id: Long) : [Feature]Destination()
|
||||
data object Create : [Feature]Destination()
|
||||
}
|
||||
|
||||
typealias [Feature]Navigator = ([Feature]Destination) -> Unit
|
||||
```
|
||||
|
||||
### Usage in Screen
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
internal fun [Feature]Screen(
|
||||
onNavigate: [Feature]Navigator,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
// Navigate to detail
|
||||
onNavigate([Feature]Destination.Detail(itemId))
|
||||
|
||||
// Navigate to create
|
||||
onNavigate([Feature]Destination.Create)
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in NavGraph
|
||||
|
||||
```kotlin
|
||||
fun NavGraphBuilder.[feature]Screen(
|
||||
navController: NavController,
|
||||
) {
|
||||
composableWithStayTransitions<[Feature]Route> {
|
||||
[Feature]Screen(
|
||||
onNavigate = { destination ->
|
||||
when (destination) {
|
||||
is [Feature]Destination.Detail -> {
|
||||
navController.navigateTo[Feature]Detail(destination.id)
|
||||
}
|
||||
is [Feature]Destination.Create -> {
|
||||
navController.navigateTo[Feature]Create()
|
||||
}
|
||||
// ...
|
||||
}
|
||||
},
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation Graph
|
||||
|
||||
### Feature Navigation Graph
|
||||
|
||||
```kotlin
|
||||
// feature/[name]/navigation/[Feature]Navigation.kt
|
||||
|
||||
@Serializable
|
||||
data object [Feature]GraphRoute
|
||||
|
||||
fun NavGraphBuilder.[feature]NavGraph(
|
||||
navController: NavController,
|
||||
) {
|
||||
navigation<[Feature]GraphRoute>(
|
||||
startDestination = [Feature]ListRoute,
|
||||
) {
|
||||
[feature]ListScreen(
|
||||
onNavigateToDetail = { id ->
|
||||
navController.navigateTo[Feature]Detail(id)
|
||||
},
|
||||
)
|
||||
|
||||
[feature]DetailScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToEdit = { id ->
|
||||
navController.navigateTo[Feature]Edit(id)
|
||||
},
|
||||
)
|
||||
|
||||
[feature]EditScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Registering in Main Graph
|
||||
|
||||
```kotlin
|
||||
// cmp-navigation/.../MainNavGraph.kt
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = HomeRoute,
|
||||
) {
|
||||
homeScreen(...)
|
||||
|
||||
[feature]NavGraph(navController)
|
||||
|
||||
// OR individual screens
|
||||
[feature]Screen(...)
|
||||
[feature]DetailScreen(...)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
```kotlin
|
||||
// feature/beneficiary/navigation/BeneficiaryNavigation.kt
|
||||
|
||||
package org.mifos.mobile.feature.beneficiary.navigation
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.toRoute
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.mifos.mobile.feature.beneficiary.list.BeneficiaryListScreen
|
||||
import org.mifos.mobile.feature.beneficiary.detail.BeneficiaryDetailScreen
|
||||
import org.mifos.mobile.feature.beneficiary.add.AddBeneficiaryScreen
|
||||
|
||||
// Routes
|
||||
@Serializable
|
||||
data object BeneficiaryListRoute
|
||||
|
||||
@Serializable
|
||||
data class BeneficiaryDetailRoute(val id: Long)
|
||||
|
||||
@Serializable
|
||||
data object AddBeneficiaryRoute
|
||||
|
||||
// NavController Extensions
|
||||
fun NavController.navigateToBeneficiaryList(navOptions: NavOptions? = null) {
|
||||
navigate(BeneficiaryListRoute, navOptions)
|
||||
}
|
||||
|
||||
fun NavController.navigateToBeneficiaryDetail(id: Long) {
|
||||
navigate(BeneficiaryDetailRoute(id))
|
||||
}
|
||||
|
||||
fun NavController.navigateToAddBeneficiary() {
|
||||
navigate(AddBeneficiaryRoute)
|
||||
}
|
||||
|
||||
// NavGraphBuilder Extensions
|
||||
fun NavGraphBuilder.beneficiaryListScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToDetail: (Long) -> Unit,
|
||||
onNavigateToAdd: () -> Unit,
|
||||
) {
|
||||
composableWithStayTransitions<BeneficiaryListRoute> {
|
||||
BeneficiaryListScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToDetail = onNavigateToDetail,
|
||||
onNavigateToAdd = onNavigateToAdd,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.beneficiaryDetailScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithStayTransitions<BeneficiaryDetailRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<BeneficiaryDetailRoute>()
|
||||
BeneficiaryDetailScreen(
|
||||
beneficiaryId = route.id,
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.addBeneficiaryScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onBeneficiaryAdded: () -> Unit,
|
||||
) {
|
||||
composableWithStayTransitions<AddBeneficiaryRoute> {
|
||||
AddBeneficiaryScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onBeneficiaryAdded = onBeneficiaryAdded,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,309 @@
|
||||
# Updating Existing Feature
|
||||
|
||||
## Table of Contents
|
||||
1. [Overview](#overview)
|
||||
2. [When to Use](#when-to-use)
|
||||
3. [Pre-Update Checklist](#pre-update-checklist)
|
||||
4. [Update Workflow](#update-workflow)
|
||||
5. [Component Updates](#component-updates)
|
||||
6. [State Migration](#state-migration)
|
||||
7. [Testing Updates](#testing-updates)
|
||||
8. [Post-Update Checklist](#post-update-checklist)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers updating existing features for v2.0 UI redesign or improvements. Unlike creating new features, updates require careful consideration of:
|
||||
- Existing state management
|
||||
- Current navigation patterns
|
||||
- Component reusability
|
||||
- Backward compatibility
|
||||
|
||||
---
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this guide when:
|
||||
|
||||
| Scenario | Example |
|
||||
|----------|---------|
|
||||
| **v2.0 UI Redesign** | Modernizing login screen with new design |
|
||||
| **Adding Screens** | Adding detail screen to existing feature |
|
||||
| **Refactoring** | Splitting monolithic screen into sections |
|
||||
| **Component Migration** | Moving from custom to design system components |
|
||||
| **Pattern Alignment** | Updating to Container + Content pattern |
|
||||
|
||||
---
|
||||
|
||||
## Pre-Update Checklist
|
||||
|
||||
Before starting updates:
|
||||
|
||||
- [ ] Read current design spec: `design-spec-layer/features/[feature]/SPEC.md`
|
||||
- [ ] Review mockups: `design-spec-layer/features/[feature]/MOCKUP.md`
|
||||
- [ ] Check API changes: `design-spec-layer/features/[feature]/API.md`
|
||||
- [ ] Understand current implementation structure
|
||||
- [ ] Identify reusable components
|
||||
- [ ] Plan state changes (if any)
|
||||
- [ ] Identify screens that can be updated independently
|
||||
|
||||
---
|
||||
|
||||
## Update Workflow
|
||||
|
||||
### Step 1: Analyze Current Implementation
|
||||
|
||||
```bash
|
||||
# List current structure
|
||||
ls -la feature/[name]/src/commonMain/kotlin/org/mifos/mobile/feature/[name]/
|
||||
|
||||
# Find all screens
|
||||
find feature/[name]/ -name "*Screen.kt"
|
||||
|
||||
# Find all ViewModels
|
||||
find feature/[name]/ -name "*ViewModel.kt"
|
||||
```
|
||||
|
||||
### Step 2: Compare with Design Spec
|
||||
|
||||
| Current | Design Spec | Action |
|
||||
|---------|-------------|--------|
|
||||
| LoginScreen.kt | Login Screen v2.0 | Update UI |
|
||||
| - | Biometric Login | Add new screen |
|
||||
| OldComponent.kt | Removed | Delete |
|
||||
|
||||
### Step 3: Update Order
|
||||
|
||||
**Recommended order:**
|
||||
1. **State/Action** - Add new fields without breaking existing
|
||||
2. **ViewModel** - Add new handlers
|
||||
3. **Components** - Create/update section components
|
||||
4. **Screen** - Update layout
|
||||
5. **Navigation** - Add new routes if needed
|
||||
6. **DI** - Update if new dependencies
|
||||
|
||||
---
|
||||
|
||||
## Component Updates
|
||||
|
||||
### Replacing Hardcoded Values
|
||||
|
||||
```kotlin
|
||||
// BEFORE - Hardcoded
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Title",
|
||||
fontSize = 24.sp,
|
||||
color = Color.Black,
|
||||
)
|
||||
}
|
||||
|
||||
// AFTER - Theme tokens
|
||||
Column(
|
||||
modifier = Modifier.padding(KptTheme.spacing.md)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.title),
|
||||
style = MifosTypography.titleLarge,
|
||||
color = KptTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Replacing Custom Components
|
||||
|
||||
```kotlin
|
||||
// BEFORE - Custom implementation
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.Gray)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Custom loading
|
||||
}
|
||||
|
||||
// AFTER - Design system component
|
||||
KptShimmerLoadingBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.clip(KptTheme.shapes.medium),
|
||||
)
|
||||
```
|
||||
|
||||
### Section Extraction
|
||||
|
||||
```kotlin
|
||||
// BEFORE - Monolithic
|
||||
@Composable
|
||||
private fun AccountContent(state: State) {
|
||||
Column {
|
||||
// 200+ lines of mixed code
|
||||
Text(state.title)
|
||||
Text(state.balance)
|
||||
// ... buttons, lists, etc.
|
||||
}
|
||||
}
|
||||
|
||||
// AFTER - Section-based
|
||||
@Composable
|
||||
private fun AccountSuccessContent(
|
||||
state: State,
|
||||
onAction: (Action) -> Unit,
|
||||
) {
|
||||
LazyColumn {
|
||||
item { AccountHeaderSection(state.title) }
|
||||
item { AccountBalanceSection(state.balance) }
|
||||
item { AccountActionsSection(onAction) }
|
||||
items(state.transactions) { TransactionItem(it) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Migration
|
||||
|
||||
### Adding New Fields
|
||||
|
||||
```kotlin
|
||||
// BEFORE
|
||||
data class [Feature]State(
|
||||
val items: List<Item> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
)
|
||||
|
||||
// AFTER - Add with defaults (non-breaking)
|
||||
data class [Feature]State(
|
||||
val items: List<Item> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
// New fields with defaults
|
||||
val selectedFilter: FilterType = FilterType.ALL,
|
||||
val searchQuery: String = "",
|
||||
val dialogState: DialogState? = null,
|
||||
)
|
||||
```
|
||||
|
||||
### Adding New Actions
|
||||
|
||||
```kotlin
|
||||
// BEFORE
|
||||
sealed interface [Feature]Action {
|
||||
data class OnItemClick(val id: Long) : [Feature]Action
|
||||
}
|
||||
|
||||
// AFTER - Add new actions
|
||||
sealed interface [Feature]Action {
|
||||
data class OnItemClick(val id: Long) : [Feature]Action
|
||||
// New actions
|
||||
data class OnFilterChange(val filter: FilterType) : [Feature]Action
|
||||
data class OnSearchQuery(val query: String) : [Feature]Action
|
||||
data object OnClearSearch : [Feature]Action
|
||||
}
|
||||
```
|
||||
|
||||
### Adding New Events
|
||||
|
||||
```kotlin
|
||||
// BEFORE
|
||||
sealed interface [Feature]Event {
|
||||
data object NavigateBack : [Feature]Event
|
||||
}
|
||||
|
||||
// AFTER - Add new events
|
||||
sealed interface [Feature]Event {
|
||||
data object NavigateBack : [Feature]Event
|
||||
// New events
|
||||
data class ShowFilterSheet(val options: List<FilterType>) : [Feature]Event
|
||||
data class ShowToast(val message: String) : [Feature]Event
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Updates
|
||||
|
||||
### Verify No Regression
|
||||
|
||||
```bash
|
||||
# Run feature tests
|
||||
./gradlew :feature:[name]:test
|
||||
|
||||
# Run lint
|
||||
./gradlew :feature:[name]:detekt
|
||||
|
||||
# Build feature
|
||||
./gradlew :feature:[name]:build
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
|
||||
- [ ] All existing flows still work
|
||||
- [ ] New UI matches mockups
|
||||
- [ ] Loading states display correctly
|
||||
- [ ] Error states handle gracefully
|
||||
- [ ] Empty states show appropriate message
|
||||
- [ ] Navigation works as expected
|
||||
- [ ] Dialogs open/close properly
|
||||
|
||||
---
|
||||
|
||||
## Post-Update Checklist
|
||||
|
||||
After completing updates:
|
||||
|
||||
- [ ] All screens follow Container + Content pattern
|
||||
- [ ] All components use KptTheme tokens (no hardcoded values)
|
||||
- [ ] All strings in strings.xml (no hardcoded text)
|
||||
- [ ] Section components in `[screen]/components/`
|
||||
- [ ] Shared components in `feature/[name]/components/`
|
||||
- [ ] Previews added for all states
|
||||
- [ ] Build passes: `./gradlew :feature:[name]:build`
|
||||
- [ ] Update STATUS.md: `design-spec-layer/features/[feature]/STATUS.md`
|
||||
- [ ] Update LAYER_STATUS.md if structure changed
|
||||
|
||||
---
|
||||
|
||||
## Common Update Patterns
|
||||
|
||||
### Pattern 1: Add Search to List Screen
|
||||
|
||||
1. Add state fields: `searchQuery`, `filteredItems`
|
||||
2. Add actions: `OnSearchQuery`, `OnClearSearch`
|
||||
3. Add `MifosSearchTextField` above list
|
||||
4. Filter in ViewModel based on query
|
||||
|
||||
### Pattern 2: Add Detail Screen to Feature
|
||||
|
||||
1. Create `[feature]Detail/` package
|
||||
2. Add `[Feature]DetailRoute` with argument
|
||||
3. Add navigation extension function
|
||||
4. Register in NavGraph
|
||||
5. Update list screen to navigate on click
|
||||
|
||||
### Pattern 3: Convert to Tab Layout
|
||||
|
||||
1. Add `selectedTab` to state
|
||||
2. Add `OnTabSelect` action
|
||||
3. Use `MifosTabPager` or `TabRow`
|
||||
4. Create content composable per tab
|
||||
|
||||
### Pattern 4: Add Pull-to-Refresh
|
||||
|
||||
1. Add `isRefreshing` to state
|
||||
2. Add `OnRefresh` action
|
||||
3. Wrap content with `PullToRefreshBox`
|
||||
4. Call refresh in ViewModel
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
|
||||
- New Feature: [LAYER_GUIDE.md](../LAYER_GUIDE.md#creating-new-feature)
|
||||
- Compose Patterns: [COMPOSE.md](COMPOSE.md)
|
||||
- ViewModel Patterns: [VIEWMODEL.md](VIEWMODEL.md)
|
||||
- Navigation: [NAVIGATION.md](NAVIGATION.md)
|
||||
495
claude-product-cycle/feature-layer/instructions/VIEWMODEL.md
Normal file
495
claude-product-cycle/feature-layer/instructions/VIEWMODEL.md
Normal file
@ -0,0 +1,495 @@
|
||||
# ViewModel Patterns
|
||||
|
||||
## Table of Contents
|
||||
1. [BaseViewModel](#baseviewmodel)
|
||||
2. [State Definition](#state-definition)
|
||||
3. [ScreenState Definition](#screenstate-definition)
|
||||
4. [Event Definition](#event-definition)
|
||||
5. [Action Definition](#action-definition)
|
||||
6. [Internal Actions](#internal-actions)
|
||||
7. [handleAction Pattern](#handleaction-pattern)
|
||||
8. [State Updates](#state-updates)
|
||||
9. [Async Operations](#async-operations)
|
||||
10. [Complete Example](#complete-example)
|
||||
|
||||
---
|
||||
|
||||
## BaseViewModel
|
||||
|
||||
All ViewModels extend `BaseViewModel<State, Event, Action>`:
|
||||
|
||||
```kotlin
|
||||
internal class [Feature]ViewModel(
|
||||
private val repository: [Feature]Repository,
|
||||
) : BaseViewModel<[Feature]State, [Feature]Event, [Feature]Action>(
|
||||
initialState = [Feature]State()
|
||||
) {
|
||||
override fun handleAction(action: [Feature]Action) {
|
||||
// Handle user actions
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Generic Parameters:**
|
||||
- `State` - UI state data class
|
||||
- `Event` - One-shot effects (navigation, toasts)
|
||||
- `Action` - User interactions
|
||||
|
||||
---
|
||||
|
||||
## State Definition
|
||||
|
||||
Immutable data class holding UI state:
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class [Feature]State(
|
||||
val isLoading: Boolean = false,
|
||||
val items: List<Item> = emptyList(),
|
||||
val selectedItem: Item? = null,
|
||||
val dialogState: DialogState? = null,
|
||||
val uiState: [Feature]ScreenState = [Feature]ScreenState.Loading,
|
||||
) {
|
||||
// Derived properties
|
||||
val hasItems: Boolean get() = items.isNotEmpty()
|
||||
val isButtonEnabled: Boolean get() = selectedItem != null
|
||||
|
||||
// Nested sealed interface for dialogs
|
||||
sealed interface DialogState {
|
||||
data class Error(val message: String) : DialogState
|
||||
data class Confirm(val item: Item) : DialogState
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Always use `@Immutable` annotation
|
||||
- Use default values for all properties
|
||||
- Derived properties via `get()` for computed values
|
||||
- Nested `DialogState` for dialog management
|
||||
|
||||
---
|
||||
|
||||
## ScreenState Definition
|
||||
|
||||
Loading/Success/Error states:
|
||||
|
||||
```kotlin
|
||||
sealed interface [Feature]ScreenState {
|
||||
data object Loading : [Feature]ScreenState
|
||||
data object Success : [Feature]ScreenState
|
||||
data object Empty : [Feature]ScreenState
|
||||
data class Error(val message: StringResource) : [Feature]ScreenState
|
||||
data object Network : [Feature]ScreenState
|
||||
}
|
||||
```
|
||||
|
||||
**Standard States:**
|
||||
| State | Purpose |
|
||||
|-------|---------|
|
||||
| `Loading` | Initial load or refresh |
|
||||
| `Success` | Data loaded successfully |
|
||||
| `Empty` | No data available |
|
||||
| `Error` | Error with message |
|
||||
| `Network` | No internet connection |
|
||||
|
||||
---
|
||||
|
||||
## Event Definition
|
||||
|
||||
One-shot effects (navigation, toasts):
|
||||
|
||||
```kotlin
|
||||
sealed interface [Feature]Event {
|
||||
data object NavigateBack : [Feature]Event
|
||||
data class NavigateToDetail(val id: Long) : [Feature]Event
|
||||
data class ShowToast(val message: String) : [Feature]Event
|
||||
data object ShowSuccess : [Feature]Event
|
||||
}
|
||||
```
|
||||
|
||||
**When to Use Events:**
|
||||
- Navigation between screens
|
||||
- Showing snackbars/toasts
|
||||
- One-time UI effects
|
||||
- NOT for state changes (use State instead)
|
||||
|
||||
---
|
||||
|
||||
## Action Definition
|
||||
|
||||
User interactions:
|
||||
|
||||
```kotlin
|
||||
sealed interface [Feature]Action {
|
||||
// User-initiated actions
|
||||
data object OnRefresh : [Feature]Action
|
||||
data object OnRetry : [Feature]Action
|
||||
data object OnNavigateBack : [Feature]Action
|
||||
data class OnItemClick(val id: Long) : [Feature]Action
|
||||
data class OnSearchQuery(val query: String) : [Feature]Action
|
||||
data object OnDismissDialog : [Feature]Action
|
||||
data class OnDeleteItem(val id: Long) : [Feature]Action
|
||||
|
||||
// Internal actions for async results
|
||||
sealed interface Internal : [Feature]Action {
|
||||
data class ReceiveData(val result: DataState<Data>) : Internal
|
||||
data class ReceiveDeleteResult(val result: DataState<Unit>) : Internal
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Naming Convention:**
|
||||
- Prefix with `On` for user actions
|
||||
- Use present tense (`OnClick`, not `Clicked`)
|
||||
|
||||
---
|
||||
|
||||
## Internal Actions
|
||||
|
||||
Handle async operation results:
|
||||
|
||||
```kotlin
|
||||
sealed interface Internal : [Feature]Action {
|
||||
data class ReceiveData(val result: DataState<Data>) : Internal
|
||||
data class ReceiveDeleteResult(val result: DataState<Unit>) : Internal
|
||||
data class ReceiveUpdateResult(val result: DataState<Item>) : Internal
|
||||
}
|
||||
```
|
||||
|
||||
**Purpose:**
|
||||
- Decouple async operations from state updates
|
||||
- Testable state transitions
|
||||
- Clear separation of concerns
|
||||
|
||||
**Usage in ViewModel:**
|
||||
```kotlin
|
||||
private fun loadData() {
|
||||
viewModelScope.launch {
|
||||
repository.getData().collect { result ->
|
||||
sendAction([Feature]Action.Internal.ReceiveData(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleAction(action: [Feature]Action) {
|
||||
when (action) {
|
||||
is [Feature]Action.Internal.ReceiveData -> handleDataResult(action.result)
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## handleAction Pattern
|
||||
|
||||
Central action handler:
|
||||
|
||||
```kotlin
|
||||
override fun handleAction(action: [Feature]Action) {
|
||||
when (action) {
|
||||
// Navigation actions
|
||||
is [Feature]Action.OnNavigateBack -> {
|
||||
sendEvent([Feature]Event.NavigateBack)
|
||||
}
|
||||
|
||||
// Data actions
|
||||
is [Feature]Action.OnRefresh -> loadData()
|
||||
is [Feature]Action.OnRetry -> loadData()
|
||||
|
||||
// Item actions
|
||||
is [Feature]Action.OnItemClick -> {
|
||||
sendEvent([Feature]Event.NavigateToDetail(action.id))
|
||||
}
|
||||
is [Feature]Action.OnDeleteItem -> deleteItem(action.id)
|
||||
|
||||
// Dialog actions
|
||||
is [Feature]Action.OnDismissDialog -> {
|
||||
updateState { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
// Internal actions
|
||||
is [Feature]Action.Internal.ReceiveData -> {
|
||||
handleDataResult(action.result)
|
||||
}
|
||||
is [Feature]Action.Internal.ReceiveDeleteResult -> {
|
||||
handleDeleteResult(action.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
- Keep `when` branches concise
|
||||
- Delegate complex logic to private functions
|
||||
- Group related actions together
|
||||
|
||||
---
|
||||
|
||||
## State Updates
|
||||
|
||||
Use `updateState` for immutable updates:
|
||||
|
||||
```kotlin
|
||||
private fun updateState(update: ([Feature]State) -> [Feature]State) {
|
||||
mutableStateFlow.update(update)
|
||||
}
|
||||
|
||||
// Simple update
|
||||
updateState { it.copy(isLoading = true) }
|
||||
|
||||
// Multiple properties
|
||||
updateState { state ->
|
||||
state.copy(
|
||||
items = newItems,
|
||||
uiState = [Feature]ScreenState.Success,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
|
||||
// Conditional update
|
||||
updateState { state ->
|
||||
state.copy(
|
||||
uiState = if (items.isEmpty()) {
|
||||
[Feature]ScreenState.Empty
|
||||
} else {
|
||||
[Feature]ScreenState.Success
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Async Operations
|
||||
|
||||
### Basic Pattern
|
||||
|
||||
```kotlin
|
||||
private var loadJob: Job? = null
|
||||
|
||||
private fun loadData() {
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
updateState { it.copy(uiState = [Feature]ScreenState.Loading) }
|
||||
|
||||
repository.getData()
|
||||
.collect { result ->
|
||||
sendAction([Feature]Action.Internal.ReceiveData(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Results
|
||||
|
||||
```kotlin
|
||||
private fun handleDataResult(result: DataState<List<Item>>) {
|
||||
when (result) {
|
||||
is DataState.Loading -> {
|
||||
updateState { it.copy(uiState = [Feature]ScreenState.Loading) }
|
||||
}
|
||||
is DataState.Success -> {
|
||||
updateState { state ->
|
||||
state.copy(
|
||||
items = result.data,
|
||||
uiState = if (result.data.isEmpty()) {
|
||||
[Feature]ScreenState.Empty
|
||||
} else {
|
||||
[Feature]ScreenState.Success
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is DataState.Error -> {
|
||||
updateState { state ->
|
||||
state.copy(
|
||||
uiState = [Feature]ScreenState.Error(
|
||||
Res.string.error_loading_data
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Operations
|
||||
|
||||
```kotlin
|
||||
private var loadJob: Job? = null
|
||||
private var deleteJob: Job? = null
|
||||
|
||||
private fun deleteItem(id: Long) {
|
||||
deleteJob?.cancel()
|
||||
deleteJob = viewModelScope.launch {
|
||||
updateState { it.copy(isDeleting = true) }
|
||||
|
||||
repository.deleteItem(id)
|
||||
.collect { result ->
|
||||
sendAction([Feature]Action.Internal.ReceiveDeleteResult(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
```kotlin
|
||||
// [Feature].kt - State/Event/Action definitions
|
||||
|
||||
@Immutable
|
||||
data class [Feature]State(
|
||||
val items: List<Item> = emptyList(),
|
||||
val selectedItem: Item? = null,
|
||||
val dialogState: DialogState? = null,
|
||||
val uiState: [Feature]ScreenState = [Feature]ScreenState.Loading,
|
||||
) {
|
||||
val hasItems: Boolean get() = items.isNotEmpty()
|
||||
|
||||
sealed interface DialogState {
|
||||
data class Error(val message: String) : DialogState
|
||||
data class ConfirmDelete(val item: Item) : DialogState
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface [Feature]ScreenState {
|
||||
data object Loading : [Feature]ScreenState
|
||||
data object Success : [Feature]ScreenState
|
||||
data object Empty : [Feature]ScreenState
|
||||
data class Error(val message: StringResource) : [Feature]ScreenState
|
||||
}
|
||||
|
||||
sealed interface [Feature]Event {
|
||||
data object NavigateBack : [Feature]Event
|
||||
data class NavigateToDetail(val id: Long) : [Feature]Event
|
||||
data class ShowToast(val message: String) : [Feature]Event
|
||||
}
|
||||
|
||||
sealed interface [Feature]Action {
|
||||
data object OnRefresh : [Feature]Action
|
||||
data object OnRetry : [Feature]Action
|
||||
data class OnItemClick(val id: Long) : [Feature]Action
|
||||
data class OnDeleteClick(val item: Item) : [Feature]Action
|
||||
data object OnConfirmDelete : [Feature]Action
|
||||
data object OnDismissDialog : [Feature]Action
|
||||
|
||||
sealed interface Internal : [Feature]Action {
|
||||
data class ReceiveData(val result: DataState<List<Item>>) : Internal
|
||||
data class ReceiveDeleteResult(val result: DataState<Unit>) : Internal
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// [Feature]ViewModel.kt
|
||||
|
||||
internal class [Feature]ViewModel(
|
||||
private val repository: [Feature]Repository,
|
||||
) : BaseViewModel<[Feature]State, [Feature]Event, [Feature]Action>(
|
||||
initialState = [Feature]State()
|
||||
) {
|
||||
private var loadJob: Job? = null
|
||||
private var deleteJob: Job? = null
|
||||
|
||||
init {
|
||||
loadData()
|
||||
}
|
||||
|
||||
override fun handleAction(action: [Feature]Action) {
|
||||
when (action) {
|
||||
is [Feature]Action.OnRefresh -> loadData()
|
||||
is [Feature]Action.OnRetry -> loadData()
|
||||
|
||||
is [Feature]Action.OnItemClick -> {
|
||||
sendEvent([Feature]Event.NavigateToDetail(action.id))
|
||||
}
|
||||
|
||||
is [Feature]Action.OnDeleteClick -> {
|
||||
updateState {
|
||||
it.copy(dialogState = [Feature]State.DialogState.ConfirmDelete(action.item))
|
||||
}
|
||||
}
|
||||
|
||||
is [Feature]Action.OnConfirmDelete -> {
|
||||
val item = (state.dialogState as? [Feature]State.DialogState.ConfirmDelete)?.item
|
||||
item?.let { deleteItem(it.id) }
|
||||
updateState { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
is [Feature]Action.OnDismissDialog -> {
|
||||
updateState { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
is [Feature]Action.Internal.ReceiveData -> {
|
||||
handleDataResult(action.result)
|
||||
}
|
||||
|
||||
is [Feature]Action.Internal.ReceiveDeleteResult -> {
|
||||
handleDeleteResult(action.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadData() {
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
repository.getData().collect { result ->
|
||||
sendAction([Feature]Action.Internal.ReceiveData(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteItem(id: Long) {
|
||||
deleteJob?.cancel()
|
||||
deleteJob = viewModelScope.launch {
|
||||
repository.deleteItem(id).collect { result ->
|
||||
sendAction([Feature]Action.Internal.ReceiveDeleteResult(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDataResult(result: DataState<List<Item>>) {
|
||||
when (result) {
|
||||
is DataState.Loading -> {
|
||||
updateState { it.copy(uiState = [Feature]ScreenState.Loading) }
|
||||
}
|
||||
is DataState.Success -> {
|
||||
updateState { state ->
|
||||
state.copy(
|
||||
items = result.data,
|
||||
uiState = if (result.data.isEmpty()) {
|
||||
[Feature]ScreenState.Empty
|
||||
} else {
|
||||
[Feature]ScreenState.Success
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is DataState.Error -> {
|
||||
updateState {
|
||||
it.copy(uiState = [Feature]ScreenState.Error(Res.string.error_loading))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeleteResult(result: DataState<Unit>) {
|
||||
when (result) {
|
||||
is DataState.Loading -> { /* Optional: show loading */ }
|
||||
is DataState.Success -> {
|
||||
sendEvent([Feature]Event.ShowToast("Item deleted"))
|
||||
loadData() // Refresh list
|
||||
}
|
||||
is DataState.Error -> {
|
||||
updateState {
|
||||
it.copy(dialogState = [Feature]State.DialogState.Error(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Loading…
Reference in New Issue
Block a user