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]>
This commit is contained in:
2026-10-17 15:49:09 +02:00
co-authored by Claude Opus 5.5
parent 08633f918c
commit 7812f62c0f
29 changed files with 675 additions and 70 deletions
@@ -13,6 +13,15 @@ class DebugAlarmActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
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)
if (timerS > 0) {
val id = runBlocking { Graph.repo.startTimer(timerS * 1000L, "Testtimer") }
+3
View File
@@ -37,6 +37,9 @@
android:turnScreenOn="true"
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" />
<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 delete(id: Long) { scope.launch { repo.deleteTimer(id) } }
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 ->
when (tab) {
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 -> {
if (keypad && timers.isNotEmpty()) BackHandler { keypad = false; digits = "" }
TimerScreen(timers, keypad, digits, { digits = it }, timerActions, pad)
@@ -31,12 +31,13 @@ class AlarmReceiver : BroadcastReceiver() {
val repo = Graph.repo
when (action) {
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_DISMISS_NEXT -> repo.dismissNext(id)
ACTION_SNOOZE_DISMISS -> repo.finishAlarm(id)
ACTION_TIMER_PAUSE -> repo.pauseTimer(id)
ACTION_TIMER_ADD_MINUTE -> repo.addMinute(id)
ACTION_SUNRISE -> Notifications.sunrise(context, id)
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_ADD_MINUTE = "app.uhr.TIMER_ADD_MINUTE"
const val ACTION_BEDTIME = "app.uhr.BEDTIME"
const val ACTION_SUNRISE = "app.uhr.SUNRISE"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
}
@@ -32,6 +32,8 @@ object Notifications {
const val ID_STOPWATCH = 2
const val ID_RINGING = 3
const val ID_BEDTIME = 4
const val ID_SLEEP = 5
const val ID_SUNRISE = 6
private const val ALARM_BASE = 1000
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 =
NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle(title).setContentText(text)
@@ -71,6 +71,20 @@ class Scheduler(private val context: Context, private val dao: ClockDao) {
fun scheduleBedtimeReminder(at: Long) =
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))
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_TIMER = 2_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 {
val stamped = alarm.copy(updatedAt = System.currentTimeMillis())
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)
id
}
}.also { id -> if (dao.alarm(id)?.isBedtimeWake == true) scheduleBedtime() }
suspend fun delete(alarm: Alarm) = lock.withLock {
scheduler.cancel(alarm.id)
@@ -182,8 +183,15 @@ class ClockRepo(private val context: Context, private val dao: ClockDao, val pre
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() {
val b = prefs.bedtime.first()
scheduler.cancelSunrise()
if (b.enabled && b.sunrise) bedtimeAlarmFireAt()?.let { scheduler.scheduleSunrise(it) }
scheduler.cancelBedtimeReminder()
if (!b.enabled || b.reminderMin < 0) return
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 days: Int = Alarm.WEEKDAYS,
val reminderMin: Int = 15,
val sunrise: Boolean = false,
)
private val Context.store by preferencesDataStore("clock")
@@ -85,6 +86,7 @@ class Prefs(private val context: Context) {
val wakeM = intPreferencesKey("wake_m")
val bedDays = intPreferencesKey("bed_days")
val bedReminder = intPreferencesKey("bed_reminder")
val sunrise = booleanPreferencesKey("sunrise")
}
private fun Preferences.settings(): Settings {
@@ -142,14 +144,14 @@ class Prefs(private val context: Context) {
Bedtime(
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,
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) {
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.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
}
}
}
@@ -22,6 +22,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bedtime
import androidx.compose.material.icons.outlined.NotificationsActive
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.Icon
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 -> 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))
}
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))
}
}
/** 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.Modifier
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.res.stringResource
import androidx.compose.ui.unit.dp
@@ -69,6 +73,7 @@ fun ClockScreen(
contentPadding: PaddingValues,
onDeleteCity: (City) -> Unit,
nowOverride: Long? = null,
onReorder: (List<Long>) -> Unit = {},
) {
val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
if (nowOverride == null) LaunchedEffect(settings.showSeconds) {
@@ -77,6 +82,11 @@ fun ClockScreen(
val now = tick.longValue
val res = LocalContext.current.resources
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(
Modifier.fillMaxSize(),
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) } }
}
}
}
@@ -39,6 +39,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -72,6 +73,7 @@ interface TimerActions {
fun reset(id: Long)
fun delete(id: Long)
fun stopRinging()
fun rename(id: Long, label: String) {}
}
/** 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)) }
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow).padding(WSpace.s20)) {
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)) }
}
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 nightText = Color(0xFFC4433A)
val nightBg = Color(0xFF000000)
val sunriseNight = Color(0xFF0D0710)
val sunriseEmber = Color(0xFF5A1A10)
val sunriseDay = Color(0xFFFFD9A0)
val sunriseSun = Color(0xFFFFF3D6)
}
object WType {
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13
View File
@@ -87,6 +87,19 @@
<string name="at_bedtime">Zur Schlafenszeit</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="snooze_length">Schlummerdauer</string>
<string name="alarm_volume">Lautstärke</string>