# 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 HoneyDue (honeyDue) 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 `honeyDueAPI-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 `../honeyDueAPI-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/honeydue/`) **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` (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> DataManager.myResidences: StateFlow DataManager.allTasks: StateFlow DataManager.taskCategories: StateFlow> // 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 } } } ``` ### Mutation & Auto-Update Pattern **CRITICAL: When implementing CRUD operations, follow this pattern to ensure UI updates automatically without requiring pull-to-refresh.** #### Kotlin DataManager Update Methods When updating a single item, ensure ALL related caches are updated: ```kotlin // ✅ CORRECT: Update all caches that contain the item fun updateResidence(residence: Residence) { // Update primary list _residences.value = _residences.value.map { if (it.id == residence.id) residence else it } // Also update related caches (myResidences is checked by getResidence) _myResidences.value?.let { myRes -> val updatedResidences = myRes.residences.map { if (it.id == residence.id) residence else it } _myResidences.value = myRes.copy(residences = updatedResidences) } persistToDisk() } // ❌ WRONG: Only updating one cache causes stale data fun updateResidence(residence: Residence) { _residences.value = _residences.value.map { if (it.id == residence.id) residence else it } // Missing: _myResidences update - getResidence may return stale data! } ``` #### iOS ViewModel: Auto-Update Selected Items When a detail view displays a `selectedItem`, the ViewModel must auto-update it when DataManager data changes: ```swift // ✅ CORRECT: Auto-update selectedResidence when data changes init() { // Observe residences list DataManagerObservable.shared.$residences .receive(on: DispatchQueue.main) .sink { [weak self] residences in self?.residences = residences // Auto-update selectedResidence if it exists in the updated list if let currentSelected = self?.selectedResidence, let updatedResidence = residences.first(where: { $0.id == currentSelected.id }) { self?.selectedResidence = updatedResidence } } .store(in: &cancellables) // Also observe myResidences (another source of residence data) DataManagerObservable.shared.$myResidences .receive(on: DispatchQueue.main) .sink { [weak self] myResidences in self?.myResidences = myResidences // Auto-update selectedResidence here too if let currentSelected = self?.selectedResidence, let updatedResidence = myResidences?.residences.first(where: { $0.id == currentSelected.id }) { self?.selectedResidence = updatedResidence } } .store(in: &cancellables) } // ❌ WRONG: Only storing list data, selectedResidence becomes stale init() { DataManagerObservable.shared.$residences .sink { [weak self] residences in self?.residences = residences // Missing: selectedResidence auto-update! } .store(in: &cancellables) } ``` #### Complete Data Flow for Mutations ``` 1. User edits item in FormView (has its own ViewModel instance) 2. FormView.viewModel calls APILayer.updateItem() 3. APILayer calls API, on success: - DataManager.updateItem() updates ALL relevant caches - Returns updated item 4. DataManager StateFlows emit new values 5. DataManagerObservable picks up changes, publishes to @Published 6. ALL ViewModels observing that data receive updates via Combine 7. Each ViewModel's sink checks if selectedItem matches and updates it 8. SwiftUI re-renders automatically - NO pull-to-refresh needed ``` #### Checklist for New CRUD Features - [ ] Kotlin `DataManager.updateX()` updates ALL caches containing that data type - [ ] iOS ViewModel observes ALL relevant `DataManagerObservable` publishers - [ ] iOS ViewModel auto-updates `selectedX` in each Combine sink - [ ] No manual refresh calls needed after mutations (architecture handles it) ### iOS Shared Components (`iosApp/iosApp/Shared/`) **CRITICAL: Always check the Shared folder for reusable components before creating new ones.** This folder contains standardized UI components, view modifiers, and utilities that ensure consistency across the app. #### Directory Structure ``` iosApp/iosApp/Shared/ ├── Components/ │ ├── FormComponents.swift # Form headers, sections, text fields │ ├── ButtonStyles.swift # Primary, secondary, destructive buttons │ └── SharedEmptyStateView.swift # Empty state views ├── Modifiers/ │ └── CardModifiers.swift # Card styling modifiers ├── Extensions/ │ ├── ViewExtensions.swift # Form styling, loading overlays │ ├── StringExtensions.swift # String utilities │ ├── DoubleExtensions.swift # Number formatting │ └── DateExtensions.swift # Date formatting └── Utilities/ ├── ValidationHelpers.swift # Form validation └── SharedErrorMessageParser.swift ``` #### Reusable Button Components ```swift // Primary filled button (main actions) PrimaryButton(title: "Save", icon: "checkmark", isLoading: isLoading) { saveAction() } // Secondary outlined button SecondaryButton(title: "Cancel", icon: "xmark") { cancelAction() } // Destructive button (delete, remove) DestructiveButton(title: "Delete", icon: "trash") { deleteAction() } // Text-only button TextButton(title: "Learn More", icon: "arrow.right") { navigateAction() } // Compact button for cards/rows CompactButton(title: "Edit", icon: "pencil", color: .appPrimary, isFilled: false) { editAction() } // Organic button with gradient (premium feel) OrganicPrimaryButton(title: "Continue", isLoading: isLoading) { continueAction() } ``` #### Reusable Form Components ```swift // Form header with icon FormHeader( icon: "house.fill", title: "Add Property", subtitle: "Enter your property details" ) // Organic form header (radial gradient style) OrganicFormHeader( icon: "person.fill", title: "Create Account", subtitle: "Join honeyDue today" ) // Form section with icon header IconFormSection(icon: "info.circle", title: "Details", footer: "Optional info") { TextField("Name", text: $name) } // Error display section if let error = errorMessage { ErrorSection(message: error) } // Success message section SuccessSection(message: "Changes saved successfully") // Form action button (submit) FormActionButton(title: "Submit", isLoading: isSubmitting) { submitForm() } // Text field with icon IconTextField( icon: "envelope", placeholder: "Email", text: $email, keyboardType: .emailAddress ) // Secure text field with visibility toggle SecureIconTextField( icon: "lock", placeholder: "Password", text: $password, isVisible: $showPassword ) // Field label with optional required indicator FieldLabel(text: "Username", isRequired: true) // Field error message FieldError(message: "This field is required") ``` #### Reusable Empty State Views ```swift // Standard empty state StandardEmptyStateView( icon: "tray", title: "No Items", subtitle: "Get started by adding your first item", actionLabel: "Add Item", action: { showAddSheet = true } ) // Organic empty state (matches app design) OrganicEmptyState( icon: "house", title: "No Properties", subtitle: "Add your first property to get started", actionLabel: "Add Property", action: { showAddSheet = true }, blobVariation: 1 ) // Simple list empty state ListEmptyState(icon: "doc.text", message: "No documents found") ``` #### Reusable Card Modifiers ```swift // Standard card styling VStack { content } .standardCard() // Default padding, background, shadow // Compact card (smaller padding) HStack { content } .compactCard() // Organic card (matches design system) VStack { content } .organicCardStyle(showBlob: true, blobVariation: 0) // List row card ForEach(items) { item in ItemRow(item: item) .listRowCard() } // Metadata pill (tags, badges) Text("Active") .metadataPill() ``` #### Reusable View Extensions ```swift // Standard form styling (ALWAYS use on Form views) Form { // content } .standardFormStyle() // Applies .listStyle(.plain), .scrollContentBackground(.hidden) // Section backgrounds Section { content } .sectionBackground() // Uses Color.appBackgroundSecondary Section { headerContent } .headerSectionBackground() // Clear background for headers // Loading overlay content .loadingOverlay(isLoading: isLoading, message: "Saving...") // Conditional modifiers content .if(condition) { view in view.opacity(0.5) } // Dismiss keyboard on tap ScrollView { content } .dismissKeyboardOnTap() // Standard loading view StandardLoadingView(message: "Loading...") ``` #### When to Create New vs Reuse **Reuse existing components when:** - Building forms (use `FormComponents.swift`) - Adding buttons (use `ButtonStyles.swift`) - Showing empty states (use `SharedEmptyStateView.swift`) - Styling cards (use `CardModifiers.swift`) - Styling forms (use `ViewExtensions.swift`) **Create new components when:** - The pattern will be used 3+ times - It doesn't fit existing components - It's domain-specific (put in feature folder, not Shared) **Add to Shared folder when:** - Component is generic and reusable across features - It follows existing design patterns - It doesn't depend on specific business logic ### 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 } } HoneyDueTheme(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(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/honeydue/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://honeyDue.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("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 `honeyDueAPI-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 ``` HoneyDueKMM/ ├── composeApp/ │ └── src/ │ ├── commonMain/kotlin/com/example/honeydue/ │ │ ├── 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**: `../honeyDueAPI-go` - Go REST API with PostgreSQL - **Documentation**: `../honeyDueAPI-go/docs` - Server configuration and API docs