2 Commits
Author SHA1 Message Date
MrBlakeandClaude Opus 5.5 7812f62c0f uhr: sleep sounds, sunrise alarm, reorder world clock, rename timers; 0.2.0
- own generated sleep sounds (rain, waves, brown noise) with sleep timer and fade-out
- sunrise: 15 min before the bedtime wake alarm, a full-screen screen brightens from ember to daylight
- world clock: long-press drag to reorder, with haptic ticks; order persists
- timers: tap the title to rename

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:49:09 +02:00
MrBlakeandClaude Opus 5.5 08633f918c 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]>
2026-10-17 15:28:36 +02:00
34 changed files with 772 additions and 97 deletions
+2 -1
View File
@@ -51,7 +51,8 @@ A clock and alarm app with the look and flow of the Pixel clock: alarms in expan
- **Ringing done right:** a full-screen screen over the lock screen with slide to snooze or stop, alarm-stream audio that gets louder gradually, vibration, automatic silence after a set time, and notifications for upcoming, snoozed and missed alarms. Volume keys can snooze or stop. - **Ringing done right:** a full-screen screen over the lock screen with slide to snooze or stop, alarm-stream audio that gets louder gradually, vibration, automatic silence after a set time, and notifications for upcoming, snoozed and missed alarms. Volume keys can snooze or stop.
- **No network permission at all.** World clock cities come from the system time zone database (names localised offline), sounds from the device, the app's own tone, or your own audio files. - **No network permission at all.** World clock cities come from the system time zone database (names localised offline), sounds from the device, the app's own tone, or your own audio files.
- **Also:** days and labels per alarm, pause until a date, skip the next ring, timers with ring progress and +1:00 that keep running in the background with a live countdown notification, stopwatch with fastest/slowest laps, analog or digital clock, wallpaper colours (Material You), three home-screen widgets (live digital clock, analog clock, next alarm). - **Also:** days and labels per alarm, pause until a date, skip the next ring, timers with ring progress and +1:00 that keep running in the background with a live countdown notification, stopwatch with fastest/slowest laps, analog or digital clock, wallpaper colours (Material You), three home-screen widgets (live digital clock, analog clock, next alarm).
- **Coming next:** widget style options like Wetter, screensaver/night clock, sunrise alarm, sleep sounds, reordering world clocks. - **Bedtime extras:** own sleep sounds (rain, waves, deep noise) with a sleep timer that fades out, and a sunrise option that slowly brightens the screen 15 minutes before the wake-up alarm.
- **Coming next:** widget style options like Wetter, screensaver/night clock.
<p align="center"> <p align="center">
<img src="docs/screenshots/uhr/S01-alarms-dark.png" width="200" alt="Alarm list"> <img src="docs/screenshots/uhr/S01-alarms-dark.png" width="200" alt="Alarm list">
+3 -3
View File
@@ -11,11 +11,11 @@ Device checks on a Pixel 10 Pro XL (Android 16) via a debug-only activity that s
| S03 Time picker | 2026-10-17 | done | – | – | | S03 Time picker | 2026-10-17 | done | – | – |
| S04 Sound picker | 2026-10-17 | done | – | own file not device-tested | | S04 Sound picker | 2026-10-17 | done | – | own file not device-tested |
| S05 Pause alarm | 2026-10-17 | done | – | – | | S05 Pause alarm | 2026-10-17 | done | – | – |
| S06 Clock | 2026-10-17 | done | reorder cities | city names offline via ICU exemplar cities | | S06 Clock | 2026-10-17 | done | – | city names offline via ICU exemplar cities |
| S07 Add city | 2026-10-17 | done | – | – | | S07 Add city | 2026-10-17 | done | – | – |
| S08 Timer | 2026-10-17 | done | label edit UI | verified on device: 5 s timer rang in the background, stopped from the notification | | S08 Timer | 2026-10-17 | done | – | verified on device: 5 s timer rang in the background, stopped from the notification |
| S09 Stopwatch | 2026-10-17 | done | – | – | | S09 Stopwatch | 2026-10-17 | done | – | – |
| S10 Bedtime | 2026-10-17 | partial | sunrise alarm, sleep sounds | – | | S10 Bedtime | 2026-10-17 | done | sunrise full-screen not confirmed on device | sleep sounds generated in-house; the sunrise needs the full-screen-intent permission, which Android 14+ does not grant sideloaded apps by default (the app asks) |
| S11 Ringing | 2026-10-17 | done | – | – | | S11 Ringing | 2026-10-17 | done | – | – |
| S12 Timer ringing | 2026-10-17 | done | – | shares the ringing screen | | S12 Timer ringing | 2026-10-17 | done | – | shares the ringing screen |
| S13 Settings | 2026-10-17 | done | timer sound picker, home time zone | – | | S13 Settings | 2026-10-17 | done | timer sound picker, home time zone | – |
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: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+4
View File
@@ -65,6 +65,10 @@ object WColor {
val timerProgress = Color(0xFF7EC4A8) val timerProgress = Color(0xFF7EC4A8)
val nightText = Color(0xFFC4433A) val nightText = Color(0xFFC4433A)
val nightBg = Color(0xFF000000) val nightBg = Color(0xFF000000)
val sunriseNight = Color(0xFF0D0710)
val sunriseEmber = Color(0xFF5A1A10)
val sunriseDay = Color(0xFFFFD9A0)
val sunriseSun = Color(0xFFFFF3D6)
} }
object WType { object WType {
+310 -51
View File
@@ -1,45 +1,230 @@
{ {
"_about": "Uhr tokens. Roles read by eye from replica/uhr/screens/ref-01..07 (Pixel Clock, dark first). Own values, no brand colour copied; on Android 12+ dynamic colour (wallpaper) replaces the accent roles. Check: python3 ~/.claude/skills/replica-design/contrast.py replica/uhr/design/tokens.json", "_about": "Uhr tokens. Roles read by eye from replica/uhr/screens/ref-01..07 (Pixel Clock, dark first). Own values, no brand colour copied; on Android 12+ dynamic colour (wallpaper) replaces the accent roles. Check: python3 ~/.claude/skills/replica-design/contrast.py replica/uhr/design/tokens.json",
"color": { "color": {
"bg": "#f8f9ff", "surface": "#ffffff", "card": "#eef0f6", "card-high": "#e2e5ec", "bg": "#f8f9ff",
"border": "#c3c6cf", "border-input": "#73777f", "surface": "#ffffff",
"text": "#191c20", "text-muted": "#43474e", "text-off": "#5c6068", "card": "#eef0f6",
"accent": "#2f5ea8", "on-accent": "#ffffff", "card-high": "#e2e5ec",
"accent-container": "#d6e3ff", "on-accent-container": "#001b3e", "border": "#c3c6cf",
"fab": "#a8c7fa", "on-fab": "#0b2a55", "border-input": "#73777f",
"chip-on": "#2f5ea8", "on-chip-on": "#ffffff", "text": "#191c20",
"secondary-pill": "#dfe2eb", "on-secondary-pill": "#191c20", "text-muted": "#43474e",
"stop": "#ffd8e4", "on-stop": "#4a1426", "text-off": "#5c6068",
"accent": "#2f5ea8",
"on-accent": "#ffffff",
"accent-container": "#d6e3ff",
"on-accent-container": "#001b3e",
"fab": "#a8c7fa",
"on-fab": "#0b2a55",
"chip-on": "#2f5ea8",
"on-chip-on": "#ffffff",
"secondary-pill": "#dfe2eb",
"on-secondary-pill": "#191c20",
"stop": "#ffd8e4",
"on-stop": "#4a1426",
"danger": "#ba1a1a", "danger": "#ba1a1a",
"nav": "#eceef4", "nav-indicator": "#d6e3ff", "nav": "#eceef4",
"nav-indicator": "#d6e3ff",
"dark-bg": "#111318", "dark-surface": "#111318", "dark-card": "#1d2024", "dark-card-high": "#272a2f", "dark-bg": "#111318",
"dark-border": "#43474e", "dark-border-input": "#8d9199", "dark-surface": "#111318",
"dark-text": "#e2e2e9", "dark-text-muted": "#c3c6cf", "dark-text-off": "#9a9ea6", "dark-card": "#1d2024",
"dark-accent": "#a8c7fa", "dark-on-accent": "#062e6f", "dark-card-high": "#272a2f",
"dark-accent-container": "#244a80", "dark-on-accent-container": "#d6e3ff", "dark-border": "#43474e",
"dark-fab": "#a8c7fa", "dark-on-fab": "#062e6f", "dark-border-input": "#8d9199",
"dark-chip-on": "#a8c7fa", "dark-on-chip-on": "#062e6f", "dark-text": "#e2e2e9",
"dark-secondary-pill": "#3a3d43", "dark-on-secondary-pill": "#e2e2e9", "dark-text-muted": "#c3c6cf",
"dark-stop": "#f2b8c9", "dark-on-stop": "#4a1426", "dark-text-off": "#9a9ea6",
"dark-accent": "#a8c7fa",
"dark-on-accent": "#062e6f",
"dark-accent-container": "#244a80",
"dark-on-accent-container": "#d6e3ff",
"dark-fab": "#a8c7fa",
"dark-on-fab": "#062e6f",
"dark-chip-on": "#a8c7fa",
"dark-on-chip-on": "#062e6f",
"dark-secondary-pill": "#3a3d43",
"dark-on-secondary-pill": "#e2e2e9",
"dark-stop": "#f2b8c9",
"dark-on-stop": "#4a1426",
"dark-danger": "#ffb4ab", "dark-danger": "#ffb4ab",
"dark-nav": "#1d2024", "dark-nav-indicator": "#3a3f47", "dark-nav": "#1d2024",
"dark-nav-indicator": "#3a3f47",
"ring-bg": "#1e5a52", "ring-on": "#b4f1e6", "ring-track": "#2b6e65", "ring-thumb": "#3f9a7f", "on-ring-thumb": "#ffffff", "ring-bg": "#1e5a52",
"timer-track": "#3a3d43", "timer-progress": "#7ec4a8", "ring-on": "#b4f1e6",
"night-text": "#c4433a", "night-bg": "#000000" "ring-track": "#2b6e65",
"ring-thumb": "#3f9a7f",
"on-ring-thumb": "#ffffff",
"timer-track": "#3a3d43",
"timer-progress": "#7ec4a8",
"night-text": "#c4433a",
"night-bg": "#000000",
"sunrise-night": "#0d0710",
"sunrise-ember": "#5a1a10",
"sunrise-day": "#ffd9a0",
"sunrise-sun": "#fff3d6"
}, },
"pairs": [ "pairs": [
["text","bg"],["text","card"],["text","card-high"],["text-muted","bg"],["text-muted","card"],["text-off","card"], [
["on-accent","accent"],["on-accent-container","accent-container"],["on-fab","fab"],["on-chip-on","chip-on"], "text",
["on-secondary-pill","secondary-pill"],["on-stop","stop"],["accent","card"],["danger","card"],["text","nav"],["text","nav-indicator"], "bg"
["border-input","surface","ui"], ],
["dark-text","dark-bg"],["dark-text","dark-card"],["dark-text","dark-card-high"],["dark-text-muted","dark-bg"],["dark-text-muted","dark-card"],["dark-text-off","dark-card"], [
["dark-on-accent","dark-accent"],["dark-on-accent-container","dark-accent-container"],["dark-on-fab","dark-fab"],["dark-on-chip-on","dark-chip-on"], "text",
["dark-on-secondary-pill","dark-secondary-pill"],["dark-on-stop","dark-stop"],["dark-accent","dark-card"],["dark-danger","dark-card"], "card"
["dark-text","dark-nav"],["dark-text","dark-nav-indicator"],["dark-border-input","dark-card","ui"], ],
["ring-on","ring-bg"],["ring-on","ring-track"],["on-ring-thumb","ring-thumb","large"], [
["timer-progress","dark-card","ui"],["night-text","night-bg","large"] "text",
"card-high"
],
[
"text-muted",
"bg"
],
[
"text-muted",
"card"
],
[
"text-off",
"card"
],
[
"on-accent",
"accent"
],
[
"on-accent-container",
"accent-container"
],
[
"on-fab",
"fab"
],
[
"on-chip-on",
"chip-on"
],
[
"on-secondary-pill",
"secondary-pill"
],
[
"on-stop",
"stop"
],
[
"accent",
"card"
],
[
"danger",
"card"
],
[
"text",
"nav"
],
[
"text",
"nav-indicator"
],
[
"border-input",
"surface",
"ui"
],
[
"dark-text",
"dark-bg"
],
[
"dark-text",
"dark-card"
],
[
"dark-text",
"dark-card-high"
],
[
"dark-text-muted",
"dark-bg"
],
[
"dark-text-muted",
"dark-card"
],
[
"dark-text-off",
"dark-card"
],
[
"dark-on-accent",
"dark-accent"
],
[
"dark-on-accent-container",
"dark-accent-container"
],
[
"dark-on-fab",
"dark-fab"
],
[
"dark-on-chip-on",
"dark-chip-on"
],
[
"dark-on-secondary-pill",
"dark-secondary-pill"
],
[
"dark-on-stop",
"dark-stop"
],
[
"dark-accent",
"dark-card"
],
[
"dark-danger",
"dark-card"
],
[
"dark-text",
"dark-nav"
],
[
"dark-text",
"dark-nav-indicator"
],
[
"dark-border-input",
"dark-card",
"ui"
],
[
"ring-on",
"ring-bg"
],
[
"ring-on",
"ring-track"
],
[
"on-ring-thumb",
"ring-thumb",
"large"
],
[
"timer-progress",
"dark-card",
"ui"
],
[
"night-text",
"night-bg",
"large"
]
], ],
"font": { "font": {
"sans": "Roboto Flex (OFL), system fallback", "sans": "Roboto Flex (OFL), system fallback",
@@ -48,21 +233,88 @@
"mono": "tabular figures (fontFeatureSettings tnum) instead of a mono font for the stopwatch" "mono": "tabular figures (fontFeatureSettings tnum) instead of a mono font for the stopwatch"
}, },
"type": { "type": {
"label-sm": {"size": 12, "line": 16, "weight": 500}, "label-sm": {
"label": {"size": 14, "line": 20, "weight": 500}, "size": 12,
"body-sm": {"size": 14, "line": 20, "weight": 400}, "line": 16,
"body": {"size": 16, "line": 24, "weight": 400}, "weight": 500
"title": {"size": 22, "line": 28, "weight": 400}, },
"headline": {"size": 28, "line": 36, "weight": 400}, "label": {
"alarm-time": {"size": 56, "line": 60, "weight": 600}, "size": 14,
"alarm-ampm": {"size": 20, "line": 24, "weight": 600}, "line": 20,
"timer-time": {"size": 44, "line": 48, "weight": 400}, "weight": 500
"clock-big": {"size": 88, "line": 92, "weight": 400}, },
"stopwatch": {"size": 72, "line": 76, "weight": 500}, "body-sm": {
"ringing": {"size": 104, "line": 108, "weight": 400} "size": 14,
"line": 20,
"weight": 400
},
"body": {
"size": 16,
"line": 24,
"weight": 400
},
"title": {
"size": 22,
"line": 28,
"weight": 400
},
"headline": {
"size": 28,
"line": 36,
"weight": 400
},
"alarm-time": {
"size": 56,
"line": 60,
"weight": 600
},
"alarm-ampm": {
"size": 20,
"line": 24,
"weight": 600
},
"timer-time": {
"size": 44,
"line": 48,
"weight": 400
},
"clock-big": {
"size": 88,
"line": 92,
"weight": 400
},
"stopwatch": {
"size": 72,
"line": 76,
"weight": 500
},
"ringing": {
"size": 104,
"line": 108,
"weight": 400
}
},
"space": [
0,
2,
4,
8,
12,
16,
20,
24,
32,
48
],
"radius": {
"xs": 8,
"sm": 12,
"md": 16,
"lg": 24,
"card": 28,
"pill": 999,
"nav-indicator": 16
}, },
"space": [0, 2, 4, 8, 12, 16, 20, 24, 32, 48],
"radius": {"xs": 8, "sm": 12, "md": 16, "lg": 24, "card": 28, "pill": 999, "nav-indicator": 16},
"shape": { "shape": {
"fab": "circle 96dp (large, centred above the nav bar)", "fab": "circle 96dp (large, centred above the nav bar)",
"stopwatch-primary": "pill 72dp tall, full width", "stopwatch-primary": "pill 72dp tall, full width",
@@ -72,7 +324,14 @@
"motion": { "motion": {
"card-expand": "spring dampingRatio 0.85 stiffness 380", "card-expand": "spring dampingRatio 0.85 stiffness 380",
"slide-act": "spring back dampingRatio 0.7", "slide-act": "spring back dampingRatio 0.7",
"fast": "120ms", "base": "250ms" "fast": "120ms",
"base": "250ms"
}, },
"layout": {"gutter": 16, "card-gap": 8, "nav-height": 80, "fab-bottom": 16, "card-padding": 20} "layout": {
"gutter": 16,
"card-gap": 8,
"nav-height": 80,
"fab-bottom": 16,
"card-padding": 20
}
} }
+4 -4
View File
@@ -24,14 +24,14 @@ Missed alarm notification,notifications,should,yes,yes,
Snoozed notification with dismiss,notifications,must,yes,yes, Snoozed notification with dismiss,notifications,must,yes,yes,
World clock: local time and date,clock,must,yes,yes, World clock: local time and date,clock,must,yes,yes,
World clock: add city via search,clock,must,yes,yes, World clock: add city via search,clock,must,yes,yes,
World clock: reorder and swipe to delete,clock,should,yes,partial,swipe to delete only World clock: reorder and swipe to delete,clock,should,yes,yes,"long-press drag; verified on device, order persists"
Analog or digital clock style,clock,should,yes,yes, Analog or digital clock style,clock,should,yes,yes,
Show seconds,clock,could,yes,yes, Show seconds,clock,could,yes,yes,
Automatic home clock when travelling,clock,could,yes,no, Automatic home clock when travelling,clock,could,yes,no,
Timer keypad h m s,timer,must,yes,yes, Timer keypad h m s,timer,must,yes,yes,
Run several timers at once,timer,must,yes,yes, Run several timers at once,timer,must,yes,yes,
Timer ring progress +1:00 pause reset delete,timer,must,yes,yes, Timer ring progress +1:00 pause reset delete,timer,must,yes,yes,
Timer labels,timer,could,yes,partial,stored and shown; no UI to edit yet Timer labels,timer,could,yes,yes,tap the title to rename
Timer runs in background with notification,timer,must,yes,yes, Timer runs in background with notification,timer,must,yes,yes,
Timer ringing screen,timer,must,yes,yes, Timer ringing screen,timer,must,yes,yes,
Timer sound and vibrate settings,settings,should,yes,partial,vibrate setting only; sound uses default Timer sound and vibrate settings,settings,should,yes,partial,vibrate setting only; sound uses default
@@ -40,8 +40,8 @@ Stopwatch laps,stopwatch,must,yes,yes,
Stopwatch running notification,stopwatch,should,yes,yes, Stopwatch running notification,stopwatch,should,yes,yes,
Bedtime schedule with wake-up alarm,bedtime,should,yes,yes, Bedtime schedule with wake-up alarm,bedtime,should,yes,yes,
Bedtime reminder notification,bedtime,should,yes,yes, Bedtime reminder notification,bedtime,should,yes,yes,
Sunrise alarm (brightening screen),bedtime,could,yes,no, Sunrise alarm (brightening screen),bedtime,could,yes,partial,scheduled 15 min before the bedtime wake alarm via full-screen notification; full-screen launch not confirmed on device (no keyguard during test)
Sleep sounds with timer,bedtime,could,yes,no,own CC0 sounds Sleep sounds with timer,bedtime,could,yes,yes,"own generated sounds (rain, waves, brown noise), sleep timer with 20 s fade-out; verified playing on device"
Screensaver / bedside clock with night mode,display,could,yes,no,DreamService Screensaver / bedside clock with night mode,display,could,yes,no,DreamService
Digital clock widget,widgets,must,yes,yes, Digital clock widget,widgets,must,yes,yes,
Analog clock widget,widgets,should,yes,yes, Analog clock widget,widgets,should,yes,yes,
1 feature area priority original clone notes
24 Snoozed notification with dismiss notifications must yes yes
25 World clock: local time and date clock must yes yes
26 World clock: add city via search clock must yes yes
27 World clock: reorder and swipe to delete clock should yes partial yes swipe to delete only long-press drag; verified on device, order persists
28 Analog or digital clock style clock should yes yes
29 Show seconds clock could yes yes
30 Automatic home clock when travelling clock could yes no
31 Timer keypad h m s timer must yes yes
32 Run several timers at once timer must yes yes
33 Timer ring progress +1:00 pause reset delete timer must yes yes
34 Timer labels timer could yes partial yes stored and shown; no UI to edit yet tap the title to rename
35 Timer runs in background with notification timer must yes yes
36 Timer ringing screen timer must yes yes
37 Timer sound and vibrate settings settings should yes partial vibrate setting only; sound uses default
40 Stopwatch running notification stopwatch should yes yes
41 Bedtime schedule with wake-up alarm bedtime should yes yes
42 Bedtime reminder notification bedtime should yes yes
43 Sunrise alarm (brightening screen) bedtime could yes no partial scheduled 15 min before the bedtime wake alarm via full-screen notification; full-screen launch not confirmed on device (no keyguard during test)
44 Sleep sounds with timer bedtime could yes no yes own CC0 sounds own generated sounds (rain, waves, brown noise), sleep timer with 20 s fade-out; verified playing on device
45 Screensaver / bedside clock with night mode display could yes no DreamService
46 Digital clock widget widgets must yes yes
47 Analog clock widget widgets should yes yes
+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 = 4
versionName = "0.1.1" versionName = "0.2.0"
} }
// 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).
@@ -13,6 +13,15 @@ class DebugAlarmActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Graph.init(this) Graph.init(this)
if (intent.getBooleanExtra("sunrise", false)) {
// through the real path: AlarmManager -> receiver -> full-screen notification, 20 s from now
Graph.repo.scheduler.scheduleSunrise(System.currentTimeMillis() + app.uhr.alarm.SunriseActivity.DURATION_MS + 20_000L)
finish(); return
}
if (intent.getBooleanExtra("cities", false)) {
runBlocking { app.uhr.data.Zones.search("").let { } ; listOf("Tokyo", "New_York", "London").forEach { c -> app.uhr.data.Zones.search(c.replace('_', ' ')).firstOrNull()?.let { Graph.repo.addCity(it) } } }
finish(); return
}
val timerS = intent.getIntExtra("timer_seconds", 0) val timerS = intent.getIntExtra("timer_seconds", 0)
if (timerS > 0) { if (timerS > 0) {
val id = runBlocking { Graph.repo.startTimer(timerS * 1000L, "Testtimer") } val id = runBlocking { Graph.repo.startTimer(timerS * 1000L, "Testtimer") }
+3
View File
@@ -37,6 +37,9 @@
android:turnScreenOn="true" android:turnScreenOn="true"
android:taskAffinity="" /> android:taskAffinity="" />
<activity android:name=".alarm.SunriseActivity" android:exported="false" android:excludeFromRecents="true"
android:launchMode="singleInstance" android:showWhenLocked="true" android:turnScreenOn="true" android:taskAffinity="" />
<service android:name=".alarm.SleepSoundService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
<service android:name=".alarm.RingingService" android:exported="false" android:foregroundServiceType="mediaPlayback" /> <service android:name=".alarm.RingingService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
<receiver android:name=".alarm.AlarmReceiver" android:exported="false" android:directBootAware="false"> <receiver android:name=".alarm.AlarmReceiver" android:exported="false" android:directBootAware="false">
@@ -164,6 +164,7 @@ private fun ClockApp(settings: Settings, requestedTab: Int) {
override fun reset(id: Long) { scope.launch { repo.resetTimer(id) } } override fun reset(id: Long) { scope.launch { repo.resetTimer(id) } }
override fun delete(id: Long) { scope.launch { repo.deleteTimer(id) } } override fun delete(id: Long) { scope.launch { repo.deleteTimer(id) } }
override fun stopRinging() { RingingService.stop(context) } override fun stopRinging() { RingingService.stop(context) }
override fun rename(id: Long, label: String) { scope.launch { repo.setTimerLabel(id, label) } }
} }
} }
@@ -209,7 +210,7 @@ private fun ClockApp(settings: Settings, requestedTab: Int) {
) { pad -> ) { pad ->
when (tab) { when (tab) {
0 -> AlarmScreen(alarms, settings, alarmActions, snackbar, pad) 0 -> AlarmScreen(alarms, settings, alarmActions, snackbar, pad)
1 -> ClockScreen(settings, cities, next, pad, onDeleteCity = { c -> scope.launch { repo.deleteCity(c.id) } }) 1 -> ClockScreen(settings, cities, next, pad, onDeleteCity = { c -> scope.launch { repo.deleteCity(c.id) } }, onReorder = { ids -> scope.launch { repo.reorderCities(ids) } })
2 -> { 2 -> {
if (keypad && timers.isNotEmpty()) BackHandler { keypad = false; digits = "" } if (keypad && timers.isNotEmpty()) BackHandler { keypad = false; digits = "" }
TimerScreen(timers, keypad, digits, { digits = it }, timerActions, pad) TimerScreen(timers, keypad, digits, { digits = it }, timerActions, pad)
@@ -31,12 +31,13 @@ class AlarmReceiver : BroadcastReceiver() {
val repo = Graph.repo val repo = Graph.repo
when (action) { when (action) {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_TIME_CHANGED, "android.intent.action.TIME_SET", Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_TIME_CHANGED, "android.intent.action.TIME_SET",
Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_MY_PACKAGE_REPLACED -> repo.scheduler.rescheduleAll() Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_MY_PACKAGE_REPLACED -> { repo.scheduler.rescheduleAll(); repo.scheduleBedtime() }
ACTION_UPCOMING -> repo.alarm(id)?.let { a -> repo.instance(id)?.let { Notifications.upcoming(context, a, it.fireAt) } } ACTION_UPCOMING -> repo.alarm(id)?.let { a -> repo.instance(id)?.let { Notifications.upcoming(context, a, it.fireAt) } }
ACTION_DISMISS_NEXT -> repo.dismissNext(id) ACTION_DISMISS_NEXT -> repo.dismissNext(id)
ACTION_SNOOZE_DISMISS -> repo.finishAlarm(id) ACTION_SNOOZE_DISMISS -> repo.finishAlarm(id)
ACTION_TIMER_PAUSE -> repo.pauseTimer(id) ACTION_TIMER_PAUSE -> repo.pauseTimer(id)
ACTION_TIMER_ADD_MINUTE -> repo.addMinute(id) ACTION_TIMER_ADD_MINUTE -> repo.addMinute(id)
ACTION_SUNRISE -> Notifications.sunrise(context, id)
ACTION_BEDTIME -> { Notifications.bedtime(context); repo.scheduleBedtime() } ACTION_BEDTIME -> { Notifications.bedtime(context); repo.scheduleBedtime() }
} }
} }
@@ -51,6 +52,7 @@ class AlarmReceiver : BroadcastReceiver() {
const val ACTION_TIMER_PAUSE = "app.uhr.TIMER_PAUSE" const val ACTION_TIMER_PAUSE = "app.uhr.TIMER_PAUSE"
const val ACTION_TIMER_ADD_MINUTE = "app.uhr.TIMER_ADD_MINUTE" const val ACTION_TIMER_ADD_MINUTE = "app.uhr.TIMER_ADD_MINUTE"
const val ACTION_BEDTIME = "app.uhr.BEDTIME" const val ACTION_BEDTIME = "app.uhr.BEDTIME"
const val ACTION_SUNRISE = "app.uhr.SUNRISE"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
} }
} }
@@ -32,6 +32,8 @@ object Notifications {
const val ID_STOPWATCH = 2 const val ID_STOPWATCH = 2
const val ID_RINGING = 3 const val ID_RINGING = 3
const val ID_BEDTIME = 4 const val ID_BEDTIME = 4
const val ID_SLEEP = 5
const val ID_SUNRISE = 6
private const val ALARM_BASE = 1000 private const val ALARM_BASE = 1000
fun createChannels(context: Context) { fun createChannels(context: Context) {
@@ -147,6 +149,26 @@ object Notifications {
) )
} }
/** Full-screen intent is the only way to bring the sunrise screen up from the background. */
fun sunrise(context: Context, fireAt: Long) {
if (!allowed(context)) return
val full = PendingIntent.getActivity(
context, 7, Intent(context, SunriseActivity::class.java).putExtra(SunriseActivity.EXTRA_FIRE_AT, fireAt)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
// an update of an existing notification does not fire its full-screen intent again
nm(context).cancel(ID_SUNRISE)
nm(context).notify(
ID_SUNRISE,
NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle(context.getString(R.string.sunrise_running)).setSilent(true)
.setCategory(NotificationCompat.CATEGORY_ALARM).setPriority(NotificationCompat.PRIORITY_MAX)
.setFullScreenIntent(full, true).setContentIntent(full).setTimeoutAfter(SunriseActivity.DURATION_MS + 60_000)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC).build(),
)
}
fun ringing(context: Context, title: String, text: String, fullScreen: PendingIntent, stop: PendingIntent, snooze: PendingIntent?): Notification = fun ringing(context: Context, title: String, text: String, fullScreen: PendingIntent, stop: PendingIntent, snooze: PendingIntent?): Notification =
NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm) NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle(title).setContentText(text) .setContentTitle(title).setContentText(text)
@@ -71,6 +71,20 @@ class Scheduler(private val context: Context, private val dao: ClockDao) {
fun scheduleBedtimeReminder(at: Long) = fun scheduleBedtimeReminder(at: Long) =
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME)) am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME))
fun scheduleSunrise(fireAt: Long) {
val at = fireAt - SunriseActivity.DURATION_MS
if (at > System.currentTimeMillis()) am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, sunriseIntent(fireAt))
}
fun cancelSunrise() = am.cancel(sunriseIntent(0))
/** One sunrise at a time (fixed request code); the wake-up time travels as an extra. */
private fun sunriseIntent(fireAt: Long) = PendingIntent.getBroadcast(
context, REQ_SUNRISE,
Intent(context, AlarmReceiver::class.java).setAction(AlarmReceiver.ACTION_SUNRISE).putExtra(AlarmReceiver.EXTRA_ID, fireAt),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
fun cancelBedtimeReminder() = am.cancel(broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME)) fun cancelBedtimeReminder() = am.cancel(broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME))
private fun ringIntent(id: Long) = broadcast(AlarmReceiver.ACTION_RING, id, REQ_RING) private fun ringIntent(id: Long) = broadcast(AlarmReceiver.ACTION_RING, id, REQ_RING)
@@ -89,5 +103,6 @@ class Scheduler(private val context: Context, private val dao: ClockDao) {
private const val REQ_UPCOMING = 1_000_000 private const val REQ_UPCOMING = 1_000_000
private const val REQ_TIMER = 2_000_000 private const val REQ_TIMER = 2_000_000
private const val REQ_BEDTIME = 3_000_000 private const val REQ_BEDTIME = 3_000_000
private const val REQ_SUNRISE = 4_000_000
} }
} }
@@ -0,0 +1,109 @@
package app.uhr.alarm
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import app.uhr.MainActivity
import app.uhr.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
enum class SleepSound(val res: Int, val title: Int) {
RAIN(R.raw.sleep_rain, R.string.sound_rain),
WAVES(R.raw.sleep_waves, R.string.sound_waves),
BROWN(R.raw.sleep_brown, R.string.sound_brown),
}
data class SleepPlayback(val sound: SleepSound, val endsAt: Long)
/** Plays a looping sleep sound on the media stream and fades it out at the end of the sleep timer. */
class SleepSoundService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var player: MediaPlayer? = null
private var job: Job? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) { stop(); return START_NOT_STICKY }
val sound = SleepSound.entries.getOrNull(intent?.getIntExtra(EXTRA_SOUND, 0) ?: 0) ?: SleepSound.RAIN
val minutes = intent?.getIntExtra(EXTRA_MINUTES, 30) ?: 30
val endsAt = System.currentTimeMillis() + minutes * 60_000L
ServiceCompat.startForeground(this, Notifications.ID_SLEEP, notification(sound, endsAt), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
player?.release()
player = MediaPlayer().apply {
setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build())
setDataSource(this@SleepSoundService, Uri.parse("android.resource://$packageName/${sound.res}"))
isLooping = true
prepare()
setVolume(0f, 0f)
start()
}
state.value = SleepPlayback(sound, endsAt)
job?.cancel()
job = scope.launch {
fade(0f, 1f, 3_000)
delay((endsAt - System.currentTimeMillis() - FADE_OUT_MS).coerceAtLeast(0))
fade(1f, 0f, FADE_OUT_MS)
stop()
}
return START_NOT_STICKY
}
private suspend fun fade(from: Float, to: Float, ms: Long) {
val steps = (ms / 100).toInt().coerceAtLeast(1)
for (i in 1..steps) {
val v = from + (to - from) * i / steps
runCatching { player?.setVolume(v, v) }
delay(100)
}
}
private fun notification(sound: SleepSound, endsAt: Long) =
NotificationCompat.Builder(this, Notifications.CH_RUNNING).setSmallIcon(R.drawable.ic_stat_bedtime)
.setContentTitle(getString(sound.title)).setContentText(getString(R.string.sleep_sound_running))
.setUsesChronometer(true).setChronometerCountDown(true).setWhen(endsAt).setShowWhen(true)
.setOngoing(true).setSilent(true)
.setContentIntent(PendingIntent.getActivity(this, 5, Intent(this, MainActivity::class.java).putExtra(MainActivity.EXTRA_TAB, 4), PendingIntent.FLAG_IMMUTABLE))
.addAction(0, getString(R.string.stop), PendingIntent.getService(this, 6, Intent(this, SleepSoundService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE))
.build()
private fun stop() {
job?.cancel()
player?.runCatching { stop(); release() }; player = null
state.value = null
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() { player?.release(); state.value = null; scope.cancel(); super.onDestroy() }
companion object {
private const val ACTION_STOP = "stop"
private const val EXTRA_SOUND = "sound"
private const val EXTRA_MINUTES = "minutes"
private const val FADE_OUT_MS = 20_000L
private val state = MutableStateFlow<SleepPlayback?>(null)
val playing: StateFlow<SleepPlayback?> get() = state
fun start(context: Context, sound: SleepSound, minutes: Int) = context.startForegroundService(
Intent(context, SleepSoundService::class.java).putExtra(EXTRA_SOUND, sound.ordinal).putExtra(EXTRA_MINUTES, minutes),
)
fun stop(context: Context) = context.startService(Intent(context, SleepSoundService::class.java).setAction(ACTION_STOP))
}
}
@@ -0,0 +1,62 @@
package app.uhr.alarm
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.lerp
import app.uhr.ui.theme.WColor
import kotlinx.coroutines.delay
/**
* Sunrise alarm: in the last minutes before the wake-up alarm the screen turns on over the lock screen
* and brightens slowly from a dim red glow to warm daylight. Tap to dismiss; the alarm still rings.
*/
class SunriseActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setShowWhenLocked(true)
setTurnScreenOn(true)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val endsAt = intent.getLongExtra(EXTRA_FIRE_AT, System.currentTimeMillis() + DURATION_MS)
val startedAt = endsAt - DURATION_MS
setContent {
val progress = remember { mutableFloatStateOf(0f) }
val ringing by RingingService.ringing.collectAsState()
LaunchedEffect(Unit) {
while (true) {
val p = ((System.currentTimeMillis() - startedAt).toFloat() / DURATION_MS).coerceIn(0f, 1f)
progress.floatValue = p
// the window brightness follows the colour, from almost off to full
window.attributes = window.attributes.apply { screenBrightness = 0.01f + 0.99f * p * p }
delay(500)
}
}
// the ringing screen takes over when the alarm starts
LaunchedEffect(ringing) { if (ringing != null) finish() }
val p = progress.floatValue
Canvas(Modifier.fillMaxSize().clickable { finish() }) {
val top = lerp(WColor.sunriseNight, WColor.sunriseDay, p)
val glow = lerp(WColor.sunriseEmber, WColor.sunriseSun, p)
drawRect(Brush.verticalGradient(listOf(top, glow)))
drawCircle(Brush.radialGradient(listOf(glow.copy(alpha = .9f), glow.copy(alpha = 0f)), Offset(size.width / 2, size.height), size.width * (0.4f + p)), size.width * (0.4f + p), Offset(size.width / 2, size.height))
}
}
}
companion object {
const val EXTRA_FIRE_AT = "fire_at"
const val DURATION_MS = 15 * 60_000L
}
}
@@ -28,10 +28,11 @@ class ClockRepo(private val context: Context, private val dao: ClockDao, val pre
suspend fun save(alarm: Alarm): Long = lock.withLock { suspend fun save(alarm: Alarm): Long = lock.withLock {
val stamped = alarm.copy(updatedAt = System.currentTimeMillis()) val stamped = alarm.copy(updatedAt = System.currentTimeMillis())
val id = if (alarm.id == 0L) dao.insert(stamped) else stamped.id.also { dao.update(stamped) } val id = if (alarm.id == 0L) dao.insert(stamped) else stamped.id.also { dao.update(stamped) }
dao.alarm(id)?.let { scheduler.reschedule(it) } val saved = dao.alarm(id)
saved?.let { scheduler.reschedule(it) }
updateClockWidgets(context) updateClockWidgets(context)
id id
} }.also { id -> if (dao.alarm(id)?.isBedtimeWake == true) scheduleBedtime() }
suspend fun delete(alarm: Alarm) = lock.withLock { suspend fun delete(alarm: Alarm) = lock.withLock {
scheduler.cancel(alarm.id) scheduler.cancel(alarm.id)
@@ -182,8 +183,15 @@ class ClockRepo(private val context: Context, private val dao: ClockDao, val pre
scheduleBedtime() scheduleBedtime()
} }
suspend fun bedtimeAlarmFireAt(): Long? {
val a = dao.bedtimeAlarm()?.takeIf { it.enabled } ?: return null
return dao.instance(a.id)?.takeIf { it.state == InstanceState.SCHEDULED }?.fireAt
}
suspend fun scheduleBedtime() { suspend fun scheduleBedtime() {
val b = prefs.bedtime.first() val b = prefs.bedtime.first()
scheduler.cancelSunrise()
if (b.enabled && b.sunrise) bedtimeAlarmFireAt()?.let { scheduler.scheduleSunrise(it) }
scheduler.cancelBedtimeReminder() scheduler.cancelBedtimeReminder()
if (!b.enabled || b.reminderMin < 0) return if (!b.enabled || b.reminderMin < 0) return
val reminder = Alarm(hour = b.bedHour, minute = b.bedMinute, days = b.days) val reminder = Alarm(hour = b.bedHour, minute = b.bedMinute, days = b.days)
+4 -2
View File
@@ -55,6 +55,7 @@ data class Bedtime(
val wakeMinute: Int = 0, val wakeMinute: Int = 0,
val days: Int = Alarm.WEEKDAYS, val days: Int = Alarm.WEEKDAYS,
val reminderMin: Int = 15, val reminderMin: Int = 15,
val sunrise: Boolean = false,
) )
private val Context.store by preferencesDataStore("clock") private val Context.store by preferencesDataStore("clock")
@@ -85,6 +86,7 @@ class Prefs(private val context: Context) {
val wakeM = intPreferencesKey("wake_m") val wakeM = intPreferencesKey("wake_m")
val bedDays = intPreferencesKey("bed_days") val bedDays = intPreferencesKey("bed_days")
val bedReminder = intPreferencesKey("bed_reminder") val bedReminder = intPreferencesKey("bed_reminder")
val sunrise = booleanPreferencesKey("sunrise")
} }
private fun Preferences.settings(): Settings { private fun Preferences.settings(): Settings {
@@ -142,14 +144,14 @@ class Prefs(private val context: Context) {
Bedtime( Bedtime(
enabled = p[K.bedOn] ?: d.enabled, bedHour = p[K.bedH] ?: d.bedHour, bedMinute = p[K.bedM] ?: d.bedMinute, enabled = p[K.bedOn] ?: d.enabled, bedHour = p[K.bedH] ?: d.bedHour, bedMinute = p[K.bedM] ?: d.bedMinute,
wakeHour = p[K.wakeH] ?: d.wakeHour, wakeMinute = p[K.wakeM] ?: d.wakeMinute, wakeHour = p[K.wakeH] ?: d.wakeHour, wakeMinute = p[K.wakeM] ?: d.wakeMinute,
days = p[K.bedDays] ?: d.days, reminderMin = p[K.bedReminder] ?: d.reminderMin, days = p[K.bedDays] ?: d.days, reminderMin = p[K.bedReminder] ?: d.reminderMin, sunrise = p[K.sunrise] ?: d.sunrise,
) )
} }
suspend fun setBedtime(b: Bedtime) { suspend fun setBedtime(b: Bedtime) {
context.store.edit { p -> context.store.edit { p ->
p[K.bedOn] = b.enabled; p[K.bedH] = b.bedHour; p[K.bedM] = b.bedMinute; p[K.wakeH] = b.wakeHour p[K.bedOn] = b.enabled; p[K.bedH] = b.bedHour; p[K.bedM] = b.bedMinute; p[K.wakeH] = b.wakeHour
p[K.wakeM] = b.wakeMinute; p[K.bedDays] = b.days; p[K.bedReminder] = b.reminderMin p[K.wakeM] = b.wakeMinute; p[K.bedDays] = b.days; p[K.bedReminder] = b.reminderMin; p[K.sunrise] = b.sunrise
} }
} }
} }
@@ -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 }
}
@@ -22,6 +22,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bedtime import androidx.compose.material.icons.outlined.Bedtime
import androidx.compose.material.icons.outlined.NotificationsActive import androidx.compose.material.icons.outlined.NotificationsActive
import androidx.compose.material.icons.outlined.WbSunny import androidx.compose.material.icons.outlined.WbSunny
import androidx.compose.material.icons.outlined.WbTwilight
import androidx.compose.material.icons.outlined.Waves
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.LocalContext
import app.uhr.alarm.SleepSound
import app.uhr.alarm.SleepSoundService
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -102,7 +110,9 @@ fun BedtimeScreen(b: Bedtime, settings: Settings, onSave: (Bedtime) -> Unit, con
{ m -> if (m < 0) stringResource(R.string.off) else if (m == 0) stringResource(R.string.at_bedtime) else stringResource(R.string.minutes_before, m) }, { m -> if (m < 0) stringResource(R.string.off) else if (m == 0) stringResource(R.string.at_bedtime) else stringResource(R.string.minutes_before, m) },
{ m -> onSave(b.copy(reminderMin = m)) }, Icons.Outlined.NotificationsActive) { m -> onSave(b.copy(reminderMin = m)) }, Icons.Outlined.NotificationsActive)
} }
item { SettingsSwitch(it, stringResource(R.string.sunrise_alarm), b.sunrise, { v -> onSave(b.copy(sunrise = v)) }, stringResource(R.string.sunrise_alarm_sub), Icons.Outlined.WbTwilight) }
} }
SleepSounds()
Spacer(Modifier.height(120.dp)) Spacer(Modifier.height(120.dp))
} }
if (pickBed) TimeDialog(b.bedHour, b.bedMinute, { pickBed = false }) { h, m -> pickBed = false; onSave(b.copy(bedHour = h, bedMinute = m)) } if (pickBed) TimeDialog(b.bedHour, b.bedMinute, { pickBed = false }) { h, m -> pickBed = false; onSave(b.copy(bedHour = h, bedMinute = m)) }
@@ -122,3 +132,29 @@ private fun TimeTile(icon: androidx.compose.ui.graphics.vector.ImageVector, labe
Text(time, style = tabular(WType.timerTime)) Text(time, style = tabular(WType.timerTime))
} }
} }
/** Own looping sounds with a sleep timer; they fade out at the end. */
@Composable
private fun SleepSounds() {
val context = LocalContext.current
val playing by SleepSoundService.playing.collectAsState()
var sound by remember { mutableStateOf(playing?.sound ?: SleepSound.RAIN) }
var minutes by remember { mutableStateOf(30) }
SettingsGroup(stringResource(R.string.sleep_sounds)) {
item {
SettingsChoice(it, stringResource(R.string.sleep_sound), SleepSound.entries, sound, { s -> stringResource(s.title) }, { s -> sound = s }, Icons.Outlined.Waves)
}
item {
SettingsChoice(it, stringResource(R.string.sleep_timer), listOf(15, 30, 45, 60, 90), minutes, { m -> stringResource(R.string.n_minutes, m) }, { m -> minutes = m }, Icons.Outlined.Bedtime)
}
item {
val on = playing != null
app.uhr.ui.components.SettingsItem(
it, stringResource(if (on) R.string.stop else R.string.sleep_play),
if (on) stringResource(R.string.sleep_ends, app.uhr.ui.components.Fmt.clock(playing!!.endsAt)) else null,
if (on) Icons.Rounded.Stop else Icons.Rounded.PlayArrow,
onClick = { if (on) SleepSoundService.stop(context) else SleepSoundService.start(context, sound, minutes) },
)
}
}
}
@@ -40,6 +40,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.zIndex
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -69,6 +73,7 @@ fun ClockScreen(
contentPadding: PaddingValues, contentPadding: PaddingValues,
onDeleteCity: (City) -> Unit, onDeleteCity: (City) -> Unit,
nowOverride: Long? = null, nowOverride: Long? = null,
onReorder: (List<Long>) -> Unit = {},
) { ) {
val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) } val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
if (nowOverride == null) LaunchedEffect(settings.showSeconds) { if (nowOverride == null) LaunchedEffect(settings.showSeconds) {
@@ -77,6 +82,11 @@ fun ClockScreen(
val now = tick.longValue val now = tick.longValue
val res = LocalContext.current.resources val res = LocalContext.current.resources
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val view = androidx.compose.ui.platform.LocalView.current
val density = androidx.compose.ui.platform.LocalDensity.current
var order by remember(cities) { mutableStateOf(cities) }
var draggedId by remember { mutableStateOf<Long?>(null) }
var dragOffset by remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
LazyColumn( LazyColumn(
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp), contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
@@ -101,7 +111,33 @@ fun ClockScreen(
} }
} }
} }
items(cities, key = { it.id }) { city -> CityRow(city, now, settings) { onDeleteCity(city) } } items(order, key = { it.id }) { city ->
val dragging = draggedId == city.id
Box(
Modifier.zIndex(if (dragging) 1f else 0f)
.graphicsLayer { translationY = if (dragging) dragOffset else 0f; scaleX = if (dragging) 1.03f else 1f; scaleY = scaleX; shadowElevation = if (dragging) 12f else 0f }
.then(if (dragging) Modifier else Modifier.animateItem())
.pointerInput(city.id) {
detectDragGesturesAfterLongPress(
onDragStart = { draggedId = city.id; dragOffset = 0f; view.performHapticFeedback(android.view.HapticFeedbackConstants.LONG_PRESS) },
onDragEnd = { draggedId = null; dragOffset = 0f; onReorder(order.map { it.id }) },
onDragCancel = { draggedId = null; dragOffset = 0f; order = cities },
) { change, drag ->
change.consume()
dragOffset += drag.y
// swap with the neighbour once the card has moved past half of it
val step = size.height + with(density) { WSpace.s8.toPx() }
val i = order.indexOfFirst { it.id == city.id }
val target = (i + (dragOffset / step).toInt()).coerceIn(0, order.lastIndex)
if (target != i) {
order = order.toMutableList().apply { add(target, removeAt(i)) }
dragOffset -= (target - i) * step
view.performHapticFeedback(android.view.HapticFeedbackConstants.SEGMENT_TICK)
}
}
},
) { CityRow(city, now, settings) { onDeleteCity(city) } }
}
} }
} }
@@ -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()))
} }
} }
@@ -39,6 +39,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -72,6 +73,7 @@ interface TimerActions {
fun reset(id: Long) fun reset(id: Long)
fun delete(id: Long) fun delete(id: Long)
fun stopRinging() fun stopRinging()
fun rename(id: Long, label: String) {}
} }
/** S08: keypad when there is no timer (or after "+"), cards for running ones. */ /** S08: keypad when there is no timer (or after "+"), cards for running ones. */
@@ -109,7 +111,9 @@ private fun TimerCard(t: Timer, now: Long, actions: TimerActions) {
val label = t.label.ifBlank { stringResource(R.string.timer_of, Fmt.duration(t.durationMs)) } val label = t.label.ifBlank { stringResource(R.string.timer_of, Fmt.duration(t.durationMs)) }
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow).padding(WSpace.s20)) { Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow).padding(WSpace.s20)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, style = WType.title, modifier = Modifier.weight(1f)) var renaming by remember { androidx.compose.runtime.mutableStateOf(false) }
Text(label, style = WType.title, modifier = Modifier.weight(1f).clip(RoundedCornerShape(WRadius.xs)).clickable { renaming = true })
if (renaming) RenameDialog(t.label, { renaming = false }) { renaming = false; actions.rename(t.id, it) }
IconButton(onClick = { actions.delete(t.id) }) { Icon(Icons.Rounded.Close, stringResource(R.string.delete)) } IconButton(onClick = { actions.delete(t.id) }) { Icon(Icons.Rounded.Close, stringResource(R.string.delete)) }
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -205,3 +209,18 @@ private fun Keypad(digits: String, onDigits: (String) -> Unit, modifier: Modifie
} }
} }
} }
@Composable
private fun RenameDialog(current: String, onDismiss: () -> Unit, onSave: (String) -> Unit) {
var text by remember { androidx.compose.runtime.mutableStateOf(current) }
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.rename_timer)) },
text = {
androidx.compose.material3.OutlinedTextField(text, { text = it.take(40) }, singleLine = true,
placeholder = { Text(stringResource(R.string.add_label)) }, modifier = Modifier.fillMaxWidth())
},
confirmButton = { androidx.compose.material3.TextButton(onClick = { onSave(text.trim()) }) { Text(stringResource(android.R.string.ok)) } },
dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
)
}
@@ -65,6 +65,10 @@ object WColor {
val timerProgress = Color(0xFF7EC4A8) val timerProgress = Color(0xFF7EC4A8)
val nightText = Color(0xFFC4433A) val nightText = Color(0xFFC4433A)
val nightBg = Color(0xFF000000) val nightBg = Color(0xFF000000)
val sunriseNight = Color(0xFF0D0710)
val sunriseEmber = Color(0xFF5A1A10)
val sunriseDay = Color(0xFFFFD9A0)
val sunriseSun = Color(0xFFFFF3D6)
} }
object WType { object WType {
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17
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>
@@ -86,6 +87,19 @@
<string name="at_bedtime">Zur Schlafenszeit</string> <string name="at_bedtime">Zur Schlafenszeit</string>
<string name="minutes_before">%1$d Min. vorher</string> <string name="minutes_before">%1$d Min. vorher</string>
<string name="sunrise_alarm">Sonnenaufgang</string>
<string name="sunrise_alarm_sub">15 Min. vor dem Aufwachen wird der Bildschirm langsam heller</string>
<string name="sunrise_running">Sonnenaufgang läuft</string>
<string name="sleep_sounds">Einschlafgeräusche</string>
<string name="sleep_sound">Geräusch</string>
<string name="sleep_timer">Ausschalten nach</string>
<string name="sleep_play">Abspielen</string>
<string name="sleep_ends">Endet um %1$s, wird leiser</string>
<string name="sleep_sound_running">Läuft, blendet am Ende aus</string>
<string name="sound_rain">Regen</string>
<string name="sound_waves">Meeresrauschen</string>
<string name="sound_brown">Tiefes Rauschen</string>
<string name="rename_timer">Timer benennen</string>
<string name="silence_after">Automatisch stumm nach</string> <string name="silence_after">Automatisch stumm nach</string>
<string name="snooze_length">Schlummerdauer</string> <string name="snooze_length">Schlummerdauer</string>
<string name="alarm_volume">Lautstärke</string> <string name="alarm_volume">Lautstärke</string>
@@ -103,6 +117,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>