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:
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 |
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
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,
|
||||
)
|
||||
},
|
||||
) { 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 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 <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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)) } }
|
||||
|
||||
Reference in New Issue
Block a user