diff --git a/replica/uhr/clone-screens/S01-alarms-dark.png b/replica/uhr/clone-screens/S01-alarms-dark.png index c21574a..a6fabcf 100644 Binary files a/replica/uhr/clone-screens/S01-alarms-dark.png and b/replica/uhr/clone-screens/S01-alarms-dark.png differ diff --git a/replica/uhr/clone-screens/S01-alarms-light.png b/replica/uhr/clone-screens/S01-alarms-light.png index 441b297..8c40ab8 100644 Binary files a/replica/uhr/clone-screens/S01-alarms-light.png and b/replica/uhr/clone-screens/S01-alarms-light.png differ diff --git a/replica/uhr/clone-screens/S02-expanded-dark.png b/replica/uhr/clone-screens/S02-expanded-dark.png index 2df4aac..d60115a 100644 Binary files a/replica/uhr/clone-screens/S02-expanded-dark.png and b/replica/uhr/clone-screens/S02-expanded-dark.png differ diff --git a/replica/uhr/clone-screens/S06-analog-light.png b/replica/uhr/clone-screens/S06-analog-light.png index b23945f..531ed1e 100644 Binary files a/replica/uhr/clone-screens/S06-analog-light.png and b/replica/uhr/clone-screens/S06-analog-light.png differ diff --git a/replica/uhr/clone-screens/S06-clock-dark.png b/replica/uhr/clone-screens/S06-clock-dark.png index 2662dcf..c7200d4 100644 Binary files a/replica/uhr/clone-screens/S06-clock-dark.png and b/replica/uhr/clone-screens/S06-clock-dark.png differ diff --git a/replica/uhr/clone-screens/S11-ringing.png b/replica/uhr/clone-screens/S11-ringing.png index d8ce79f..81315ca 100644 Binary files a/replica/uhr/clone-screens/S11-ringing.png and b/replica/uhr/clone-screens/S11-ringing.png differ diff --git a/uhr/app/build.gradle.kts b/uhr/app/build.gradle.kts index 440bce3..7103558 100644 --- a/uhr/app/build.gradle.kts +++ b/uhr/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "app.uhr" minSdk = 29 targetSdk = 36 - versionCode = 2 - versionName = "0.1.1" + versionCode = 3 + versionName = "0.1.2" } // Release key lives outside the repo; without it, release builds fall back to the debug key (local only). diff --git a/uhr/app/src/main/java/app/uhr/ui/screens/AlarmScreen.kt b/uhr/app/src/main/java/app/uhr/ui/screens/AlarmScreen.kt index 96c2546..df482dc 100644 --- a/uhr/app/src/main/java/app/uhr/ui/screens/AlarmScreen.kt +++ b/uhr/app/src/main/java/app/uhr/ui/screens/AlarmScreen.kt @@ -8,6 +8,9 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize 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.border 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.Vibration 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.ExpandMore import androidx.compose.material3.AlertDialog @@ -93,6 +97,12 @@ import java.time.LocalDate import java.time.ZoneId 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(dampingRatio = 0.9f, stiffness = 500f) +private fun androidx.compose.animation.core.SpringSpec.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. */ interface AlarmActions { fun save(alarm: Alarm) @@ -131,12 +141,12 @@ fun AlarmScreen( ) { if (bedtime.isNotEmpty()) item(key = "h-bed") { SectionHeader(stringResource(R.string.wake_up)) } 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)) } items(others, key = { it.alarm.id }) { a -> 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 = { actions.delete(a.alarm) expanded = null @@ -157,6 +167,7 @@ private fun SectionHeader(text: String) = @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AlarmCard( + modifier: Modifier, item: AlarmWithInstance, settings: Settings, expanded: Boolean, @@ -181,21 +192,23 @@ private fun AlarmCard( else -> "" } Column( - Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow) - .clickable(onClick = onToggleExpand).padding(WSpace.s20).animateContentSize(spring(dampingRatio = .85f, stiffness = 380f)) + modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow) + .clickable(onClick = onToggleExpand).padding(WSpace.s20) .semantics(mergeDescendants = !expanded) { 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) }, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (expanded) { - LabelField(a.label) { actions.save(a.copy(label = it)) } - } else { - Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f)) + val rotation by androidx.compose.animation.core.animateFloatAsState(if (expanded) 180f else 0f, expandSpring, label = "chevron") + Row(Modifier.height(48.dp), verticalAlignment = Alignment.CenterVertically) { + androidx.compose.animation.Crossfade(expanded, Modifier.weight(1f), animationSpec = tween(180), label = "label") { open -> + Row(Modifier.fillMaxWidth().height(48.dp), verticalAlignment = Alignment.CenterVertically) { + 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) { - 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)) @@ -208,7 +221,11 @@ private fun AlarmCard( Text(status, style = WType.bodySm, color = scheme.onSurface, modifier = Modifier.weight(1f)) 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 { Spacer(Modifier.height(WSpace.s12)) DayChips(a, settings.weekStart) { actions.save(it) } @@ -247,17 +264,23 @@ private fun AlarmCard( @Composable private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) { var text by remember(value) { mutableStateOf(value) } - TextField( + androidx.compose.foundation.text.BasicTextField( text, { text = it }, Modifier.weight(1f), - placeholder = { Text(stringResource(R.string.add_label)) }, - leadingIcon = { Icon(Icons.Outlined.Label, null) }, - singleLine = true, textStyle = WType.body, + textStyle = WType.body.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary), + singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }), - colors = TextFieldDefaults.colors( - focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, - unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - ), + decorationBox = { inner -> + Row(verticalAlignment = Alignment.CenterVertically) { + 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()) } } } @@ -366,12 +389,14 @@ fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Un } list } - var preview by remember { mutableStateOf(null) } - androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview?.stop() } } + // demo on the alarm stream, like the real ring: audible in silent mode and Do Not Disturb + val preview = remember { SoundPreview(context) } + var playing by remember { mutableStateOf("none") } + androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview.stop() } } fun play(uri: String?) { - preview?.stop() - val u = when (uri) { null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}"); Alarm.SILENT -> null; else -> Uri.parse(uri) } - preview = u?.let { RingtoneManager.getRingtone(context, it) }?.also { it.play() } + if (playing == uri && preview.isPlaying) { preview.stop(); playing = "none"; return } + playing = uri + preview.play(uri) } val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri != null) { @@ -394,12 +419,49 @@ fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Un ) { RadioButton(selected == uri, onClick = null) 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)) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } }, + confirmButton = { TextButton(onClick = { preview.stop(); onPick(selected) }) { Text(stringResource(android.R.string.ok)) } }, + 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 } +} diff --git a/uhr/app/src/main/java/app/uhr/ui/screens/SettingsScreen.kt b/uhr/app/src/main/java/app/uhr/ui/screens/SettingsScreen.kt index 3ad321e..e003da7 100644 --- a/uhr/app/src/main/java/app/uhr/ui/screens/SettingsScreen.kt +++ b/uhr/app/src/main/java/app/uhr/ui/screens/SettingsScreen.kt @@ -11,6 +11,7 @@ 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.AccessTime +import androidx.compose.material.icons.outlined.BatteryChargingFull import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.DarkMode 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) } } + 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())) } } diff --git a/uhr/app/src/main/res/values/strings.xml b/uhr/app/src/main/res/values/strings.xml index d3abe53..471fe1e 100644 --- a/uhr/app/src/main/res/values/strings.xml +++ b/uhr/app/src/main/res/values/strings.xml @@ -46,6 +46,7 @@ Weckton Standard (Morgenklang) Ohne Ton + Wird abgespielt + Eigene Audiodatei wählen Uhrzeit wählen Zifferblatt @@ -103,6 +104,9 @@ System Hell Dunkel + Laden + Adaptives Laden + 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. %1$d Minuten %1$d Sekunden