diff --git a/replica/clone-screens/S08-settings-dark.png b/replica/clone-screens/S08-settings-dark.png new file mode 100644 index 0000000..0c4ab0a Binary files /dev/null and b/replica/clone-screens/S08-settings-dark.png differ diff --git a/replica/clone-screens/S08-settings.png b/replica/clone-screens/S08-settings.png index 5e78758..1992e2b 100644 Binary files a/replica/clone-screens/S08-settings.png and b/replica/clone-screens/S08-settings.png differ diff --git a/replica/design/Tokens.kt b/replica/design/Tokens.kt index 3b7e5a8..803858d 100644 --- a/replica/design/Tokens.kt +++ b/replica/design/Tokens.kt @@ -184,6 +184,8 @@ object WColor { val widgetGraphiteDarkOnpanel = Color(0xFFEAEBEB) val widgetGraphiteDarkBadge = Color(0xFF3F444A) val widgetGraphiteDarkOnbadge = Color(0xFFE9EAEC) + val darkAccentContainer = Color(0xFF244A80) + val darkOnAccentContainer = Color(0xFFD6E3FF) } object WType { diff --git a/replica/design/tokens.json b/replica/design/tokens.json index 515357e..5135a5f 100644 --- a/replica/design/tokens.json +++ b/replica/design/tokens.json @@ -176,7 +176,9 @@ "widget-graphite-dark-panel": "#111212", "widget-graphite-dark-onpanel": "#eaebeb", "widget-graphite-dark-badge": "#3f444a", - "widget-graphite-dark-onbadge": "#e9eaec" + "widget-graphite-dark-onbadge": "#e9eaec", + "dark-accent-container": "#244a80", + "dark-on-accent-container": "#d6e3ff" }, "pairs": [ [ @@ -626,6 +628,10 @@ [ "widget-graphite-dark-onbadge", "widget-graphite-dark-badge" + ], + [ + "dark-on-accent-container", + "dark-accent-container" ] ], "font": { diff --git a/wetter/app/src/main/java/app/wetter/ui/components/SettingsKit.kt b/wetter/app/src/main/java/app/wetter/ui/components/SettingsKit.kt new file mode 100644 index 0000000..a826238 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/ui/components/SettingsKit.kt @@ -0,0 +1,161 @@ +package app.wetter.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import app.wetter.ui.theme.WSpace +import app.wetter.ui.theme.WType + +/* + * Settings in the style of current Pixel system settings: grouped tiles with large outer + * corners and small inner ones, 2dp gaps, tonal icon circles, values as summaries, + * choices in dialogs. + */ + +private val Outer = 24.dp +private val Inner = 4.dp + +private fun tileShape(index: Int, count: Int): Shape = when { + count == 1 -> RoundedCornerShape(Outer) + index == 0 -> RoundedCornerShape(topStart = Outer, topEnd = Outer, bottomStart = Inner, bottomEnd = Inner) + index == count - 1 -> RoundedCornerShape(topStart = Inner, topEnd = Inner, bottomStart = Outer, bottomEnd = Outer) + else -> RoundedCornerShape(Inner) +} + +class GroupScope internal constructor() { + internal val items = mutableListOf<@Composable (Shape) -> Unit>() + fun item(content: @Composable (Shape) -> Unit) { items += content } +} + +@Composable +fun SettingsGroup(title: String? = null, modifier: Modifier = Modifier, content: GroupScope.() -> Unit) { + val scope = GroupScope().apply(content) + Column(modifier.padding(horizontal = WSpace.s16)) { + if (title != null) { + Text( + title, style = WType.label.copy(fontSize = WType.bodySm.fontSize), color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = WSpace.s16, top = WSpace.s24, bottom = WSpace.s8).semantics { heading() }, + ) + } else Spacer(Modifier.padding(top = WSpace.s16)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + scope.items.forEachIndexed { i, item -> item(tileShape(i, scope.items.size)) } + } + } +} + +@Composable +private fun Tile(shape: Shape, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Box( + modifier.fillMaxWidth().clip(shape).background(MaterialTheme.colorScheme.surfaceContainerLowest).heightIn(min = 72.dp), + contentAlignment = Alignment.CenterStart, + ) { content() } +} + +@Composable +private fun RowContent(icon: ImageVector?, title: String, summary: String?, trailing: (@Composable () -> Unit)?) { + Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16, vertical = WSpace.s16), verticalAlignment = Alignment.CenterVertically) { + if (icon != null) { + Box(Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) { + Icon(icon, null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(22.dp)) + } + Spacer(Modifier.width(WSpace.s16)) + } + Column(Modifier.weight(1f)) { + Text(title, style = WType.body, color = MaterialTheme.colorScheme.onSurface) + if (summary != null) Text(summary, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (trailing != null) { Spacer(Modifier.width(WSpace.s12)); trailing() } + } +} + +@Composable +fun SettingsItem(shape: Shape, title: String, summary: String? = null, icon: ImageVector? = null, onClick: (() -> Unit)? = null, trailing: (@Composable () -> Unit)? = null) { + Tile(shape, if (onClick != null) Modifier.clip(shape).clickable(onClick = onClick) else Modifier) { RowContent(icon, title, summary, trailing) } +} + +@Composable +fun SettingsSwitch(shape: Shape, title: String, checked: Boolean, onChange: (Boolean) -> Unit, summary: String? = null, icon: ImageVector? = null) { + Tile(shape, Modifier.clip(shape).toggleable(checked, role = Role.Switch, onValueChange = onChange)) { + RowContent(icon, title, summary) { + Switch( + checked, onCheckedChange = null, + thumbContent = if (checked) { { Icon(Icons.Rounded.Check, null, Modifier.size(SwitchDefaults.IconSize)) } } else null, + ) + } + } +} + +/** A tile whose value is picked from a list in a dialog, like Pixel's radio settings. */ +@Composable +fun SettingsChoice(shape: Shape, title: String, options: List, selected: T, label: (T) -> String, onPick: (T) -> Unit, icon: ImageVector? = null) { + var open by remember { mutableStateOf(false) } + SettingsItem(shape, title, label(selected), icon, onClick = { open = true }) + if (open) { + AlertDialog( + onDismissRequest = { open = false }, + title = { Text(title) }, + text = { + Column(Modifier.selectableGroup()) { + options.forEach { o -> + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)) + .selectable(o == selected, role = Role.RadioButton) { onPick(o); open = false } + .padding(vertical = WSpace.s12, horizontal = WSpace.s4), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(o == selected, onClick = null) + Spacer(Modifier.width(WSpace.s16)) + Text(label(o), style = WType.body) + } + } + } + }, + confirmButton = { TextButton(onClick = { open = false }) { Text(androidx.compose.ui.res.stringResource(android.R.string.cancel)) } }, + ) + } +} + +/** Free content inside a group tile (swatches, sliders). */ +@Composable +fun SettingsCustom(shape: Shape, content: @Composable ColumnScope.() -> Unit) { + Tile(shape) { Column(Modifier.fillMaxWidth().padding(WSpace.s16), content = content) } +} diff --git a/wetter/app/src/main/java/app/wetter/ui/screens/SettingsScreen.kt b/wetter/app/src/main/java/app/wetter/ui/screens/SettingsScreen.kt index e9f3aa7..a998386 100644 --- a/wetter/app/src/main/java/app/wetter/ui/screens/SettingsScreen.kt +++ b/wetter/app/src/main/java/app/wetter/ui/screens/SettingsScreen.kt @@ -1,34 +1,35 @@ package app.wetter.ui.screens -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.outlined.Air +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Compress +import androidx.compose.material.icons.outlined.DarkMode +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Notes +import androidx.compose.material.icons.outlined.Thermostat +import androidx.compose.material.icons.outlined.Umbrella +import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.semantics import app.wetter.R import app.wetter.data.DistanceUnit import app.wetter.data.Graph @@ -38,8 +39,10 @@ import app.wetter.data.Settings import app.wetter.data.TempUnit import app.wetter.data.ThemeMode import app.wetter.data.WindUnit -import app.wetter.ui.theme.WSpace -import app.wetter.ui.theme.WType +import app.wetter.ui.components.SettingsChoice +import app.wetter.ui.components.SettingsGroup +import app.wetter.ui.components.SettingsItem +import app.wetter.ui.components.SettingsSwitch import kotlinx.coroutines.launch /** S08 + S09: units, look, data sources. */ @@ -48,73 +51,48 @@ import kotlinx.coroutines.launch fun SettingsScreen(settings: Settings, onBack: () -> Unit) { val scope = rememberCoroutineScope() val set: ((Settings) -> Settings) -> Unit = { t -> scope.launch { Graph.settings.update(t) } } + val bar = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + val themeLabels = mapOf( + ThemeMode.SYSTEM to stringResource(R.string.theme_system), + ThemeMode.LIGHT to stringResource(R.string.theme_light), + ThemeMode.DARK to stringResource(R.string.theme_dark), + ) Scaffold( + modifier = Modifier.nestedScroll(bar.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { - TopAppBar( + LargeTopAppBar( title = { Text(stringResource(R.string.settings)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) } }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + scrollBehavior = bar, ) }, ) { pad -> - Column(Modifier.padding(pad).verticalScroll(rememberScrollState()).padding(horizontal = WSpace.s16)) { - Header(stringResource(R.string.set_units)) - Choice(stringResource(R.string.set_temp), TempUnit.entries, settings.temp, { if (it == TempUnit.C) "°C" else "°F" }) { v -> set { it.copy(temp = v) } } - Choice(stringResource(R.string.set_wind), WindUnit.entries, settings.wind, { - when (it) { WindUnit.KMH -> "km/h"; WindUnit.MS -> "m/s"; WindUnit.MPH -> "mph"; WindUnit.KN -> "kn" } - }) { v -> set { it.copy(wind = v) } } - Choice(stringResource(R.string.set_precip), PrecipUnit.entries, settings.precip, { if (it == PrecipUnit.MM) "mm" else "in" }) { v -> set { it.copy(precip = v) } } - Choice(stringResource(R.string.set_pressure), PressureUnit.entries, settings.pressure, { if (it == PressureUnit.HPA) "hPa" else "inHg" }) { v -> set { it.copy(pressure = v) } } - Choice(stringResource(R.string.set_distance), DistanceUnit.entries, settings.distance, { if (it == DistanceUnit.KM) "km" else "mi" }) { v -> set { it.copy(distance = v) } } - - Header(stringResource(R.string.set_look)) - val themeLabels = mapOf( - ThemeMode.SYSTEM to stringResource(R.string.theme_system), - ThemeMode.LIGHT to stringResource(R.string.theme_light), - ThemeMode.DARK to stringResource(R.string.theme_dark), - ) - Choice(stringResource(R.string.set_theme), ThemeMode.entries, settings.theme, { themeLabels.getValue(it) }) { v -> set { it.copy(theme = v) } } - Toggle(stringResource(R.string.set_animations), stringResource(R.string.set_animations_sub), settings.animations) { v -> set { it.copy(animations = v) } } - Toggle(stringResource(R.string.set_brief), null, settings.brief) { v -> set { it.copy(brief = v) } } - - Header(stringResource(R.string.set_about)) - Text(stringResource(R.string.attribution), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = WSpace.s32)) - } - } -} - -@Composable -private fun Header(text: String) = Text( - text, style = WType.title, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = WSpace.s24, bottom = WSpace.s8).semantics { heading() }, -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun Choice(label: String, options: List, selected: T, name: (T) -> String, onPick: (T) -> Unit) { - Column(Modifier.padding(vertical = WSpace.s8)) { - Text(label, style = WType.body) - SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(top = WSpace.s8).selectableGroup()) { - options.forEachIndexed { i, o -> - SegmentedButton( - selected = o == selected, - onClick = { onPick(o) }, - shape = SegmentedButtonDefaults.itemShape(i, options.size), - ) { Text(name(o)) } + androidx.compose.foundation.layout.Column(Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).navigationBarsPadding()) { + SettingsGroup(stringResource(R.string.set_units)) { + item { SettingsChoice(it, stringResource(R.string.set_temp), TempUnit.entries, settings.temp, { u -> if (u == TempUnit.C) "Celsius (°C)" else "Fahrenheit (°F)" }, { v -> set { s -> s.copy(temp = v) } }, Icons.Outlined.Thermostat) } + item { + SettingsChoice(it, stringResource(R.string.set_wind), WindUnit.entries, settings.wind, { u -> + when (u) { WindUnit.KMH -> "km/h"; WindUnit.MS -> "m/s"; WindUnit.MPH -> "mph"; WindUnit.KN -> "Knoten (kn)" } + }, { v -> set { s -> s.copy(wind = v) } }, Icons.Outlined.Air) + } + item { SettingsChoice(it, stringResource(R.string.set_precip), PrecipUnit.entries, settings.precip, { u -> if (u == PrecipUnit.MM) "Millimeter (mm)" else "Zoll (in)" }, { v -> set { s -> s.copy(precip = v) } }, Icons.Outlined.Umbrella) } + item { SettingsChoice(it, stringResource(R.string.set_pressure), PressureUnit.entries, settings.pressure, { u -> if (u == PressureUnit.HPA) "Hektopascal (hPa)" else "Zoll Quecksilber (inHg)" }, { v -> set { s -> s.copy(pressure = v) } }, Icons.Outlined.Compress) } + item { SettingsChoice(it, stringResource(R.string.set_distance), DistanceUnit.entries, settings.distance, { u -> if (u == DistanceUnit.KM) "Kilometer (km)" else "Meilen (mi)" }, { v -> set { s -> s.copy(distance = v) } }, Icons.Outlined.Visibility) } } + SettingsGroup(stringResource(R.string.set_look)) { + item { SettingsChoice(it, stringResource(R.string.set_theme), ThemeMode.entries, settings.theme, { m -> themeLabels.getValue(m) }, { v -> set { s -> s.copy(theme = v) } }, Icons.Outlined.DarkMode) } + item { SettingsSwitch(it, stringResource(R.string.set_animations), settings.animations, { v -> set { s -> s.copy(animations = v) } }, stringResource(R.string.set_animations_sub), Icons.Outlined.AutoAwesome) } + item { SettingsSwitch(it, stringResource(R.string.set_brief), settings.brief, { v -> set { s -> s.copy(brief = v) } }, null, Icons.Outlined.Notes) } + } + SettingsGroup(stringResource(R.string.set_about)) { + item { SettingsItem(it, stringResource(R.string.set_about), stringResource(R.string.attribution), Icons.Outlined.Info) } + } + androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = app.wetter.ui.theme.WSpace.s32)) } } } - -@Composable -private fun Toggle(label: String, sub: String?, checked: Boolean, onChange: (Boolean) -> Unit) { - Row( - Modifier.fillMaxWidth().toggleable(checked, role = Role.Switch, onValueChange = onChange).padding(vertical = WSpace.s12), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text(label, style = WType.body) - if (sub != null) Text(sub, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Switch(checked, onCheckedChange = null) - } -} diff --git a/wetter/app/src/main/java/app/wetter/ui/theme/Theme.kt b/wetter/app/src/main/java/app/wetter/ui/theme/Theme.kt index 685d039..47f4023 100644 --- a/wetter/app/src/main/java/app/wetter/ui/theme/Theme.kt +++ b/wetter/app/src/main/java/app/wetter/ui/theme/Theme.kt @@ -63,15 +63,18 @@ private val LightScheme = lightColorScheme( background = WColor.bg, onBackground = WColor.text, surface = WColor.surface, onSurface = WColor.text, onSurfaceVariant = WColor.textMuted, surfaceContainer = WColor.surfaceContainer, surfaceContainerHigh = WColor.surfaceContainerHigh, + surfaceContainerLowest = WColor.surface, outline = WColor.borderInput, outlineVariant = WColor.border, error = WColor.danger, ) private val DarkScheme = darkColorScheme( primary = WColor.darkAccent, onPrimary = WColor.darkOnAccent, + primaryContainer = WColor.darkAccentContainer, onPrimaryContainer = WColor.darkOnAccentContainer, secondaryContainer = WColor.darkFillPrimary, onSecondaryContainer = WColor.darkOnFillPrimary, background = WColor.darkBg, onBackground = WColor.darkText, surface = WColor.darkSurface, onSurface = WColor.darkText, onSurfaceVariant = WColor.darkTextMuted, - surfaceContainer = WColor.darkSurfaceContainer, surfaceContainerHigh = WColor.darkSurfaceContainer, + surfaceContainer = WColor.darkBg, surfaceContainerHigh = WColor.darkSurfaceContainer, + surfaceContainerLowest = WColor.darkSurfaceContainer, outline = WColor.darkBorderInput, outlineVariant = WColor.darkBorder, error = WColor.darkDanger, ) diff --git a/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt b/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt index 3b7e5a8..803858d 100644 --- a/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt +++ b/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt @@ -184,6 +184,8 @@ object WColor { val widgetGraphiteDarkOnpanel = Color(0xFFEAEBEB) val widgetGraphiteDarkBadge = Color(0xFF3F444A) val widgetGraphiteDarkOnbadge = Color(0xFFE9EAEC) + val darkAccentContainer = Color(0xFF244A80) + val darkOnAccentContainer = Color(0xFFD6E3FF) } object WType { diff --git a/wetter/app/src/main/java/app/wetter/widget/WidgetConfigActivity.kt b/wetter/app/src/main/java/app/wetter/widget/WidgetConfigActivity.kt index f3cc1cd..e289453 100644 --- a/wetter/app/src/main/java/app/wetter/widget/WidgetConfigActivity.kt +++ b/wetter/app/src/main/java/app/wetter/widget/WidgetConfigActivity.kt @@ -72,6 +72,14 @@ import app.wetter.ui.theme.WType import app.wetter.ui.theme.WeatherTheme import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch +import androidx.compose.material.icons.outlined.DarkMode +import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material3.Slider +import app.wetter.ui.components.SettingsChoice +import app.wetter.ui.components.SettingsCustom +import app.wetter.ui.components.SettingsGroup +import app.wetter.ui.components.WShapes +import kotlin.math.roundToInt /** Opens when a widget is added, and again from the launcher's "customise widget". */ class WidgetConfigActivity : ComponentActivity() { @@ -132,7 +140,15 @@ private fun ConfigScreen(appWidgetId: Int, widget: GlanceAppWidget, size: DpSize WidgetPreviewOverride.config = null } - Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.widget_customize)) }) }) { pad -> + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.widget_customize)) }, + colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) + }, + ) { pad -> Column(Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState())) { Box( Modifier.fillMaxWidth().height(size.height + 48.dp).padding(horizontal = WSpace.s16).clip(RoundedCornerShape(28.dp)) @@ -148,61 +164,61 @@ private fun ConfigScreen(appWidgetId: Int, widget: GlanceAppWidget, size: DpSize } } - Section(stringResource(R.string.widget_location)) - Row(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = WSpace.s16), horizontalArrangement = Arrangement.spacedBy(WSpace.s8)) { - FilterChip(selected = config.locationId == null, onClick = { config = config.copy(locationId = null) }, label = { Text(stringResource(R.string.widget_follow)) }) - locations.filter { !it.isCurrent }.forEach { loc -> - FilterChip(selected = config.locationId == loc.id, onClick = { config = config.copy(locationId = loc.id) }, label = { Text(loc.name) }) - } - } - - Section(stringResource(R.string.set_theme)) val themeLabels = listOf(R.string.theme_system, R.string.theme_light, R.string.theme_dark).map { stringResource(it) } - SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16)) { - WidgetStyle.THEMES.forEachIndexed { i, key -> - SegmentedButton(config.theme == key, { config = config.copy(theme = key) }, SegmentedButtonDefaults.itemShape(i, WidgetStyle.THEMES.size)) { Text(themeLabels[i]) } + val follow = stringResource(R.string.widget_follow) + val names = context.resources.getStringArray(R.array.widget_palettes) + val choices = listOf(null) + locations.filter { !it.isCurrent }.map { it.id } + SettingsGroup(stringResource(R.string.widget_content)) { + item { + SettingsChoice(it, stringResource(R.string.widget_location), choices, config.locationId, + { id -> if (id == null) follow else locations.firstOrNull { l -> l.id == id }?.name.orEmpty() }, + { id -> config = config.copy(locationId = id) }, Icons.Outlined.LocationOn) } } - - Section(stringResource(R.string.widget_colors)) - val names = context.resources.getStringArray(R.array.widget_palettes) - Row(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = WSpace.s16), horizontalArrangement = Arrangement.spacedBy(WSpace.s12)) { - WidgetStyle.PALETTES.forEachIndexed { i, key -> - val swatch = resolvePalette(context, WidgetStyle(key, config.theme, 100), null) - val selected = config.style == key - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(64.dp)) { - Box( - Modifier.size(52.dp).clip(CircleShape) - .background(Brush.linearGradient(listOf(swatch.bg, swatch.accent))) - .border(if (selected) 3.dp else 1.dp, if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, CircleShape) - .clickable { config = config.copy(style = key) } - .semantics { contentDescription = names[i]; this.selected = selected }, - contentAlignment = Alignment.Center, - ) { if (selected) Icon(Icons.Rounded.Check, null, tint = swatch.onBg) } - Text(names[i], style = WType.labelSm, maxLines = 1) + SettingsGroup(stringResource(R.string.set_look)) { + item { + SettingsChoice(it, stringResource(R.string.set_theme), WidgetStyle.THEMES, config.theme, + { k -> themeLabels[WidgetStyle.THEMES.indexOf(k)] }, { k -> config = config.copy(theme = k) }, Icons.Outlined.DarkMode) + } + item { + SettingsCustom(it) { + Text(stringResource(R.string.widget_colors), style = WType.body) + Text(names[WidgetStyle.PALETTES.indexOf(config.style).coerceAtLeast(0)], style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.horizontalScroll(rememberScrollState()).padding(top = WSpace.s12), horizontalArrangement = Arrangement.spacedBy(WSpace.s12)) { + WidgetStyle.PALETTES.forEachIndexed { i, key -> + val swatch = resolvePalette(context, WidgetStyle(key, config.theme, 100), null) + val selected = config.style == key + Box( + Modifier.size(48.dp).clip(if (selected) WShapes.cookie8 else CircleShape) + .background(Brush.linearGradient(listOf(swatch.bg, swatch.accent))) + .clickable { config = config.copy(style = key) } + .semantics { contentDescription = names[i]; this.selected = selected }, + contentAlignment = Alignment.Center, + ) { if (selected) Icon(Icons.Rounded.Check, null, tint = swatch.onBg) } + } + } + } + } + item { + SettingsCustom(it) { + Text(stringResource(R.string.widget_opacity), style = WType.body) + Text("${config.opacity} %", style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) + val levels = WidgetStyle.OPACITIES.sorted() + Slider( + value = levels.indexOf(config.opacity).coerceAtLeast(0).toFloat(), + onValueChange = { v -> config = config.copy(opacity = levels[v.roundToInt()]) }, + valueRange = 0f..(levels.size - 1).toFloat(), + steps = levels.size - 2, + ) + if (config.opacity <= 25) Text(stringResource(R.string.widget_opacity_hint), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } - Section(stringResource(R.string.widget_opacity)) - SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16)) { - WidgetStyle.OPACITIES.forEachIndexed { i, o -> - SegmentedButton(config.opacity == o, { config = config.copy(opacity = o) }, SegmentedButtonDefaults.itemShape(i, WidgetStyle.OPACITIES.size)) { Text("$o %") } - } - } - if (config.opacity <= 25) { - Text(stringResource(R.string.widget_opacity_hint), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = WSpace.s16, vertical = WSpace.s8)) - } - - Button(onClick = { onDone(config) }, enabled = loaded, modifier = Modifier.fillMaxWidth().padding(WSpace.s16).navigationBarsPadding()) { + Button(onClick = { onDone(config) }, enabled = loaded, modifier = Modifier.fillMaxWidth().padding(horizontal = WSpace.s16, vertical = WSpace.s24).navigationBarsPadding().height(56.dp)) { Text(stringResource(R.string.widget_done)) } } } } -@Composable -private fun Section(title: String) = Text( - title, style = WType.title, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = WSpace.s16, end = WSpace.s16, top = WSpace.s24, bottom = WSpace.s8), -) diff --git a/wetter/app/src/main/res/values/strings.xml b/wetter/app/src/main/res/values/strings.xml index 489a1ce..38c546a 100644 --- a/wetter/app/src/main/res/values/strings.xml +++ b/wetter/app/src/main/res/values/strings.xml @@ -125,6 +125,7 @@ Ort, Wetterlage und die nächsten Stunden Widget anpassen Ort + Inhalt Farben Deckkraft des Hintergrunds Bei wenig Deckkraft hängt die Lesbarkeit vom Hintergrundbild ab. diff --git a/wetter/app/src/test/java/app/wetter/ScreenshotTest.kt b/wetter/app/src/test/java/app/wetter/ScreenshotTest.kt index ae99030..2fc0c4a 100644 --- a/wetter/app/src/test/java/app/wetter/ScreenshotTest.kt +++ b/wetter/app/src/test/java/app/wetter/ScreenshotTest.kt @@ -99,6 +99,7 @@ class ScreenshotTest { @Test fun s15_preview() = shot("S15-preview", ThemeMode.LIGHT) { Home(fixture().with(1, true), "Zwickau", preview = true) } @Test fun s08_settings() = shot("S08-settings", ThemeMode.LIGHT) { SettingsScreen(Settings(), onBack = {}) } + @Test fun s08_settings_dark() = shot("S08-settings-dark", ThemeMode.DARK) { SettingsScreen(Settings(), onBack = {}) } @Config(qualifiers = "de-rDE-w840dp-h900dp-xxhdpi") @Test fun s01_tablet() = shot("S01-tablet", ThemeMode.LIGHT) { Box { Home(fixture().with(2, true)) } }