uhr: smooth card expand, audible sound demos, adaptive charging note; 0.1.2

- one spring for expand, content fade and neighbour placement; fixed-height header with
  cross-faded label; rotating chevron (release build: 0.5 % janky frames while toggling)
- sound preview on the alarm stream (audible in silent/DND), 8 s demo, playing indicator
- settings explain that alarms are published via setAlarmClock, which system adaptive
  charging uses; apps cannot control charging themselves

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 15:28:36 +02:00
co-authored by Claude Opus 5.5
parent 62867fcd07
commit 08633f918c
10 changed files with 99 additions and 29 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

+2 -2
View File
@@ -16,8 +16,8 @@ android {
applicationId = "app.uhr" applicationId = "app.uhr"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
versionCode = 2 versionCode = 3
versionName = "0.1.1" versionName = "0.1.2"
} }
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only). // Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
@@ -8,6 +8,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -36,6 +39,7 @@ import androidx.compose.material.icons.outlined.NotificationsActive
import androidx.compose.material.icons.outlined.PauseCircle import androidx.compose.material.icons.outlined.PauseCircle
import androidx.compose.material.icons.outlined.Vibration import androidx.compose.material.icons.outlined.Vibration
import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.GraphicEq
import androidx.compose.material.icons.rounded.ExpandLess import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -93,6 +97,12 @@ import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
/** One spring for everything that moves when a card opens, so size, content and neighbours stay in step. */
private val expandSpring = spring<Float>(dampingRatio = 0.9f, stiffness = 500f)
private fun androidx.compose.animation.core.SpringSpec<Float>.toIntOffset() =
spring(dampingRatio, stiffness, androidx.compose.ui.unit.IntOffset.VisibilityThreshold)
private val expandSpringSize = spring(0.9f, 500f, androidx.compose.ui.unit.IntSize.VisibilityThreshold)
/** Actions the alarm screen needs; MainActivity passes the repository, tests pass fakes. */ /** Actions the alarm screen needs; MainActivity passes the repository, tests pass fakes. */
interface AlarmActions { interface AlarmActions {
fun save(alarm: Alarm) fun save(alarm: Alarm)
@@ -131,12 +141,12 @@ fun AlarmScreen(
) { ) {
if (bedtime.isNotEmpty()) item(key = "h-bed") { SectionHeader(stringResource(R.string.wake_up)) } if (bedtime.isNotEmpty()) item(key = "h-bed") { SectionHeader(stringResource(R.string.wake_up)) }
items(bedtime, key = { it.alarm.id }) { a -> items(bedtime, key = { it.alarm.id }) { a ->
AlarmCard(a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, onDelete = null) AlarmCard(Modifier.animateItem(placementSpec = expandSpring.toIntOffset()), a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, onDelete = null)
} }
if (bedtime.isNotEmpty() && others.isNotEmpty()) item(key = "h-other") { SectionHeader(stringResource(R.string.other_alarms)) } if (bedtime.isNotEmpty() && others.isNotEmpty()) item(key = "h-other") { SectionHeader(stringResource(R.string.other_alarms)) }
items(others, key = { it.alarm.id }) { a -> items(others, key = { it.alarm.id }) { a ->
AlarmCard( AlarmCard(
a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, Modifier.animateItem(placementSpec = expandSpring.toIntOffset()), a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions,
onDelete = { onDelete = {
actions.delete(a.alarm) actions.delete(a.alarm)
expanded = null expanded = null
@@ -157,6 +167,7 @@ private fun SectionHeader(text: String) =
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AlarmCard( private fun AlarmCard(
modifier: Modifier,
item: AlarmWithInstance, item: AlarmWithInstance,
settings: Settings, settings: Settings,
expanded: Boolean, expanded: Boolean,
@@ -181,21 +192,23 @@ private fun AlarmCard(
else -> "" else -> ""
} }
Column( Column(
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow) modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow)
.clickable(onClick = onToggleExpand).padding(WSpace.s20).animateContentSize(spring(dampingRatio = .85f, stiffness = 380f)) .clickable(onClick = onToggleExpand).padding(WSpace.s20)
.semantics(mergeDescendants = !expanded) { .semantics(mergeDescendants = !expanded) {
contentDescription = listOf(a.label, Fmt.time(a.hour, a.minute), status).filter { it.isNotBlank() }.joinToString(", ") contentDescription = listOf(a.label, Fmt.time(a.hour, a.minute), status).filter { it.isNotBlank() }.joinToString(", ")
stateDescription = res.getString(if (a.enabled) R.string.on else R.string.off) stateDescription = res.getString(if (a.enabled) R.string.on else R.string.off)
}, },
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { val rotation by androidx.compose.animation.core.animateFloatAsState(if (expanded) 180f else 0f, expandSpring, label = "chevron")
if (expanded) { Row(Modifier.height(48.dp), verticalAlignment = Alignment.CenterVertically) {
LabelField(a.label) { actions.save(a.copy(label = it)) } androidx.compose.animation.Crossfade(expanded, Modifier.weight(1f), animationSpec = tween(180), label = "label") { open ->
} else { Row(Modifier.fillMaxWidth().height(48.dp), verticalAlignment = Alignment.CenterVertically) {
Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f)) if (open) LabelField(a.label) { actions.save(a.copy(label = it)) }
else Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
}
} }
Box(Modifier.size(28.dp).clip(CircleShape).background(scheme.surfaceContainerHigh), contentAlignment = Alignment.Center) { Box(Modifier.size(28.dp).clip(CircleShape).background(scheme.surfaceContainerHigh), contentAlignment = Alignment.Center) {
Icon(if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, null, Modifier.size(20.dp), tint = scheme.onSurface) Icon(Icons.Rounded.ExpandMore, null, Modifier.size(20.dp).graphicsLayer { rotationZ = rotation }, tint = scheme.onSurface)
} }
} }
Spacer(Modifier.height(WSpace.s8)) Spacer(Modifier.height(WSpace.s8))
@@ -208,7 +221,11 @@ private fun AlarmCard(
Text(status, style = WType.bodySm, color = scheme.onSurface, modifier = Modifier.weight(1f)) Text(status, style = WType.bodySm, color = scheme.onSurface, modifier = Modifier.weight(1f))
Switch(a.enabled, onCheckedChange = { actions.save(a.copy(enabled = it, skipUntil = null)) }) Switch(a.enabled, onCheckedChange = { actions.save(a.copy(enabled = it, skipUntil = null)) })
} }
AnimatedVisibility(expanded) { AnimatedVisibility(
expanded,
enter = androidx.compose.animation.expandVertically(expandSpringSize, expandFrom = Alignment.Top) + androidx.compose.animation.fadeIn(tween(220, delayMillis = 60)),
exit = androidx.compose.animation.shrinkVertically(expandSpringSize, shrinkTowards = Alignment.Top) + androidx.compose.animation.fadeOut(tween(120)),
) {
Column { Column {
Spacer(Modifier.height(WSpace.s12)) Spacer(Modifier.height(WSpace.s12))
DayChips(a, settings.weekStart) { actions.save(it) } DayChips(a, settings.weekStart) { actions.save(it) }
@@ -247,17 +264,23 @@ private fun AlarmCard(
@Composable @Composable
private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) { private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) {
var text by remember(value) { mutableStateOf(value) } var text by remember(value) { mutableStateOf(value) }
TextField( androidx.compose.foundation.text.BasicTextField(
text, { text = it }, Modifier.weight(1f), text, { text = it }, Modifier.weight(1f),
placeholder = { Text(stringResource(R.string.add_label)) }, textStyle = WType.body.copy(color = MaterialTheme.colorScheme.onSurface),
leadingIcon = { Icon(Icons.Outlined.Label, null) }, cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary),
singleLine = true, textStyle = WType.body, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }), keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }),
colors = TextFieldDefaults.colors( decorationBox = { inner ->
focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, Row(verticalAlignment = Alignment.CenterVertically) {
unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, Icon(Icons.Outlined.Label, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
), Spacer(Modifier.width(WSpace.s12))
Box {
if (text.isEmpty()) Text(stringResource(R.string.add_label), style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
inner()
}
}
},
) )
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { if (text.trim() != value) onDone(text.trim()) } } androidx.compose.runtime.DisposableEffect(Unit) { onDispose { if (text.trim() != value) onDone(text.trim()) } }
} }
@@ -366,12 +389,14 @@ fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Un
} }
list list
} }
var preview by remember { mutableStateOf<android.media.Ringtone?>(null) } // demo on the alarm stream, like the real ring: audible in silent mode and Do Not Disturb
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview?.stop() } } val preview = remember { SoundPreview(context) }
var playing by remember { mutableStateOf<String?>("none") }
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview.stop() } }
fun play(uri: String?) { fun play(uri: String?) {
preview?.stop() if (playing == uri && preview.isPlaying) { preview.stop(); playing = "none"; return }
val u = when (uri) { null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}"); Alarm.SILENT -> null; else -> Uri.parse(uri) } playing = uri
preview = u?.let { RingtoneManager.getRingtone(context, it) }?.also { it.play() } preview.play(uri)
} }
val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) { if (uri != null) {
@@ -394,12 +419,49 @@ fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Un
) { ) {
RadioButton(selected == uri, onClick = null) RadioButton(selected == uri, onClick = null)
Spacer(Modifier.width(WSpace.s12)) Spacer(Modifier.width(WSpace.s12))
Text(name, style = WType.body) Text(name, style = WType.body, modifier = Modifier.weight(1f))
if (playing == uri && uri != Alarm.SILENT) Icon(Icons.Rounded.GraphicEq, stringResource(R.string.playing), tint = MaterialTheme.colorScheme.primary)
} }
} }
} }
}, },
confirmButton = { TextButton(onClick = { onPick(selected) }) { Text(stringResource(android.R.string.ok)) } }, confirmButton = { TextButton(onClick = { preview.stop(); onPick(selected) }) { Text(stringResource(android.R.string.ok)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } }, dismissButton = { TextButton(onClick = { preview.stop(); onDismiss() }) { Text(stringResource(android.R.string.cancel)) } },
) )
} }
/** Plays a sound for a few seconds on the alarm stream at the user's alarm volume, then fades out. */
class SoundPreview(private val context: android.content.Context) {
private var player: android.media.MediaPlayer? = null
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
val isPlaying: Boolean get() = runCatching { player?.isPlaying == true }.getOrDefault(false)
fun play(uri: String?) {
stop()
val u = when (uri) {
Alarm.SILENT -> return
null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}")
else -> Uri.parse(uri)
}
player = runCatching {
android.media.MediaPlayer().apply {
setAudioAttributes(android.media.AudioAttributes.Builder().setUsage(android.media.AudioAttributes.USAGE_ALARM)
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
setDataSource(context, u)
isLooping = true
prepare()
start()
}
}.getOrNull()
// a demo, not an alarm: stop after a few seconds
handler.postDelayed({ stop() }, DEMO_MS)
}
fun stop() {
handler.removeCallbacksAndMessages(null)
player?.runCatching { stop(); release() }
player = null
}
private companion object { const val DEMO_MS = 8_000L }
}
@@ -11,6 +11,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.AccessTime import androidx.compose.material.icons.outlined.AccessTime
import androidx.compose.material.icons.outlined.BatteryChargingFull
import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.DarkMode import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.Snooze import androidx.compose.material.icons.outlined.Snooze
@@ -84,6 +85,9 @@ fun SettingsScreen(s: Settings, onChange: ((Settings) -> Settings) -> Unit, onBa
SettingsChoice(it, stringResource(R.string.theme), ThemeMode.entries, s.theme, { v -> stringResource(labels.getValue(v)) }, { v -> onChange { x -> x.copy(theme = v) } }, Icons.Outlined.DarkMode) SettingsChoice(it, stringResource(R.string.theme), ThemeMode.entries, s.theme, { v -> stringResource(labels.getValue(v)) }, { v -> onChange { x -> x.copy(theme = v) } }, Icons.Outlined.DarkMode)
} }
} }
SettingsGroup(stringResource(R.string.charging)) {
item { app.uhr.ui.components.SettingsItem(it, stringResource(R.string.adaptive_charging), stringResource(R.string.adaptive_charging_text), Icons.Outlined.BatteryChargingFull) }
}
Spacer(Modifier.height(32.dp())) Spacer(Modifier.height(32.dp()))
} }
} }
+4
View File
@@ -46,6 +46,7 @@
<string name="alarm_sound">Weckton</string> <string name="alarm_sound">Weckton</string>
<string name="sound_default">Standard (Morgenklang)</string> <string name="sound_default">Standard (Morgenklang)</string>
<string name="sound_silent">Ohne Ton</string> <string name="sound_silent">Ohne Ton</string>
<string name="playing">Wird abgespielt</string>
<string name="add_own_sound">+ Eigene Audiodatei wählen</string> <string name="add_own_sound">+ Eigene Audiodatei wählen</string>
<string name="pick_time">Uhrzeit wählen</string> <string name="pick_time">Uhrzeit wählen</string>
<string name="use_dial">Zifferblatt</string> <string name="use_dial">Zifferblatt</string>
@@ -103,6 +104,9 @@
<string name="theme_system">System</string> <string name="theme_system">System</string>
<string name="theme_light">Hell</string> <string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string> <string name="theme_dark">Dunkel</string>
<string name="charging">Laden</string>
<string name="adaptive_charging">Adaptives Laden</string>
<string name="adaptive_charging_text">Diese App meldet jeden Wecker als „nächsten Wecker“ an das System. Ladefunktionen wie das adaptive Laden auf Pixel-Geräten richten sich danach und laden bis zum Klingeln langsam auf 100 %. Die Ladegeschwindigkeit selbst kann nur das System steuern, keine App. Auf GrapheneOS gibt es stattdessen die Ladebegrenzung auf 80 % in den Akku-Einstellungen.</string>
<string name="n_minutes">%1$d Minuten</string> <string name="n_minutes">%1$d Minuten</string>
<string name="n_seconds">%1$d Sekunden</string> <string name="n_seconds">%1$d Sekunden</string>