Skip to content

Commit 4538a47

Browse files
committed
Modernize the app's theming system by implementing Material 3 (M3) design principles and adding support for dynamic coloring.
1 parent ef45e92 commit 4538a47

10 files changed

Lines changed: 563 additions & 126 deletions

File tree

android_app/app/src/main/java/com/health/openscale/MainActivity.kt

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,9 @@ import android.os.Bundle
2222
import androidx.activity.ComponentActivity
2323
import androidx.activity.compose.setContent
2424
import androidx.activity.enableEdgeToEdge
25-
import androidx.compose.runtime.DisposableEffect
25+
import androidx.compose.foundation.isSystemInDarkTheme
2626
import androidx.compose.runtime.LaunchedEffect
2727
import androidx.compose.runtime.getValue
28-
import androidx.compose.ui.platform.LocalView
29-
import androidx.core.view.WindowCompat
3028
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
3129
import androidx.lifecycle.compose.collectAsStateWithLifecycle
3230
import com.health.openscale.core.facade.SettingsFacade
@@ -58,7 +56,15 @@ class MainActivity : ComponentActivity() {
5856
enableEdgeToEdge()
5957

6058
setContent {
61-
OpenScaleTheme {
59+
// The main UI of the app.
60+
val sharedViewModel: SharedViewModel = hiltViewModel()
61+
val darkTheme = isSystemInDarkTheme()
62+
val useDynamicColor by settingsFacade.useDynamicColor.collectAsStateWithLifecycle(initialValue = true)
63+
64+
OpenScaleTheme(
65+
darkTheme = darkTheme,
66+
useDynamicColor = useDynamicColor,
67+
) {
6268
// For APIs before Android 13 (Tiramisu), we need to manually
6369
// listen for language changes and recreate the activity.
6470
// For API 33+, the system handles this automatically via LocaleManager.
@@ -77,19 +83,6 @@ class MainActivity : ComponentActivity() {
7783
}
7884
}
7985

80-
// The main UI of the app.
81-
val sharedViewModel: SharedViewModel = hiltViewModel()
82-
83-
val view = LocalView.current
84-
if (!view.isInEditMode) {
85-
DisposableEffect(Unit) {
86-
val window = this@MainActivity.window
87-
val insetsController = WindowCompat.getInsetsController(window, view)
88-
insetsController.isAppearanceLightStatusBars = false
89-
insetsController.isAppearanceLightNavigationBars = false
90-
onDispose { }
91-
}
92-
}
9386
AppNavigation(sharedViewModel)
9487
}
9588
}

android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ object SettingsPreferenceKeys {
7171
val CURRENT_USER_ID = intPreferencesKey("current_user_id")
7272
val APP_LANGUAGE_CODE = stringPreferencesKey("app_language_code")
7373
val HAPTIC_ON_MEASUREMENT = booleanPreferencesKey("haptic_on_measurement")
74+
val USE_DYNAMIC_COLOR = booleanPreferencesKey("use_dynamic_color")
7475

7576
// Settings for specific UI components
7677
val SELECTED_TYPES_TABLE = stringSetPreferencesKey("selected_types_table") // IDs of measurement types selected for the data table
@@ -164,6 +165,9 @@ interface SettingsFacade {
164165
val hapticOnMeasurement: Flow<Boolean>
165166
suspend fun setHapticOnMeasurement(value: Boolean)
166167

168+
val useDynamicColor: Flow<Boolean>
169+
suspend fun setUseDynamicColor(enabled: Boolean)
170+
167171
val currentUserId: Flow<Int?>
168172
suspend fun setCurrentUserId(userId: Int?)
169173

@@ -353,6 +357,15 @@ class SettingsFacadeImpl @Inject constructor(
353357
saveSetting(SettingsPreferenceKeys.HAPTIC_ON_MEASUREMENT.name, value)
354358
}
355359

360+
override val useDynamicColor: Flow<Boolean> = observeSetting(
361+
SettingsPreferenceKeys.USE_DYNAMIC_COLOR.name,
362+
false
363+
)
364+
365+
override suspend fun setUseDynamicColor(enabled: Boolean) {
366+
saveSetting(SettingsPreferenceKeys.USE_DYNAMIC_COLOR.name, enabled)
367+
}
368+
356369
override val currentUserId: Flow<Int?> = dataStore.data
357370
.catch { exception ->
358371
LogManager.e(TAG, "Error reading currentUserId from DataStore.", exception)

android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,7 @@ import com.health.openscale.ui.screen.settings.UserDetailScreen
133133
import com.health.openscale.ui.screen.settings.UserSettingsScreen
134134
import com.health.openscale.ui.screen.statistics.StatisticsScreen
135135
import com.health.openscale.ui.screen.table.TableScreen
136-
import com.health.openscale.ui.theme.Black
137-
import com.health.openscale.ui.theme.Blue
138-
import com.health.openscale.ui.theme.White
136+
import com.health.openscale.ui.theme.AppBrandBlue
139137
import kotlinx.coroutines.flow.debounce
140138
import kotlinx.coroutines.flow.distinctUntilChanged
141139
import kotlinx.coroutines.launch
@@ -433,16 +431,13 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
433431
ModalNavigationDrawer(
434432
drawerState = drawerState,
435433
drawerContent = {
436-
ModalDrawerSheet(
437-
drawerContainerColor = Black, // Custom drawer background color
438-
drawerContentColor = White // Custom drawer content color for icons and text
439-
) {
434+
ModalDrawerSheet {
440435
// Drawer Header: Displays the app logo and name.
441436
Row(
442437
verticalAlignment = Alignment.CenterVertically,
443438
modifier = Modifier
444439
.clip(RoundedCornerShape(topEnd = 24.dp)) // Specific rounding for visual style
445-
.background(Blue) // Themed background for the header
440+
.background(AppBrandBlue) // Themed background for the header
446441
.padding(8.dp)
447442
.fillMaxWidth()
448443
) {
@@ -456,14 +451,15 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
456451
Spacer(modifier = Modifier.width(8.dp))
457452
Text(
458453
text = stringResource(id = R.string.app_name),
459-
style = MaterialTheme.typography.titleMedium
454+
style = MaterialTheme.typography.titleMedium,
455+
color = Color.White
460456
)
461457
}
462458
Spacer(modifier = Modifier.height(8.dp)) // Spacing after the header
463459

464460
// Drawer Items: Dynamically created for each main route.
465461
LazyColumn() {
466-
items(mainRoutes, key = {it}) { route ->
462+
items(mainRoutes, key = { it }) { route ->
467463
// Add a divider before the "Settings" item for visual separation.
468464
if (route == Routes.SETTINGS) {
469465
HorizontalDivider(
@@ -493,7 +489,8 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
493489
navController.navigate(route) {
494490
// Pop up to the start destination of the graph to avoid building up a large back stack.
495491
popUpTo(navController.graph.startDestinationId) {
496-
saveState = true // Save the state of popped destinations.
492+
saveState =
493+
true // Save the state of popped destinations.
497494
}
498495
// Avoid multiple copies of the same destination when reselecting the same item.
499496
launchSingleTop = true
@@ -503,14 +500,14 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
503500
scope.launch { drawerState.close() } // Close the drawer after selection.
504501
},
505502
colors = NavigationDrawerItemDefaults.colors(
506-
// Custom colors for selected and unselected drawer items.
507-
selectedIconColor = Blue,
508-
selectedTextColor = Blue,
509-
selectedContainerColor = Color.Transparent, // No background for the selected item itself.
510-
511-
unselectedIconColor = White,
512-
unselectedTextColor = White,
513-
unselectedContainerColor = Color.Transparent
503+
selectedIconColor = MaterialTheme.colorScheme.primary,
504+
selectedTextColor = MaterialTheme.colorScheme.primary,
505+
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer.copy(
506+
alpha = 0.3f
507+
),
508+
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
509+
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
510+
unselectedContainerColor = Color.Transparent,
514511
)
515512
)
516513
}
@@ -525,8 +522,8 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
525522
Snackbar(
526523
modifier = Modifier.padding(8.dp), // Padding around the snackbar.
527524
shape = RoundedCornerShape(8.dp), // Rounded corners for the snackbar.
528-
containerColor = Blue, // Custom background color.
529-
contentColor = White, // Custom text and icon color.
525+
containerColor = MaterialTheme.colorScheme.inverseSurface,
526+
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
530527
) {
531528
Row(verticalAlignment = Alignment.CenterVertically) {
532529
Icon(
@@ -548,10 +545,10 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
548545
overflow = TextOverflow.Ellipsis
549546
) },
550547
colors = TopAppBarDefaults.topAppBarColors(
551-
containerColor = Black,
552-
titleContentColor = White,
553-
navigationIconContentColor = White,
554-
actionIconContentColor = White
548+
containerColor = MaterialTheme.colorScheme.surfaceContainer,
549+
titleContentColor = MaterialTheme.colorScheme.onSurface,
550+
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
551+
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
555552
),
556553
navigationIcon = {
557554
if (currentRoute in mainRoutes) {
@@ -804,7 +801,7 @@ fun AppNavigation(sharedViewModel: SharedViewModel) {
804801
.asPaddingValues()
805802
.calculateBottomPadding() // Calculate its height.
806803
)
807-
.background(Black) // Match TopAppBar color or general theme background.
804+
.background(MaterialTheme.colorScheme.surfaceContainer) // Match TopAppBar color or general theme background.
808805
)
809806
}
810807
}

android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartLayers.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import com.health.openscale.core.data.MeasurementType
3131
import com.health.openscale.core.data.UnitType
3232
import com.health.openscale.core.data.UserGoals
3333
import com.health.openscale.core.utils.LocaleUtils
34-
import com.health.openscale.ui.theme.White
3534
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
3635
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisGuidelineComponent
3736
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
@@ -273,8 +272,9 @@ internal fun rememberGoalLine(goal: UserGoals, type: MeasurementType?): Horizont
273272
val goalColor = type?.let { Color(it.color) } ?: MaterialTheme.colorScheme.onSurface
274273
val goalFill = Fill(goalColor.copy(alpha = 0.7f))
275274
val line = rememberLineComponent(fill = goalFill, thickness = 2.dp)
275+
val labelTextColor = MaterialTheme.colorScheme.onPrimary
276276
val labelComponent = rememberTextComponent(
277-
style = TextStyle(color = White),
277+
style = TextStyle(color = labelTextColor),
278278
margins = Insets(start = 6.dp),
279279
padding = Insets(start = 8.dp, end = 8.dp, bottom = 2.dp, top = 2.dp),
280280
background = ShapeComponent(goalFill, shape = RoundedCornerShape(50)),

android_app/app/src/main/java/com/health/openscale/ui/screen/settings/GeneralSettingsScreen.kt

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import androidx.compose.material.icons.filled.Description
5050
import androidx.compose.material.icons.filled.Info
5151
import androidx.compose.material.icons.filled.Language
5252
import androidx.compose.material.icons.filled.Notifications
53+
import androidx.compose.material.icons.filled.Palette
5354
import androidx.compose.material.icons.filled.Vibration
5455
import androidx.compose.material.icons.filled.Warning
5556
import androidx.compose.material3.AlertDialog
@@ -124,6 +125,7 @@ fun GeneralSettingsScreen(
124125
val currentLanguageCode by sharedViewModel.appLanguageCode.collectAsState(initial = null)
125126
var expandedLanguageMenu by remember { mutableStateOf(false) }
126127
val hapticsEnabled by sharedViewModel.hapticOnMeasurement.collectAsState(initial = false)
128+
val useDynamicColor by sharedViewModel.useDynamicColor.collectAsState(initial = false)
127129

128130
val selectedLanguage: SupportedLanguage = remember(currentLanguageCode, supportedLanguagesEnumEntries) {
129131
val systemDefault = SupportedLanguage.getDefault().code
@@ -352,6 +354,24 @@ fun GeneralSettingsScreen(
352354
}
353355
}
354356

357+
// --- Appearance ---
358+
SettingsSectionTitle(text = stringResource(R.string.settings_appearance_title))
359+
360+
SettingsGroup(
361+
leadingIcon = {
362+
Icon(
363+
imageVector = Icons.Filled.Palette,
364+
contentDescription = null,
365+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
366+
)
367+
},
368+
title = stringResource(R.string.settings_dynamic_color_label),
369+
checked = useDynamicColor,
370+
onCheckedChange = { enabled ->
371+
scope.launch { sharedViewModel.setUseDynamicColor(enabled) }
372+
}
373+
)
374+
355375
// ---- Haptic section ----
356376
SettingsSectionTitle(text = stringResource(R.string.settings_feedback_title))
357377

@@ -375,8 +395,6 @@ fun GeneralSettingsScreen(
375395
context.getString(R.string.settings_haptics_disabled_snackbar)
376396
)
377397
}
378-
},
379-
content = {
380398
}
381399
)
382400

@@ -622,62 +640,64 @@ fun SettingsGroup(
622640
checked: Boolean,
623641
onCheckedChange: (Boolean) -> Unit,
624642
summary: String? = null,
625-
content: @Composable ColumnScope.() -> Unit,
626-
persistentContent: (@Composable ColumnScope.() -> Unit)? = null
643+
content: (@Composable ColumnScope.() -> Unit)? = null,
644+
persistentContent: (@Composable ColumnScope.() -> Unit)? = null,
645+
showCard: Boolean = content != null || persistentContent != null,
627646
) {
628647
val container = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
629648
val borderColor = if (checked)
630649
MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
631650
else
632651
MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)
633652

634-
Column(
635-
modifier = Modifier
653+
val cardModifier = if (showCard) {
654+
Modifier
636655
.fillMaxWidth()
637656
.padding(top = 12.dp)
638-
.then(
639-
Modifier
640-
.clip(MaterialTheme.shapes.medium)
641-
.border(1.dp, borderColor, MaterialTheme.shapes.medium)
642-
.background(container)
643-
)
657+
.clip(MaterialTheme.shapes.medium)
658+
.border(1.dp, borderColor, MaterialTheme.shapes.medium)
659+
.background(container)
644660
.padding(horizontal = 16.dp, vertical = 12.dp)
645-
) {
661+
} else {
662+
Modifier
663+
.fillMaxWidth()
664+
.padding(top = 4.dp, bottom = 4.dp)
665+
}
666+
667+
Column(modifier = cardModifier) {
646668
Row(
647669
modifier = Modifier
648670
.fillMaxWidth()
649671
.padding(vertical = 4.dp)
650672
.semantics { contentDescription = title }
651673
.clickable { onCheckedChange(!checked) },
652674
verticalAlignment = Alignment.CenterVertically,
653-
horizontalArrangement = Arrangement.SpaceBetween
675+
horizontalArrangement = Arrangement.SpaceBetween,
654676
) {
655677
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
656678
leadingIcon?.invoke()
657679
Text(
658-
text = title,
659-
style = MaterialTheme.typography.titleMedium,
660-
modifier = Modifier.padding(start = if (leadingIcon != null) 12.dp else 0.dp)
680+
text = title,
681+
style = MaterialTheme.typography.titleMedium,
682+
modifier = Modifier.padding(start = if (leadingIcon != null) 12.dp else 0.dp),
661683
)
662684
}
663685
Switch(checked = checked, onCheckedChange = onCheckedChange)
664686
}
665687

666688
if (!summary.isNullOrBlank()) {
667689
Text(
668-
text = summary,
669-
style = MaterialTheme.typography.bodySmall,
670-
color = MaterialTheme.colorScheme.onSurfaceVariant,
671-
modifier = Modifier.padding(top = 4.dp)
690+
text = summary,
691+
style = MaterialTheme.typography.bodySmall,
692+
color = MaterialTheme.colorScheme.onSurfaceVariant,
693+
modifier = Modifier.padding(top = 4.dp),
672694
)
673695
}
674696

675697
if (checked) {
676-
content()
698+
content?.invoke(this)
677699
}
678700

679-
if (persistentContent != null) {
680-
persistentContent()
681-
}
701+
persistentContent?.invoke(this)
682702
}
683-
}
703+
}

0 commit comments

Comments
 (0)