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>
42 KiB
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
# Build debug APK
./gradlew :composeApp:assembleDebug
# Build release APK
./gradlew :composeApp:assembleRelease
# Run on connected device/emulator
./gradlew :composeApp:installDebug
iOS
# 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)
./gradlew :composeApp:run
Web
# 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:
-
DataManager (
data/DataManager.kt) - Single Source of Truth- Unified cache for ALL app data (auth, residences, tasks, lookups, etc.)
- All data is exposed via
StateFlowfor 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.
-
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
forceRefreshparameter - ETag-based conditional fetching for lookups (304 Not Modified support)
- Guards against concurrent initialization/prefetch calls
- Returns
ApiResult<T>(Success/Error/Loading/Idle states)
-
API Clients (
network/*Api.kt)- Domain-specific API clients:
ResidenceApi,TaskApi,ContractorApi, etc. - Low-level HTTP calls using Ktor
- Error parsing and response handling
- Domain-specific API clients:
-
PersistenceManager (
data/PersistenceManager.kt)- Platform-specific disk persistence (expect/actual pattern)
- Stores serialized JSON for offline access
- Loads cached data on app startup
-
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:
// 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 datain Swift observes KotlinStateFlowvia async iteration - Navigation uses SwiftUI
NavigationStackwith sheets for modals
Key iOS Files:
MainTabView.swift: Tab-based navigation*ViewModel.swift(Swift): Wraps shared Kotlin ViewModels, exposes@Publishedproperties*View.swift: SwiftUI screens- Directory structure mirrors feature organization (Residence/, Task/, Contractor/, etc.)
iOS ↔ Kotlin Bridge:
// 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:
// ✅ 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:
// ✅ 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
DataManagerObservablepublishers - iOS ViewModel auto-updates
selectedXin 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
// 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
// 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
// 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
// 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
// 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:
// 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 useColor.appError - Icons: Use
Color.appPrimaryfor main actions,Color.appAccentfor secondary/info icons - Cards: Always use
Color.appBackgroundSecondaryfor card backgrounds - Screens: Always use
Color.appBackgroundPrimaryfor main view backgrounds - Text: Use
Color.appTextPrimaryfor body text,Color.appTextSecondaryfor captions/subtitles
Creating New Views
Standard Form/List View Pattern:
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:
-
Always add these three modifiers to Form/List:
.listStyle(.plain) .scrollContentBackground(.hidden) .background(Color.appBackgroundPrimary) -
Always add
.listRowBackground()to EVERY Section:Section { // content } .listRowBackground(Color.appBackgroundSecondary) // ← REQUIRED -
Exception for header sections: Use
.listRowBackground(Color.clear)for decorative headers
Creating Custom Cards
Standard Card Pattern:
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
VStackfor vertical layout,HStackfor horizontal
Creating Buttons
Primary Button:
Button(action: { /* action */ }) {
Text("Primary Action")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.foregroundColor(Color.appTextOnPrimary)
.padding()
.background(Color.appPrimary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Destructive Button:
Button(action: { /* action */ }) {
Label("Delete", systemImage: "trash")
.foregroundColor(Color.appError)
}
Secondary Button (bordered):
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:
// 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:
// 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:
- Open
iosApp/iosApp/Assets.xcassets/Colors/Semantic/ - Create new
.colorsetfolder - Add
Contents.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 }
}
- Add extension in
DesignSystem.swift:
extension Color {
static let appNewColor = Color("NewColor")
}
View Modifiers and Helpers
Error Handling Modifier:
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.retryAction() }
)
Loading State:
if viewModel.isLoading {
ProgressView()
.tint(Color.appPrimary)
} else {
// content
}
Empty States:
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 colorsui/theme/ThemeManager.kt- Singleton for dynamic theme switching with persistenceui/theme/Spacing.kt- Standardized spacing constantsui/theme/Theme.kt- Material3 theme integration
Theme Usage:
@Composable
fun App() {
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
HoneyDueTheme(themeColors = currentTheme) {
// App content
}
}
Changing Themes:
// 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:
ThemeStorage.initialize(ThemeStorageManager.getInstance(applicationContext))
ThemeManager.initialize() // Loads saved theme
Color System
ALWAYS use MaterialTheme.colorScheme instead of hardcoded colors:
// ✅ 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 itsecondary,onSecondary- Secondary brand colorerror,onError- Error statesbackground,onBackground- Screen backgroundssurface,onSurface- Card/surface backgroundssurfaceVariant,onSurfaceVariant- Alternative surface colors- Custom extensions:
backgroundSecondary- For cards and elevated surfacestextPrimary,textSecondary- Semantic text colors
Spacing System
ALWAYS use AppSpacing constants instead of hardcoded dp values:
// ✅ 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:
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:
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:
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:
CompactCard {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Text("Title")
Icon(Icons.Default.ChevronRight, null)
}
}
3. FormTextField - Standardized input field:
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:
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:
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:
@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:
@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
// 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
@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
- Always use theme-aware colors from MaterialTheme.colorScheme
- Always use spacing constants from AppSpacing/AppRadius
- Use standard components (StandardCard, FormTextField, etc.) for consistency
- Follow Material3 guidelines for component usage
- Support dynamic theming - never assume a specific theme
- 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):
val CURRENT_ENV = Environment.DEV // or Environment.LOCAL
Environment.LOCAL: Points tohttp://10.0.2.2:8000/api(Android emulator) orhttp://127.0.0.1:8000/api(iOS simulator)Environment.DEV: Points tohttps://honeyDue.treytartt.com/api
Change this to switch between local Go backend and production server.
Common Development Patterns
Adding a New API Endpoint
- Add API call to appropriate
*Api.ktclass innetwork/(e.g.,TaskApi.kt) - Add method to
APILayer.ktthat manages caching (if applicable) - Add method to relevant ViewModel that calls APILayer
- 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 callsAPILayer.shareddirectly in Swift rather than observing Kotlin StateFlows — matches the convention iniosApp/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:
// 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(useKotlinDouble(double:)constructor)Int→KotlinInt(useKotlinInt(int:)constructor)StringstaysString- Optional types: Kotlin nullable (
Type?) becomes Swift optional (Type?)
Example iOS form submission:
// 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:
.sheet(isPresented: $showingAddForm) {
AddFormView(
isPresented: $showingAddForm,
onSuccess: {
viewModel.loadData(forceRefresh: true)
}
)
}
Android Pattern:
// 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/orcomposeApp/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:
// 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.
// 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:
- Check that cost fields (estimatedCost, actualCost, purchasePrice) use
KotlinDouble, notString - Verify preview/mock data matches current model signatures
- 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