Files
honeyDueKMP/CLAUDE.md
Trey t 9ececfa48a Wire onboarding task suggestions to backend, delete hardcoded catalog
Both "For You" and "Browse All" tabs are now fully server-driven on
iOS and Android. No on-device task list, no client-side scoring rules.
When the API fails the screen shows error + Retry + Skip so onboarding
can still complete on a flaky network.

Shared (KMM)
- TaskCreateRequest + TaskResponse carry templateId
- New BulkCreateTasksRequest/Response, TaskApi.bulkCreateTasks,
  APILayer.bulkCreateTasks (updates DataManager + TotalSummary)
- OnboardingViewModel: templatesGroupedState + loadTemplatesGrouped;
  createTasks(residenceId, requests) posts once via the bulk path
- Deleted regional-template plumbing: APILayer.getRegionalTemplates,
  OnboardingViewModel.loadRegionalTemplates, TaskTemplateApi.
  getTemplatesByRegion, TaskTemplate.regionId/regionName
- 5 new AnalyticsEvents constants for the onboarding funnel

Android (Compose)
- OnboardingFirstTaskContent rewritten against the server catalog;
  ~70 lines of hardcoded taskCategories gone. Loading / Error / Empty
  panes with Retry + Skip buttons. Category icons derived from name
  keywords, colours from a 5-value palette keyed by category id
- Browse selection carries template.id into the bulk request so
  task_template_id is populated server-side

iOS (SwiftUI)
- New OnboardingTasksViewModel (@MainActor ObservableObject) wrapping
  APILayer.shared for suggestions / grouped / bulk-submit with
  loading + error state (mirrors the TaskViewModel.swift pattern)
- OnboardingFirstTaskView rewritten: buildForYouSuggestions (130 lines)
  and fallbackCategories (68 lines) deleted; both tabs show the same
  error+skip UX as Android; ForYouSuggestion/SuggestionRelevance gone
- 5 new AnalyticsEvent cases with identical PostHog event names to
  the Kotlin constants so cross-platform funnels join cleanly
- Existing TaskCreateRequest / TaskResponse call sites in TaskCard,
  TasksSection, TaskFormView updated for the new templateId parameter

Docs
- CLAUDE.md gains an "Onboarding task suggestions (server-driven)"
  subsection covering the data flow, key files on both platforms,
  and the KotlinInt(int: template.id) wrapping requirement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:25:01 -05:00

1429 lines
42 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
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<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
}
}
}
```
### 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<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/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
### Onboarding task suggestions (server-driven)
The First-Task onboarding screen is **fully server-driven** on both
platforms. There is no hardcoded catalog or client-side suggestion rules;
when the API fails the screen shows error + Retry + Skip.
**Data flow:**
```
"For You" tab → APILayer.getTaskSuggestions(residenceId)
→ GET /api/tasks/suggestions/?residence_id=X
→ scored against 15 home-profile fields (incl. climate zone)
"Browse All" tab → APILayer.getTaskTemplatesGrouped()
→ GET /api/tasks/templates/grouped/
→ cached on DataManager.taskTemplatesGrouped (24h TTL)
Submit → APILayer.bulkCreateTasks(BulkCreateTasksRequest)
→ POST /api/tasks/bulk/
→ single DB transaction, all-or-nothing
```
**Key files:**
- Shared ViewModel: `composeApp/.../viewmodel/OnboardingViewModel.kt`
(`suggestionsState`, `templatesGroupedState`, `createTasks`)
- Android screen: `composeApp/.../ui/screens/onboarding/OnboardingFirstTaskContent.kt`
- iOS Swift wrapper: `iosApp/iosApp/Onboarding/OnboardingTasksViewModel.swift`
(mirrors the Kotlin ViewModel but calls `APILayer.shared` directly in
Swift rather than observing Kotlin StateFlows — matches the convention in
`iosApp/iosApp/Task/TaskViewModel.swift`)
- iOS view: `iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift`
- Analytics: 5 shared event names in `AnalyticsEvents` (Kotlin) +
`AnalyticsEvent` (Swift) — `onboarding_suggestions_loaded`,
`onboarding_suggestion_accepted`, `onboarding_browse_template_accepted`,
`onboarding_tasks_created`, `onboarding_task_step_skipped`.
**When selecting a template from either tab**, always populate
`TaskCreateRequest.templateId` with the backend `TaskTemplate.id` so the
created task carries the template backlink for reporting. Swift wraps the
id as `KotlinInt(int: template.id)`.
### 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 `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