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>
This commit is contained in:
Trey t
2025-11-22 10:53:00 -06:00
parent f1f71224aa
commit 15fac54f14
7 changed files with 476 additions and 6 deletions

340
CLAUDE.md
View File

@@ -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<String?>(null) }
FormTextField(
value = text,
onValueChange = { text = it },
label = "Property Name",
placeholder = "Enter name",
leadingIcon = Icons.Default.Home,
error = error,
helperText = "This will be displayed on your dashboard",
keyboardType = KeyboardType.Text
)
```
**4. FormSection - Group related form fields:**
```kotlin
FormSection(
header = "Property Details",
footer = "Enter the basic information about your property"
) {
FormTextField(value = name, onValueChange = { name = it }, label = "Name")
FormTextField(value = address, onValueChange = { address = it }, label = "Address")
}
```
**5. StandardEmptyState - Consistent empty states:**
```kotlin
if (items.isEmpty()) {
StandardEmptyState(
icon = Icons.Default.Home,
title = "No Properties",
subtitle = "Add your first property to get started",
actionLabel = "Add Property",
onAction = { navigateToAddProperty() }
)
}
```
#### Screen Patterns
**Standard Screen Structure:**
```kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MyScreen(
onNavigateBack: () -> Unit,
viewModel: MyViewModel = viewModel { MyViewModel() }
) {
val state by viewModel.state.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Title", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
// Content with proper padding
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
when (state) {
is ApiResult.Success -> {
// Content
}
is ApiResult.Loading -> {
CircularProgressIndicator()
}
is ApiResult.Error -> {
ErrorCard(message = state.message)
}
}
}
}
}
```
**List Screen with Pull-to-Refresh:**
```kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListScreen() {
var isRefreshing by remember { mutableStateOf(false) }
val items by viewModel.items.collectAsState()
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.loadItems(forceRefresh = true)
}
) {
LazyColumn {
items(items) { item ->
StandardCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(item) }
) {
// Item content
}
}
}
}
}
```
#### Button Patterns
```kotlin
// Primary Action Button
Button(
onClick = { /* action */ },
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(AppRadius.md)
) {
Icon(Icons.Default.Save, null)
Spacer(Modifier.width(AppSpacing.sm))
Text("Save Changes", fontWeight = FontWeight.SemiBold)
}
// Destructive Button
Button(
onClick = { /* action */ },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Icon(Icons.Default.Delete, null)
Text("Delete")
}
// Text Button
TextButton(onClick = { /* action */ }) {
Text("Cancel")
}
```
#### Dialog Pattern
```kotlin
@Composable
fun ThemePickerDialog(
currentTheme: ThemeColors,
onThemeSelected: (ThemeColors) -> Unit,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Card(
shape = RoundedCornerShape(AppRadius.lg),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background
)
) {
Column(modifier = Modifier.padding(AppSpacing.xl)) {
Text(
"Choose Theme",
style = MaterialTheme.typography.headlineSmall
)
// Content...
}
}
}
}
```
#### Key Design Principles
1. **Always use theme-aware colors** from MaterialTheme.colorScheme
2. **Always use spacing constants** from AppSpacing/AppRadius
3. **Use standard components** (StandardCard, FormTextField, etc.) for consistency
4. **Follow Material3 guidelines** for component usage
5. **Support dynamic theming** - never assume a specific theme
6. **Test in both light and dark mode** - all themes support both
## Environment Configuration
**API Environment Toggle** (`composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiConfig.kt`):

View File

@@ -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)

View File

@@ -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 }
}
}
}
}

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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
}
}