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:
340
CLAUDE.md
340
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<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`):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user