build(uhr): alarms, ringing, timer, stopwatch, world clock, bedtime, settings, widgets
- exact alarms via setAlarmClock, rescheduled on boot/time/zone change and app update - foreground ringing service: alarm-stream audio with gradual volume, vibration, full-screen screen with slide to snooze/stop, silence-after, missed notification - timers and stopwatch keep timestamps only; live countdown/chronometer notifications - world clock offline from the time zone database (no network permission at all) - own alarm tone, own analog face and launcher icon - next-ring logic unit-tested incl. DST gaps and zone changes; screenshot tests Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- no INTERNET, no location: nothing leaves the device -->
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:name=".UhrApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar">
|
||||
|
||||
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".alarm.RingingActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:taskAffinity="" />
|
||||
|
||||
<service android:name=".alarm.RingingService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<receiver android:name=".alarm.AlarmReceiver" android:exported="false" android:directBootAware="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".widget.DigitalClockReceiver" android:exported="true" android:label="@string/widget_digital_name">
|
||||
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_digital" />
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AnalogClockReceiver" android:exported="true" android:label="@string/widget_analog_name">
|
||||
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_analog" />
|
||||
</receiver>
|
||||
<receiver android:name=".widget.NextAlarmReceiver" android:exported="true" android:label="@string/widget_next_name">
|
||||
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_next" />
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,260 @@
|
||||
package app.uhr
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings as SystemSettings
|
||||
import android.text.format.DateFormat
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Alarm
|
||||
import androidx.compose.material.icons.outlined.Bedtime
|
||||
import androidx.compose.material.icons.outlined.HourglassEmpty
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material.icons.outlined.Timer
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.alarm.RingingService
|
||||
import app.uhr.data.Alarm
|
||||
import app.uhr.data.Graph
|
||||
import app.uhr.data.Settings
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.screens.AddCityDialog
|
||||
import app.uhr.ui.screens.AlarmActions
|
||||
import app.uhr.ui.screens.AlarmScreen
|
||||
import app.uhr.ui.screens.BedtimeScreen
|
||||
import app.uhr.ui.screens.ClockScreen
|
||||
import app.uhr.ui.screens.SettingsScreen
|
||||
import app.uhr.ui.screens.StopwatchScreen
|
||||
import app.uhr.ui.screens.TimeDialog
|
||||
import app.uhr.ui.screens.TimerActions
|
||||
import app.uhr.ui.screens.TimerScreen
|
||||
import app.uhr.ui.screens.digitsToMs
|
||||
import app.uhr.ui.theme.ClockTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalTime
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private var tabFromIntent = mutableIntStateOf(-1)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
Graph.init(this)
|
||||
Fmt.is24h = DateFormat.is24HourFormat(this)
|
||||
tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1)
|
||||
setContent {
|
||||
val settings by Graph.repo.prefs.settings.collectAsState(initial = Settings())
|
||||
ClockTheme(settings.theme) { ClockApp(settings, tabFromIntent.intValue) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
Fmt.is24h = DateFormat.is24HourFormat(this)
|
||||
}
|
||||
|
||||
companion object { const val EXTRA_TAB = "tab" }
|
||||
}
|
||||
|
||||
private data class Tab(val title: Int, val icon: ImageVector)
|
||||
|
||||
private val TABS = listOf(
|
||||
Tab(R.string.tab_alarm, Icons.Outlined.Alarm),
|
||||
Tab(R.string.tab_clock, Icons.Outlined.Schedule),
|
||||
Tab(R.string.tab_timer, Icons.Outlined.HourglassEmpty),
|
||||
Tab(R.string.tab_stopwatch, Icons.Outlined.Timer),
|
||||
Tab(R.string.tab_bedtime, Icons.Outlined.Bedtime),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ClockApp(settings: Settings, requestedTab: Int) {
|
||||
val repo = Graph.repo
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||
LaunchedEffect(requestedTab) { if (requestedTab in TABS.indices) tab = requestedTab }
|
||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
var addAlarm by remember { mutableStateOf(false) }
|
||||
var addCity by remember { mutableStateOf(false) }
|
||||
var keypad by rememberSaveable { mutableStateOf(false) }
|
||||
var digits by rememberSaveable { mutableStateOf("") }
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
val alarms by repo.alarms.collectAsState(initial = emptyList())
|
||||
val timers by repo.timers.collectAsState(initial = emptyList())
|
||||
val cities by repo.cities.collectAsState(initial = emptyList())
|
||||
val next by repo.nextInstance.collectAsState(initial = null)
|
||||
val sw by repo.stopwatch.collectAsState(initial = app.uhr.data.Stopwatch())
|
||||
val bedtime by repo.bedtime.collectAsState(initial = app.uhr.data.Bedtime())
|
||||
|
||||
PermissionGate()
|
||||
|
||||
if (showSettings) {
|
||||
BackHandler { showSettings = false }
|
||||
SettingsScreen(settings, { t -> scope.launch { repo.prefs.update(t) } }, onBack = { showSettings = false })
|
||||
return
|
||||
}
|
||||
|
||||
val alarmActions = remember {
|
||||
object : AlarmActions {
|
||||
override fun save(alarm: Alarm) { scope.launch { repo.save(alarm) } }
|
||||
override fun delete(alarm: Alarm) { scope.launch { repo.delete(alarm) } }
|
||||
override fun restore(alarm: Alarm) { scope.launch { repo.restore(alarm) } }
|
||||
override fun dismissNext(alarm: Alarm) { scope.launch { repo.dismissNext(alarm.id) } }
|
||||
}
|
||||
}
|
||||
val timerActions = remember {
|
||||
object : TimerActions {
|
||||
override fun start(durationMs: Long) { scope.launch { repo.startTimer(durationMs) } }
|
||||
override fun pause(id: Long) { scope.launch { repo.pauseTimer(id) } }
|
||||
override fun resume(id: Long) { scope.launch { repo.resumeTimer(id) } }
|
||||
override fun addMinute(id: Long) { scope.launch { repo.addMinute(id) } }
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(TABS[tab].title)) },
|
||||
actions = {
|
||||
IconButton(onClick = { menu = true }) { Icon(Icons.Rounded.MoreVert, stringResource(R.string.more)) }
|
||||
DropdownMenu(menu, { menu = false }) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.settings)) }, onClick = { menu = false; showSettings = true })
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
TABS.forEachIndexed { i, t ->
|
||||
NavigationBarItem(selected = tab == i, onClick = { tab = i }, icon = { Icon(t.icon, null) }, label = { Text(stringResource(t.title)) })
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
val fab: (() -> Unit)? = when (tab) {
|
||||
0 -> ({ addAlarm = true })
|
||||
1 -> ({ addCity = true })
|
||||
2 -> if (timers.isEmpty() || keypad) {
|
||||
if (digits.isNotEmpty()) ({ timerActions.start(digitsToMs(digits)); digits = ""; keypad = false }) else null
|
||||
} else ({ keypad = true })
|
||||
else -> null
|
||||
}
|
||||
if (fab != null) {
|
||||
val icon = if (tab == 2 && (timers.isEmpty() || keypad)) Icons.Rounded.PlayArrow else Icons.Rounded.Add
|
||||
FloatingActionButton(onClick = fab, shape = CircleShape, modifier = Modifier.size(96.dp),
|
||||
containerColor = app.uhr.ui.theme.fabColors().first, contentColor = app.uhr.ui.theme.fabColors().second) {
|
||||
Icon(icon, stringResource(R.string.add), Modifier.size(36.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButtonPosition = androidx.compose.material3.FabPosition.Center,
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
) { pad ->
|
||||
when (tab) {
|
||||
0 -> AlarmScreen(alarms, settings, alarmActions, snackbar, pad)
|
||||
1 -> ClockScreen(settings, cities, next, pad, onDeleteCity = { c -> scope.launch { repo.deleteCity(c.id) } })
|
||||
2 -> {
|
||||
if (keypad && timers.isNotEmpty()) BackHandler { keypad = false; digits = "" }
|
||||
TimerScreen(timers, keypad, digits, { digits = it }, timerActions, pad)
|
||||
}
|
||||
3 -> StopwatchScreen(sw, { scope.launch { repo.stopwatchToggle() } }, { scope.launch { repo.stopwatchLap() } }, { scope.launch { repo.stopwatchReset() } }, pad)
|
||||
4 -> BedtimeScreen(bedtime, settings, { b -> scope.launch { repo.saveBedtime(b) } }, pad)
|
||||
}
|
||||
}
|
||||
|
||||
if (addAlarm) {
|
||||
val t = LocalTime.now().plusHours(1).withMinute(0)
|
||||
TimeDialog(t.hour, t.minute, { addAlarm = false }) { h, m ->
|
||||
addAlarm = false
|
||||
scope.launch {
|
||||
val id = repo.save(Alarm(hour = h, minute = m))
|
||||
repo.instance(id)?.let { snackbar.showSnackbar(context.getString(R.string.alarm_set_in, Fmt.until(context.resources, it.fireAt))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (addCity) AddCityDialog({ addCity = false }) { hit -> addCity = false; scope.launch { repo.addCity(hit) } }
|
||||
}
|
||||
|
||||
/** Notifications and full-screen ringing are what make an alarm app work: ask once, explain if missing. */
|
||||
@Composable
|
||||
private fun PermissionGate() {
|
||||
val context = LocalContext.current
|
||||
var asked by rememberSaveable { mutableStateOf(false) }
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
LaunchedEffect(Unit) {
|
||||
if (!asked && Build.VERSION.SDK_INT >= 33) { asked = true; launcher.launch(Manifest.permission.POST_NOTIFICATIONS) }
|
||||
}
|
||||
val nm = context.getSystemService(NotificationManager::class.java)
|
||||
var hide by rememberSaveable { mutableStateOf(false) }
|
||||
if (Build.VERSION.SDK_INT >= 34 && !nm.canUseFullScreenIntent() && !hide) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { hide = true },
|
||||
title = { Text(stringResource(R.string.fsi_title)) },
|
||||
text = { Text(stringResource(R.string.fsi_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
hide = true
|
||||
context.startActivity(Intent(SystemSettings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, android.net.Uri.parse("package:${context.packageName}")))
|
||||
}) { Text(stringResource(R.string.open_settings)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { hide = true }) { Text(stringResource(R.string.later)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package app.uhr
|
||||
|
||||
import android.app.Application
|
||||
import app.uhr.alarm.Notifications
|
||||
import app.uhr.data.Graph
|
||||
|
||||
class UhrApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Graph.init(this)
|
||||
Notifications.createChannels(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package app.uhr.alarm
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import app.uhr.data.Graph
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Entry point for every scheduled event and every notification action. */
|
||||
class AlarmReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Graph.init(context)
|
||||
val id = intent.getLongExtra(EXTRA_ID, -1)
|
||||
when (intent.action) {
|
||||
// ringing must start while the receiver is alive: foreground service right away
|
||||
ACTION_RING -> RingingService.startAlarm(context, id)
|
||||
ACTION_TIMER -> RingingService.startTimer(context, id)
|
||||
else -> {
|
||||
val pending = goAsync()
|
||||
scope.launch {
|
||||
try { handle(context, intent.action, id) } finally { pending.finish() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handle(context: Context, action: String?, id: Long) {
|
||||
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()
|
||||
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_BEDTIME -> { Notifications.bedtime(context); repo.scheduleBedtime() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ID = "id"
|
||||
const val ACTION_RING = "app.uhr.RING"
|
||||
const val ACTION_UPCOMING = "app.uhr.UPCOMING"
|
||||
const val ACTION_DISMISS_NEXT = "app.uhr.DISMISS_NEXT"
|
||||
const val ACTION_SNOOZE_DISMISS = "app.uhr.SNOOZE_DISMISS"
|
||||
const val ACTION_TIMER = "app.uhr.TIMER"
|
||||
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"
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package app.uhr.alarm
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import app.uhr.MainActivity
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Alarm
|
||||
import app.uhr.data.Stopwatch
|
||||
import app.uhr.data.StopwatchState
|
||||
import app.uhr.data.Timer
|
||||
import app.uhr.data.TimerState
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
object Notifications {
|
||||
const val CH_RINGING = "ringing"
|
||||
const val CH_UPCOMING = "upcoming"
|
||||
const val CH_MISSED = "missed"
|
||||
const val CH_RUNNING = "running"
|
||||
const val CH_BEDTIME = "bedtime"
|
||||
|
||||
const val ID_TIMERS = 1
|
||||
const val ID_STOPWATCH = 2
|
||||
const val ID_RINGING = 3
|
||||
const val ID_BEDTIME = 4
|
||||
private const val ALARM_BASE = 1000
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
val nm = context.getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannels(
|
||||
listOf(
|
||||
NotificationChannel(CH_RINGING, context.getString(R.string.ch_ringing), NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
setSound(null, null); enableVibration(false); setBypassDnd(true)
|
||||
},
|
||||
NotificationChannel(CH_UPCOMING, context.getString(R.string.ch_upcoming), NotificationManager.IMPORTANCE_LOW),
|
||||
NotificationChannel(CH_MISSED, context.getString(R.string.ch_missed), NotificationManager.IMPORTANCE_DEFAULT),
|
||||
NotificationChannel(CH_RUNNING, context.getString(R.string.ch_running), NotificationManager.IMPORTANCE_LOW),
|
||||
NotificationChannel(CH_BEDTIME, context.getString(R.string.ch_bedtime), NotificationManager.IMPORTANCE_DEFAULT),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun allowed(context: Context) =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun nm(context: Context) = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
private fun open(context: Context, tab: Int = 0) = PendingIntent.getActivity(
|
||||
context, 100 + tab, Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_TAB, tab),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private fun action(context: Context, action: String, id: Long) = PendingIntent.getBroadcast(
|
||||
context, (action.hashCode() * 31 + id).toInt(),
|
||||
Intent(context, AlarmReceiver::class.java).setAction(action).putExtra(AlarmReceiver.EXTRA_ID, id),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private fun time(at: Long) = DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(at))
|
||||
private fun title(context: Context, a: Alarm) = a.label.ifBlank { context.getString(R.string.alarm) }
|
||||
|
||||
fun upcoming(context: Context, a: Alarm, fireAt: Long) {
|
||||
if (!allowed(context)) return
|
||||
nm(context).notify(
|
||||
(ALARM_BASE + a.id).toInt(),
|
||||
NotificationCompat.Builder(context, CH_UPCOMING).setSmallIcon(R.drawable.ic_stat_alarm)
|
||||
.setContentTitle(context.getString(R.string.notif_upcoming, time(fireAt)))
|
||||
.setContentText(title(context, a)).setContentIntent(open(context))
|
||||
.addAction(0, context.getString(R.string.dismiss_now), action(context, AlarmReceiver.ACTION_DISMISS_NEXT, a.id))
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER).build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun snoozed(context: Context, a: Alarm, until: Long) {
|
||||
if (!allowed(context)) return
|
||||
nm(context).notify(
|
||||
(ALARM_BASE + a.id).toInt(),
|
||||
NotificationCompat.Builder(context, CH_UPCOMING).setSmallIcon(R.drawable.ic_stat_alarm)
|
||||
.setContentTitle(context.getString(R.string.notif_snoozed, time(until)))
|
||||
.setContentText(title(context, a)).setContentIntent(open(context)).setOngoing(true)
|
||||
.addAction(0, context.getString(R.string.stop), action(context, AlarmReceiver.ACTION_SNOOZE_DISMISS, a.id))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun missed(context: Context, a: Alarm) {
|
||||
if (!allowed(context)) return
|
||||
nm(context).notify(
|
||||
(ALARM_BASE + a.id).toInt(),
|
||||
NotificationCompat.Builder(context, CH_MISSED).setSmallIcon(R.drawable.ic_stat_alarm)
|
||||
.setContentTitle(context.getString(R.string.notif_missed))
|
||||
.setContentText("${title(context, a)} · ${String.format("%d:%02d", a.hour, a.minute)}")
|
||||
.setContentIntent(open(context)).setAutoCancel(true).build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelAlarm(context: Context, id: Long) = nm(context).cancel((ALARM_BASE + id).toInt())
|
||||
|
||||
/** One notification for all running/paused timers; the soonest one counts down live. */
|
||||
fun timers(context: Context, timers: List<Timer>) {
|
||||
val active = timers.filter { it.state != TimerState.RINGING }
|
||||
if (active.isEmpty() || !allowed(context)) { nm(context).cancel(ID_TIMERS); return }
|
||||
val next = active.filter { it.state == TimerState.RUNNING }.minByOrNull { it.endAt ?: Long.MAX_VALUE } ?: active.first()
|
||||
val b = NotificationCompat.Builder(context, CH_RUNNING).setSmallIcon(R.drawable.ic_stat_timer)
|
||||
.setContentTitle(next.label.ifBlank { context.getString(R.string.timer) })
|
||||
.setContentIntent(open(context, 2)).setOngoing(true).setOnlyAlertOnce(true).setSilent(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_STOPWATCH)
|
||||
if (next.state == TimerState.RUNNING) {
|
||||
b.setUsesChronometer(true).setChronometerCountDown(true).setWhen(next.endAt ?: 0).setShowWhen(true)
|
||||
b.addAction(0, context.getString(R.string.pause), action(context, AlarmReceiver.ACTION_TIMER_PAUSE, next.id))
|
||||
} else {
|
||||
b.setContentText(context.getString(R.string.paused))
|
||||
}
|
||||
b.addAction(0, context.getString(R.string.plus_minute), action(context, AlarmReceiver.ACTION_TIMER_ADD_MINUTE, next.id))
|
||||
if (active.size > 1) b.setSubText(context.getString(R.string.n_timers, active.size))
|
||||
nm(context).notify(ID_TIMERS, b.build())
|
||||
}
|
||||
|
||||
fun stopwatch(context: Context, s: Stopwatch) {
|
||||
if (s.state != StopwatchState.RUNNING || !allowed(context)) { nm(context).cancel(ID_STOPWATCH); return }
|
||||
// chronometer counts from a wall-clock base: translate the elapsedRealtime start
|
||||
val base = System.currentTimeMillis() - (SystemClock.elapsedRealtime() - s.startedAt) - s.accumulatedMs
|
||||
nm(context).notify(
|
||||
ID_STOPWATCH,
|
||||
NotificationCompat.Builder(context, CH_RUNNING).setSmallIcon(R.drawable.ic_stat_timer)
|
||||
.setContentTitle(context.getString(R.string.stopwatch)).setUsesChronometer(true).setWhen(base).setShowWhen(true)
|
||||
.setContentIntent(open(context, 3)).setOngoing(true).setSilent(true).build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun bedtime(context: Context) {
|
||||
if (!allowed(context)) return
|
||||
nm(context).notify(
|
||||
ID_BEDTIME,
|
||||
NotificationCompat.Builder(context, CH_BEDTIME).setSmallIcon(R.drawable.ic_stat_bedtime)
|
||||
.setContentTitle(context.getString(R.string.notif_bedtime)).setContentText(context.getString(R.string.notif_bedtime_text))
|
||||
.setContentIntent(open(context, 4)).setAutoCancel(true).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)
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM).setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setFullScreenIntent(fullScreen, true).setContentIntent(fullScreen).setOngoing(true)
|
||||
.apply { if (snooze != null) addAction(0, context.getString(R.string.snooze), snooze) }
|
||||
.addAction(0, context.getString(R.string.stop), stop)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC).build()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package app.uhr.alarm
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Alarm
|
||||
import androidx.compose.material.icons.rounded.HourglassBottom
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Graph
|
||||
import app.uhr.data.VolumeButtons
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.theme.WType
|
||||
import app.uhr.ui.theme.WColor
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Full-screen ringing UI, shown over the lock screen. */
|
||||
class RingingActivity : ComponentActivity() {
|
||||
private var volumeButtons = VolumeButtons.VOLUME
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
enableEdgeToEdge()
|
||||
Graph.init(this)
|
||||
setContent {
|
||||
val ring by RingingService.ringing.collectAsState()
|
||||
LaunchedEffect(Unit) { volumeButtons = Graph.repo.prefs.settingsOnce().volumeButtons }
|
||||
LaunchedEffect(ring) { if (ring == null) { delay(300); if (RingingService.ringing.value == null) finish() } }
|
||||
ring?.let { RingingScreen(it, onSnooze = { RingingService.snooze(this) }, onStop = { RingingService.stop(this) }) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) return super.onKeyDown(keyCode, event)
|
||||
when (volumeButtons) {
|
||||
VolumeButtons.SNOOZE -> if (RingingService.ringing.value is Ring.AlarmRing) RingingService.snooze(this) else RingingService.stop(this)
|
||||
VolumeButtons.STOP -> RingingService.stop(this)
|
||||
VolumeButtons.NOTHING -> Unit
|
||||
VolumeButtons.VOLUME -> return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RingingScreen(ring: Ring, onSnooze: () -> Unit, onStop: () -> Unit) {
|
||||
val now = remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) { while (true) { now.longValue = System.currentTimeMillis(); delay(1000) } }
|
||||
val label = when (ring) {
|
||||
is Ring.AlarmRing -> ring.alarm.label.ifBlank { stringResource(R.string.alarm) }
|
||||
is Ring.TimerRing -> ring.label.ifBlank { stringResource(R.string.timer_done) }
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(WColor.ringBg).navigationBarsPadding().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
val big = when (ring) {
|
||||
is Ring.AlarmRing -> Fmt.clock(now.longValue)
|
||||
// a finished timer counts up the time since it ended
|
||||
is Ring.TimerRing -> "−" + Fmt.duration(now.longValue - ring.since)
|
||||
}
|
||||
Text(big, style = WType.ringing, color = WColor.ringOn)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(label, style = WType.title, color = WColor.ringOn)
|
||||
Spacer(Modifier.weight(1.2f))
|
||||
val snooze = if (ring is Ring.AlarmRing) onSnooze else null
|
||||
SlideToAct(
|
||||
leftLabel = if (snooze != null) stringResource(R.string.snooze) else stringResource(R.string.stop),
|
||||
rightLabel = stringResource(R.string.stop),
|
||||
icon = if (ring is Ring.AlarmRing) Icons.Rounded.Alarm else Icons.Rounded.HourglassBottom,
|
||||
onLeft = snooze ?: onStop, onRight = onStop,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Pill track with an alarm thumb in the middle: drag left to snooze, right to stop. */
|
||||
@Composable
|
||||
fun SlideToAct(leftLabel: String, rightLabel: String, icon: ImageVector, onLeft: () -> Unit, onRight: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val offset = remember { Animatable(0f) }
|
||||
BoxWithConstraints(
|
||||
Modifier.fillMaxWidth().height(76.dp).clip(RoundedCornerShape(38.dp)).background(WColor.ringTrack)
|
||||
.semantics {
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction(leftLabel) { onLeft(); true },
|
||||
CustomAccessibilityAction(rightLabel) { onRight(); true },
|
||||
)
|
||||
},
|
||||
) {
|
||||
val maxPx = with(LocalDensity.current) { (maxWidth / 2 - 38.dp).toPx() }
|
||||
Row(Modifier.fillMaxSize().padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(leftLabel, style = WType.label, color = WColor.ringOn, modifier = Modifier.clickable(onClick = onLeft))
|
||||
Text(rightLabel, style = WType.label, color = WColor.ringOn, modifier = Modifier.clickable(onClick = onRight))
|
||||
}
|
||||
Box(
|
||||
Modifier.align(Alignment.Center).offset { IntOffset(offset.value.roundToInt(), 0) }
|
||||
.size(width = 96.dp, height = 64.dp).clip(RoundedCornerShape(32.dp)).background(WColor.ringThumb)
|
||||
.pointerInput(maxPx) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragEnd = {
|
||||
scope.launch {
|
||||
val v = offset.value
|
||||
when {
|
||||
v < -maxPx * .6f -> { offset.snapTo(-maxPx); onLeft() }
|
||||
v > maxPx * .6f -> { offset.snapTo(maxPx); onRight() }
|
||||
else -> offset.animateTo(0f, spring(dampingRatio = .6f))
|
||||
}
|
||||
}
|
||||
},
|
||||
) { _, drag -> scope.launch { offset.snapTo((offset.value + drag).coerceIn(-maxPx, maxPx)) } }
|
||||
}
|
||||
.semantics { contentDescription = "$leftLabel / $rightLabel" },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Icon(icon, null, tint = WColor.onRingThumb, modifier = Modifier.size(28.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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.AudioManager
|
||||
import android.media.MediaPlayer
|
||||
import android.media.RingtoneManager
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import androidx.core.app.ServiceCompat
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Alarm
|
||||
import app.uhr.data.Graph
|
||||
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
|
||||
|
||||
/** What is ringing right now, for the full-screen activity. */
|
||||
sealed interface Ring {
|
||||
data class AlarmRing(val alarm: Alarm) : Ring
|
||||
data class TimerRing(val timerId: Long, val label: String, val since: Long) : Ring
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the alarm or timer: sound on the alarm stream with gradual volume, vibration,
|
||||
* a full-screen notification, and the "silence after" timeout.
|
||||
*/
|
||||
class RingingService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var player: MediaPlayer? = null
|
||||
private var vibrator: Vibrator? = null
|
||||
private var timeout: Job? = null
|
||||
private var ramp: Job? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Graph.init(this)
|
||||
val id = intent?.getLongExtra(AlarmReceiver.EXTRA_ID, -1) ?: -1
|
||||
// must be in the foreground within seconds, before any database work
|
||||
goForeground(getString(R.string.alarm), "")
|
||||
when (intent?.action) {
|
||||
ACTION_ALARM -> scope.launch { startAlarm(id) }
|
||||
ACTION_TIMER -> scope.launch { startTimer(id) }
|
||||
ACTION_SNOOZE -> scope.launch { (current.value as? Ring.AlarmRing)?.let { Graph.repo.snooze(it.alarm.id) }; stop() }
|
||||
ACTION_STOP -> scope.launch { stopCurrent(); stop() }
|
||||
else -> stop()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private suspend fun startAlarm(id: Long) {
|
||||
val repo = Graph.repo
|
||||
val alarm = repo.alarm(id) ?: return stop()
|
||||
if (!alarm.enabled) return stop()
|
||||
repo.markRinging(id)
|
||||
val ring = Ring.AlarmRing(alarm)
|
||||
current.value = ring
|
||||
val title = alarm.label.ifBlank { getString(R.string.alarm) }
|
||||
goForeground(title, String.format("%d:%02d", alarm.hour, alarm.minute), snooze = true)
|
||||
val s = repo.prefs.settingsOnce()
|
||||
play(alarm.ringtoneUri, s.alarmVolume, s.gradualVolumeS)
|
||||
if (alarm.vibrate) vibrate()
|
||||
timeout?.cancel()
|
||||
timeout = scope.launch {
|
||||
delay(s.silenceAfterMin * 60_000L)
|
||||
repo.finishAlarm(id, missed = true)
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startTimer(id: Long) {
|
||||
val repo = Graph.repo
|
||||
val t = repo.timer(id) ?: return stop()
|
||||
repo.markTimerRinging(id)
|
||||
current.value = Ring.TimerRing(id, t.label, t.endAt ?: System.currentTimeMillis())
|
||||
goForeground(t.label.ifBlank { getString(R.string.timer_done) }, getString(R.string.timer_done), snooze = false)
|
||||
val s = repo.prefs.settingsOnce()
|
||||
play(s.timerSound, s.alarmVolume, 0)
|
||||
if (s.timerVibrate) vibrate()
|
||||
timeout?.cancel()
|
||||
timeout = scope.launch { delay(s.silenceAfterMin * 60_000L); stopCurrent(); stop() }
|
||||
}
|
||||
|
||||
private suspend fun stopCurrent() {
|
||||
when (val r = current.value) {
|
||||
is Ring.AlarmRing -> Graph.repo.finishAlarm(r.alarm.id)
|
||||
is Ring.TimerRing -> Graph.repo.deleteTimer(r.timerId)
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun goForeground(title: String, text: String, snooze: Boolean = false) {
|
||||
val full = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, RingingActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val n = Notifications.ringing(this, title, text, full, self(ACTION_STOP), if (snooze) self(ACTION_SNOOZE) else null)
|
||||
ServiceCompat.startForeground(this, Notifications.ID_RINGING, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
||||
}
|
||||
|
||||
private fun self(action: String) = PendingIntent.getService(
|
||||
this, action.hashCode(), Intent(this, RingingService::class.java).setAction(action), PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun play(uri: String?, volumePercent: Int, rampSeconds: Int) {
|
||||
player?.release(); player = null
|
||||
if (uri == Alarm.SILENT) return
|
||||
val audio = getSystemService(AudioManager::class.java)
|
||||
val target = (volumePercent / 100f).coerceIn(0.05f, 1f)
|
||||
val candidates = listOfNotNull(
|
||||
uri?.let(Uri::parse),
|
||||
RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM),
|
||||
Uri.parse("android.resource://$packageName/${R.raw.alarm_default}"),
|
||||
)
|
||||
// a picked file may be gone: try the next one rather than ring silently
|
||||
for (u in candidates) {
|
||||
val p = runCatching {
|
||||
MediaPlayer().apply {
|
||||
setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
|
||||
setDataSource(this@RingingService, u)
|
||||
isLooping = true
|
||||
prepare()
|
||||
}
|
||||
}.getOrNull() ?: continue
|
||||
player = p
|
||||
break
|
||||
}
|
||||
val p = player ?: return
|
||||
if (audio.getStreamVolume(AudioManager.STREAM_ALARM) == 0) {
|
||||
audio.setStreamVolume(AudioManager.STREAM_ALARM, audio.getStreamMaxVolume(AudioManager.STREAM_ALARM) / 2, 0)
|
||||
}
|
||||
p.setVolume(if (rampSeconds > 0) 0.05f else target, if (rampSeconds > 0) 0.05f else target)
|
||||
p.start()
|
||||
ramp?.cancel()
|
||||
if (rampSeconds > 0) ramp = scope.launch {
|
||||
val steps = rampSeconds * 4
|
||||
for (i in 1..steps) {
|
||||
delay(250)
|
||||
val v = 0.05f + (target - 0.05f) * i / steps
|
||||
runCatching { p.setVolume(v, v) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun vibrate() {
|
||||
val v = getSystemService(VibratorManager::class.java)?.defaultVibrator ?: return
|
||||
vibrator = v
|
||||
v.vibrate(VibrationEffect.createWaveform(longArrayOf(0, 600, 400, 600, 1200), 0),
|
||||
android.os.VibrationAttributes.createForUsage(android.os.VibrationAttributes.USAGE_ALARM))
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
timeout?.cancel(); ramp?.cancel()
|
||||
player?.runCatching { stop(); release() }; player = null
|
||||
vibrator?.cancel(); vibrator = null
|
||||
current.value = null
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
player?.release(); vibrator?.cancel(); current.value = null
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_ALARM = "alarm"
|
||||
private const val ACTION_TIMER = "timer"
|
||||
const val ACTION_SNOOZE = "snooze"
|
||||
const val ACTION_STOP = "stop"
|
||||
|
||||
private val _current = MutableStateFlow<Ring?>(null)
|
||||
val current: MutableStateFlow<Ring?> get() = _current
|
||||
val ringing: StateFlow<Ring?> get() = _current
|
||||
|
||||
fun startAlarm(context: Context, id: Long) = start(context, ACTION_ALARM, id)
|
||||
fun startTimer(context: Context, id: Long) = start(context, ACTION_TIMER, id)
|
||||
fun snooze(context: Context) = start(context, ACTION_SNOOZE, -1)
|
||||
fun stop(context: Context) = start(context, ACTION_STOP, -1)
|
||||
|
||||
private fun start(context: Context, action: String, id: Long) = context.startForegroundService(
|
||||
Intent(context, RingingService::class.java).setAction(action).putExtra(AlarmReceiver.EXTRA_ID, id),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package app.uhr.alarm
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import app.uhr.MainActivity
|
||||
import app.uhr.data.Alarm
|
||||
import app.uhr.data.AlarmInstance
|
||||
import app.uhr.data.ClockDao
|
||||
import app.uhr.data.InstanceState
|
||||
import app.uhr.data.Timer
|
||||
import app.uhr.data.nextRing
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/** All AlarmManager access. Alarms use setAlarmClock: Doze-exempt and shown in the status bar. */
|
||||
class Scheduler(private val context: Context, private val dao: ClockDao) {
|
||||
private val am = context.getSystemService(AlarmManager::class.java)
|
||||
|
||||
suspend fun reschedule(alarm: Alarm, now: ZonedDateTime = ZonedDateTime.now()) {
|
||||
cancel(alarm.id)
|
||||
if (!alarm.enabled) { dao.deleteInstance(alarm.id); Notifications.cancelAlarm(context, alarm.id); return }
|
||||
val fireAt = nextRing(alarm, now).toInstant().toEpochMilli()
|
||||
dao.putInstance(AlarmInstance(alarm.id, fireAt, InstanceState.SCHEDULED))
|
||||
setRing(alarm.id, fireAt)
|
||||
scheduleUpcomingNotice(alarm.id, fireAt)
|
||||
Notifications.cancelAlarm(context, alarm.id)
|
||||
}
|
||||
|
||||
suspend fun snooze(alarmId: Long, minutes: Int) {
|
||||
val fireAt = System.currentTimeMillis() + minutes * 60_000L
|
||||
dao.putInstance(AlarmInstance(alarmId, fireAt, InstanceState.SNOOZED))
|
||||
setRing(alarmId, fireAt)
|
||||
}
|
||||
|
||||
/** After a reboot, a time change or an app update: every alarm from scratch, snoozes kept. */
|
||||
suspend fun rescheduleAll() {
|
||||
val instances = dao.instancesOnce().associateBy { it.alarmId }
|
||||
val now = System.currentTimeMillis()
|
||||
for (alarm in dao.alarmsOnce()) {
|
||||
val snoozed = instances[alarm.id]?.takeIf { it.state == InstanceState.SNOOZED && it.fireAt > now }
|
||||
if (alarm.enabled && snoozed != null) setRing(alarm.id, snoozed.fireAt) else reschedule(alarm)
|
||||
}
|
||||
dao.timersOnce().filter { it.endAt != null && it.state == app.uhr.data.TimerState.RUNNING }.forEach { scheduleTimer(it) }
|
||||
}
|
||||
|
||||
fun cancel(alarmId: Long) {
|
||||
am.cancel(ringIntent(alarmId))
|
||||
am.cancel(upcomingIntent(alarmId))
|
||||
}
|
||||
|
||||
private fun setRing(alarmId: Long, fireAt: Long) {
|
||||
val show = PendingIntent.getActivity(
|
||||
context, alarmId.toInt(), Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
am.setAlarmClock(AlarmManager.AlarmClockInfo(fireAt, show), ringIntent(alarmId))
|
||||
}
|
||||
|
||||
private fun scheduleUpcomingNotice(alarmId: Long, fireAt: Long) {
|
||||
val at = fireAt - UPCOMING_MS
|
||||
if (at > System.currentTimeMillis()) am.set(AlarmManager.RTC_WAKEUP, at, upcomingIntent(alarmId))
|
||||
}
|
||||
|
||||
fun scheduleTimer(timer: Timer) {
|
||||
val end = timer.endAt ?: return
|
||||
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, end, timerIntent(timer.id))
|
||||
}
|
||||
|
||||
fun cancelTimer(id: Long) = am.cancel(timerIntent(id))
|
||||
|
||||
fun scheduleBedtimeReminder(at: Long) =
|
||||
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, 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 upcomingIntent(id: Long) = broadcast(AlarmReceiver.ACTION_UPCOMING, id, REQ_UPCOMING)
|
||||
private fun timerIntent(id: Long) = broadcast(AlarmReceiver.ACTION_TIMER, id, REQ_TIMER)
|
||||
|
||||
private fun broadcast(action: String, id: Long, base: Int): PendingIntent = PendingIntent.getBroadcast(
|
||||
context, base + id.toInt(),
|
||||
Intent(context, AlarmReceiver::class.java).setAction(action).putExtra(AlarmReceiver.EXTRA_ID, id),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val UPCOMING_MS = 2 * 60 * 60 * 1000L
|
||||
private const val REQ_RING = 0
|
||||
private const val REQ_UPCOMING = 1_000_000
|
||||
private const val REQ_TIMER = 2_000_000
|
||||
private const val REQ_BEDTIME = 3_000_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package app.uhr.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import app.uhr.alarm.Notifications
|
||||
import app.uhr.alarm.Scheduler
|
||||
import app.uhr.widget.updateClockWidgets
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.time.LocalDate
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
class ClockRepo(private val context: Context, private val dao: ClockDao, val prefs: Prefs) {
|
||||
val scheduler = Scheduler(context, dao)
|
||||
/** Ringing, snooze and dismiss can race (notification vs. screen); every transition goes through here. */
|
||||
private val lock = Mutex()
|
||||
|
||||
// --- alarms -------------------------------------------------------------------------------
|
||||
|
||||
val alarms: Flow<List<AlarmWithInstance>> = dao.alarms()
|
||||
val nextInstance: Flow<AlarmInstance?> = dao.nextInstance()
|
||||
suspend fun alarm(id: Long) = dao.alarm(id)
|
||||
suspend fun instance(id: Long) = dao.instance(id)
|
||||
suspend fun nextInstanceOnce() = dao.nextInstanceOnce()
|
||||
|
||||
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) }
|
||||
updateClockWidgets(context)
|
||||
id
|
||||
}
|
||||
|
||||
suspend fun delete(alarm: Alarm) = lock.withLock {
|
||||
scheduler.cancel(alarm.id)
|
||||
Notifications.cancelAlarm(context, alarm.id)
|
||||
dao.delete(alarm)
|
||||
updateClockWidgets(context)
|
||||
}
|
||||
|
||||
/** Re-insert after "undo"; keeps the id so nothing else has to change. */
|
||||
suspend fun restore(alarm: Alarm) = lock.withLock {
|
||||
dao.insert(alarm)
|
||||
scheduler.reschedule(alarm)
|
||||
updateClockWidgets(context)
|
||||
}
|
||||
|
||||
suspend fun snooze(id: Long) = lock.withLock {
|
||||
val minutes = prefs.settingsOnce().snoozeMin
|
||||
scheduler.snooze(id, minutes)
|
||||
dao.alarm(id)?.let { a -> dao.instance(id)?.let { Notifications.snoozed(context, a, it.fireAt) } }
|
||||
updateClockWidgets(context)
|
||||
}
|
||||
|
||||
/** Stop: a one-time alarm switches off, a repeating one moves to its next day. */
|
||||
suspend fun finishAlarm(id: Long, missed: Boolean = false) = lock.withLock {
|
||||
val alarm = dao.alarm(id) ?: return@withLock
|
||||
Notifications.cancelAlarm(context, id)
|
||||
if (missed) Notifications.missed(context, alarm)
|
||||
if (alarm.repeats) scheduler.reschedule(alarm)
|
||||
else {
|
||||
val off = alarm.copy(enabled = false, skipUntil = null)
|
||||
dao.update(off); scheduler.reschedule(off)
|
||||
}
|
||||
updateClockWidgets(context)
|
||||
}
|
||||
|
||||
/** Skip only the upcoming ring ("dismiss now" before it rings). */
|
||||
suspend fun dismissNext(id: Long) = lock.withLock {
|
||||
val alarm = dao.alarm(id) ?: return@withLock
|
||||
val inst = dao.instance(id) ?: return@withLock
|
||||
Notifications.cancelAlarm(context, id)
|
||||
if (!alarm.repeats) { val off = alarm.copy(enabled = false); dao.update(off); scheduler.reschedule(off) }
|
||||
else {
|
||||
val after = java.time.Instant.ofEpochMilli(inst.fireAt).atZone(java.time.ZoneId.systemDefault()).plusMinutes(1)
|
||||
scheduler.reschedule(alarm, after)
|
||||
}
|
||||
updateClockWidgets(context)
|
||||
}
|
||||
|
||||
suspend fun markRinging(id: Long) = lock.withLock {
|
||||
dao.instance(id)?.let { dao.putInstance(it.copy(state = InstanceState.RINGING, updatedAt = System.currentTimeMillis())) }
|
||||
}
|
||||
|
||||
// --- timers -------------------------------------------------------------------------------
|
||||
|
||||
val timers: Flow<List<Timer>> = dao.timers()
|
||||
suspend fun timer(id: Long) = dao.timer(id)
|
||||
|
||||
suspend fun startTimer(durationMs: Long, label: String = ""): Long {
|
||||
val end = System.currentTimeMillis() + durationMs
|
||||
val id = dao.insert(Timer(label = label, durationMs = durationMs, remainingMs = durationMs, endAt = end, state = TimerState.RUNNING))
|
||||
dao.timer(id)?.let { scheduler.scheduleTimer(it); Notifications.timers(context, dao.timersOnce()) }
|
||||
return id
|
||||
}
|
||||
|
||||
suspend fun pauseTimer(id: Long) = updateTimer(id) { t ->
|
||||
if (t.state != TimerState.RUNNING) t else t.copy(state = TimerState.PAUSED, remainingMs = t.remaining().coerceAtLeast(0), endAt = null)
|
||||
}
|
||||
|
||||
suspend fun resumeTimer(id: Long) = updateTimer(id) { t ->
|
||||
if (t.state != TimerState.PAUSED) t else t.copy(state = TimerState.RUNNING, endAt = System.currentTimeMillis() + t.remainingMs)
|
||||
}
|
||||
|
||||
suspend fun addMinute(id: Long) = updateTimer(id) { t ->
|
||||
when (t.state) {
|
||||
TimerState.PAUSED -> t.copy(remainingMs = t.remainingMs + 60_000, durationMs = t.durationMs + 60_000)
|
||||
// a ringing timer restarts with one minute
|
||||
TimerState.RINGING -> t.copy(state = TimerState.RUNNING, endAt = System.currentTimeMillis() + 60_000, durationMs = 60_000, remainingMs = 60_000)
|
||||
TimerState.RUNNING -> t.copy(endAt = (t.endAt ?: System.currentTimeMillis()) + 60_000, durationMs = t.durationMs + 60_000)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetTimer(id: Long) = updateTimer(id) { t ->
|
||||
t.copy(state = TimerState.PAUSED, remainingMs = t.durationMs, endAt = null)
|
||||
}
|
||||
|
||||
suspend fun setTimerLabel(id: Long, label: String) = updateTimer(id) { it.copy(label = label) }
|
||||
|
||||
suspend fun markTimerRinging(id: Long) = updateTimer(id) { it.copy(state = TimerState.RINGING) }
|
||||
|
||||
suspend fun deleteTimer(id: Long) {
|
||||
scheduler.cancelTimer(id); dao.deleteTimer(id)
|
||||
Notifications.timers(context, dao.timersOnce())
|
||||
}
|
||||
|
||||
private suspend fun updateTimer(id: Long, f: (Timer) -> Timer) {
|
||||
val t = dao.timer(id) ?: return
|
||||
val n = f(t)
|
||||
dao.update(n)
|
||||
scheduler.cancelTimer(id)
|
||||
if (n.state == TimerState.RUNNING) scheduler.scheduleTimer(n)
|
||||
Notifications.timers(context, dao.timersOnce())
|
||||
}
|
||||
|
||||
// --- stopwatch ----------------------------------------------------------------------------
|
||||
|
||||
val stopwatch: Flow<Stopwatch> = prefs.stopwatch
|
||||
|
||||
suspend fun stopwatchToggle() {
|
||||
val s = prefs.stopwatch.first()
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val next = when (s.state) {
|
||||
StopwatchState.RUNNING -> s.copy(state = StopwatchState.STOPPED, accumulatedMs = s.elapsed(now))
|
||||
else -> s.copy(state = StopwatchState.RUNNING, startedAt = now)
|
||||
}
|
||||
prefs.setStopwatch(next)
|
||||
Notifications.stopwatch(context, next)
|
||||
}
|
||||
|
||||
suspend fun stopwatchLap() {
|
||||
val s = prefs.stopwatch.first()
|
||||
if (s.state != StopwatchState.RUNNING) return
|
||||
prefs.setStopwatch(s.copy(laps = s.laps + s.elapsed(SystemClock.elapsedRealtime())))
|
||||
}
|
||||
|
||||
suspend fun stopwatchReset() {
|
||||
prefs.setStopwatch(Stopwatch())
|
||||
Notifications.stopwatch(context, Stopwatch())
|
||||
}
|
||||
|
||||
// --- world clock --------------------------------------------------------------------------
|
||||
|
||||
val cities: Flow<List<City>> = dao.cities()
|
||||
suspend fun addCity(hit: ZoneHit) { dao.insert(City(zoneId = hit.zoneId, name = hit.city, sortOrder = dao.nextCityOrder())); updateClockWidgets(context) }
|
||||
suspend fun deleteCity(id: Long) { dao.deleteCity(id); updateClockWidgets(context) }
|
||||
suspend fun reorderCities(ids: List<Long>) = dao.reorderCities(ids)
|
||||
|
||||
// --- bedtime ------------------------------------------------------------------------------
|
||||
|
||||
val bedtime: Flow<Bedtime> = prefs.bedtime
|
||||
|
||||
/** Bedtime owns exactly one alarm (is_bedtime_wake) and one reminder. */
|
||||
suspend fun saveBedtime(b: Bedtime) {
|
||||
prefs.setBedtime(b)
|
||||
val existing = dao.bedtimeAlarm()
|
||||
val wake = (existing ?: Alarm(hour = b.wakeHour, minute = b.wakeMinute, isBedtimeWake = true))
|
||||
.copy(hour = b.wakeHour, minute = b.wakeMinute, days = b.days, enabled = b.enabled, isBedtimeWake = true)
|
||||
save(wake)
|
||||
scheduleBedtime()
|
||||
}
|
||||
|
||||
suspend fun scheduleBedtime() {
|
||||
val b = prefs.bedtime.first()
|
||||
scheduler.cancelBedtimeReminder()
|
||||
if (!b.enabled || b.reminderMin < 0) return
|
||||
val reminder = Alarm(hour = b.bedHour, minute = b.bedMinute, days = b.days)
|
||||
val at = nextRing(reminder, ZonedDateTime.now().plusMinutes(b.reminderMin.toLong())).minusMinutes(b.reminderMin.toLong())
|
||||
scheduler.scheduleBedtimeReminder(at.toInstant().toEpochMilli())
|
||||
}
|
||||
|
||||
fun today(): LocalDate = LocalDate.now()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package app.uhr.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Database
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.TypeConverter
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.Update
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.time.LocalDate
|
||||
|
||||
class Converters {
|
||||
@TypeConverter fun fromDate(d: LocalDate?): String? = d?.toString()
|
||||
@TypeConverter fun toDate(s: String?): LocalDate? = s?.let(LocalDate::parse)
|
||||
@TypeConverter fun fromState(s: InstanceState): String = s.name.lowercase()
|
||||
@TypeConverter fun toState(s: String): InstanceState = InstanceState.valueOf(s.uppercase())
|
||||
@TypeConverter fun fromTimerState(s: TimerState): String = s.name.lowercase()
|
||||
@TypeConverter fun toTimerState(s: String): TimerState = TimerState.valueOf(s.uppercase())
|
||||
}
|
||||
|
||||
data class AlarmWithInstance(
|
||||
@androidx.room.Embedded val alarm: Alarm,
|
||||
@androidx.room.Relation(parentColumn = "id", entityColumn = "alarm_id") val instance: AlarmInstance?,
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface ClockDao {
|
||||
@Transaction
|
||||
@Query("SELECT * FROM alarms ORDER BY is_bedtime_wake DESC, hour, minute, id")
|
||||
fun alarms(): Flow<List<AlarmWithInstance>>
|
||||
|
||||
@Query("SELECT * FROM alarms") suspend fun alarmsOnce(): List<Alarm>
|
||||
@Query("SELECT * FROM alarms WHERE id = :id") suspend fun alarm(id: Long): Alarm?
|
||||
@Query("SELECT * FROM alarms WHERE is_bedtime_wake = 1 LIMIT 1") suspend fun bedtimeAlarm(): Alarm?
|
||||
@Insert suspend fun insert(alarm: Alarm): Long
|
||||
@Update suspend fun update(alarm: Alarm)
|
||||
@Delete suspend fun delete(alarm: Alarm)
|
||||
|
||||
@Query("SELECT * FROM alarm_instances WHERE alarm_id = :id") suspend fun instance(id: Long): AlarmInstance?
|
||||
@Query("SELECT * FROM alarm_instances") suspend fun instancesOnce(): List<AlarmInstance>
|
||||
@Upsert suspend fun putInstance(instance: AlarmInstance)
|
||||
@Query("DELETE FROM alarm_instances WHERE alarm_id = :id") suspend fun deleteInstance(id: Long)
|
||||
|
||||
/** The soonest alarm that will actually ring, for widgets and the clock header. */
|
||||
@Query("SELECT * FROM alarm_instances WHERE state IN ('scheduled','snoozed') ORDER BY fire_at LIMIT 1")
|
||||
fun nextInstance(): Flow<AlarmInstance?>
|
||||
@Query("SELECT * FROM alarm_instances WHERE state IN ('scheduled','snoozed') ORDER BY fire_at LIMIT 1")
|
||||
suspend fun nextInstanceOnce(): AlarmInstance?
|
||||
|
||||
@Query("SELECT * FROM timers ORDER BY created_at") fun timers(): Flow<List<Timer>>
|
||||
@Query("SELECT * FROM timers ORDER BY created_at") suspend fun timersOnce(): List<Timer>
|
||||
@Query("SELECT * FROM timers WHERE id = :id") suspend fun timer(id: Long): Timer?
|
||||
@Insert suspend fun insert(timer: Timer): Long
|
||||
@Update suspend fun update(timer: Timer)
|
||||
@Query("DELETE FROM timers WHERE id = :id") suspend fun deleteTimer(id: Long)
|
||||
|
||||
@Query("SELECT * FROM cities ORDER BY sort_order, id") fun cities(): Flow<List<City>>
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(city: City): Long
|
||||
@Query("DELETE FROM cities WHERE id = :id") suspend fun deleteCity(id: Long)
|
||||
@Query("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM cities") suspend fun nextCityOrder(): Int
|
||||
@Query("UPDATE cities SET sort_order = :order WHERE id = :id") suspend fun setCityOrder(id: Long, order: Int)
|
||||
@Transaction suspend fun reorderCities(ids: List<Long>) = ids.forEachIndexed { i, id -> setCityOrder(id, i) }
|
||||
}
|
||||
|
||||
@Database(entities = [Alarm::class, AlarmInstance::class, Timer::class, City::class], version = 1, exportSchema = true)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class ClockDb : RoomDatabase() {
|
||||
abstract fun dao(): ClockDao
|
||||
|
||||
companion object {
|
||||
fun create(context: Context) = Room.databaseBuilder(context, ClockDb::class.java, "clock.db").build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package app.uhr.data
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object Graph {
|
||||
lateinit var repo: ClockRepo private set
|
||||
|
||||
fun init(context: Context) {
|
||||
if (::repo.isInitialized) return
|
||||
val app = context.applicationContext
|
||||
repo = ClockRepo(app, ClockDb.create(app).dao(), Prefs(app))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package app.uhr.data
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
|
||||
@Entity(tableName = "alarms", indices = [Index("hour", "minute")])
|
||||
data class Alarm(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val hour: Int,
|
||||
val minute: Int,
|
||||
val enabled: Boolean = true,
|
||||
/** bit 0 = Monday … bit 6 = Sunday; 0 = rings once */
|
||||
val days: Int = 0,
|
||||
val label: String = "",
|
||||
/** null = default sound, [SILENT] = no sound */
|
||||
@ColumnInfo(name = "ringtone_uri") val ringtoneUri: String? = null,
|
||||
val vibrate: Boolean = true,
|
||||
/** paused until and including this date */
|
||||
@ColumnInfo(name = "skip_until") val skipUntil: LocalDate? = null,
|
||||
@ColumnInfo(name = "is_bedtime_wake") val isBedtimeWake: Boolean = false,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
|
||||
@ColumnInfo(name = "updated_at") val updatedAt: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val repeats: Boolean get() = days != 0
|
||||
fun ringsOn(day: DayOfWeek): Boolean = days and (1 shl (day.value - 1)) != 0
|
||||
fun withDay(day: DayOfWeek, on: Boolean): Alarm =
|
||||
copy(days = if (on) days or (1 shl (day.value - 1)) else days and (1 shl (day.value - 1)).inv())
|
||||
|
||||
companion object {
|
||||
const val SILENT = "silent"
|
||||
const val WEEKDAYS = 0b0011111
|
||||
const val EVERY_DAY = 0b1111111
|
||||
}
|
||||
}
|
||||
|
||||
enum class InstanceState { SCHEDULED, SNOOZED, RINGING, MISSED, DISMISSED }
|
||||
|
||||
/** The next concrete ring of an alarm; snooze and "dismiss early" live here. */
|
||||
@Entity(
|
||||
tableName = "alarm_instances",
|
||||
foreignKeys = [ForeignKey(Alarm::class, ["id"], ["alarm_id"], onDelete = ForeignKey.CASCADE)],
|
||||
indices = [Index("state", "fire_at")],
|
||||
)
|
||||
data class AlarmInstance(
|
||||
@PrimaryKey @ColumnInfo(name = "alarm_id") val alarmId: Long,
|
||||
@ColumnInfo(name = "fire_at") val fireAt: Long,
|
||||
val state: InstanceState = InstanceState.SCHEDULED,
|
||||
@ColumnInfo(name = "updated_at") val updatedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
enum class TimerState { RUNNING, PAUSED, RINGING }
|
||||
|
||||
@Entity(tableName = "timers", indices = [Index("state", "end_at")])
|
||||
data class Timer(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val label: String = "",
|
||||
@ColumnInfo(name = "duration_ms") val durationMs: Long,
|
||||
/** valid while paused */
|
||||
@ColumnInfo(name = "remaining_ms") val remainingMs: Long,
|
||||
/** set while running or ringing */
|
||||
@ColumnInfo(name = "end_at") val endAt: Long?,
|
||||
val state: TimerState,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
fun remaining(now: Long = System.currentTimeMillis()): Long = when (state) {
|
||||
TimerState.PAUSED -> remainingMs
|
||||
else -> (endAt ?: now) - now
|
||||
}
|
||||
}
|
||||
|
||||
@Entity(tableName = "cities", indices = [Index(value = ["zone_id"], unique = true), Index("sort_order")])
|
||||
data class City(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
@ColumnInfo(name = "zone_id") val zoneId: String,
|
||||
val name: String,
|
||||
@ColumnInfo(name = "sort_order") val sortOrder: Int = 0,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package app.uhr.data
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* When an alarm rings next, strictly after [now]. Wall-clock based: 7:00 stays 7:00 across
|
||||
* time zone and DST changes. A time that does not exist (spring forward) rings at the first valid
|
||||
* instant after it, which is what ZonedDateTime.of does.
|
||||
*/
|
||||
fun nextRing(alarm: Alarm, now: ZonedDateTime): ZonedDateTime {
|
||||
val zone: ZoneId = now.zone
|
||||
var date = now.toLocalDate()
|
||||
val skipUntil = alarm.skipUntil
|
||||
repeat(400) {
|
||||
val candidate = ZonedDateTime.of(LocalDateTime.of(date, java.time.LocalTime.of(alarm.hour, alarm.minute)), zone)
|
||||
val dayOk = !alarm.repeats || alarm.ringsOn(date.dayOfWeek)
|
||||
val notSkipped = skipUntil == null || date.isAfter(skipUntil)
|
||||
if (candidate.isAfter(now) && dayOk && notSkipped) return candidate
|
||||
date = date.plusDays(1)
|
||||
}
|
||||
error("no next ring for $alarm")
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package app.uhr.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||
enum class VolumeButtons { VOLUME, SNOOZE, STOP, NOTHING }
|
||||
enum class ClockStyle { DIGITAL, ANALOG }
|
||||
|
||||
data class Settings(
|
||||
val silenceAfterMin: Int = 10,
|
||||
val snoozeMin: Int = 10,
|
||||
/** 0..100, share of the alarm stream's max volume */
|
||||
val alarmVolume: Int = 80,
|
||||
/** seconds to ramp up from quiet; 0 = off */
|
||||
val gradualVolumeS: Int = 30,
|
||||
val volumeButtons: VolumeButtons = VolumeButtons.VOLUME,
|
||||
/** java.time.DayOfWeek value, 1 = Monday */
|
||||
val weekStart: Int = 1,
|
||||
val clockStyle: ClockStyle = ClockStyle.DIGITAL,
|
||||
val showSeconds: Boolean = false,
|
||||
val autoHomeClock: Boolean = true,
|
||||
val homeZone: String? = null,
|
||||
val timerSound: String? = null,
|
||||
val timerVibrate: Boolean = true,
|
||||
val theme: ThemeMode = ThemeMode.SYSTEM,
|
||||
)
|
||||
|
||||
enum class StopwatchState { IDLE, RUNNING, STOPPED }
|
||||
|
||||
/** Timestamps only (elapsedRealtime), never a running counter, so process death changes nothing. */
|
||||
data class Stopwatch(
|
||||
val state: StopwatchState = StopwatchState.IDLE,
|
||||
val startedAt: Long = 0,
|
||||
val accumulatedMs: Long = 0,
|
||||
val laps: List<Long> = emptyList(),
|
||||
) {
|
||||
fun elapsed(now: Long): Long = accumulatedMs + if (state == StopwatchState.RUNNING) now - startedAt else 0
|
||||
}
|
||||
|
||||
data class Bedtime(
|
||||
val enabled: Boolean = false,
|
||||
val bedHour: Int = 23,
|
||||
val bedMinute: Int = 0,
|
||||
val wakeHour: Int = 7,
|
||||
val wakeMinute: Int = 0,
|
||||
val days: Int = Alarm.WEEKDAYS,
|
||||
val reminderMin: Int = 15,
|
||||
)
|
||||
|
||||
private val Context.store by preferencesDataStore("clock")
|
||||
|
||||
class Prefs(private val context: Context) {
|
||||
private object K {
|
||||
val silence = intPreferencesKey("silence_after")
|
||||
val snooze = intPreferencesKey("snooze")
|
||||
val volume = intPreferencesKey("alarm_volume")
|
||||
val gradual = intPreferencesKey("gradual")
|
||||
val buttons = stringPreferencesKey("volume_buttons")
|
||||
val weekStart = intPreferencesKey("week_start")
|
||||
val style = stringPreferencesKey("clock_style")
|
||||
val seconds = booleanPreferencesKey("seconds")
|
||||
val autoHome = booleanPreferencesKey("auto_home")
|
||||
val homeZone = stringPreferencesKey("home_zone")
|
||||
val timerSound = stringPreferencesKey("timer_sound")
|
||||
val timerVibrate = booleanPreferencesKey("timer_vibrate")
|
||||
val theme = stringPreferencesKey("theme")
|
||||
val swState = stringPreferencesKey("sw_state")
|
||||
val swStarted = longPreferencesKey("sw_started")
|
||||
val swAcc = longPreferencesKey("sw_acc")
|
||||
val swLaps = stringPreferencesKey("sw_laps")
|
||||
val bedOn = booleanPreferencesKey("bed_on")
|
||||
val bedH = intPreferencesKey("bed_h")
|
||||
val bedM = intPreferencesKey("bed_m")
|
||||
val wakeH = intPreferencesKey("wake_h")
|
||||
val wakeM = intPreferencesKey("wake_m")
|
||||
val bedDays = intPreferencesKey("bed_days")
|
||||
val bedReminder = intPreferencesKey("bed_reminder")
|
||||
}
|
||||
|
||||
private fun Preferences.settings(): Settings {
|
||||
val d = Settings()
|
||||
return Settings(
|
||||
silenceAfterMin = this[K.silence] ?: d.silenceAfterMin,
|
||||
snoozeMin = this[K.snooze] ?: d.snoozeMin,
|
||||
alarmVolume = this[K.volume] ?: d.alarmVolume,
|
||||
gradualVolumeS = this[K.gradual] ?: d.gradualVolumeS,
|
||||
volumeButtons = this[K.buttons].enumOr(d.volumeButtons),
|
||||
weekStart = this[K.weekStart] ?: d.weekStart,
|
||||
clockStyle = this[K.style].enumOr(d.clockStyle),
|
||||
showSeconds = this[K.seconds] ?: d.showSeconds,
|
||||
autoHomeClock = this[K.autoHome] ?: d.autoHomeClock,
|
||||
homeZone = this[K.homeZone],
|
||||
timerSound = this[K.timerSound],
|
||||
timerVibrate = this[K.timerVibrate] ?: d.timerVibrate,
|
||||
theme = this[K.theme].enumOr(d.theme),
|
||||
)
|
||||
}
|
||||
|
||||
val settings: Flow<Settings> = context.store.data.map { it.settings() }
|
||||
suspend fun settingsOnce(): Settings = settings.first()
|
||||
|
||||
suspend fun update(transform: (Settings) -> Settings) {
|
||||
context.store.edit { p ->
|
||||
val s = transform(p.settings())
|
||||
p[K.silence] = s.silenceAfterMin; p[K.snooze] = s.snoozeMin; p[K.volume] = s.alarmVolume
|
||||
p[K.gradual] = s.gradualVolumeS; p[K.buttons] = s.volumeButtons.name; p[K.weekStart] = s.weekStart
|
||||
p[K.style] = s.clockStyle.name; p[K.seconds] = s.showSeconds; p[K.autoHome] = s.autoHomeClock
|
||||
if (s.homeZone != null) p[K.homeZone] = s.homeZone else p.remove(K.homeZone)
|
||||
if (s.timerSound != null) p[K.timerSound] = s.timerSound else p.remove(K.timerSound)
|
||||
p[K.timerVibrate] = s.timerVibrate; p[K.theme] = s.theme.name
|
||||
}
|
||||
}
|
||||
|
||||
val stopwatch: Flow<Stopwatch> = context.store.data.map { p ->
|
||||
Stopwatch(
|
||||
state = p[K.swState].enumOr(StopwatchState.IDLE),
|
||||
startedAt = p[K.swStarted] ?: 0,
|
||||
accumulatedMs = p[K.swAcc] ?: 0,
|
||||
laps = p[K.swLaps]?.split(',')?.filter { it.isNotBlank() }?.map { it.toLong() } ?: emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setStopwatch(s: Stopwatch) {
|
||||
context.store.edit { p ->
|
||||
p[K.swState] = s.state.name; p[K.swStarted] = s.startedAt; p[K.swAcc] = s.accumulatedMs
|
||||
p[K.swLaps] = s.laps.joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
val bedtime: Flow<Bedtime> = context.store.data.map { p ->
|
||||
val d = Bedtime()
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.enumOr(default: E): E =
|
||||
this?.let { runCatching { enumValueOf<E>(it) }.getOrNull() } ?: default
|
||||
@@ -0,0 +1,34 @@
|
||||
package app.uhr.data
|
||||
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
|
||||
data class ZoneHit(val zoneId: String, val city: String, val region: String)
|
||||
|
||||
/** Offline city search over the system time zone database; names localized by ICU. */
|
||||
object Zones {
|
||||
private val all: List<ZoneHit> by lazy {
|
||||
ZoneId.getAvailableZoneIds()
|
||||
.filter { it.contains('/') && !it.startsWith("Etc/") && !it.startsWith("SystemV/") && it.first().isUpperCase() }
|
||||
.mapNotNull { id ->
|
||||
val parts = id.split('/')
|
||||
val city = cityName(id) ?: parts.last().replace('_', ' ')
|
||||
ZoneHit(id, city, parts.first())
|
||||
}
|
||||
.distinctBy { it.city to it.region }
|
||||
.sortedBy { it.city }
|
||||
}
|
||||
|
||||
fun cityName(zoneId: String, locale: Locale = Locale.getDefault()): String? =
|
||||
runCatching {
|
||||
android.icu.text.TimeZoneNames.getInstance(android.icu.util.ULocale.forLocale(locale)).getExemplarLocationName(zoneId)
|
||||
}.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
|
||||
fun search(query: String): List<ZoneHit> {
|
||||
val q = query.trim()
|
||||
if (q.isEmpty()) return all.take(50)
|
||||
return all.filter { it.city.contains(q, ignoreCase = true) || it.zoneId.contains(q.replace(' ', '_'), ignoreCase = true) }
|
||||
.sortedBy { if (it.city.startsWith(q, ignoreCase = true)) 0 else 1 }
|
||||
.take(50)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package app.uhr.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/** Our own face: filled disc, thick rounded hour hand, long minute hand, optional second hand. */
|
||||
@Composable
|
||||
fun AnalogClock(time: ZonedDateTime, size: Dp, face: Color, hourHand: Color, minuteHand: Color, secondHand: Color?, modifier: Modifier = Modifier) {
|
||||
Canvas(modifier.size(size)) { drawAnalog(time.hour, time.minute, time.second, face, hourHand, minuteHand, secondHand) }
|
||||
}
|
||||
|
||||
fun DrawScope.drawAnalog(h: Int, m: Int, s: Int, face: Color, hourHand: Color, minuteHand: Color, secondHand: Color?) {
|
||||
val r = size.minDimension / 2
|
||||
val c = center
|
||||
drawCircle(face, r, c)
|
||||
for (i in 0 until 12) {
|
||||
val a = 2 * PI * i / 12
|
||||
drawCircle(minuteHand.copy(alpha = .35f), r * .025f, Offset(c.x + r * .86f * sin(a).toFloat(), c.y - r * .86f * cos(a).toFloat()))
|
||||
}
|
||||
fun hand(angle: Double, length: Float, width: Float, color: Color) {
|
||||
drawLine(color, c, Offset(c.x + length * sin(angle).toFloat(), c.y - length * cos(angle).toFloat()), width, StrokeCap.Round)
|
||||
}
|
||||
hand(2 * PI * ((h % 12) + m / 60.0) / 12, r * .5f, r * .11f, hourHand)
|
||||
hand(2 * PI * (m + s / 60.0) / 60, r * .74f, r * .06f, minuteHand)
|
||||
if (secondHand != null) hand(2 * PI * s / 60, r * .8f, r * .02f, secondHand)
|
||||
drawCircle(minuteHand, r * .06f, c)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package app.uhr.ui.components
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.text.format.DateFormat
|
||||
import app.uhr.R
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
object Fmt {
|
||||
/** Set once from the system setting (DateFormat.is24HourFormat). */
|
||||
var is24h: Boolean = true
|
||||
|
||||
fun time(h: Int, m: Int): String =
|
||||
if (is24h) String.format(Locale.getDefault(), "%d:%02d", h, m)
|
||||
else String.format(Locale.getDefault(), "%d:%02d", if (h % 12 == 0) 12 else h % 12, m)
|
||||
|
||||
fun ampm(h: Int): String? = if (is24h) null else if (h < 12) "AM" else "PM"
|
||||
|
||||
fun clock(at: Long, zone: ZoneId = ZoneId.systemDefault(), seconds: Boolean = false): String {
|
||||
val t = Instant.ofEpochMilli(at).atZone(zone)
|
||||
val base = time(t.hour, t.minute)
|
||||
return if (seconds) base + String.format(Locale.getDefault(), ":%02d", t.second) else base
|
||||
}
|
||||
|
||||
fun longDate(at: Long): String =
|
||||
Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("EEE, d. MMMM", Locale.getDefault()))
|
||||
|
||||
fun dayShort(d: DayOfWeek): String = d.getDisplayName(TextStyle.SHORT, Locale.getDefault()).trimEnd('.')
|
||||
fun dayNarrow(d: DayOfWeek): String = d.getDisplayName(TextStyle.NARROW, Locale.getDefault())
|
||||
fun dayFull(d: DayOfWeek): String = d.getDisplayName(TextStyle.FULL, Locale.getDefault())
|
||||
|
||||
/** mm:ss or h:mm:ss */
|
||||
fun duration(ms: Long): String {
|
||||
val s = abs(ms) / 1000
|
||||
val h = s / 3600; val m = s / 60 % 60; val sec = s % 60
|
||||
return if (h > 0) String.format(Locale.getDefault(), "%d:%02d:%02d", h, m, sec) else String.format(Locale.getDefault(), "%d:%02d", m, sec)
|
||||
}
|
||||
|
||||
/** mm:ss.cc, with hours when needed */
|
||||
fun stopwatch(ms: Long): String {
|
||||
val cs = ms / 10 % 100; val s = ms / 1000
|
||||
val h = s / 3600; val m = s / 60 % 60; val sec = s % 60
|
||||
return if (h > 0) String.format(Locale.getDefault(), "%d:%02d:%02d.%02d", h, m, sec, cs)
|
||||
else String.format(Locale.getDefault(), "%02d:%02d.%02d", m, sec, cs)
|
||||
}
|
||||
|
||||
/** "in 7 Std. 12 Min." */
|
||||
fun until(res: Resources, fireAt: Long, now: Long = System.currentTimeMillis()): String {
|
||||
val mins = ((fireAt - now + 59_999) / 60_000).coerceAtLeast(0)
|
||||
val d = mins / 1440; val h = mins / 60 % 24; val m = mins % 60
|
||||
return when {
|
||||
d > 0 -> res.getString(R.string.in_days_hours, d, h)
|
||||
h > 0 -> res.getString(R.string.in_hours_minutes, h, m)
|
||||
else -> res.getString(R.string.in_minutes, m)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Heute", "Morgen", or the weekday, for the next ring. */
|
||||
fun ringDay(res: Resources, fireAt: Long): String {
|
||||
val d = Instant.ofEpochMilli(fireAt).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
val today = LocalDate.now()
|
||||
return when (d) {
|
||||
today -> res.getString(R.string.today)
|
||||
today.plusDays(1) -> res.getString(R.string.tomorrow)
|
||||
else -> d.format(DateTimeFormatter.ofPattern("EEE, d. MMM", Locale.getDefault()))
|
||||
}
|
||||
}
|
||||
|
||||
/** Repeat summary: "Jeden Tag", "Werktags", "Mo, Mi, Fr". */
|
||||
fun days(res: Resources, days: Int, weekStart: Int): String = when (days) {
|
||||
0 -> ""
|
||||
0b1111111 -> res.getString(R.string.every_day)
|
||||
0b0011111 -> res.getString(R.string.weekdays)
|
||||
0b1100000 -> res.getString(R.string.weekends)
|
||||
else -> orderedDays(weekStart).filter { days and (1 shl (it.value - 1)) != 0 }.joinToString(", ") { dayShort(it) }
|
||||
}
|
||||
|
||||
fun orderedDays(weekStart: Int): List<DayOfWeek> = (0 until 7).map { DayOfWeek.of((weekStart - 1 + it) % 7 + 1) }
|
||||
|
||||
/** "+7 Std." / "−1:30 Std." relative to local time */
|
||||
fun offset(res: Resources, zone: ZoneId, now: Long = System.currentTimeMillis()): String {
|
||||
val i = Instant.ofEpochMilli(now)
|
||||
val diff = Duration.ofSeconds((zone.rules.getOffset(i).totalSeconds - ZoneId.systemDefault().rules.getOffset(i).totalSeconds).toLong())
|
||||
if (diff.isZero) return res.getString(R.string.same_time)
|
||||
val sign = if (diff.isNegative) "−" else "+"
|
||||
val a = diff.abs()
|
||||
val txt = if (a.toMinutesPart() == 0) "${a.toHours()}" else String.format(Locale.getDefault(), "%d:%02d", a.toHours(), a.toMinutesPart())
|
||||
val there = i.atZone(zone).toLocalDate(); val here = i.atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
val day = when { there.isAfter(here) -> res.getString(R.string.tomorrow) + " · "; there.isBefore(here) -> res.getString(R.string.yesterday) + " · "; else -> "" }
|
||||
return day + res.getString(R.string.hours_offset, sign + txt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package app.uhr.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.ui.theme.WSpace
|
||||
import app.uhr.ui.theme.WType
|
||||
|
||||
/*
|
||||
* Settings in the style of current Pixel system settings: grouped tiles with large outer
|
||||
* corners and small inner ones, 2dp gaps, tonal icon circles, values as summaries,
|
||||
* choices in dialogs.
|
||||
*/
|
||||
|
||||
private val Outer = 24.dp
|
||||
private val Inner = 4.dp
|
||||
|
||||
private fun tileShape(index: Int, count: Int): Shape = when {
|
||||
count == 1 -> RoundedCornerShape(Outer)
|
||||
index == 0 -> RoundedCornerShape(topStart = Outer, topEnd = Outer, bottomStart = Inner, bottomEnd = Inner)
|
||||
index == count - 1 -> RoundedCornerShape(topStart = Inner, topEnd = Inner, bottomStart = Outer, bottomEnd = Outer)
|
||||
else -> RoundedCornerShape(Inner)
|
||||
}
|
||||
|
||||
class GroupScope internal constructor() {
|
||||
internal val items = mutableListOf<@Composable (Shape) -> Unit>()
|
||||
fun item(content: @Composable (Shape) -> Unit) { items += content }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsGroup(title: String? = null, modifier: Modifier = Modifier, content: GroupScope.() -> Unit) {
|
||||
val scope = GroupScope().apply(content)
|
||||
Column(modifier.padding(horizontal = WSpace.s16)) {
|
||||
if (title != null) {
|
||||
Text(
|
||||
title, style = WType.label.copy(fontSize = WType.bodySm.fontSize), color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = WSpace.s16, top = WSpace.s24, bottom = WSpace.s8).semantics { heading() },
|
||||
)
|
||||
} else Spacer(Modifier.padding(top = WSpace.s16))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
scope.items.forEachIndexed { i, item -> item(tileShape(i, scope.items.size)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Tile(shape: Shape, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier.fillMaxWidth().clip(shape).background(MaterialTheme.colorScheme.surfaceContainerLowest).heightIn(min = 72.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowContent(icon: ImageVector?, title: String, summary: String?, trailing: (@Composable () -> Unit)?) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16, vertical = WSpace.s16), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (icon != null) {
|
||||
Box(Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) {
|
||||
Icon(icon, null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Spacer(Modifier.width(WSpace.s16))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = WType.body, color = MaterialTheme.colorScheme.onSurface)
|
||||
if (summary != null) Text(summary, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (trailing != null) { Spacer(Modifier.width(WSpace.s12)); trailing() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsItem(shape: Shape, title: String, summary: String? = null, icon: ImageVector? = null, onClick: (() -> Unit)? = null, trailing: (@Composable () -> Unit)? = null) {
|
||||
Tile(shape, if (onClick != null) Modifier.clip(shape).clickable(onClick = onClick) else Modifier) { RowContent(icon, title, summary, trailing) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSwitch(shape: Shape, title: String, checked: Boolean, onChange: (Boolean) -> Unit, summary: String? = null, icon: ImageVector? = null) {
|
||||
Tile(shape, Modifier.clip(shape).toggleable(checked, role = Role.Switch, onValueChange = onChange)) {
|
||||
RowContent(icon, title, summary) {
|
||||
Switch(
|
||||
checked, onCheckedChange = null,
|
||||
thumbContent = if (checked) { { Icon(Icons.Rounded.Check, null, Modifier.size(SwitchDefaults.IconSize)) } } else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A tile whose value is picked from a list in a dialog, like Pixel's radio settings. */
|
||||
@Composable
|
||||
fun <T> SettingsChoice(shape: Shape, title: String, options: List<T>, selected: T, label: @Composable (T) -> String, onPick: (T) -> Unit, icon: ImageVector? = null) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
SettingsItem(shape, title, label(selected), icon, onClick = { open = true })
|
||||
if (open) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { open = false },
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(Modifier.selectableGroup()) {
|
||||
options.forEach { o ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
|
||||
.selectable(o == selected, role = Role.RadioButton) { onPick(o); open = false }
|
||||
.padding(vertical = WSpace.s12, horizontal = WSpace.s4),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(o == selected, onClick = null)
|
||||
Spacer(Modifier.width(WSpace.s16))
|
||||
Text(label(o), style = WType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { open = false }) { Text(androidx.compose.ui.res.stringResource(android.R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Free content inside a group tile (swatches, sliders). */
|
||||
@Composable
|
||||
fun SettingsCustom(shape: Shape, content: @Composable ColumnScope.() -> Unit) {
|
||||
Tile(shape) { Column(Modifier.fillMaxWidth().padding(WSpace.s16), content = content) }
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package app.uhr.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.media.RingtoneManager
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AlarmOff
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Label
|
||||
import androidx.compose.material.icons.outlined.NotificationsActive
|
||||
import androidx.compose.material.icons.outlined.PauseCircle
|
||||
import androidx.compose.material.icons.outlined.Vibration
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.ExpandLess
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TimeInput
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Alarm
|
||||
import app.uhr.data.AlarmWithInstance
|
||||
import app.uhr.data.InstanceState
|
||||
import app.uhr.data.Settings
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.theme.WRadius
|
||||
import app.uhr.ui.theme.WSpace
|
||||
import app.uhr.ui.theme.WType
|
||||
import app.uhr.ui.theme.tabular
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/** Actions the alarm screen needs; MainActivity passes the repository, tests pass fakes. */
|
||||
interface AlarmActions {
|
||||
fun save(alarm: Alarm)
|
||||
fun delete(alarm: Alarm)
|
||||
fun restore(alarm: Alarm)
|
||||
fun dismissNext(alarm: Alarm)
|
||||
}
|
||||
|
||||
/** S01 + S02: list of alarms, each card expands to edit in place. */
|
||||
@Composable
|
||||
fun AlarmScreen(
|
||||
alarms: List<AlarmWithInstance>,
|
||||
settings: Settings,
|
||||
actions: AlarmActions,
|
||||
snackbar: SnackbarHostState,
|
||||
contentPadding: PaddingValues,
|
||||
initiallyExpanded: Long? = null,
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val res = LocalContext.current.resources
|
||||
if (alarms.isEmpty()) {
|
||||
Column(Modifier.fillMaxSize().padding(contentPadding), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Icon(Icons.Outlined.AlarmOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.height(WSpace.s12))
|
||||
Text(stringResource(R.string.no_alarms), style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
return
|
||||
}
|
||||
val bedtime = alarms.filter { it.alarm.isBedtimeWake }
|
||||
val others = alarms.filter { !it.alarm.isBedtimeWake }
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(WSpace.s8),
|
||||
) {
|
||||
if (bedtime.isNotEmpty()) item(key = "h-bed") { SectionHeader(stringResource(R.string.wake_up)) }
|
||||
items(bedtime, key = { it.alarm.id }) { a ->
|
||||
AlarmCard(a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, onDelete = null)
|
||||
}
|
||||
if (bedtime.isNotEmpty() && others.isNotEmpty()) item(key = "h-other") { SectionHeader(stringResource(R.string.other_alarms)) }
|
||||
items(others, key = { it.alarm.id }) { a ->
|
||||
AlarmCard(
|
||||
a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions,
|
||||
onDelete = {
|
||||
actions.delete(a.alarm)
|
||||
expanded = null
|
||||
scope.launch {
|
||||
val r = snackbar.showSnackbar(res.getString(R.string.alarm_deleted), res.getString(R.string.undo), duration = SnackbarDuration.Short)
|
||||
if (r == SnackbarResult.ActionPerformed) actions.restore(a.alarm)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) =
|
||||
Text(text, style = WType.label, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = WSpace.s8, top = WSpace.s12, bottom = WSpace.s4))
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AlarmCard(
|
||||
item: AlarmWithInstance,
|
||||
settings: Settings,
|
||||
expanded: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
actions: AlarmActions,
|
||||
onDelete: (() -> Unit)?,
|
||||
) {
|
||||
val a = item.alarm
|
||||
val inst = item.instance
|
||||
val res = LocalContext.current.resources
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var pickTime by remember { mutableStateOf(false) }
|
||||
var pickSound by remember { mutableStateOf(false) }
|
||||
var pickPause by remember { mutableStateOf(false) }
|
||||
val timeColor = if (a.enabled) scheme.onSurface else scheme.onSurfaceVariant.copy(alpha = .7f)
|
||||
val status = when {
|
||||
!a.enabled -> if (a.repeats) Fmt.days(res, a.days, settings.weekStart) else stringResource(R.string.not_scheduled)
|
||||
inst?.state == InstanceState.SNOOZED -> stringResource(R.string.snoozed_until, Fmt.clock(inst.fireAt))
|
||||
a.skipUntil != null && inst != null -> stringResource(R.string.paused_until, Fmt.ringDay(res, inst.fireAt))
|
||||
a.repeats -> Fmt.days(res, a.days, settings.weekStart)
|
||||
inst != null -> Fmt.ringDay(res, inst.fireAt)
|
||||
else -> ""
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow)
|
||||
.clickable(onClick = onToggleExpand).padding(WSpace.s20).animateContentSize(spring(dampingRatio = .85f, stiffness = 380f))
|
||||
.semantics(mergeDescendants = !expanded) {
|
||||
contentDescription = listOf(a.label, Fmt.time(a.hour, a.minute), status).filter { it.isNotBlank() }.joinToString(", ")
|
||||
stateDescription = res.getString(if (a.enabled) R.string.on else R.string.off)
|
||||
},
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (expanded) {
|
||||
LabelField(a.label) { actions.save(a.copy(label = it)) }
|
||||
} else {
|
||||
Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(WSpace.s8))
|
||||
Row(Modifier.clip(RoundedCornerShape(WRadius.sm)).clickable { pickTime = true }, verticalAlignment = Alignment.Bottom) {
|
||||
Text(Fmt.time(a.hour, a.minute), style = tabular(WType.alarmTime), color = timeColor)
|
||||
Fmt.ampm(a.hour)?.let { Text(" $it", style = WType.alarmAmpm, color = timeColor, modifier = Modifier.padding(bottom = 6.dp)) }
|
||||
}
|
||||
Spacer(Modifier.height(WSpace.s8))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(status, style = WType.bodySm, color = scheme.onSurface, modifier = Modifier.weight(1f))
|
||||
Switch(a.enabled, onCheckedChange = { actions.save(a.copy(enabled = it, skipUntil = null)) })
|
||||
}
|
||||
AnimatedVisibility(expanded) {
|
||||
Column {
|
||||
Spacer(Modifier.height(WSpace.s12))
|
||||
DayChips(a, settings.weekStart) { actions.save(it) }
|
||||
Spacer(Modifier.height(WSpace.s8))
|
||||
if (a.repeats) OptionRow(Icons.Outlined.PauseCircle, if (a.skipUntil != null) stringResource(R.string.paused_until_date, a.skipUntil.toString()) else stringResource(R.string.pause_alarm)) { pickPause = true }
|
||||
if (inst != null && a.enabled && inst.state == InstanceState.SCHEDULED) OptionRow(Icons.Outlined.AlarmOff, stringResource(R.string.skip_next)) { actions.dismissNext(a) }
|
||||
OptionRow(Icons.Outlined.NotificationsActive, soundName(a.ringtoneUri)) { pickSound = true }
|
||||
OptionRow(Icons.Outlined.Vibration, stringResource(R.string.vibrate), trailing = { if (a.vibrate) Icon(Icons.Rounded.Check, null, tint = scheme.primary) }) {
|
||||
actions.save(a.copy(vibrate = !a.vibrate))
|
||||
}
|
||||
if (onDelete != null) OptionRow(Icons.Outlined.Delete, stringResource(R.string.delete), onClick = onDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pickTime) TimeDialog(a.hour, a.minute, onDismiss = { pickTime = false }) { h, m -> pickTime = false; actions.save(a.copy(hour = h, minute = m, enabled = true)) }
|
||||
if (pickSound) SoundDialog(a.ringtoneUri, onDismiss = { pickSound = false }) { uri -> pickSound = false; actions.save(a.copy(ringtoneUri = uri)) }
|
||||
if (pickPause) {
|
||||
val state = rememberDatePickerState(
|
||||
initialSelectedDateMillis = (a.skipUntil ?: LocalDate.now().plusDays(1)).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(),
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { pickPause = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pickPause = false
|
||||
state.selectedDateMillis?.let { actions.save(a.copy(skipUntil = Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate(), enabled = true)) }
|
||||
}) { Text(stringResource(android.R.string.ok)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pickPause = false; actions.save(a.copy(skipUntil = null)) }) { Text(stringResource(R.string.resume)) }
|
||||
},
|
||||
) { DatePicker(state, title = { Text(stringResource(R.string.pause_until), Modifier.padding(start = 24.dp, top = 16.dp)) }) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) {
|
||||
var text by remember(value) { mutableStateOf(value) }
|
||||
TextField(
|
||||
text, { text = it }, Modifier.weight(1f),
|
||||
placeholder = { Text(stringResource(R.string.add_label)) },
|
||||
leadingIcon = { Icon(Icons.Outlined.Label, null) },
|
||||
singleLine = true, textStyle = WType.body,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||
unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||
),
|
||||
)
|
||||
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { if (text.trim() != value) onDone(text.trim()) } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayChips(a: Alarm, weekStart: Int, onChange: (Alarm) -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Fmt.orderedDays(weekStart).forEach { d ->
|
||||
val on = a.ringsOn(d)
|
||||
Box(
|
||||
Modifier.size(38.dp).clip(CircleShape)
|
||||
.background(if (on) scheme.primary else androidx.compose.ui.graphics.Color.Transparent)
|
||||
.border(1.dp, if (on) scheme.primary else scheme.outline, CircleShape)
|
||||
.clickable(role = Role.Checkbox) { onChange(a.withDay(d, !on)) }
|
||||
.semantics { contentDescription = Fmt.dayFull(d) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text(Fmt.dayNarrow(d), style = WType.label, color = if (on) scheme.onPrimary else scheme.onSurface) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OptionRow(icon: ImageVector, text: String, trailing: @Composable (() -> Unit)? = null, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.sm)).clickable(onClick = onClick).padding(vertical = WSpace.s12, horizontal = WSpace.s4),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(WSpace.s16))
|
||||
Text(text, style = WType.bodySm, modifier = Modifier.weight(1f))
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun soundName(uri: String?): String {
|
||||
val context = LocalContext.current
|
||||
return when (uri) {
|
||||
null -> stringResource(R.string.sound_default)
|
||||
Alarm.SILENT -> stringResource(R.string.sound_silent)
|
||||
else -> remember(uri) {
|
||||
runCatching { RingtoneManager.getRingtone(context, Uri.parse(uri))?.getTitle(context) }.getOrNull()
|
||||
?: Uri.parse(uri).lastPathSegment ?: uri
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** S03: M3 time picker with dial/keyboard switch. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TimeDialog(hour: Int, minute: Int, onDismiss: () -> Unit, onPick: (Int, Int) -> Unit) {
|
||||
val state = rememberTimePickerState(hour, minute, Fmt.is24h)
|
||||
var keyboard by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.pick_time)) },
|
||||
text = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (keyboard) TimeInput(state) else TimePicker(state)
|
||||
TextButton(onClick = { keyboard = !keyboard }) { Text(stringResource(if (keyboard) R.string.use_dial else R.string.use_keyboard)) }
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onPick(state.hour, state.minute) }) { Text(stringResource(android.R.string.ok)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** S04: system alarm tones, the bundled one, silence, or an own file (persisted read permission). */
|
||||
@Composable
|
||||
fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val tones = remember {
|
||||
val list = mutableListOf<Pair<String, String>>()
|
||||
runCatching {
|
||||
val rm = RingtoneManager(context).apply { setType(RingtoneManager.TYPE_ALARM) }
|
||||
val c = rm.cursor
|
||||
while (c.moveToNext()) list += rm.getRingtoneUri(c.position).toString() to c.getString(RingtoneManager.TITLE_COLUMN_INDEX)
|
||||
}
|
||||
list
|
||||
}
|
||||
var preview by remember { mutableStateOf<android.media.Ringtone?>(null) }
|
||||
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview?.stop() } }
|
||||
fun play(uri: String?) {
|
||||
preview?.stop()
|
||||
val u = when (uri) { null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}"); Alarm.SILENT -> null; else -> Uri.parse(uri) }
|
||||
preview = u?.let { RingtoneManager.getRingtone(context, it) }?.also { it.play() }
|
||||
}
|
||||
val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri != null) {
|
||||
runCatching { context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
|
||||
onPick(uri.toString())
|
||||
}
|
||||
}
|
||||
var selected by remember { mutableStateOf(current) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.alarm_sound)) },
|
||||
text = {
|
||||
LazyColumn(Modifier.height(420.dp)) {
|
||||
item { TextButton(onClick = { file.launch(arrayOf("audio/*")) }) { Text(stringResource(R.string.add_own_sound)) } }
|
||||
val rows = listOf<Pair<String?, String>>(null to context.getString(R.string.sound_default), Alarm.SILENT to context.getString(R.string.sound_silent)) + tones
|
||||
items(rows) { (uri, name) ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable { selected = uri; play(uri) }.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected == uri, onClick = null)
|
||||
Spacer(Modifier.width(WSpace.s12))
|
||||
Text(name, style = WType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onPick(selected) }) { Text(stringResource(android.R.string.ok)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package app.uhr.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Alarm
|
||||
import app.uhr.data.Bedtime
|
||||
import app.uhr.data.Settings
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.components.SettingsChoice
|
||||
import app.uhr.ui.components.SettingsGroup
|
||||
import app.uhr.ui.components.SettingsSwitch
|
||||
import app.uhr.ui.theme.WRadius
|
||||
import app.uhr.ui.theme.WSpace
|
||||
import app.uhr.ui.theme.WType
|
||||
import app.uhr.ui.theme.tabular
|
||||
|
||||
/** S10: bedtime and wake-up, with the expected sleep; the wake-up becomes the "Aufwachen" alarm. */
|
||||
@Composable
|
||||
fun BedtimeScreen(b: Bedtime, settings: Settings, onSave: (Bedtime) -> Unit, contentPadding: PaddingValues) {
|
||||
var pickBed by remember { mutableStateOf(false) }
|
||||
var pickWake by remember { mutableStateOf(false) }
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
if (!b.enabled && b == Bedtime()) {
|
||||
Column(Modifier.fillMaxSize().padding(contentPadding).padding(WSpace.s32), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Icon(Icons.Outlined.Bedtime, null, Modifier.size(72.dp), tint = scheme.primary)
|
||||
Spacer(Modifier.height(WSpace.s16))
|
||||
Text(stringResource(R.string.bedtime_intro_title), style = WType.headline, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(WSpace.s8))
|
||||
Text(stringResource(R.string.bedtime_intro), style = WType.body, color = scheme.onSurfaceVariant, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(WSpace.s24))
|
||||
Button(onClick = { onSave(b.copy(enabled = true)) }) { Text(stringResource(R.string.get_started)) }
|
||||
}
|
||||
return
|
||||
}
|
||||
val sleepMin = ((b.wakeHour * 60 + b.wakeMinute) - (b.bedHour * 60 + b.bedMinute) + 1440) % 1440
|
||||
Column(Modifier.fillMaxSize().padding(contentPadding).verticalScroll(rememberScrollState())) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16, vertical = WSpace.s8), horizontalArrangement = Arrangement.spacedBy(WSpace.s8)) {
|
||||
TimeTile(Icons.Outlined.Bedtime, stringResource(R.string.bedtime), Fmt.time(b.bedHour, b.bedMinute), Modifier.weight(1f)) { pickBed = true }
|
||||
TimeTile(Icons.Outlined.WbSunny, stringResource(R.string.wake_up), Fmt.time(b.wakeHour, b.wakeMinute), Modifier.weight(1f)) { pickWake = true }
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.sleep_duration, sleepMin / 60, sleepMin % 60), style = WType.body, color = scheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().padding(WSpace.s8), textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s24, vertical = WSpace.s8), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Fmt.orderedDays(settings.weekStart).forEach { d ->
|
||||
val on = b.days and (1 shl (d.value - 1)) != 0
|
||||
Box(
|
||||
Modifier.size(38.dp).clip(CircleShape).background(if (on) scheme.primary else Color.Transparent)
|
||||
.border(1.dp, if (on) scheme.primary else scheme.outline, CircleShape)
|
||||
.clickable(role = Role.Checkbox) { onSave(b.copy(days = if (on) b.days and (1 shl (d.value - 1)).inv() else b.days or (1 shl (d.value - 1)))) }
|
||||
.semantics { contentDescription = Fmt.dayFull(d) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text(Fmt.dayNarrow(d), style = WType.label, color = if (on) scheme.onPrimary else scheme.onSurface) }
|
||||
}
|
||||
}
|
||||
SettingsGroup {
|
||||
item { SettingsSwitch(it, stringResource(R.string.bedtime_schedule), b.enabled, { v -> onSave(b.copy(enabled = v)) }, icon = Icons.Outlined.Bedtime) }
|
||||
item {
|
||||
val options = listOf(-1, 0, 15, 30, 60)
|
||||
SettingsChoice(it, stringResource(R.string.bedtime_reminder), options, b.reminderMin,
|
||||
{ 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)
|
||||
}
|
||||
}
|
||||
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 (pickWake) TimeDialog(b.wakeHour, b.wakeMinute, { pickWake = false }) { h, m -> pickWake = false; onSave(b.copy(wakeHour = h, wakeMinute = m)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeTile(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, time: String, modifier: Modifier, onClick: () -> Unit) {
|
||||
Column(
|
||||
modifier.clip(RoundedCornerShape(WRadius.card)).background(MaterialTheme.colorScheme.surfaceContainerLow).clickable(onClick = onClick).padding(WSpace.s20),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Text(" $label", style = WType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(Modifier.height(WSpace.s8))
|
||||
Text(time, style = tabular(WType.timerTime))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package app.uhr.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Alarm
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.R
|
||||
import app.uhr.data.AlarmInstance
|
||||
import app.uhr.data.City
|
||||
import app.uhr.data.ClockStyle
|
||||
import app.uhr.data.Settings
|
||||
import app.uhr.data.ZoneHit
|
||||
import app.uhr.data.Zones
|
||||
import app.uhr.ui.components.AnalogClock
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.theme.WRadius
|
||||
import app.uhr.ui.theme.WSpace
|
||||
import app.uhr.ui.theme.WType
|
||||
import app.uhr.ui.theme.tabular
|
||||
import kotlinx.coroutines.delay
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
/** S06: local time and the world clock. [nowOverride] pins the time for screenshot tests. */
|
||||
@Composable
|
||||
fun ClockScreen(
|
||||
settings: Settings,
|
||||
cities: List<City>,
|
||||
next: AlarmInstance?,
|
||||
contentPadding: PaddingValues,
|
||||
onDeleteCity: (City) -> Unit,
|
||||
nowOverride: Long? = null,
|
||||
) {
|
||||
val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
|
||||
if (nowOverride == null) LaunchedEffect(settings.showSeconds) {
|
||||
while (true) { tick.longValue = System.currentTimeMillis(); delay(if (settings.showSeconds) 1000 else 1000 - tick.longValue % 1000) }
|
||||
}
|
||||
val now = tick.longValue
|
||||
val res = LocalContext.current.resources
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(WSpace.s8),
|
||||
) {
|
||||
item(key = "header") {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = WSpace.s24), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (settings.clockStyle == ClockStyle.ANALOG) {
|
||||
AnalogClock(
|
||||
Instant.ofEpochMilli(now).atZone(ZoneId.systemDefault()), 240.dp,
|
||||
face = scheme.surfaceContainerLow, hourHand = scheme.primary, minuteHand = scheme.onSurface,
|
||||
secondHand = if (settings.showSeconds) scheme.tertiary else null,
|
||||
)
|
||||
} else {
|
||||
Text(Fmt.clock(now, seconds = settings.showSeconds), style = tabular(WType.clockBig), color = scheme.onSurface)
|
||||
}
|
||||
Spacer(Modifier.height(WSpace.s8))
|
||||
Text(Fmt.longDate(now), style = WType.body, color = scheme.onSurfaceVariant)
|
||||
if (next != null) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = WSpace.s4)) {
|
||||
Icon(Icons.Outlined.Alarm, null, Modifier.size(16.dp), tint = scheme.onSurfaceVariant)
|
||||
Text(" " + Fmt.ringDay(res, next.fireAt) + ", " + Fmt.clock(next.fireAt), style = WType.bodySm, color = scheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(cities, key = { it.id }) { city -> CityRow(city, now, settings) { onDeleteCity(city) } }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CityRow(city: City, now: Long, settings: Settings, onDelete: () -> Unit) {
|
||||
val res = LocalContext.current.resources
|
||||
val zone = remember(city.zoneId) { ZoneId.of(city.zoneId) }
|
||||
val state = rememberSwipeToDismissBoxState()
|
||||
LaunchedEffect(state.currentValue) { if (state.currentValue != SwipeToDismissBoxValue.Settled) onDelete() }
|
||||
SwipeToDismissBox(
|
||||
state,
|
||||
backgroundContent = {
|
||||
Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WRadius.card)).background(MaterialTheme.colorScheme.errorContainer).padding(horizontal = 24.dp), contentAlignment = Alignment.CenterEnd) {
|
||||
Icon(Icons.Rounded.Delete, stringResource(R.string.delete), tint = MaterialTheme.colorScheme.onErrorContainer)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(MaterialTheme.colorScheme.surfaceContainerLow).padding(WSpace.s20),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(city.name, style = WType.title)
|
||||
Text(Fmt.offset(res, zone, now), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Text(Fmt.clock(now, zone, settings.showSeconds), style = tabular(WType.timerTime))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** S07: offline city search. */
|
||||
@Composable
|
||||
fun AddCityDialog(onDismiss: () -> Unit, onPick: (ZoneHit) -> Unit) {
|
||||
var q by remember { mutableStateOf("") }
|
||||
val hits = remember(q) { Zones.search(q) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.add_city)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(q, { q = it }, Modifier.fillMaxWidth(), placeholder = { Text(stringResource(R.string.search_city)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, null) }, singleLine = true, shape = RoundedCornerShape(WRadius.pill))
|
||||
LazyColumn(Modifier.height(360.dp).padding(top = WSpace.s8)) {
|
||||
items(hits, key = { it.zoneId }) { h ->
|
||||
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable { onPick(h) }.padding(vertical = 10.dp, horizontal = 4.dp)) {
|
||||
Text(h.city, style = WType.body)
|
||||
Text(h.zoneId.replace('_', ' '), style = WType.labelSm, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package app.uhr.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.AccessTime
|
||||
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||
import androidx.compose.material.icons.outlined.DarkMode
|
||||
import androidx.compose.material.icons.outlined.Snooze
|
||||
import androidx.compose.material.icons.outlined.TrendingUp
|
||||
import androidx.compose.material.icons.outlined.Vibration
|
||||
import androidx.compose.material.icons.outlined.VolumeOff
|
||||
import androidx.compose.material.icons.outlined.VolumeUp
|
||||
import androidx.compose.material.icons.outlined.Watch
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.uhr.R
|
||||
import app.uhr.data.ClockStyle
|
||||
import app.uhr.data.Settings
|
||||
import app.uhr.data.ThemeMode
|
||||
import app.uhr.data.VolumeButtons
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.components.SettingsChoice
|
||||
import app.uhr.ui.components.SettingsGroup
|
||||
import app.uhr.ui.components.SettingsSwitch
|
||||
import java.time.DayOfWeek
|
||||
|
||||
/** S13 */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(s: Settings, onChange: ((Settings) -> Settings) -> Unit, onBack: () -> Unit) {
|
||||
val bar = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
val minutes = @Composable { m: Int -> if (m == 0) stringResource(R.string.never) else stringResource(R.string.n_minutes, m) }
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(bar.nestedScrollConnection),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
topBar = {
|
||||
LargeTopAppBar(
|
||||
title = { Text(stringResource(R.string.settings)) },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) } },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer, scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
scrollBehavior = bar,
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).navigationBarsPadding()) {
|
||||
SettingsGroup(stringResource(R.string.alarms)) {
|
||||
item { SettingsChoice(it, stringResource(R.string.silence_after), listOf(1, 5, 10, 15, 20, 25, 0), s.silenceAfterMin, { m -> minutes(m) }, { v -> onChange { x -> x.copy(silenceAfterMin = v) } }, Icons.Outlined.VolumeOff) }
|
||||
item { SettingsChoice(it, stringResource(R.string.snooze_length), (1..30).toList(), s.snoozeMin, { m -> stringResource(R.string.n_minutes, m) }, { v -> onChange { x -> x.copy(snoozeMin = v) } }, Icons.Outlined.Snooze) }
|
||||
item { SettingsChoice(it, stringResource(R.string.alarm_volume), listOf(20, 40, 60, 80, 100), s.alarmVolume, { v -> "$v %" }, { v -> onChange { x -> x.copy(alarmVolume = v) } }, Icons.Outlined.VolumeUp) }
|
||||
item { SettingsChoice(it, stringResource(R.string.gradual_volume), listOf(0, 5, 10, 15, 20, 30, 60), s.gradualVolumeS, { v -> if (v == 0) stringResource(R.string.off) else stringResource(R.string.n_seconds, v) }, { v -> onChange { x -> x.copy(gradualVolumeS = v) } }, Icons.Outlined.TrendingUp) }
|
||||
item {
|
||||
val labels = mapOf(VolumeButtons.VOLUME to R.string.vb_volume, VolumeButtons.SNOOZE to R.string.snooze, VolumeButtons.STOP to R.string.stop, VolumeButtons.NOTHING to R.string.vb_nothing)
|
||||
SettingsChoice(it, stringResource(R.string.volume_buttons), VolumeButtons.entries, s.volumeButtons, { v -> stringResource(labels.getValue(v)) }, { v -> onChange { x -> x.copy(volumeButtons = v) } }, Icons.Outlined.VolumeUp)
|
||||
}
|
||||
item { SettingsChoice(it, stringResource(R.string.week_start), listOf(1, 6, 7), s.weekStart, { d -> Fmt.dayFull(DayOfWeek.of(d)) }, { v -> onChange { x -> x.copy(weekStart = v) } }, Icons.Outlined.CalendarMonth) }
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.timer)) {
|
||||
item { SettingsSwitch(it, stringResource(R.string.timer_vibrate), s.timerVibrate, { v -> onChange { x -> x.copy(timerVibrate = v) } }, icon = Icons.Outlined.Vibration) }
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.clock)) {
|
||||
item { SettingsChoice(it, stringResource(R.string.style), ClockStyle.entries, s.clockStyle, { v -> stringResource(if (v == ClockStyle.DIGITAL) R.string.digital else R.string.analog) }, { v -> onChange { x -> x.copy(clockStyle = v) } }, Icons.Outlined.Watch) }
|
||||
item { SettingsSwitch(it, stringResource(R.string.show_seconds), s.showSeconds, { v -> onChange { x -> x.copy(showSeconds = v) } }, icon = Icons.Outlined.AccessTime) }
|
||||
item {
|
||||
val labels = mapOf(ThemeMode.SYSTEM to R.string.theme_system, ThemeMode.LIGHT to R.string.theme_light, ThemeMode.DARK to R.string.theme_dark)
|
||||
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)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(32.dp()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.dp() = androidx.compose.ui.unit.Dp(this.toFloat())
|
||||
@@ -0,0 +1,74 @@
|
||||
package app.uhr.ui.screens
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameMillis
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Stopwatch
|
||||
import app.uhr.data.StopwatchState
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.theme.WSpace
|
||||
import app.uhr.ui.theme.WType
|
||||
import app.uhr.ui.theme.tabular
|
||||
|
||||
/** S09: big time, laps, three large pill buttons at the bottom. */
|
||||
@Composable
|
||||
fun StopwatchScreen(sw: Stopwatch, onToggle: () -> Unit, onLap: () -> Unit, onReset: () -> Unit, contentPadding: PaddingValues, nowOverride: Long? = null) {
|
||||
val tick = remember { mutableLongStateOf(nowOverride ?: SystemClock.elapsedRealtime()) }
|
||||
if (nowOverride == null && sw.state == StopwatchState.RUNNING) LaunchedEffect(sw) { while (true) withFrameMillis { tick.longValue = SystemClock.elapsedRealtime() } }
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val elapsed = sw.elapsed(tick.longValue)
|
||||
Column(Modifier.fillMaxSize().padding(contentPadding).padding(horizontal = WSpace.s16)) {
|
||||
Spacer(Modifier.height(WSpace.s32))
|
||||
Text(Fmt.stopwatch(elapsed), style = tabular(WType.stopwatch), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||
val laps = sw.laps
|
||||
LazyColumn(Modifier.weight(1f).padding(top = WSpace.s16)) {
|
||||
val splits = laps.mapIndexed { i, total -> total - (laps.getOrNull(i - 1) ?: 0) }
|
||||
val fastest = splits.minOrNull(); val slowest = splits.maxOrNull()
|
||||
itemsIndexed(laps.reversed()) { ri, total ->
|
||||
val i = laps.size - 1 - ri
|
||||
val split = splits[i]
|
||||
val color = when { laps.size < 2 -> scheme.onSurface; split == fastest -> scheme.primary; split == slowest -> scheme.error; else -> scheme.onSurface }
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = WSpace.s8, horizontal = WSpace.s8)) {
|
||||
Text(stringResource(R.string.lap_n, i + 1), style = WType.body, color = color, modifier = Modifier.weight(1f))
|
||||
Text(Fmt.stopwatch(split), style = tabular(WType.body), color = color, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
|
||||
Text(Fmt.stopwatch(total), style = tabular(WType.body), color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f), textAlign = TextAlign.End)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(WSpace.s8), modifier = Modifier.padding(bottom = WSpace.s16)) {
|
||||
val running = sw.state == StopwatchState.RUNNING
|
||||
Pill(
|
||||
stringResource(if (running) R.string.stop else R.string.start),
|
||||
if (running) scheme.tertiaryContainer else scheme.primaryContainer,
|
||||
if (running) scheme.onTertiaryContainer else scheme.onPrimaryContainer,
|
||||
height = 88, onClick = onToggle,
|
||||
)
|
||||
if (sw.state != StopwatchState.IDLE) {
|
||||
Pill(stringResource(R.string.reset), scheme.surfaceContainerHigh, scheme.onSurface, height = 72, onClick = onReset)
|
||||
if (running) Pill(stringResource(R.string.lap), scheme.surfaceContainerHigh, scheme.onSurface, height = 72, onClick = onLap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package app.uhr.ui.screens
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Backspace
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Pause
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Replay
|
||||
import androidx.compose.material.icons.rounded.Stop
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Timer
|
||||
import app.uhr.data.TimerState
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.theme.WRadius
|
||||
import app.uhr.ui.theme.WSpace
|
||||
import app.uhr.ui.theme.WType
|
||||
import app.uhr.ui.theme.tabular
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
interface TimerActions {
|
||||
fun start(durationMs: Long)
|
||||
fun pause(id: Long)
|
||||
fun resume(id: Long)
|
||||
fun addMinute(id: Long)
|
||||
fun reset(id: Long)
|
||||
fun delete(id: Long)
|
||||
fun stopRinging()
|
||||
}
|
||||
|
||||
/** S08: keypad when there is no timer (or after "+"), cards for running ones. */
|
||||
@Composable
|
||||
fun TimerScreen(
|
||||
timers: List<Timer>,
|
||||
showKeypad: Boolean,
|
||||
digits: String,
|
||||
onDigits: (String) -> Unit,
|
||||
actions: TimerActions,
|
||||
contentPadding: PaddingValues,
|
||||
nowOverride: Long? = null,
|
||||
) {
|
||||
if (timers.isEmpty() || showKeypad) {
|
||||
Keypad(digits, onDigits, Modifier.padding(contentPadding))
|
||||
return
|
||||
}
|
||||
val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
|
||||
if (nowOverride == null) LaunchedEffect(Unit) { while (true) { tick.longValue = System.currentTimeMillis(); delay(100) } }
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(WSpace.s8),
|
||||
) {
|
||||
items(timers, key = { it.id }) { TimerCard(it, tick.longValue, actions) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimerCard(t: Timer, now: Long, actions: TimerActions) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val remaining = t.remaining(now)
|
||||
val progress = (remaining.toFloat() / t.durationMs).coerceIn(0f, 1f)
|
||||
val blink = rememberInfiniteTransition(label = "blink").animateFloat(1f, .3f, infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "a")
|
||||
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))
|
||||
IconButton(onClick = { actions.delete(t.id) }) { Icon(Icons.Rounded.Close, stringResource(R.string.delete)) }
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.weight(1f).aspectRatio(1f).padding(WSpace.s8), contentAlignment = Alignment.Center) {
|
||||
val track = scheme.surfaceContainerHigh
|
||||
val ring = scheme.primary
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val w = 6.dp.toPx()
|
||||
drawArc(track, 0f, 360f, false, style = Stroke(w))
|
||||
drawArc(ring, -90f, 360f * progress, false, style = Stroke(w, cap = StrokeCap.Round))
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
(if (remaining < 0) "−" else "") + Fmt.duration(remaining + if (remaining > 0) 999 else 0),
|
||||
style = tabular(WType.timerTime),
|
||||
modifier = Modifier.alpha(if (t.state == TimerState.PAUSED || t.state == TimerState.RINGING) blink.value else 1f)
|
||||
.semantics { contentDescription = Fmt.duration(remaining) },
|
||||
)
|
||||
IconButton(onClick = { actions.reset(t.id) }) { Icon(Icons.Rounded.Replay, stringResource(R.string.reset)) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(WSpace.s16))
|
||||
Column(Modifier.width(120.dp), verticalArrangement = Arrangement.spacedBy(WSpace.s12)) {
|
||||
Pill(stringResource(R.string.plus_minute), scheme.surfaceContainerHigh, scheme.onSurface) { actions.addMinute(t.id) }
|
||||
when (t.state) {
|
||||
TimerState.RUNNING -> PillIcon(Icons.Rounded.Pause, stringResource(R.string.pause), app.uhr.ui.theme.fabColors().first, app.uhr.ui.theme.fabColors().second) { actions.pause(t.id) }
|
||||
TimerState.PAUSED -> PillIcon(Icons.Rounded.PlayArrow, stringResource(R.string.start), app.uhr.ui.theme.fabColors().first, app.uhr.ui.theme.fabColors().second) { actions.resume(t.id) }
|
||||
TimerState.RINGING -> PillIcon(Icons.Rounded.Stop, stringResource(R.string.stop), scheme.tertiaryContainer, scheme.onTertiaryContainer) { actions.stopRinging() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Pill(text: String, bg: androidx.compose.ui.graphics.Color, fg: androidx.compose.ui.graphics.Color, modifier: Modifier = Modifier, height: Int = 56, onClick: () -> Unit) {
|
||||
Box(modifier.fillMaxWidth().height(height.dp).clip(RoundedCornerShape(WRadius.pill)).background(bg).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
|
||||
Text(text, style = if (height >= 72) WType.title else WType.label, color = fg)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PillIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, bg: androidx.compose.ui.graphics.Color, fg: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
|
||||
Box(Modifier.fillMaxWidth().height(64.dp).clip(RoundedCornerShape(WRadius.pill)).background(bg).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
|
||||
Icon(icon, label, tint = fg)
|
||||
}
|
||||
}
|
||||
|
||||
/** Digits fill from the right: "130" = 1 min 30 s. Max 6 digits (99h 59m 59s). */
|
||||
fun digitsToMs(d: String): Long {
|
||||
val p = d.padStart(6, '0')
|
||||
return ((p.substring(0, 2).toLong() * 3600) + p.substring(2, 4).toLong() * 60 + p.substring(4, 6).toLong()) * 1000
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Keypad(digits: String, onDigits: (String) -> Unit, modifier: Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val p = digits.padStart(6, '0')
|
||||
Column(modifier.fillMaxSize().padding(horizontal = WSpace.s24), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
val active = scheme.primary; val idle = scheme.onSurfaceVariant
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
listOf(p.substring(0, 2) to "h", p.substring(2, 4) to "m", p.substring(4, 6) to "s").forEachIndexed { i, (v, u) ->
|
||||
val c = if (digits.length > 4 - i * 2) active else idle
|
||||
withStyle(SpanStyle(color = c, fontSize = WType.clockBig.fontSize * .7f)) { append(v) }
|
||||
withStyle(SpanStyle(color = c, fontSize = WType.title.fontSize)) { append(u + " ") }
|
||||
}
|
||||
},
|
||||
style = tabular(WType.clockBig),
|
||||
)
|
||||
Spacer(Modifier.height(WSpace.s32))
|
||||
val keys = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "00", "0", "⌫")
|
||||
keys.chunked(3).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(WSpace.s16), modifier = Modifier.padding(vertical = WSpace.s8)) {
|
||||
row.forEach { k ->
|
||||
Box(
|
||||
Modifier.size(80.dp).clip(CircleShape).background(if (k == "⌫") scheme.secondaryContainer else scheme.surfaceContainerHigh)
|
||||
.clickable {
|
||||
onDigits(
|
||||
when (k) {
|
||||
"⌫" -> digits.dropLast(1)
|
||||
else -> (digits + k).trimStart('0').take(6)
|
||||
}
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (k == "⌫") Icon(Icons.AutoMirrored.Rounded.Backspace, stringResource(R.string.backspace))
|
||||
else Text(k, style = WType.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package app.uhr.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import app.uhr.data.ThemeMode
|
||||
|
||||
private val Light = lightColorScheme(
|
||||
primary = WColor.accent, onPrimary = WColor.onAccent,
|
||||
primaryContainer = WColor.accentContainer, onPrimaryContainer = WColor.onAccentContainer,
|
||||
secondaryContainer = WColor.navIndicator, onSecondaryContainer = WColor.text,
|
||||
tertiaryContainer = WColor.stop, onTertiaryContainer = WColor.onStop,
|
||||
background = WColor.bg, onBackground = WColor.text,
|
||||
surface = WColor.bg, onSurface = WColor.text, onSurfaceVariant = WColor.textMuted,
|
||||
surfaceContainer = WColor.nav, surfaceContainerLow = WColor.card, surfaceContainerHigh = WColor.cardHigh,
|
||||
surfaceContainerLowest = WColor.surface,
|
||||
outline = WColor.borderInput, outlineVariant = WColor.border, error = WColor.danger,
|
||||
)
|
||||
|
||||
private val Dark = darkColorScheme(
|
||||
primary = WColor.darkAccent, onPrimary = WColor.darkOnAccent,
|
||||
primaryContainer = WColor.darkAccentContainer, onPrimaryContainer = WColor.darkOnAccentContainer,
|
||||
secondaryContainer = WColor.darkNavIndicator, onSecondaryContainer = WColor.darkText,
|
||||
tertiaryContainer = WColor.darkStop, onTertiaryContainer = WColor.darkOnStop,
|
||||
background = WColor.darkBg, onBackground = WColor.darkText,
|
||||
surface = WColor.darkBg, onSurface = WColor.darkText, onSurfaceVariant = WColor.darkTextMuted,
|
||||
surfaceContainer = WColor.darkNav, surfaceContainerLow = WColor.darkCard, surfaceContainerHigh = WColor.darkCardHigh,
|
||||
surfaceContainerLowest = WColor.darkCardHigh,
|
||||
outline = WColor.darkBorderInput, outlineVariant = WColor.darkBorder, error = WColor.darkDanger,
|
||||
)
|
||||
|
||||
private val AppTypography = Typography(
|
||||
displayLarge = WType.clockBig, headlineLarge = WType.headline, titleLarge = WType.title,
|
||||
bodyLarge = WType.body, bodyMedium = WType.bodySm, labelLarge = WType.label, labelMedium = WType.label, labelSmall = WType.labelSm,
|
||||
)
|
||||
|
||||
/** Tabular figures so times do not jump while they tick. */
|
||||
fun tabular(style: androidx.compose.ui.text.TextStyle) = style.copy(fontFeatureSettings = "tnum")
|
||||
|
||||
@Composable
|
||||
fun ClockTheme(mode: ThemeMode = ThemeMode.SYSTEM, content: @Composable () -> Unit) {
|
||||
val dark = when (mode) { ThemeMode.SYSTEM -> isSystemInDarkTheme(); ThemeMode.LIGHT -> false; ThemeMode.DARK -> true }
|
||||
val context = LocalContext.current
|
||||
// wallpaper colours on Android 12+, like the original; our palette below that
|
||||
val scheme = when {
|
||||
Build.VERSION.SDK_INT >= 31 -> if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
dark -> Dark
|
||||
else -> Light
|
||||
}
|
||||
MaterialTheme(colorScheme = scheme, typography = AppTypography, content = content)
|
||||
}
|
||||
|
||||
/** Fixed palette, for screenshot tests that must not depend on the wallpaper. */
|
||||
@Composable
|
||||
fun StaticClockTheme(dark: Boolean, content: @Composable () -> Unit) =
|
||||
MaterialTheme(colorScheme = if (dark) Dark else Light, typography = AppTypography, content = content)
|
||||
|
||||
/** The big FAB is light with a dark icon in dark mode, tonal in light mode (as in the reference). */
|
||||
@Composable
|
||||
fun fabColors(): Pair<androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color> {
|
||||
val s = MaterialTheme.colorScheme
|
||||
val dark = s.background.run { 0.2126f * red + 0.7152f * green + 0.0722f * blue } < .5f
|
||||
return if (dark) s.primary to s.onPrimary else s.primaryContainer to s.onPrimaryContainer
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
|
||||
package app.uhr.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
object WColor {
|
||||
val bg = Color(0xFFF8F9FF)
|
||||
val surface = Color(0xFFFFFFFF)
|
||||
val card = Color(0xFFEEF0F6)
|
||||
val cardHigh = Color(0xFFE2E5EC)
|
||||
val border = Color(0xFFC3C6CF)
|
||||
val borderInput = Color(0xFF73777F)
|
||||
val text = Color(0xFF191C20)
|
||||
val textMuted = Color(0xFF43474E)
|
||||
val textOff = Color(0xFF5C6068)
|
||||
val accent = Color(0xFF2F5EA8)
|
||||
val onAccent = Color(0xFFFFFFFF)
|
||||
val accentContainer = Color(0xFFD6E3FF)
|
||||
val onAccentContainer = Color(0xFF001B3E)
|
||||
val fab = Color(0xFFA8C7FA)
|
||||
val onFab = Color(0xFF0B2A55)
|
||||
val chipOn = Color(0xFF2F5EA8)
|
||||
val onChipOn = Color(0xFFFFFFFF)
|
||||
val secondaryPill = Color(0xFFDFE2EB)
|
||||
val onSecondaryPill = Color(0xFF191C20)
|
||||
val stop = Color(0xFFFFD8E4)
|
||||
val onStop = Color(0xFF4A1426)
|
||||
val danger = Color(0xFFBA1A1A)
|
||||
val nav = Color(0xFFECEEF4)
|
||||
val navIndicator = Color(0xFFD6E3FF)
|
||||
val darkBg = Color(0xFF111318)
|
||||
val darkSurface = Color(0xFF111318)
|
||||
val darkCard = Color(0xFF1D2024)
|
||||
val darkCardHigh = Color(0xFF272A2F)
|
||||
val darkBorder = Color(0xFF43474E)
|
||||
val darkBorderInput = Color(0xFF8D9199)
|
||||
val darkText = Color(0xFFE2E2E9)
|
||||
val darkTextMuted = Color(0xFFC3C6CF)
|
||||
val darkTextOff = Color(0xFF9A9EA6)
|
||||
val darkAccent = Color(0xFFA8C7FA)
|
||||
val darkOnAccent = Color(0xFF062E6F)
|
||||
val darkAccentContainer = Color(0xFF244A80)
|
||||
val darkOnAccentContainer = Color(0xFFD6E3FF)
|
||||
val darkFab = Color(0xFFA8C7FA)
|
||||
val darkOnFab = Color(0xFF062E6F)
|
||||
val darkChipOn = Color(0xFFA8C7FA)
|
||||
val darkOnChipOn = Color(0xFF062E6F)
|
||||
val darkSecondaryPill = Color(0xFF3A3D43)
|
||||
val darkOnSecondaryPill = Color(0xFFE2E2E9)
|
||||
val darkStop = Color(0xFFF2B8C9)
|
||||
val darkOnStop = Color(0xFF4A1426)
|
||||
val darkDanger = Color(0xFFFFB4AB)
|
||||
val darkNav = Color(0xFF1D2024)
|
||||
val darkNavIndicator = Color(0xFF3A3F47)
|
||||
val ringBg = Color(0xFF1E5A52)
|
||||
val ringOn = Color(0xFFB4F1E6)
|
||||
val ringTrack = Color(0xFF2B6E65)
|
||||
val ringThumb = Color(0xFF3F9A7F)
|
||||
val onRingThumb = Color(0xFFFFFFFF)
|
||||
val timerTrack = Color(0xFF3A3D43)
|
||||
val timerProgress = Color(0xFF7EC4A8)
|
||||
val nightText = Color(0xFFC4433A)
|
||||
val nightBg = Color(0xFF000000)
|
||||
}
|
||||
|
||||
object WType {
|
||||
val labelSm = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.W500)
|
||||
val label = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W500)
|
||||
val bodySm = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W400)
|
||||
val body = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
|
||||
val title = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
|
||||
val headline = TextStyle(fontSize = 28.sp, lineHeight = 36.sp, fontWeight = FontWeight.W400)
|
||||
val alarmTime = TextStyle(fontSize = 56.sp, lineHeight = 60.sp, fontWeight = FontWeight.W600)
|
||||
val alarmAmpm = TextStyle(fontSize = 20.sp, lineHeight = 24.sp, fontWeight = FontWeight.W600)
|
||||
val timerTime = TextStyle(fontSize = 44.sp, lineHeight = 48.sp, fontWeight = FontWeight.W400)
|
||||
val clockBig = TextStyle(fontSize = 88.sp, lineHeight = 92.sp, fontWeight = FontWeight.W400)
|
||||
val stopwatch = TextStyle(fontSize = 72.sp, lineHeight = 76.sp, fontWeight = FontWeight.W500)
|
||||
val ringing = TextStyle(fontSize = 104.sp, lineHeight = 108.sp, fontWeight = FontWeight.W400)
|
||||
}
|
||||
|
||||
object WSpace {
|
||||
val s0 = 0.dp
|
||||
val s2 = 2.dp
|
||||
val s4 = 4.dp
|
||||
val s8 = 8.dp
|
||||
val s12 = 12.dp
|
||||
val s16 = 16.dp
|
||||
val s20 = 20.dp
|
||||
val s24 = 24.dp
|
||||
val s32 = 32.dp
|
||||
val s48 = 48.dp
|
||||
}
|
||||
|
||||
object WRadius {
|
||||
val xs = 8.dp
|
||||
val sm = 12.dp
|
||||
val md = 16.dp
|
||||
val lg = 24.dp
|
||||
val card = 28.dp
|
||||
val pill = 999.dp
|
||||
val navIndicator = 16.dp
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package app.uhr.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.util.TypedValue
|
||||
import android.widget.RemoteViews
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.AndroidRemoteViews
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import app.uhr.MainActivity
|
||||
import app.uhr.R
|
||||
import app.uhr.data.Graph
|
||||
import app.uhr.ui.components.Fmt
|
||||
import app.uhr.ui.theme.WColor
|
||||
|
||||
private val VERSION = longPreferencesKey("v")
|
||||
|
||||
/** Widget colours: wallpaper on Android 12+, our tokens below. */
|
||||
private data class WPal(val bg: Color, val fg: Color, val accent: Color)
|
||||
|
||||
private fun palette(context: Context): WPal {
|
||||
val dark = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val s = if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
return WPal(s.primaryContainer, s.onPrimaryContainer, s.primary)
|
||||
}
|
||||
return if (dark) WPal(WColor.darkCard, WColor.darkText, WColor.darkAccent) else WPal(WColor.card, WColor.text, WColor.accent)
|
||||
}
|
||||
|
||||
/** Glance's update() only recomposes; bumping state makes widgets read the next alarm again. */
|
||||
suspend fun updateClockWidgets(context: Context) {
|
||||
val manager = GlanceAppWidgetManager(context)
|
||||
for (w in listOf(DigitalClockWidget(), AnalogClockWidget(), NextAlarmWidget())) {
|
||||
manager.getGlanceIds(w.javaClass).forEach { id ->
|
||||
runCatching { updateAppWidgetState(context, id) { it[VERSION] = System.nanoTime() }; w.update(context, id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun nextAlarmText(context: Context): String? {
|
||||
Graph.init(context)
|
||||
val i = Graph.repo.nextInstanceOnce() ?: return null
|
||||
return Fmt.ringDay(context.resources, i.fireAt) + ", " + Fmt.clock(i.fireAt)
|
||||
}
|
||||
|
||||
abstract class ClockWidget : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
Fmt.is24h = android.text.format.DateFormat.is24HourFormat(context)
|
||||
val first = nextAlarmText(context)
|
||||
provideContent {
|
||||
val v = currentState(VERSION) ?: 0L
|
||||
val next by produceState(first, v) { if (v != 0L) value = nextAlarmText(context) }
|
||||
Content(next)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) {
|
||||
val next = nextAlarmText(context)
|
||||
provideContent { Content(next) }
|
||||
}
|
||||
|
||||
@Composable abstract fun Content(next: String?)
|
||||
}
|
||||
|
||||
private fun RemoteViews.color(id: Int, c: Color) = setTextColor(id, c.toArgb())
|
||||
|
||||
/** Big live time, date, next alarm. Transparent, like a home-screen clock. */
|
||||
class DigitalClockWidget : ClockWidget() {
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
@Composable
|
||||
override fun Content(next: String?) {
|
||||
val ctx = LocalContext.current
|
||||
val size = LocalSize.current
|
||||
val views = RemoteViews(ctx.packageName, R.layout.widget_digital).apply {
|
||||
setTextViewTextSize(R.id.w_time, TypedValue.COMPLEX_UNIT_SP, (size.height.value * .45f).coerceIn(40f, 110f))
|
||||
if (next != null) setTextViewText(R.id.w_next, "⏰ $next") else setTextViewText(R.id.w_next, "")
|
||||
}
|
||||
Box(GlanceModifier.fillMaxSize().clickable(actionStartActivity(android.content.Intent(ctx, MainActivity::class.java))), contentAlignment = Alignment.Center) {
|
||||
AndroidRemoteViews(views)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Own analog face on a wallpaper-coloured disc. */
|
||||
class AnalogClockWidget : ClockWidget() {
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
@Composable
|
||||
override fun Content(next: String?) {
|
||||
val ctx = LocalContext.current
|
||||
val p = palette(ctx)
|
||||
val views = RemoteViews(ctx.packageName, R.layout.widget_analog)
|
||||
Box(
|
||||
GlanceModifier.fillMaxSize().cornerRadius(999.dp).background(p.bg).clickable(actionStartActivity(android.content.Intent(ctx, MainActivity::class.java))),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { AndroidRemoteViews(views) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Pill with the next alarm. */
|
||||
class NextAlarmWidget : ClockWidget() {
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
@Composable
|
||||
override fun Content(next: String?) {
|
||||
val ctx = LocalContext.current
|
||||
val p = palette(ctx)
|
||||
val views = RemoteViews(ctx.packageName, R.layout.widget_next).apply {
|
||||
setTextViewText(R.id.w_next_text, next ?: ctx.getString(R.string.no_alarm_set))
|
||||
color(R.id.w_next_text, p.fg)
|
||||
setInt(R.id.w_next_icon, "setColorFilter", p.accent.toArgb())
|
||||
}
|
||||
Box(
|
||||
GlanceModifier.fillMaxSize().cornerRadius(999.dp).background(p.bg).clickable(actionStartActivity(android.content.Intent(ctx, MainActivity::class.java))),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { AndroidRemoteViews(views) }
|
||||
}
|
||||
}
|
||||
|
||||
class DigitalClockReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = DigitalClockWidget() }
|
||||
class AnalogClockReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = AnalogClockWidget() }
|
||||
class NextAlarmReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = NextAlarmWidget() }
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="200dp" android:height="200dp" android:viewportWidth="200" android:viewportHeight="200">
|
||||
<path android:fillColor="#40FFFFFF" android:pathData="M100,14m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
|
||||
<path android:fillColor="#40FFFFFF" android:pathData="M186,100m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
|
||||
<path android:fillColor="#40FFFFFF" android:pathData="M100,186m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
|
||||
<path android:fillColor="#40FFFFFF" android:pathData="M14,100m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="200dp" android:height="200dp" android:viewportWidth="200" android:viewportHeight="200">
|
||||
<path android:strokeColor="#FFA8C7FA" android:strokeWidth="14" android:strokeLineCap="round" android:pathData="M100,100 L100,52"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="200dp" android:height="200dp" android:viewportWidth="200" android:viewportHeight="200">
|
||||
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="8" android:strokeLineCap="round" android:pathData="M100,100 L100,30"/>
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M100,100m-9,0a9,9 0 1,0 18,0a9,9 0 1,0 -18,0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"><path android:fillColor="#FF2F5EA8" android:pathData="M0,0h108v108h-108z"/></vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="#FFD6E3FF" android:pathData="M82.22,54.00 L82.03,55.22 L81.50,56.41 L80.77,57.52 L79.99,58.58 L79.33,59.62 L78.91,60.67 L78.75,61.80 L78.80,63.03 L78.94,64.33 L79.02,65.67 L78.88,66.95 L78.43,68.11 L77.66,69.07 L76.61,69.84 L75.42,70.44 L74.22,70.96 L73.13,71.53 L72.23,72.23 L71.53,73.13 L70.96,74.22 L70.44,75.42 L69.84,76.61 L69.07,77.66 L68.11,78.43 L66.95,78.88 L65.67,79.02 L64.33,78.94 L63.03,78.80 L61.80,78.75 L60.67,78.91 L59.62,79.33 L58.58,79.99 L57.52,80.77 L56.41,81.50 L55.22,82.03 L54.00,82.22 L52.78,82.03 L51.59,81.50 L50.48,80.77 L49.42,79.99 L48.38,79.33 L47.33,78.91 L46.20,78.75 L44.97,78.80 L43.67,78.94 L42.33,79.02 L41.05,78.88 L39.89,78.43 L38.93,77.66 L38.16,76.61 L37.56,75.42 L37.04,74.22 L36.47,73.13 L35.77,72.23 L34.87,71.53 L33.78,70.96 L32.58,70.44 L31.39,69.84 L30.34,69.07 L29.57,68.11 L29.12,66.95 L28.98,65.67 L29.06,64.33 L29.20,63.03 L29.25,61.80 L29.09,60.67 L28.67,59.62 L28.01,58.58 L27.23,57.52 L26.50,56.41 L25.97,55.22 L25.79,54.00 L25.97,52.78 L26.50,51.59 L27.23,50.48 L28.01,49.42 L28.67,48.38 L29.09,47.33 L29.25,46.20 L29.20,44.97 L29.06,43.67 L28.98,42.33 L29.12,41.05 L29.57,39.89 L30.34,38.93 L31.39,38.16 L32.58,37.56 L33.78,37.04 L34.87,36.47 L35.77,35.77 L36.47,34.87 L37.04,33.78 L37.56,32.58 L38.16,31.39 L38.93,30.34 L39.89,29.57 L41.05,29.12 L42.33,28.98 L43.67,29.06 L44.97,29.20 L46.20,29.25 L47.33,29.09 L48.38,28.67 L49.42,28.01 L50.48,27.23 L51.59,26.50 L52.78,25.97 L54.00,25.79 L55.22,25.97 L56.41,26.50 L57.52,27.23 L58.58,28.01 L59.62,28.67 L60.67,29.09 L61.80,29.25 L63.03,29.20 L64.33,29.06 L65.67,28.98 L66.95,29.12 L68.11,29.57 L69.07,30.34 L69.84,31.39 L70.44,32.58 L70.96,33.78 L71.53,34.87 L72.23,35.77 L73.13,36.47 L74.22,37.04 L75.42,37.56 L76.61,38.16 L77.66,38.93 L78.43,39.89 L78.88,41.05 L79.02,42.33 L78.94,43.67 L78.80,44.97 L78.75,46.20 L78.91,47.33 L79.33,48.38 L79.99,49.42 L80.77,50.48 L81.50,51.59 L82.03,52.78 Z"/>
|
||||
<path android:strokeColor="#FF0B2A55" android:strokeWidth="5.5" android:strokeLineCap="round" android:pathData="M54,54 L54,38"/>
|
||||
<path android:strokeColor="#FF2F5EA8" android:strokeWidth="3.5" android:strokeLineCap="round" android:pathData="M54,54 L66,64"/>
|
||||
<path android:fillColor="#FF0B2A55" android:pathData="M54,54m-3.5,0a3.5,3.5 0 1,0 7,0a3.5,3.5 0 1,0 -7,0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="#FF000000" android:pathData="M82.22,54.00 L82.03,55.22 L81.50,56.41 L80.77,57.52 L79.99,58.58 L79.33,59.62 L78.91,60.67 L78.75,61.80 L78.80,63.03 L78.94,64.33 L79.02,65.67 L78.88,66.95 L78.43,68.11 L77.66,69.07 L76.61,69.84 L75.42,70.44 L74.22,70.96 L73.13,71.53 L72.23,72.23 L71.53,73.13 L70.96,74.22 L70.44,75.42 L69.84,76.61 L69.07,77.66 L68.11,78.43 L66.95,78.88 L65.67,79.02 L64.33,78.94 L63.03,78.80 L61.80,78.75 L60.67,78.91 L59.62,79.33 L58.58,79.99 L57.52,80.77 L56.41,81.50 L55.22,82.03 L54.00,82.22 L52.78,82.03 L51.59,81.50 L50.48,80.77 L49.42,79.99 L48.38,79.33 L47.33,78.91 L46.20,78.75 L44.97,78.80 L43.67,78.94 L42.33,79.02 L41.05,78.88 L39.89,78.43 L38.93,77.66 L38.16,76.61 L37.56,75.42 L37.04,74.22 L36.47,73.13 L35.77,72.23 L34.87,71.53 L33.78,70.96 L32.58,70.44 L31.39,69.84 L30.34,69.07 L29.57,68.11 L29.12,66.95 L28.98,65.67 L29.06,64.33 L29.20,63.03 L29.25,61.80 L29.09,60.67 L28.67,59.62 L28.01,58.58 L27.23,57.52 L26.50,56.41 L25.97,55.22 L25.79,54.00 L25.97,52.78 L26.50,51.59 L27.23,50.48 L28.01,49.42 L28.67,48.38 L29.09,47.33 L29.25,46.20 L29.20,44.97 L29.06,43.67 L28.98,42.33 L29.12,41.05 L29.57,39.89 L30.34,38.93 L31.39,38.16 L32.58,37.56 L33.78,37.04 L34.87,36.47 L35.77,35.77 L36.47,34.87 L37.04,33.78 L37.56,32.58 L38.16,31.39 L38.93,30.34 L39.89,29.57 L41.05,29.12 L42.33,28.98 L43.67,29.06 L44.97,29.20 L46.20,29.25 L47.33,29.09 L48.38,28.67 L49.42,28.01 L50.48,27.23 L51.59,26.50 L52.78,25.97 L54.00,25.79 L55.22,25.97 L56.41,26.50 L57.52,27.23 L58.58,28.01 L59.62,28.67 L60.67,29.09 L61.80,29.25 L63.03,29.20 L64.33,29.06 L65.67,28.98 L66.95,29.12 L68.11,29.57 L69.07,30.34 L69.84,31.39 L70.44,32.58 L70.96,33.78 L71.53,34.87 L72.23,35.77 L73.13,36.47 L74.22,37.04 L75.42,37.56 L76.61,38.16 L77.66,38.93 L78.43,39.89 L78.88,41.05 L79.02,42.33 L78.94,43.67 L78.80,44.97 L78.75,46.20 L78.91,47.33 L79.33,48.38 L79.99,49.42 L80.77,50.48 L81.50,51.59 L82.03,52.78 Z"/>
|
||||
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="5.5" android:strokeLineCap="round" android:pathData="M54,54 L54,38"/>
|
||||
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="3.5" android:strokeLineCap="round" android:pathData="M54,54 L66,64"/>
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M54,54m-3.5,0a3.5,3.5 0 1,0 7,0a3.5,3.5 0 1,0 -7,0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFFFF" android:pathData="M12,4a8,8 0 1,0 0,16a8,8 0 1,0 0,-16zM12,18a6,6 0 1,1 0,-12a6,6 0 1,1 0,12zM12.5,8H11v5l3.9,2.4 0.8,-1.2 -3.2,-1.9zM6.2,2.3L2.3,5.6 3.6,7.1 7.4,3.9zM17.8,2.3l-1.3,1.5 3.8,3.2 1.3,-1.5z"/></vector>
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFFFF" android:pathData="M12.3,22h-0.1a10.3,10.3 0 0,1 -7.3,-3A10.3,10.3 0 0,1 9.1,1.8a1,1 0 0,1 1.3,1.2 8.2,8.2 0 0,0 10.6,10.6 1,1 0,0 1 1.2,1.3A10.3,10.3 0 0,1 12.3,22z"/></vector>
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFFFF" android:pathData="M6,2v6h0.01L6,8.01 10,12l-4,4 0.01,0.01H6V22h12v-5.99h-0.01L18,16l-4,-4 4,-3.99 -0.01,-0.01H18V2H6zM16,16.5V20H8v-3.5l4,-4 4,4zM12,11.5l-4,-4V4h8v3.5l-4,4z"/></vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<AnalogClock xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent" android:layout_height="match_parent" android:padding="8dp"
|
||||
android:dial="@drawable/analog_dial" android:hand_hour="@drawable/analog_hour" android:hand_minute="@drawable/analog_minute" />
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:gravity="center">
|
||||
<TextClock android:id="@+id/w_time" android:layout_width="wrap_content" android:layout_height="wrap_content"
|
||||
android:format24Hour="HH:mm" android:format12Hour="h:mm" android:fontFamily="sans-serif-light"
|
||||
android:textSize="80sp" android:includeFontPadding="false" android:textColor="#FFFFFFFF" android:shadowColor="#66000000" android:shadowRadius="8" android:shadowDy="2" />
|
||||
<TextClock android:layout_width="wrap_content" android:layout_height="wrap_content"
|
||||
android:format24Hour="EEEE, d. MMMM" android:format12Hour="EEEE, MMMM d" android:fontFamily="sans-serif-medium"
|
||||
android:textSize="16sp" android:textColor="#FFFFFFFF" android:shadowColor="#66000000" android:shadowRadius="8" android:shadowDy="2" />
|
||||
<TextView android:id="@+id/w_next" android:layout_width="wrap_content" android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp" android:textSize="14sp" android:textColor="#E6FFFFFF" android:shadowColor="#66000000" android:shadowRadius="8" android:shadowDy="2" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal"
|
||||
android:gravity="center" android:paddingHorizontal="20dp">
|
||||
<ImageView android:id="@+id/w_next_icon" android:layout_width="24dp" android:layout_height="24dp" android:src="@drawable/ic_stat_alarm" android:importantForAccessibility="no" />
|
||||
<TextView android:id="@+id/w_next_text" android:layout_width="wrap_content" android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp" android:fontFamily="sans-serif-medium" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
Binary file not shown.
@@ -0,0 +1,130 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="tab_alarm">Wecker</string>
|
||||
<string name="tab_clock">Uhr</string>
|
||||
<string name="tab_timer">Timer</string>
|
||||
<string name="tab_stopwatch">Stoppuhr</string>
|
||||
<string name="tab_bedtime">Schlafen</string>
|
||||
<string name="more">Weitere Optionen</string>
|
||||
<string name="settings">Einstellungen</string>
|
||||
<string name="back">Zurück</string>
|
||||
<string name="add">Hinzufügen</string>
|
||||
<string name="delete">Löschen</string>
|
||||
<string name="undo">Rückgängig</string>
|
||||
<string name="on">An</string>
|
||||
<string name="off">Aus</string>
|
||||
<string name="never">Nie</string>
|
||||
<string name="later">Später</string>
|
||||
<string name="open_settings">Einstellungen öffnen</string>
|
||||
|
||||
<string name="alarm">Wecker</string>
|
||||
<string name="alarms">Wecker</string>
|
||||
<string name="no_alarms">Noch keine Wecker. Tippe auf +, um einen zu stellen.</string>
|
||||
<string name="wake_up">Aufwachen</string>
|
||||
<string name="other_alarms">Weitere</string>
|
||||
<string name="alarm_deleted">Wecker gelöscht</string>
|
||||
<string name="alarm_set_in">Klingelt %1$s</string>
|
||||
<string name="in_days_hours">in %1$d T. %2$d Std.</string>
|
||||
<string name="in_hours_minutes">in %1$d Std. %2$d Min.</string>
|
||||
<string name="in_minutes">in %1$d Min.</string>
|
||||
<string name="today">Heute</string>
|
||||
<string name="tomorrow">Morgen</string>
|
||||
<string name="yesterday">Gestern</string>
|
||||
<string name="every_day">Jeden Tag</string>
|
||||
<string name="weekdays">Werktags</string>
|
||||
<string name="weekends">Am Wochenende</string>
|
||||
<string name="not_scheduled">Nicht geplant</string>
|
||||
<string name="snoozed_until">Schlummert bis %1$s</string>
|
||||
<string name="paused_until">Pausiert · nächstes Mal %1$s</string>
|
||||
<string name="paused_until_date">Pausiert bis %1$s</string>
|
||||
<string name="pause_alarm">Wecker pausieren</string>
|
||||
<string name="pause_until">Pausieren bis einschließlich</string>
|
||||
<string name="resume">Nicht pausieren</string>
|
||||
<string name="skip_next">Nächsten Weckruf überspringen</string>
|
||||
<string name="add_label">Name hinzufügen</string>
|
||||
<string name="vibrate">Vibration</string>
|
||||
<string name="alarm_sound">Weckton</string>
|
||||
<string name="sound_default">Standard (Morgenklang)</string>
|
||||
<string name="sound_silent">Ohne Ton</string>
|
||||
<string name="add_own_sound">+ Eigene Audiodatei wählen</string>
|
||||
<string name="pick_time">Uhrzeit wählen</string>
|
||||
<string name="use_dial">Zifferblatt</string>
|
||||
<string name="use_keyboard">Tastatur</string>
|
||||
|
||||
<string name="snooze">Schlummern</string>
|
||||
<string name="stop">Stopp</string>
|
||||
<string name="dismiss_now">Jetzt beenden</string>
|
||||
|
||||
<string name="clock">Uhr</string>
|
||||
<string name="add_city">Stadt hinzufügen</string>
|
||||
<string name="search_city">Stadt oder Zeitzone</string>
|
||||
<string name="same_time">Gleiche Zeit</string>
|
||||
<string name="hours_offset">%1$s Std.</string>
|
||||
|
||||
<string name="timer">Timer</string>
|
||||
<string name="timer_of">Timer · %1$s</string>
|
||||
<string name="timer_done">Timer abgelaufen</string>
|
||||
<string name="plus_minute">+1:00</string>
|
||||
<string name="pause">Pause</string>
|
||||
<string name="paused">Pausiert</string>
|
||||
<string name="start">Start</string>
|
||||
<string name="reset">Zurücksetzen</string>
|
||||
<string name="backspace">Löschen</string>
|
||||
<string name="n_timers">%1$d Timer</string>
|
||||
|
||||
<string name="stopwatch">Stoppuhr</string>
|
||||
<string name="lap">Runde</string>
|
||||
<string name="lap_n">Runde %1$d</string>
|
||||
|
||||
<string name="bedtime">Schlafenszeit</string>
|
||||
<string name="bedtime_intro_title">Ein fester Schlafrhythmus</string>
|
||||
<string name="bedtime_intro">Lege fest, wann du schlafen gehst und aufstehst. Du wirst rechtzeitig erinnert, und der Wecker zum Aufwachen wird automatisch gestellt.</string>
|
||||
<string name="get_started">Los geht\'s</string>
|
||||
<string name="sleep_duration">%1$d Std. %2$d Min. Schlaf</string>
|
||||
<string name="bedtime_schedule">Zeitplan aktiv</string>
|
||||
<string name="bedtime_reminder">Erinnerung</string>
|
||||
<string name="at_bedtime">Zur Schlafenszeit</string>
|
||||
<string name="minutes_before">%1$d Min. vorher</string>
|
||||
|
||||
<string name="silence_after">Automatisch stumm nach</string>
|
||||
<string name="snooze_length">Schlummerdauer</string>
|
||||
<string name="alarm_volume">Lautstärke</string>
|
||||
<string name="gradual_volume">Langsam lauter werden</string>
|
||||
<string name="volume_buttons">Lautstärketasten beim Klingeln</string>
|
||||
<string name="vb_volume">Lautstärke ändern</string>
|
||||
<string name="vb_nothing">Nichts tun</string>
|
||||
<string name="week_start">Woche beginnt am</string>
|
||||
<string name="timer_vibrate">Timer vibriert</string>
|
||||
<string name="style">Darstellung</string>
|
||||
<string name="digital">Digital</string>
|
||||
<string name="analog">Analog</string>
|
||||
<string name="show_seconds">Sekunden anzeigen</string>
|
||||
<string name="theme">Design</string>
|
||||
<string name="theme_system">System</string>
|
||||
<string name="theme_light">Hell</string>
|
||||
<string name="theme_dark">Dunkel</string>
|
||||
<string name="n_minutes">%1$d Minuten</string>
|
||||
<string name="n_seconds">%1$d Sekunden</string>
|
||||
|
||||
<string name="ch_ringing">Klingeln</string>
|
||||
<string name="ch_upcoming">Anstehende Wecker</string>
|
||||
<string name="ch_missed">Verpasste Wecker</string>
|
||||
<string name="ch_running">Laufende Timer und Stoppuhr</string>
|
||||
<string name="ch_bedtime">Schlafenszeit</string>
|
||||
<string name="notif_upcoming">Wecker um %1$s</string>
|
||||
<string name="notif_snoozed">Schlummert bis %1$s</string>
|
||||
<string name="notif_missed">Wecker verpasst</string>
|
||||
<string name="notif_bedtime">Bald ist Schlafenszeit</string>
|
||||
<string name="notif_bedtime_text">Leg das Handy weg und komm zur Ruhe.</string>
|
||||
|
||||
<string name="fsi_title">Wecker im Vollbild</string>
|
||||
<string name="fsi_text">Damit der Wecker auch über dem Sperrbildschirm erscheint, braucht die App die Erlaubnis für Vollbild-Benachrichtigungen. Ohne sie klingelt er trotzdem, aber nur als Benachrichtigung.</string>
|
||||
|
||||
<string name="no_alarm_set">Kein Wecker gestellt</string>
|
||||
<string name="widget_digital_name">Uhrzeit</string>
|
||||
<string name="widget_digital_desc">Große Uhrzeit, Datum und nächster Wecker</string>
|
||||
<string name="widget_analog_name">Analoge Uhr</string>
|
||||
<string name="widget_analog_desc">Zifferblatt in deinen Farben</string>
|
||||
<string name="widget_next_name">Nächster Wecker</string>
|
||||
<string name="widget_next_desc">Wann der nächste Wecker klingelt</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_analog_desc"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="110dp" android:minHeight="110dp" android:targetCellWidth="2" android:targetCellHeight="2"
|
||||
android:minResizeWidth="80dp" android:minResizeHeight="40dp"
|
||||
android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0"
|
||||
android:widgetCategory="home_screen|keyguard" />
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_digital_desc"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="250dp" android:minHeight="110dp" android:targetCellWidth="4" android:targetCellHeight="2"
|
||||
android:minResizeWidth="80dp" android:minResizeHeight="40dp"
|
||||
android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0"
|
||||
android:widgetCategory="home_screen|keyguard" />
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_next_desc"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="180dp" android:minHeight="48dp" android:targetCellWidth="3" android:targetCellHeight="1"
|
||||
android:minResizeWidth="80dp" android:minResizeHeight="40dp"
|
||||
android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0"
|
||||
android:widgetCategory="home_screen|keyguard" />
|
||||
Reference in New Issue
Block a user