diff --git a/CLAUDE.md b/CLAUDE.md index a2f3ec6..9d6c073 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -513,6 +513,346 @@ if items.isEmpty { 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 } } + + MyCribTheme(themeColors = currentTheme) { + // App content + } +} +``` + +**Changing Themes:** +```kotlin +// In ProfileScreen or settings +ThemeManager.setTheme("ocean") // By ID +// or +ThemeManager.setTheme(AppThemes.Ocean) // By object +``` + +**Theme Persistence:** +Themes are automatically persisted using `ThemeStorage` (SharedPreferences on Android, UserDefaults on iOS). Initialize in MainActivity: +```kotlin +ThemeStorage.initialize(ThemeStorageManager.getInstance(applicationContext)) +ThemeManager.initialize() // Loads saved theme +``` + +#### Color System + +**ALWAYS use MaterialTheme.colorScheme instead of hardcoded colors:** + +```kotlin +// ✅ CORRECT +Text( + text = "Hello", + color = MaterialTheme.colorScheme.onBackground +) + +Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.backgroundSecondary + ) +) + +// ❌ WRONG +Text( + text = "Hello", + color = Color(0xFF000000) // Never hardcode colors! +) +``` + +**Available Material3 ColorScheme Properties:** +- `primary`, `onPrimary` - Primary brand color and text on it +- `secondary`, `onSecondary` - Secondary brand color +- `error`, `onError` - Error states +- `background`, `onBackground` - Screen backgrounds +- `surface`, `onSurface` - Card/surface backgrounds +- `surfaceVariant`, `onSurfaceVariant` - Alternative surface colors +- **Custom extensions:** + - `backgroundSecondary` - For cards and elevated surfaces + - `textPrimary`, `textSecondary` - Semantic text colors + +#### Spacing System + +**ALWAYS use AppSpacing constants instead of hardcoded dp values:** + +```kotlin +// ✅ CORRECT +Column( + verticalArrangement = Arrangement.spacedBy(AppSpacing.md) +) { + Box(modifier = Modifier.padding(AppSpacing.lg)) +} + +// ❌ WRONG +Column( + verticalArrangement = Arrangement.spacedBy(12.dp) // Never hardcode spacing! +) +``` + +**Available Spacing:** +```kotlin +AppSpacing.xs // 4.dp - Minimal spacing +AppSpacing.sm // 8.dp - Small spacing +AppSpacing.md // 12.dp - Medium spacing (default) +AppSpacing.lg // 16.dp - Large spacing +AppSpacing.xl // 24.dp - Extra large spacing +``` + +**Available Radius:** +```kotlin +AppRadius.xs // 4.dp +AppRadius.sm // 8.dp +AppRadius.md // 12.dp - Standard card radius +AppRadius.lg // 16.dp +AppRadius.xl // 20.dp +AppRadius.xxl // 24.dp +``` + +#### Standard Components + +**Use the provided standard components for consistency:** + +**1. StandardCard - Primary card component:** +```kotlin +StandardCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = AppSpacing.lg // Default +) { + Text("Card content") + // More content... +} + +// With custom background +StandardCard( + backgroundColor = MaterialTheme.colorScheme.primaryContainer +) { + Text("Highlighted card") +} +``` + +**2. CompactCard - Smaller card variant:** +```kotlin +CompactCard { + Row(horizontalArrangement = Arrangement.SpaceBetween) { + Text("Title") + Icon(Icons.Default.ChevronRight, null) + } +} +``` + +**3. FormTextField - Standardized input field:** +```kotlin +var text by remember { mutableStateOf("") } +var error by remember { mutableStateOf(null) } + +FormTextField( + value = text, + onValueChange = { text = it }, + label = "Property Name", + placeholder = "Enter name", + leadingIcon = Icons.Default.Home, + error = error, + helperText = "This will be displayed on your dashboard", + keyboardType = KeyboardType.Text +) +``` + +**4. FormSection - Group related form fields:** +```kotlin +FormSection( + header = "Property Details", + footer = "Enter the basic information about your property" +) { + FormTextField(value = name, onValueChange = { name = it }, label = "Name") + FormTextField(value = address, onValueChange = { address = it }, label = "Address") +} +``` + +**5. StandardEmptyState - Consistent empty states:** +```kotlin +if (items.isEmpty()) { + StandardEmptyState( + icon = Icons.Default.Home, + title = "No Properties", + subtitle = "Add your first property to get started", + actionLabel = "Add Property", + onAction = { navigateToAddProperty() } + ) +} +``` + +#### Screen Patterns + +**Standard Screen Structure:** +```kotlin +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MyScreen( + onNavigateBack: () -> Unit, + viewModel: MyViewModel = viewModel { MyViewModel() } +) { + val state by viewModel.state.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Title", fontWeight = FontWeight.SemiBold) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + // Content with proper padding + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md), + verticalArrangement = Arrangement.spacedBy(AppSpacing.md) + ) { + when (state) { + is ApiResult.Success -> { + // Content + } + is ApiResult.Loading -> { + CircularProgressIndicator() + } + is ApiResult.Error -> { + ErrorCard(message = state.message) + } + } + } + } +} +``` + +**List Screen with Pull-to-Refresh:** +```kotlin +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListScreen() { + var isRefreshing by remember { mutableStateOf(false) } + val items by viewModel.items.collectAsState() + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { + isRefreshing = true + viewModel.loadItems(forceRefresh = true) + } + ) { + LazyColumn { + items(items) { item -> + StandardCard( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick(item) } + ) { + // Item content + } + } + } + } +} +``` + +#### Button Patterns + +```kotlin +// Primary Action Button +Button( + onClick = { /* action */ }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(AppRadius.md) +) { + Icon(Icons.Default.Save, null) + Spacer(Modifier.width(AppSpacing.sm)) + Text("Save Changes", fontWeight = FontWeight.SemiBold) +} + +// Destructive Button +Button( + onClick = { /* action */ }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) +) { + Icon(Icons.Default.Delete, null) + Text("Delete") +} + +// Text Button +TextButton(onClick = { /* action */ }) { + Text("Cancel") +} +``` + +#### Dialog Pattern + +```kotlin +@Composable +fun ThemePickerDialog( + currentTheme: ThemeColors, + onThemeSelected: (ThemeColors) -> Unit, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Card( + shape = RoundedCornerShape(AppRadius.lg), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) { + Column(modifier = Modifier.padding(AppSpacing.xl)) { + Text( + "Choose Theme", + style = MaterialTheme.typography.headlineSmall + ) + // Content... + } + } + } +} +``` + +#### Key Design Principles + +1. **Always use theme-aware colors** from MaterialTheme.colorScheme +2. **Always use spacing constants** from AppSpacing/AppRadius +3. **Use standard components** (StandardCard, FormTextField, etc.) for consistency +4. **Follow Material3 guidelines** for component usage +5. **Support dynamic theming** - never assume a specific theme +6. **Test in both light and dark mode** - all themes support both + ## Environment Configuration **API Environment Toggle** (`composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiConfig.kt`): diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt index 4edb828..4b4d4d9 100644 --- a/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt @@ -26,6 +26,9 @@ import com.example.mycrib.storage.TokenManager import com.example.mycrib.storage.TokenStorage import com.example.mycrib.storage.TaskCacheManager import com.example.mycrib.storage.TaskCacheStorage +import com.example.mycrib.storage.ThemeStorage +import com.example.mycrib.storage.ThemeStorageManager +import com.example.mycrib.ui.theme.ThemeManager import com.example.mycrib.fcm.FCMManager import kotlinx.coroutines.launch @@ -42,6 +45,10 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { // Initialize TaskCacheStorage for offline task caching TaskCacheStorage.initialize(TaskCacheManager.getInstance(applicationContext)) + // Initialize ThemeStorage and ThemeManager + ThemeStorage.initialize(ThemeStorageManager.getInstance(applicationContext)) + ThemeManager.initialize() + // Handle deep link from intent handleDeepLink(intent) diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/storage/ThemeStorageManager.android.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/storage/ThemeStorageManager.android.kt new file mode 100644 index 0000000..1c14a80 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/storage/ThemeStorageManager.android.kt @@ -0,0 +1,37 @@ +package com.example.mycrib.storage + +import android.content.Context +import android.content.SharedPreferences + +/** + * Android implementation of theme storage using SharedPreferences. + */ +actual class ThemeStorageManager(context: Context) { + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + actual fun saveThemeId(themeId: String) { + prefs.edit().putString(KEY_THEME_ID, themeId).apply() + } + + actual fun getThemeId(): String? { + return prefs.getString(KEY_THEME_ID, null) + } + + actual fun clearThemeId() { + prefs.edit().remove(KEY_THEME_ID).apply() + } + + companion object { + private const val PREFS_NAME = "mycrib_theme_prefs" + private const val KEY_THEME_ID = "theme_id" + + @Volatile + private var instance: ThemeStorageManager? = null + + fun getInstance(context: Context): ThemeStorageManager { + return instance ?: synchronized(this) { + instance ?: ThemeStorageManager(context.applicationContext).also { instance = it } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/ThemeStorage.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/ThemeStorage.kt new file mode 100644 index 0000000..7de4546 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/ThemeStorage.kt @@ -0,0 +1,35 @@ +package com.example.mycrib.storage + +/** + * Cross-platform theme storage for persisting theme selection. + * Uses platform-specific implementations (SharedPreferences on Android, UserDefaults on iOS). + */ +object ThemeStorage { + private var manager: ThemeStorageManager? = null + + fun initialize(themeManager: ThemeStorageManager) { + manager = themeManager + } + + fun saveThemeId(themeId: String) { + manager?.saveThemeId(themeId) + } + + fun getThemeId(): String? { + return manager?.getThemeId() + } + + fun clearThemeId() { + manager?.clearThemeId() + } +} + +/** + * Platform-specific theme storage interface. + * Each platform implements this using their native storage mechanisms. + */ +expect class ThemeStorageManager { + fun saveThemeId(themeId: String) + fun getThemeId(): String? + fun clearThemeId() +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/theme/ThemeManager.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/theme/ThemeManager.kt index cab7776..0c1e45c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/theme/ThemeManager.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/theme/ThemeManager.kt @@ -3,11 +3,11 @@ package com.example.mycrib.ui.theme import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import com.example.mycrib.storage.ThemeStorage /** * ThemeManager - Singleton for managing app themes - * Matches iOS ThemeManager functionality - * TODO: Add DataStore persistence + * Matches iOS ThemeManager functionality with persistent storage */ object ThemeManager { private const val DEFAULT_THEME_ID = "default" @@ -19,16 +19,28 @@ object ThemeManager { private set /** - * Set theme by ID + * Initialize theme manager and load saved theme + * Call this after ThemeStorage.initialize() + */ + fun initialize() { + val savedThemeId = ThemeStorage.getThemeId() + if (savedThemeId != null) { + val savedTheme = AppThemes.getThemeById(savedThemeId) + currentTheme = savedTheme + } + } + + /** + * Set theme by ID and persist the selection */ fun setTheme(themeId: String) { val newTheme = AppThemes.getThemeById(themeId) currentTheme = newTheme - // TODO: Persist theme selection + ThemeStorage.saveThemeId(themeId) } /** - * Set theme by ThemeColors object + * Set theme by ThemeColors object and persist the selection */ fun setTheme(theme: ThemeColors) { setTheme(theme.id) @@ -42,7 +54,7 @@ object ThemeManager { } /** - * Reset to default theme + * Reset to default theme and persist the selection */ fun resetToDefault() { setTheme(DEFAULT_THEME_ID) diff --git a/composeApp/src/iosMain/kotlin/com/example/mycrib/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/example/mycrib/MainViewController.kt index b955889..956c147 100644 --- a/composeApp/src/iosMain/kotlin/com/example/mycrib/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/example/mycrib/MainViewController.kt @@ -5,6 +5,9 @@ import com.example.mycrib.storage.TokenManager import com.example.mycrib.storage.TokenStorage import com.example.mycrib.storage.TaskCacheManager import com.example.mycrib.storage.TaskCacheStorage +import com.example.mycrib.storage.ThemeStorage +import com.example.mycrib.storage.ThemeStorageManager +import com.example.mycrib.ui.theme.ThemeManager fun MainViewController() = ComposeUIViewController { // Initialize TokenStorage with iOS TokenManager @@ -13,5 +16,9 @@ fun MainViewController() = ComposeUIViewController { // Initialize TaskCacheStorage for offline task caching TaskCacheStorage.initialize(TaskCacheManager.getInstance()) + // Initialize ThemeStorage and ThemeManager + ThemeStorage.initialize(ThemeStorageManager.getInstance()) + ThemeManager.initialize() + App() } \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/example/mycrib/storage/ThemeStorageManager.ios.kt b/composeApp/src/iosMain/kotlin/com/example/mycrib/storage/ThemeStorageManager.ios.kt new file mode 100644 index 0000000..115fa3c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/mycrib/storage/ThemeStorageManager.ios.kt @@ -0,0 +1,32 @@ +package com.example.mycrib.storage + +import platform.Foundation.NSUserDefaults + +/** + * iOS implementation of theme storage using NSUserDefaults. + */ +actual class ThemeStorageManager { + private val defaults = NSUserDefaults.standardUserDefaults + + actual fun saveThemeId(themeId: String) { + defaults.setObject(themeId, forKey = KEY_THEME_ID) + defaults.synchronize() + } + + actual fun getThemeId(): String? { + return defaults.stringForKey(KEY_THEME_ID) + } + + actual fun clearThemeId() { + defaults.removeObjectForKey(KEY_THEME_ID) + defaults.synchronize() + } + + companion object { + private const val KEY_THEME_ID = "theme_id" + + private val instance by lazy { ThemeStorageManager() } + + fun getInstance(): ThemeStorageManager = instance + } +}