diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3ea4e3d..9488d6f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -35,6 +35,22 @@
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
+
+
+
+
+
+
+
+
+ TileModel(
+ phase = timerState.phase,
+ minutes = when (timerState.phase) {
+ TimerPhase.IDLE -> settings.presetMinutes
+ else -> remainingMillisToDisplayMinutes(timerState.remainingMillis)
+ },
+ )
+ }
+ // remainingMillis changes every second but the displayed minutes only
+ // change once a minute — skip the redundant updateTile() calls.
+ .distinctUntilChanged()
+ .collect(::render)
+ }
+ }
+
+ override fun onStopListening() {
+ listeningJob?.cancel()
+ listeningJob = null
+ super.onStopListening()
+ }
+
+ override fun onDestroy() {
+ serviceScope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onClick() {
+ super.onClick()
+ when (timerRepository.timerState.value.phase) {
+ TimerPhase.IDLE -> startTimerWithPreset()
+ // RUNNING / FADING_OUT: cancel restores volume and stops the service; if
+ // the countdown ended in the meantime, the service's stale-intent guard
+ // turns this into a no-op stopSelf.
+ else -> startService(timerServiceIntent(SleepTimerService.ACTION_CANCEL))
+ }
+ }
+
+ private fun startTimerWithPreset() {
+ // The preset needs a DataStore read. The tap keeps the app in a state that
+ // may start a foreground service, and the read is local and fast, so doing
+ // it inside that window is fine.
+ serviceScope.launch {
+ val minutes = settingsRepository.settings.first().presetMinutes
+ val intent = timerServiceIntent(SleepTimerService.ACTION_START).apply {
+ putExtra(SleepTimerService.EXTRA_DURATION_MILLIS, minutes * 60_000L)
+ }
+ startForegroundService(intent)
+ }
+ }
+
+ private fun render(model: TileModel) {
+ val tile = qsTile ?: return
+ tile.state = if (model.phase == TimerPhase.IDLE) Tile.STATE_INACTIVE else Tile.STATE_ACTIVE
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ tile.subtitle = when (model.phase) {
+ TimerPhase.FADING_OUT -> getString(R.string.qs_tile_fading_out)
+ // coerceAtLeast: remaining can round down to 0 in the brief window
+ // between expiry and teardown; never show "0 min".
+ else -> getString(R.string.qs_tile_minutes, model.minutes.coerceAtLeast(1))
+ }
+ }
+ tile.updateTile()
+ }
+
+ private fun timerServiceIntent(actionName: String): Intent =
+ Intent().apply {
+ action = actionName
+ setClassName(this@SleepTimerTileService, SleepTimerService::class.java.name)
+ }
+
+ private data class TileModel(val phase: TimerPhase, val minutes: Int)
+}
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index bc8b66a..3125909 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -21,6 +21,11 @@
Haptisches Feedback
Vibrieren bei Verwendung von Timer und Bedienelementen
+
+ Schlaf-Timer
+ %d Min
+ Wird ausgeblendet
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2dbcd59..d8bf552 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -21,6 +21,11 @@
Haptic feedback
Vibrate when using timer and controls
+
+ Sleep Timer
+ %d min
+ Fading out
+