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