ui: Pixel-style settings (grouped tiles, tonal icons, dialogs, check switches) for app and widget config

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 14:28:19 +02:00
co-authored by Claude Opus 5.5
parent 0c42d8570b
commit 79e6031823
11 changed files with 293 additions and 123 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 148 KiB

+2
View File
@@ -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 {
+7 -1
View File
@@ -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": {
@@ -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 <T> SettingsChoice(shape: Shape, title: String, options: List<T>, 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) }
}
@@ -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) } }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings)) },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) } },
)
},
) { 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 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),
)
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() },
Scaffold(
modifier = Modifier.nestedScroll(bar.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
topBar = {
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,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> Choice(label: String, options: List<T>, 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)) }
},
) { pad ->
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)
}
}
@@ -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,
)
@@ -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 {
@@ -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]) }
}
}
Section(stringResource(R.string.widget_colors))
val follow = stringResource(R.string.widget_follow)
val names = context.resources.getStringArray(R.array.widget_palettes)
Row(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = WSpace.s16), horizontalArrangement = Arrangement.spacedBy(WSpace.s12)) {
val choices = listOf<Long?>(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)
}
}
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
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(64.dp)) {
Box(
Modifier.size(52.dp).clip(CircleShape)
Modifier.size(48.dp).clip(if (selected) WShapes.cookie8 else 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)
}
}
}
}
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),
)
@@ -125,6 +125,7 @@
<string name="widget_classic_desc">Ort, Wetterlage und die nächsten Stunden</string>
<string name="widget_customize">Widget anpassen</string>
<string name="widget_location">Ort</string>
<string name="widget_content">Inhalt</string>
<string name="widget_colors">Farben</string>
<string name="widget_opacity">Deckkraft des Hintergrunds</string>
<string name="widget_opacity_hint">Bei wenig Deckkraft hängt die Lesbarkeit vom Hintergrundbild ab.</string>
@@ -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)) } }