Files
honeyDueKMP/CLAUDE.md
Trey t 15fac54f14 Add theme persistence and comprehensive Android design guidelines
This commit adds persistent theme storage and comprehensive documentation for Android development.

Theme Persistence:
- Created ThemeStorage with platform-specific implementations (SharedPreferences/UserDefaults)
- Updated ThemeManager.initialize() to load saved theme on app start
- Integrated ThemeStorage initialization in MainActivity and MainViewController
- Theme selection now persists across app restarts

Documentation (CLAUDE.md):
- Added comprehensive Android Design System section
- Documented all 11 themes and theme management
- Provided color system guidelines (use MaterialTheme.colorScheme)
- Documented spacing system (AppSpacing/AppRadius constants)
- Added standard component usage examples (StandardCard, FormTextField, etc.)
- Included screen patterns (Scaffold, pull-to-refresh, lists)
- Provided button and dialog patterns
- Listed key design principles for Android development

Build Status:
-  Android builds successfully
-  iOS builds successfully
-  Theme persistence works on both platforms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 10:53:00 -06:00

30 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

MyCrib is a Kotlin Multiplatform Mobile (KMM) property management application with shared business logic and platform-specific UI implementations. The backend is a Django REST Framework API (located in the sibling myCribAPI directory).

Tech Stack:

  • Shared (Kotlin): Compose Multiplatform for Android, networking layer, ViewModels, models
  • iOS: SwiftUI with Kotlin shared layer integration via SKIE
  • Backend: Django REST Framework (separate repository at ../myCribAPI)

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/mycrib/)

Core Components:

  1. APILayer (network/APILayer.kt)

    • Single entry point for all API calls
    • Manages caching via DataCache
    • Handles automatic cache updates on mutations (create/update/delete)
    • Pattern: Cache-first reads with optional forceRefresh parameter
    • Returns ApiResult<T> (Success/Error/Loading states)
  2. DataCache (cache/DataCache.kt)

    • In-memory cache for lookup data (residence types, task categories, priorities, statuses, etc.)
    • Must be initialized via APILayer.initializeLookups() after login
    • Stores MutableState objects that UI can observe directly
    • Cleared on logout
  3. TokenStorage (storage/TokenStorage.kt)

    • Platform-specific secure token storage
    • Android: EncryptedSharedPreferences
    • iOS: Keychain
    • All API calls automatically include token from TokenStorage
  4. ViewModels (viewmodel/)

    • Shared ViewModels expose StateFlow for UI observation
    • Pattern: ViewModel calls APILayer → APILayer manages cache + network → ViewModel emits ApiResult states
    • ViewModels: ResidenceViewModel, TaskViewModel, AuthViewModel, ContractorViewModel, etc.
  5. Navigation (navigation/)

    • Type-safe navigation using kotlinx.serialization
    • Routes defined as @Serializable data classes
    • Shared between Android Compose Navigation

Data Flow:

UI → ViewModel → APILayer → (Cache Check) → Network API → Update Cache → Return to ViewModel → UI observes StateFlow

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 ViewModel wraps Kotlin ViewModel
@StateObject private var viewModel = ResidenceViewModel() // Swift wrapper
// Inside: let sharedViewModel: ComposeApp.ResidenceViewModel // Kotlin

// Observe Kotlin StateFlow
Task {
    for await state in sharedViewModel.residencesState {
        await MainActor.run {
            self.residences = (state as? ApiResultSuccess)?.data
        }
    }
}

iOS Design System

CRITICAL: Always use the custom design system colors defined in iosApp/iosApp/Design/DesignSystem.swift and Xcode Asset Catalog. Never use system colors directly.

Color Palette

The app uses a 5-color semantic design system:

// 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:

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:

    .listStyle(.plain)
    .scrollContentBackground(.hidden)
    .background(Color.appBackgroundPrimary)
    
  2. Always add .listRowBackground() to EVERY Section:

    Section {
        // content
    }
    .listRowBackground(Color.appBackgroundSecondary)  // ← REQUIRED
    
  3. 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 VStack for vertical layout, HStack for 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:

  1. Open iosApp/iosApp/Assets.xcassets/Colors/Semantic/
  2. Create new .colorset folder
  3. 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 }
}
  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 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:

@Composable
fun App() {
    val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }

    MyCribTheme(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 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:

// ✅ 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

  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/mycrib/network/ApiConfig.kt):

val CURRENT_ENV = Environment.DEV  // or Environment.LOCAL
  • Environment.LOCAL: Points to http://10.0.2.2:8000/api (Android emulator) or http://127.0.0.1:8000/api (iOS simulator)
  • Environment.DEV: Points to https://mycrib.treytartt.com/api

Change this to switch between local Django 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:

// 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:

  • DoubleKotlinDouble (use KotlinDouble(double:) constructor)
  • IntKotlinInt (use KotlinInt(int:) constructor)
  • String stays String
  • 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/ or composeApp/src/commonTest/
  • iOS: Use XCTest framework in Xcode project

Key Dependencies

  • Kotlin Multiplatform: 2.1.0
  • Compose Multiplatform: 1.7.1
  • Ktor Client: Network requests
  • kotlinx.serialization: JSON serialization
  • kotlinx.coroutines: Async operations
  • SKIE: Kotlin ↔ Swift interop improvements

Important Notes

Committing Changes

When committing changes that span both iOS and Android, commit them together in the KMM repository. If backend changes are needed, commit separately in the myCribAPI repository.

Data Cache Initialization

Critical: After user login, call APILayer.initializeLookups() to populate DataCache with reference data. Without this, dropdowns and pickers will be empty.

// After successful login
val initResult = APILayer.initializeLookups()
if (initResult is ApiResult.Success) {
    // Navigate to main screen
}

iOS Build Issues

If iOS build fails with type mismatch errors:

  1. Check that cost fields (estimatedCost, actualCost, purchasePrice) use KotlinDouble, not String
  2. Verify preview/mock data matches current model signatures
  3. Clean build folder in Xcode (Cmd+Shift+K) and rebuild

Force Refresh Pattern

Always use forceRefresh: true when data should be fresh:

  • After creating/updating/deleting items
  • On pull-to-refresh gestures
  • When explicitly requested by user

Without forceRefresh, APILayer returns cached data.

Project Structure Summary

MyCribKMM/
├── composeApp/
│   └── src/
│       ├── commonMain/kotlin/com/example/mycrib/
│       │   ├── cache/          # DataCache
│       │   ├── models/         # Shared data models
│       │   ├── network/        # APILayer, API clients
│       │   ├── repository/     # Additional data repositories
│       │   ├── storage/        # TokenStorage
│       │   ├── ui/             # Compose UI (Android)
│       │   │   ├── components/ # Reusable components
│       │   │   ├── screens/    # Screen composables
│       │   │   └── theme/      # Material theme
│       │   ├── viewmodel/      # Shared ViewModels
│       │   └── App.kt          # Android navigation
│       ├── androidMain/        # Android-specific code
│       ├── 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 (spacing, colors)
│   ├── Extensions/             # Swift extensions
│   ├── Helpers/                # Utility helpers
│   ├── PushNotifications/      # APNs integration
│   └── [Feature]/              # Feature-grouped files
│       ├── Task/
│       ├── Residence/
│       ├── Contractor/
│       └── Documents/
│
└── gradle/                     # Gradle wrapper and configs
  • Backend API: ../myCribAPI - Django REST Framework backend
  • Load Testing: ../myCribAPI/locust - Locust load testing scripts
  • Documentation: ../myCribAPI/docs - Server configuration guides