- Compute task stats locally from kanban data for both summary card and residence cards - Filter out completed_tasks and cancelled_tasks columns from counts - Use startOfDay for accurate date comparisons (overdue, due this week, next 30 days) - Add parseDate helper to DateUtils - Make address tappable to open in Apple Maps - Remove navigation title from residences list - Update CLAUDE.md with Go backend references and DataManager architecture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1065 lines
31 KiB
Markdown
1065 lines
31 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
**⚠️ Important:** This is the KMM mobile client repository. For full-stack documentation covering both the mobile app and backend API, see the root CLAUDE.md at `../CLAUDE.md`.
|
|
|
|
## Important Guidelines
|
|
|
|
**⚠️ DO NOT auto-commit code changes.** Always ask the user before committing. Only create commits when the user explicitly requests it with commands like "commit this work" or "create a commit".
|
|
|
|
## Project Overview
|
|
|
|
MyCrib (Casera) is a Kotlin Multiplatform Mobile (KMM) property management application with shared business logic and platform-specific UI implementations. The backend is a Go REST API with PostgreSQL (located in the sibling `myCribAPI-go` directory).
|
|
|
|
**Tech Stack:**
|
|
- **Shared (Kotlin)**: Compose Multiplatform for Android, networking layer, ViewModels, models
|
|
- **iOS**: SwiftUI with Kotlin shared layer integration via SKIE
|
|
- **Backend**: Go REST API with PostgreSQL (separate directory at `../myCribAPI-go`)
|
|
|
|
## Build Commands
|
|
|
|
### Android
|
|
```bash
|
|
# Build debug APK
|
|
./gradlew :composeApp:assembleDebug
|
|
|
|
# Build release APK
|
|
./gradlew :composeApp:assembleRelease
|
|
|
|
# Run on connected device/emulator
|
|
./gradlew :composeApp:installDebug
|
|
```
|
|
|
|
### iOS
|
|
```bash
|
|
# Build from command line (use Xcode for best experience)
|
|
xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build
|
|
|
|
# Or open in Xcode
|
|
open iosApp/iosApp.xcodeproj
|
|
```
|
|
|
|
### Desktop (JVM)
|
|
```bash
|
|
./gradlew :composeApp:run
|
|
```
|
|
|
|
### Web
|
|
```bash
|
|
# Wasm target (modern browsers)
|
|
./gradlew :composeApp:wasmJsBrowserDevelopmentRun
|
|
|
|
# JS target (older browser support)
|
|
./gradlew :composeApp:jsBrowserDevelopmentRun
|
|
```
|
|
|
|
## Architecture
|
|
|
|
### Shared Kotlin Layer (`composeApp/src/commonMain/kotlin/com/example/casera/`)
|
|
|
|
**Core Components:**
|
|
|
|
1. **DataManager** (`data/DataManager.kt`) - **Single Source of Truth**
|
|
- Unified cache for ALL app data (auth, residences, tasks, lookups, etc.)
|
|
- All data is exposed via `StateFlow` for reactive UI updates
|
|
- Automatic cache timeout validation (1 hour default)
|
|
- Persists data to disk for offline access
|
|
- Platform-specific initialization (TokenManager, ThemeStorage, PersistenceManager)
|
|
- O(1) lookup helpers: `getTaskPriority(id)`, `getTaskCategory(id)`, etc.
|
|
|
|
2. **APILayer** (`network/APILayer.kt`) - **Single Entry Point for Network Calls**
|
|
- Every API response immediately updates DataManager
|
|
- All screens observe DataManager StateFlows directly
|
|
- Handles cache-first reads with `forceRefresh` parameter
|
|
- ETag-based conditional fetching for lookups (304 Not Modified support)
|
|
- Guards against concurrent initialization/prefetch calls
|
|
- Returns `ApiResult<T>` (Success/Error/Loading/Idle states)
|
|
|
|
3. **API Clients** (`network/*Api.kt`)
|
|
- Domain-specific API clients: `ResidenceApi`, `TaskApi`, `ContractorApi`, etc.
|
|
- Low-level HTTP calls using Ktor
|
|
- Error parsing and response handling
|
|
|
|
4. **PersistenceManager** (`data/PersistenceManager.kt`)
|
|
- Platform-specific disk persistence (expect/actual pattern)
|
|
- Stores serialized JSON for offline access
|
|
- Loads cached data on app startup
|
|
|
|
5. **ViewModels** (`viewmodel/`)
|
|
- Thin wrappers that call APILayer methods
|
|
- Expose loading/error states for UI feedback
|
|
- ViewModels: `ResidenceViewModel`, `TaskViewModel`, `AuthViewModel`, etc.
|
|
|
|
**Data Flow:**
|
|
```
|
|
User Action → ViewModel → APILayer → API Client → Server Response
|
|
↓
|
|
DataManager Updated (cache + disk)
|
|
↓
|
|
All Screens React (StateFlow observers)
|
|
```
|
|
|
|
**Cache Architecture:**
|
|
```kotlin
|
|
// DataManager exposes StateFlows that UI observes directly
|
|
DataManager.residences: StateFlow<List<Residence>>
|
|
DataManager.myResidences: StateFlow<MyResidencesResponse?>
|
|
DataManager.allTasks: StateFlow<TaskColumnsResponse?>
|
|
DataManager.taskCategories: StateFlow<List<TaskCategory>>
|
|
|
|
// Cache validation (1 hour timeout)
|
|
DataManager.isCacheValid(DataManager.residencesCacheTime)
|
|
|
|
// O(1) lookups for IDs
|
|
DataManager.getTaskPriority(task.priorityId) // Returns TaskPriority?
|
|
DataManager.getTaskCategory(task.categoryId) // Returns TaskCategory?
|
|
```
|
|
|
|
### iOS Layer (`iosApp/iosApp/`)
|
|
|
|
**Integration Pattern:**
|
|
- SwiftUI views wrap Kotlin ViewModels via `@StateObject`
|
|
- iOS-specific ViewModels (Swift) wrap shared Kotlin ViewModels
|
|
- Pattern: `@Published var data` in Swift observes Kotlin `StateFlow` via async iteration
|
|
- Navigation uses SwiftUI `NavigationStack` with sheets for modals
|
|
|
|
**Key iOS Files:**
|
|
- `MainTabView.swift`: Tab-based navigation
|
|
- `*ViewModel.swift` (Swift): Wraps shared Kotlin ViewModels, exposes `@Published` properties
|
|
- `*View.swift`: SwiftUI screens
|
|
- Directory structure mirrors feature organization (Residence/, Task/, Contractor/, etc.)
|
|
|
|
**iOS ↔ Kotlin Bridge:**
|
|
```swift
|
|
// Swift ViewModel wraps Kotlin ViewModel
|
|
@StateObject private var viewModel = ResidenceViewModel() // Swift wrapper
|
|
// Inside: let sharedViewModel: ComposeApp.ResidenceViewModel // Kotlin
|
|
|
|
// Observe Kotlin StateFlow
|
|
Task {
|
|
for await state in sharedViewModel.residencesState {
|
|
await MainActor.run {
|
|
self.residences = (state as? ApiResultSuccess)?.data
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### iOS Design System
|
|
|
|
**CRITICAL**: Always use the custom design system colors defined in `iosApp/iosApp/Design/DesignSystem.swift` and Xcode Asset Catalog. Never use system colors directly.
|
|
|
|
#### Color Palette
|
|
|
|
The app uses a 5-color semantic design system:
|
|
|
|
```swift
|
|
// Primary Colors
|
|
Color.appPrimary // #07A0C3 (BlueGreen) - Primary actions, important icons
|
|
Color.appSecondary // #0055A5 (Cerulean) - Secondary actions
|
|
Color.appAccent // #F5A623 (BrightAmber) - Highlights, notifications, accents
|
|
|
|
// Status Colors
|
|
Color.appError // #DD1C1A (PrimaryScarlet) - Errors, destructive actions
|
|
|
|
// Background Colors
|
|
Color.appBackgroundPrimary // #FFF1D0 (cream) light / #0A1929 dark - Screen backgrounds
|
|
Color.appBackgroundSecondary // Blue-gray - Cards, list rows, elevated surfaces
|
|
|
|
// Text Colors
|
|
Color.appTextPrimary // Primary text (dark mode aware)
|
|
Color.appTextSecondary // Secondary text (less emphasis)
|
|
Color.appTextOnPrimary // Text on primary colored backgrounds (white)
|
|
```
|
|
|
|
**Color Usage Guidelines:**
|
|
- **Buttons**: Primary buttons use `Color.appPrimary`, destructive buttons use `Color.appError`
|
|
- **Icons**: Use `Color.appPrimary` for main actions, `Color.appAccent` for secondary/info icons
|
|
- **Cards**: Always use `Color.appBackgroundSecondary` for card backgrounds
|
|
- **Screens**: Always use `Color.appBackgroundPrimary` for main view backgrounds
|
|
- **Text**: Use `Color.appTextPrimary` for body text, `Color.appTextSecondary` for captions/subtitles
|
|
|
|
#### Creating New Views
|
|
|
|
**Standard Form/List View Pattern:**
|
|
|
|
```swift
|
|
import SwiftUI
|
|
import ComposeApp
|
|
|
|
struct MyNewView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@StateObject private var viewModel = MyViewModel()
|
|
@FocusState private var focusedField: Field?
|
|
|
|
enum Field {
|
|
case fieldOne, fieldTwo
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
// Header Section (optional, with clear background)
|
|
Section {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "icon.name")
|
|
.font(.system(size: 60))
|
|
.foregroundStyle(Color.appPrimary.gradient)
|
|
|
|
Text("View Title")
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Text("Subtitle description")
|
|
.font(.subheadline)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical)
|
|
}
|
|
.listRowBackground(Color.clear)
|
|
|
|
// Data Section
|
|
Section {
|
|
TextField("Field One", text: $viewModel.fieldOne)
|
|
.focused($focusedField, equals: .fieldOne)
|
|
|
|
TextField("Field Two", text: $viewModel.fieldTwo)
|
|
.focused($focusedField, equals: .fieldTwo)
|
|
} header: {
|
|
Text("Section Header")
|
|
} footer: {
|
|
Text("Helper text here")
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
|
|
// Error Section (conditional)
|
|
if let error = viewModel.errorMessage {
|
|
Section {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(Color.appError)
|
|
Text(error)
|
|
.foregroundColor(Color.appError)
|
|
.font(.subheadline)
|
|
}
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
}
|
|
|
|
// Action Button Section
|
|
Section {
|
|
Button(action: { /* action */ }) {
|
|
HStack {
|
|
Spacer()
|
|
if viewModel.isLoading {
|
|
ProgressView()
|
|
} else {
|
|
Text("Submit")
|
|
.fontWeight(.semibold)
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
.disabled(viewModel.isLoading)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color.appBackgroundPrimary)
|
|
.navigationTitle("Title")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**CRITICAL Form/List Styling Rules:**
|
|
|
|
1. **Always add these three modifiers to Form/List:**
|
|
```swift
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color.appBackgroundPrimary)
|
|
```
|
|
|
|
2. **Always add `.listRowBackground()` to EVERY Section:**
|
|
```swift
|
|
Section {
|
|
// content
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary) // ← REQUIRED
|
|
```
|
|
|
|
3. **Exception for header sections:** Use `.listRowBackground(Color.clear)` for decorative headers
|
|
|
|
#### Creating Custom Cards
|
|
|
|
**Standard Card Pattern:**
|
|
|
|
```swift
|
|
struct MyCard: View {
|
|
let item: MyModel
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
// Header
|
|
HStack {
|
|
Image(systemName: "icon.name")
|
|
.font(.title2)
|
|
.foregroundColor(Color.appPrimary)
|
|
|
|
Text(item.title)
|
|
.font(.headline)
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Spacer()
|
|
|
|
// Badge or status indicator
|
|
Text("Status")
|
|
.font(.caption)
|
|
.foregroundColor(Color.appTextOnPrimary)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.appPrimary)
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
// Content
|
|
Text(item.description)
|
|
.font(.subheadline)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.lineLimit(2)
|
|
|
|
// Footer
|
|
HStack {
|
|
Label("Info", systemImage: "info.circle")
|
|
.font(.caption)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color.appBackgroundSecondary)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Card Design Guidelines:**
|
|
- Background: `Color.appBackgroundSecondary`
|
|
- Corner radius: 12pt
|
|
- Padding: 16pt (standard) or 12pt (compact)
|
|
- Shadow: `Color.black.opacity(0.1), radius: 2, x: 0, y: 1`
|
|
- Use `VStack` for vertical layout, `HStack` for horizontal
|
|
|
|
#### Creating Buttons
|
|
|
|
**Primary Button:**
|
|
```swift
|
|
Button(action: { /* action */ }) {
|
|
Text("Primary Action")
|
|
.fontWeight(.semibold)
|
|
.frame(maxWidth: .infinity)
|
|
.foregroundColor(Color.appTextOnPrimary)
|
|
.padding()
|
|
.background(Color.appPrimary)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
```
|
|
|
|
**Destructive Button:**
|
|
```swift
|
|
Button(action: { /* action */ }) {
|
|
Label("Delete", systemImage: "trash")
|
|
.foregroundColor(Color.appError)
|
|
}
|
|
```
|
|
|
|
**Secondary Button (bordered):**
|
|
```swift
|
|
Button(action: { /* action */ }) {
|
|
Text("Secondary")
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
```
|
|
|
|
#### Icons and SF Symbols
|
|
|
|
**Icon Coloring:**
|
|
- Primary actions: `Color.appPrimary` (e.g., add, edit)
|
|
- Secondary info: `Color.appAccent` (e.g., info, notification)
|
|
- Destructive: `Color.appError` (e.g., delete, warning)
|
|
- Neutral: `Color.appTextSecondary` (e.g., chevrons, decorative)
|
|
|
|
**Common Icon Patterns:**
|
|
```swift
|
|
// Large decorative icon
|
|
Image(systemName: "house.fill")
|
|
.font(.system(size: 60))
|
|
.foregroundStyle(Color.appPrimary.gradient)
|
|
|
|
// Inline icon with label
|
|
Label("Title", systemImage: "folder")
|
|
.foregroundColor(Color.appPrimary)
|
|
|
|
// Status indicator icon
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(Color.appPrimary)
|
|
```
|
|
|
|
#### Spacing and Layout
|
|
|
|
Use constants from `DesignSystem.swift`:
|
|
|
|
```swift
|
|
// Standard spacing
|
|
AppSpacing.xs // 4pt
|
|
AppSpacing.sm // 8pt
|
|
AppSpacing.md // 12pt
|
|
AppSpacing.lg // 16pt
|
|
AppSpacing.xl // 24pt
|
|
|
|
// Example usage
|
|
VStack(spacing: AppSpacing.md) {
|
|
// content
|
|
}
|
|
```
|
|
|
|
#### Adding New Colors to Asset Catalog
|
|
|
|
If you need to add a new semantic color:
|
|
|
|
1. Open `iosApp/iosApp/Assets.xcassets/Colors/Semantic/`
|
|
2. Create new `.colorset` folder
|
|
3. Add `Contents.json`:
|
|
```json
|
|
{
|
|
"colors" : [
|
|
{
|
|
"color" : {
|
|
"color-space" : "srgb",
|
|
"components" : {
|
|
"alpha" : "1.000",
|
|
"blue" : "0xHH",
|
|
"green" : "0xHH",
|
|
"red" : "0xHH"
|
|
}
|
|
},
|
|
"idiom" : "universal"
|
|
},
|
|
{
|
|
"appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ],
|
|
"color" : {
|
|
"color-space" : "srgb",
|
|
"components" : {
|
|
"alpha" : "1.000",
|
|
"blue" : "0xHH",
|
|
"green" : "0xHH",
|
|
"red" : "0xHH"
|
|
}
|
|
},
|
|
"idiom" : "universal"
|
|
}
|
|
],
|
|
"info" : { "author" : "xcode", "version" : 1 }
|
|
}
|
|
```
|
|
|
|
4. Add extension in `DesignSystem.swift`:
|
|
```swift
|
|
extension Color {
|
|
static let appNewColor = Color("NewColor")
|
|
}
|
|
```
|
|
|
|
#### View Modifiers and Helpers
|
|
|
|
**Error Handling Modifier:**
|
|
```swift
|
|
.handleErrors(
|
|
error: viewModel.errorMessage,
|
|
onRetry: { viewModel.retryAction() }
|
|
)
|
|
```
|
|
|
|
**Loading State:**
|
|
```swift
|
|
if viewModel.isLoading {
|
|
ProgressView()
|
|
.tint(Color.appPrimary)
|
|
} else {
|
|
// content
|
|
}
|
|
```
|
|
|
|
**Empty States:**
|
|
```swift
|
|
if items.isEmpty {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "tray")
|
|
.font(.system(size: 60))
|
|
.foregroundColor(Color.appTextSecondary.opacity(0.5))
|
|
|
|
Text("No Items")
|
|
.font(.headline)
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Text("Get started by adding your first item")
|
|
.font(.subheadline)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
```
|
|
|
|
### Android Layer
|
|
|
|
Android uses Compose UI directly from `composeApp` with shared ViewModels. Navigation via Jetpack Compose Navigation in `App.kt`.
|
|
|
|
### Android Design System
|
|
|
|
**CRITICAL**: Always use the theme-aware design system components and colors. Never use hardcoded colors or spacing values.
|
|
|
|
#### Theme System
|
|
|
|
The app uses a comprehensive theming system with 11 themes matching iOS:
|
|
- **Default** (vibrant iOS system colors)
|
|
- **Teal**, **Ocean**, **Forest**, **Sunset**
|
|
- **Monochrome**, **Lavender**, **Crimson**, **Midnight**, **Desert**, **Mint**
|
|
|
|
**Theme Files:**
|
|
- `ui/theme/ThemeColors.kt` - All 11 themes with light/dark mode colors
|
|
- `ui/theme/ThemeManager.kt` - Singleton for dynamic theme switching with persistence
|
|
- `ui/theme/Spacing.kt` - Standardized spacing constants
|
|
- `ui/theme/Theme.kt` - Material3 theme integration
|
|
|
|
**Theme Usage:**
|
|
```kotlin
|
|
@Composable
|
|
fun App() {
|
|
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
|
|
|
|
MyCribTheme(themeColors = currentTheme) {
|
|
// App content
|
|
}
|
|
}
|
|
```
|
|
|
|
**Changing Themes:**
|
|
```kotlin
|
|
// In ProfileScreen or settings
|
|
ThemeManager.setTheme("ocean") // By ID
|
|
// or
|
|
ThemeManager.setTheme(AppThemes.Ocean) // By object
|
|
```
|
|
|
|
**Theme Persistence:**
|
|
Themes are automatically persisted using `ThemeStorage` (SharedPreferences on Android, UserDefaults on iOS). Initialize in MainActivity:
|
|
```kotlin
|
|
ThemeStorage.initialize(ThemeStorageManager.getInstance(applicationContext))
|
|
ThemeManager.initialize() // Loads saved theme
|
|
```
|
|
|
|
#### Color System
|
|
|
|
**ALWAYS use MaterialTheme.colorScheme instead of hardcoded colors:**
|
|
|
|
```kotlin
|
|
// ✅ CORRECT
|
|
Text(
|
|
text = "Hello",
|
|
color = MaterialTheme.colorScheme.onBackground
|
|
)
|
|
|
|
Card(
|
|
colors = CardDefaults.cardColors(
|
|
containerColor = MaterialTheme.colorScheme.backgroundSecondary
|
|
)
|
|
)
|
|
|
|
// ❌ WRONG
|
|
Text(
|
|
text = "Hello",
|
|
color = Color(0xFF000000) // Never hardcode colors!
|
|
)
|
|
```
|
|
|
|
**Available Material3 ColorScheme Properties:**
|
|
- `primary`, `onPrimary` - Primary brand color and text on it
|
|
- `secondary`, `onSecondary` - Secondary brand color
|
|
- `error`, `onError` - Error states
|
|
- `background`, `onBackground` - Screen backgrounds
|
|
- `surface`, `onSurface` - Card/surface backgrounds
|
|
- `surfaceVariant`, `onSurfaceVariant` - Alternative surface colors
|
|
- **Custom extensions:**
|
|
- `backgroundSecondary` - For cards and elevated surfaces
|
|
- `textPrimary`, `textSecondary` - Semantic text colors
|
|
|
|
#### Spacing System
|
|
|
|
**ALWAYS use AppSpacing constants instead of hardcoded dp values:**
|
|
|
|
```kotlin
|
|
// ✅ CORRECT
|
|
Column(
|
|
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
|
|
) {
|
|
Box(modifier = Modifier.padding(AppSpacing.lg))
|
|
}
|
|
|
|
// ❌ WRONG
|
|
Column(
|
|
verticalArrangement = Arrangement.spacedBy(12.dp) // Never hardcode spacing!
|
|
)
|
|
```
|
|
|
|
**Available Spacing:**
|
|
```kotlin
|
|
AppSpacing.xs // 4.dp - Minimal spacing
|
|
AppSpacing.sm // 8.dp - Small spacing
|
|
AppSpacing.md // 12.dp - Medium spacing (default)
|
|
AppSpacing.lg // 16.dp - Large spacing
|
|
AppSpacing.xl // 24.dp - Extra large spacing
|
|
```
|
|
|
|
**Available Radius:**
|
|
```kotlin
|
|
AppRadius.xs // 4.dp
|
|
AppRadius.sm // 8.dp
|
|
AppRadius.md // 12.dp - Standard card radius
|
|
AppRadius.lg // 16.dp
|
|
AppRadius.xl // 20.dp
|
|
AppRadius.xxl // 24.dp
|
|
```
|
|
|
|
#### Standard Components
|
|
|
|
**Use the provided standard components for consistency:**
|
|
|
|
**1. StandardCard - Primary card component:**
|
|
```kotlin
|
|
StandardCard(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
contentPadding = AppSpacing.lg // Default
|
|
) {
|
|
Text("Card content")
|
|
// More content...
|
|
}
|
|
|
|
// With custom background
|
|
StandardCard(
|
|
backgroundColor = MaterialTheme.colorScheme.primaryContainer
|
|
) {
|
|
Text("Highlighted card")
|
|
}
|
|
```
|
|
|
|
**2. CompactCard - Smaller card variant:**
|
|
```kotlin
|
|
CompactCard {
|
|
Row(horizontalArrangement = Arrangement.SpaceBetween) {
|
|
Text("Title")
|
|
Icon(Icons.Default.ChevronRight, null)
|
|
}
|
|
}
|
|
```
|
|
|
|
**3. FormTextField - Standardized input field:**
|
|
```kotlin
|
|
var text by remember { mutableStateOf("") }
|
|
var error by remember { mutableStateOf<String?>(null) }
|
|
|
|
FormTextField(
|
|
value = text,
|
|
onValueChange = { text = it },
|
|
label = "Property Name",
|
|
placeholder = "Enter name",
|
|
leadingIcon = Icons.Default.Home,
|
|
error = error,
|
|
helperText = "This will be displayed on your dashboard",
|
|
keyboardType = KeyboardType.Text
|
|
)
|
|
```
|
|
|
|
**4. FormSection - Group related form fields:**
|
|
```kotlin
|
|
FormSection(
|
|
header = "Property Details",
|
|
footer = "Enter the basic information about your property"
|
|
) {
|
|
FormTextField(value = name, onValueChange = { name = it }, label = "Name")
|
|
FormTextField(value = address, onValueChange = { address = it }, label = "Address")
|
|
}
|
|
```
|
|
|
|
**5. StandardEmptyState - Consistent empty states:**
|
|
```kotlin
|
|
if (items.isEmpty()) {
|
|
StandardEmptyState(
|
|
icon = Icons.Default.Home,
|
|
title = "No Properties",
|
|
subtitle = "Add your first property to get started",
|
|
actionLabel = "Add Property",
|
|
onAction = { navigateToAddProperty() }
|
|
)
|
|
}
|
|
```
|
|
|
|
#### Screen Patterns
|
|
|
|
**Standard Screen Structure:**
|
|
```kotlin
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun MyScreen(
|
|
onNavigateBack: () -> Unit,
|
|
viewModel: MyViewModel = viewModel { MyViewModel() }
|
|
) {
|
|
val state by viewModel.state.collectAsState()
|
|
|
|
Scaffold(
|
|
topBar = {
|
|
TopAppBar(
|
|
title = { Text("Title", fontWeight = FontWeight.SemiBold) },
|
|
navigationIcon = {
|
|
IconButton(onClick = onNavigateBack) {
|
|
Icon(Icons.Default.ArrowBack, "Back")
|
|
}
|
|
},
|
|
colors = TopAppBarDefaults.topAppBarColors(
|
|
containerColor = MaterialTheme.colorScheme.surface
|
|
)
|
|
)
|
|
}
|
|
) { paddingValues ->
|
|
// Content with proper padding
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(paddingValues)
|
|
.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md),
|
|
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
|
|
) {
|
|
when (state) {
|
|
is ApiResult.Success -> {
|
|
// Content
|
|
}
|
|
is ApiResult.Loading -> {
|
|
CircularProgressIndicator()
|
|
}
|
|
is ApiResult.Error -> {
|
|
ErrorCard(message = state.message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**List Screen with Pull-to-Refresh:**
|
|
```kotlin
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun ListScreen() {
|
|
var isRefreshing by remember { mutableStateOf(false) }
|
|
val items by viewModel.items.collectAsState()
|
|
|
|
PullToRefreshBox(
|
|
isRefreshing = isRefreshing,
|
|
onRefresh = {
|
|
isRefreshing = true
|
|
viewModel.loadItems(forceRefresh = true)
|
|
}
|
|
) {
|
|
LazyColumn {
|
|
items(items) { item ->
|
|
StandardCard(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable { onClick(item) }
|
|
) {
|
|
// Item content
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Button Patterns
|
|
|
|
```kotlin
|
|
// Primary Action Button
|
|
Button(
|
|
onClick = { /* action */ },
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(56.dp),
|
|
shape = RoundedCornerShape(AppRadius.md)
|
|
) {
|
|
Icon(Icons.Default.Save, null)
|
|
Spacer(Modifier.width(AppSpacing.sm))
|
|
Text("Save Changes", fontWeight = FontWeight.SemiBold)
|
|
}
|
|
|
|
// Destructive Button
|
|
Button(
|
|
onClick = { /* action */ },
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = MaterialTheme.colorScheme.error
|
|
)
|
|
) {
|
|
Icon(Icons.Default.Delete, null)
|
|
Text("Delete")
|
|
}
|
|
|
|
// Text Button
|
|
TextButton(onClick = { /* action */ }) {
|
|
Text("Cancel")
|
|
}
|
|
```
|
|
|
|
#### Dialog Pattern
|
|
|
|
```kotlin
|
|
@Composable
|
|
fun ThemePickerDialog(
|
|
currentTheme: ThemeColors,
|
|
onThemeSelected: (ThemeColors) -> Unit,
|
|
onDismiss: () -> Unit
|
|
) {
|
|
Dialog(onDismissRequest = onDismiss) {
|
|
Card(
|
|
shape = RoundedCornerShape(AppRadius.lg),
|
|
colors = CardDefaults.cardColors(
|
|
containerColor = MaterialTheme.colorScheme.background
|
|
)
|
|
) {
|
|
Column(modifier = Modifier.padding(AppSpacing.xl)) {
|
|
Text(
|
|
"Choose Theme",
|
|
style = MaterialTheme.typography.headlineSmall
|
|
)
|
|
// Content...
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Key Design Principles
|
|
|
|
1. **Always use theme-aware colors** from MaterialTheme.colorScheme
|
|
2. **Always use spacing constants** from AppSpacing/AppRadius
|
|
3. **Use standard components** (StandardCard, FormTextField, etc.) for consistency
|
|
4. **Follow Material3 guidelines** for component usage
|
|
5. **Support dynamic theming** - never assume a specific theme
|
|
6. **Test in both light and dark mode** - all themes support both
|
|
|
|
## Environment Configuration
|
|
|
|
**API Environment Toggle** (`composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt`):
|
|
|
|
```kotlin
|
|
val CURRENT_ENV = Environment.DEV // or Environment.LOCAL
|
|
```
|
|
|
|
- `Environment.LOCAL`: Points to `http://10.0.2.2:8000/api` (Android emulator) or `http://127.0.0.1:8000/api` (iOS simulator)
|
|
- `Environment.DEV`: Points to `https://mycrib.treytartt.com/api`
|
|
|
|
**Change this to switch between local Go backend and production server.**
|
|
|
|
## Common Development Patterns
|
|
|
|
### Adding a New API Endpoint
|
|
|
|
1. Add API call to appropriate `*Api.kt` class in `network/` (e.g., `TaskApi.kt`)
|
|
2. Add method to `APILayer.kt` that manages caching (if applicable)
|
|
3. Add method to relevant ViewModel that calls APILayer
|
|
4. Update UI to observe the new StateFlow
|
|
|
|
### Handling Platform-Specific Code
|
|
|
|
Use `expect/actual` pattern:
|
|
|
|
```kotlin
|
|
// commonMain
|
|
expect fun platformSpecificFunction(): String
|
|
|
|
// androidMain
|
|
actual fun platformSpecificFunction(): String = "Android"
|
|
|
|
// iosMain
|
|
actual fun platformSpecificFunction(): String = "iOS"
|
|
```
|
|
|
|
### Type Conversions for iOS
|
|
|
|
Kotlin types bridge to Swift with special wrappers:
|
|
- `Double` → `KotlinDouble` (use `KotlinDouble(double:)` constructor)
|
|
- `Int` → `KotlinInt` (use `KotlinInt(int:)` constructor)
|
|
- `String` stays `String`
|
|
- Optional types: Kotlin nullable (`Type?`) becomes Swift optional (`Type?`)
|
|
|
|
**Example iOS form submission:**
|
|
```swift
|
|
// TextField uses String binding
|
|
@State private var estimatedCost: String = ""
|
|
|
|
// Convert to KotlinDouble for API
|
|
estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0)
|
|
```
|
|
|
|
### Refreshing Lists After Mutations
|
|
|
|
**iOS Pattern:**
|
|
```swift
|
|
.sheet(isPresented: $showingAddForm) {
|
|
AddFormView(
|
|
isPresented: $showingAddForm,
|
|
onSuccess: {
|
|
viewModel.loadData(forceRefresh: true)
|
|
}
|
|
)
|
|
}
|
|
```
|
|
|
|
**Android Pattern:**
|
|
```kotlin
|
|
// Use savedStateHandle to pass refresh flag between screens
|
|
navController.previousBackStackEntry?.savedStateHandle?.set("refresh", true)
|
|
navController.popBackStack()
|
|
|
|
// In destination composable
|
|
val shouldRefresh = backStackEntry.savedStateHandle.get<Boolean>("refresh") ?: false
|
|
LaunchedEffect(shouldRefresh) {
|
|
if (shouldRefresh) viewModel.loadData(forceRefresh = true)
|
|
}
|
|
```
|
|
|
|
## Testing
|
|
|
|
Currently tests are minimal. When adding tests:
|
|
- Android: Place in `composeApp/src/androidUnitTest/` or `composeApp/src/commonTest/`
|
|
- iOS: Use XCTest framework in Xcode project
|
|
|
|
## Key Dependencies
|
|
|
|
- Kotlin Multiplatform: 2.1.0
|
|
- Compose Multiplatform: 1.7.1
|
|
- Ktor Client: Network requests
|
|
- kotlinx.serialization: JSON serialization
|
|
- kotlinx.coroutines: Async operations
|
|
- SKIE: Kotlin ↔ Swift interop improvements
|
|
|
|
## Important Notes
|
|
|
|
### Committing Changes
|
|
|
|
When committing changes that span both iOS and Android, commit them together in the KMM repository. If backend changes are needed, commit separately in the `myCribAPI-go` repository.
|
|
|
|
### DataManager Initialization
|
|
|
|
**Critical**: DataManager must be initialized at app startup with platform-specific managers:
|
|
|
|
```kotlin
|
|
// In Application.onCreate() or equivalent
|
|
DataManager.initialize(
|
|
tokenMgr = TokenManager(context),
|
|
themeMgr = ThemeStorageManager(context),
|
|
persistenceMgr = PersistenceManager(context)
|
|
)
|
|
```
|
|
|
|
After user login, call `APILayer.initializeLookups()` to populate DataManager with reference data. This uses ETag-based caching - if data hasn't changed, server returns 304 Not Modified.
|
|
|
|
```kotlin
|
|
// After successful login
|
|
val initResult = APILayer.initializeLookups()
|
|
if (initResult is ApiResult.Success) {
|
|
// Now safe to navigate to main screen
|
|
// Lookups are cached in DataManager and persisted to disk
|
|
}
|
|
```
|
|
|
|
Without this, dropdowns and pickers will be empty.
|
|
|
|
### iOS Build Issues
|
|
|
|
If iOS build fails with type mismatch errors:
|
|
1. Check that cost fields (estimatedCost, actualCost, purchasePrice) use `KotlinDouble`, not `String`
|
|
2. Verify preview/mock data matches current model signatures
|
|
3. Clean build folder in Xcode (Cmd+Shift+K) and rebuild
|
|
|
|
### Force Refresh Pattern
|
|
|
|
Always use `forceRefresh: true` when data should be fresh:
|
|
- After creating/updating/deleting items
|
|
- On pull-to-refresh gestures
|
|
- When explicitly requested by user
|
|
|
|
Without `forceRefresh`, APILayer returns cached data.
|
|
|
|
## Project Structure Summary
|
|
|
|
```
|
|
MyCribKMM/
|
|
├── composeApp/
|
|
│ └── src/
|
|
│ ├── commonMain/kotlin/com/example/casera/
|
|
│ │ ├── data/ # DataManager, PersistenceManager
|
|
│ │ ├── models/ # Shared data models (kotlinx.serialization)
|
|
│ │ ├── network/ # APILayer, *Api clients, ApiConfig
|
|
│ │ ├── storage/ # TokenManager, ThemeStorageManager
|
|
│ │ ├── util/ # DateUtils, helpers
|
|
│ │ ├── ui/ # Compose UI (Android)
|
|
│ │ │ ├── components/ # Reusable components
|
|
│ │ │ ├── screens/ # Screen composables
|
|
│ │ │ └── theme/ # Material theme, ThemeManager
|
|
│ │ ├── viewmodel/ # Shared ViewModels
|
|
│ │ └── App.kt # Android navigation
|
|
│ ├── androidMain/ # Android-specific (TokenManager, etc.)
|
|
│ ├── iosMain/ # iOS-specific Kotlin code
|
|
│ └── commonTest/ # Shared tests
|
|
│
|
|
├── iosApp/iosApp/
|
|
│ ├── *ViewModel.swift # Swift wrappers for Kotlin VMs
|
|
│ ├── *View.swift # SwiftUI screens
|
|
│ ├── Components/ # Reusable SwiftUI components
|
|
│ ├── Design/ # Design system (DesignSystem.swift, OrganicDesign.swift)
|
|
│ ├── Extensions/ # Swift extensions
|
|
│ ├── Helpers/ # Utility helpers (DateUtils, etc.)
|
|
│ ├── PushNotifications/ # APNs integration
|
|
│ └── [Feature]/ # Feature-grouped files
|
|
│ ├── Task/
|
|
│ ├── Residence/
|
|
│ ├── Contractor/
|
|
│ └── Documents/
|
|
│
|
|
└── gradle/ # Gradle wrapper and configs
|
|
```
|
|
|
|
## Related Repositories
|
|
|
|
- **Backend API**: `../myCribAPI-go` - Go REST API with PostgreSQL
|
|
- **Documentation**: `../myCribAPI-go/docs` - Server configuration and API docs
|