Document the 5-color semantic design system, including color palette reference, usage guidelines, and complete patterns for creating new views, cards, buttons, and UI components. Add critical styling rules for Form/List views with proper background colors and row styling requirements. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
21 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:
-
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
forceRefreshparameter - Returns
ApiResult<T>(Success/Error/Loading states)
-
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
MutableStateobjects that UI can observe directly - Cleared on logout
-
TokenStorage (
storage/TokenStorage.kt)- Platform-specific secure token storage
- Android: EncryptedSharedPreferences
- iOS: Keychain
- All API calls automatically include token from TokenStorage
-
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.
-
Navigation (
navigation/)- Type-safe navigation using kotlinx.serialization
- Routes defined as
@Serializabledata 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 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
}
}
}
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.
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 tohttp://10.0.2.2:8000/api(Android emulator) orhttp://127.0.0.1:8000/api(iOS simulator)Environment.DEV: Points tohttps://mycrib.treytartt.com/api
Change this to switch between local Django 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
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 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:
- 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
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
Related Repositories
- Backend API:
../myCribAPI- Django REST Framework backend - Load Testing:
../myCribAPI/locust- Locust load testing scripts - Documentation:
../myCribAPI/docs- Server configuration guides