diff --git a/app/src/main/java/com/marcus/meditationtimer/MainActivity.kt b/app/src/main/java/com/marcus/meditationtimer/MainActivity.kt index a5ae4a2..6bda5d7 100644 --- a/app/src/main/java/com/marcus/meditationtimer/MainActivity.kt +++ b/app/src/main/java/com/marcus/meditationtimer/MainActivity.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -184,6 +185,8 @@ private fun TimerTab() { val context = LocalContext.current val timer by TimerService.state.collectAsState() var durationMin by rememberSaveable { mutableIntStateOf(20) } + val settings = remember { context.getSharedPreferences("settings", android.content.Context.MODE_PRIVATE) } + var intervalMin by rememberSaveable { mutableIntStateOf(settings.getInt("interval_min", 0)) } val phase = timer.phase val selectedTotalMs = durationMin * 60_000L @@ -211,9 +214,17 @@ private fun TimerTab() { if (phase == Phase.Idle || phase == Phase.Done) { MinutePicker(durationMin = durationMin, onChange = { durationMin = it }) - Spacer(Modifier.height(32.dp)) + Spacer(Modifier.height(20.dp)) + IntervalBellRow( + intervalMin = intervalMin, + onChange = { + intervalMin = it + settings.edit().putInt("interval_min", it).apply() + }, + ) + Spacer(Modifier.height(20.dp)) Button( - onClick = { TimerService.start(context, selectedTotalMs) }, + onClick = { TimerService.start(context, selectedTotalMs, intervalMin) }, colors = ButtonDefaults.buttonColors(containerColor = Gold, contentColor = DeepBlue), ) { Text(if (phase == Phase.Done) "Again" else "Begin", fontSize = 18.sp) @@ -259,6 +270,36 @@ private fun TimerTab() { } } +private val INTERVAL_OPTIONS = listOf(0, 5, 10, 15, 20, 30) + +@Composable +private fun IntervalBellRow(intervalMin: Int, onChange: (Int) -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "INTERVAL BELL", + color = Color.White.copy(alpha = 0.35f), + fontSize = 11.sp, + letterSpacing = 2.sp, + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + INTERVAL_OPTIONS.forEach { min -> + val selected = intervalMin == min + Text( + text = if (min == 0) "Off" else "${min}m", + color = if (selected) DeepBlue else Gold.copy(alpha = 0.7f), + fontSize = 13.sp, + modifier = Modifier + .clip(RoundedCornerShape(14.dp)) + .background(if (selected) Gold else Color.White.copy(alpha = 0.06f)) + .clickable { onChange(min) } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } + } +} + private val PRESETS = listOf(20, 45, 60, 90) private const val MAX_MINUTES = 180 diff --git a/app/src/main/java/com/marcus/meditationtimer/SessionStore.kt b/app/src/main/java/com/marcus/meditationtimer/SessionStore.kt index a42aa46..1354590 100644 --- a/app/src/main/java/com/marcus/meditationtimer/SessionStore.kt +++ b/app/src/main/java/com/marcus/meditationtimer/SessionStore.kt @@ -22,6 +22,7 @@ data class Session( val pauseCount: Int, val rating: Int? = null, val note: String? = null, + val intervalMin: Int? = null, val synced: Boolean = false, ) { val completionPct: Int @@ -148,6 +149,7 @@ object SessionStore { pauseCount = o.optInt("pauseCount", 0), rating = if (o.isNull("rating")) null else o.optInt("rating"), note = o.optString("note").ifEmpty { null }, + intervalMin = if (o.isNull("intervalMin")) null else o.optInt("intervalMin"), synced = true, ) } @@ -174,6 +176,7 @@ object SessionStore { put("pauseCount", s.pauseCount) s.rating?.let { put("rating", it) } s.note?.let { put("note", it) } + s.intervalMin?.let { put("intervalMin", it) } } conn.outputStream.use { it.write(body.toString().toByteArray()) } val ok = conn.responseCode in 200..299 @@ -199,6 +202,7 @@ object SessionStore { pauseCount = o.optInt("pauseCount", 0), rating = if (o.has("rating")) o.getInt("rating") else null, note = o.optString("note").ifEmpty { null }, + intervalMin = if (o.has("intervalMin")) o.getInt("intervalMin") else null, synced = o.optBoolean("synced", false), ) } @@ -220,6 +224,7 @@ object SessionStore { put("pauseCount", s.pauseCount) s.rating?.let { put("rating", it) } s.note?.let { put("note", it) } + s.intervalMin?.let { put("intervalMin", it) } put("synced", s.synced) }) } diff --git a/app/src/main/java/com/marcus/meditationtimer/TimerService.kt b/app/src/main/java/com/marcus/meditationtimer/TimerService.kt index 19be79b..d17cc50 100644 --- a/app/src/main/java/com/marcus/meditationtimer/TimerService.kt +++ b/app/src/main/java/com/marcus/meditationtimer/TimerService.kt @@ -40,14 +40,16 @@ class TimerService : Service() { const val ACTION_RESUME = "resume" const val ACTION_STOP = "stop" const val EXTRA_DURATION_MS = "duration_ms" + const val EXTRA_INTERVAL_MIN = "interval_min" val state = MutableStateFlow(TimerState()) - fun start(context: Context, durationMs: Long) { + fun start(context: Context, durationMs: Long, intervalMin: Int = 0) { context.startForegroundService( Intent(context, TimerService::class.java) .setAction(ACTION_START) .putExtra(EXTRA_DURATION_MS, durationMs) + .putExtra(EXTRA_INTERVAL_MIN, intervalMin) ) } @@ -65,6 +67,8 @@ class TimerService : Service() { private var remainingMs = 0L private var startedAtIso = "" private var pauseCount = 0 + private var intervalMin = 0 + private var nextBellElapsedMs = 0L override fun onBind(intent: Intent?) = null @@ -83,6 +87,8 @@ class TimerService : Service() { endAt = SystemClock.elapsedRealtime() + totalMs startedAtIso = Instant.now().toString() pauseCount = 0 + intervalMin = intent.getIntExtra(EXTRA_INTERVAL_MIN, 0) + nextBellElapsedMs = if (intervalMin > 0) intervalMin * 60_000L else Long.MAX_VALUE startForeground(NOTIF_ID, buildNotification(remainingMs, paused = false)) acquireWakeLock(totalMs) playGong() @@ -130,6 +136,13 @@ class TimerService : Service() { } remainingMs = left state.value = TimerState(Phase.Running, totalMs, left) + // Interval bell on each boundary — skipped near the end so it + // never collides with the closing gong. + val elapsed = totalMs - left + if (elapsed >= nextBellElapsedMs) { + if (left > 30_000L) playBell() + nextBellElapsedMs += intervalMin * 60_000L + } delay(200L) } } @@ -156,11 +169,20 @@ class TimerService : Service() { actualMin = (elapsedMs / 60_000.0).roundToInt().coerceAtLeast(1), completed = completed, pauseCount = pauseCount, + intervalMin = if (intervalMin > 0) intervalMin else null, ) SessionStore.record(session) SessionStore.lastSessionId.value = session.id } + private fun playBell() { + // Separate one-shot player so a bell never cuts off the session gong. + MediaPlayer.create(this, R.raw.bell)?.apply { + setOnCompletionListener { it.release() } + start() + } + } + private fun playGong(onComplete: (() -> Unit)? = null) { player?.release() player = MediaPlayer.create(this, R.raw.gong)?.apply { diff --git a/app/src/main/res/raw/bell.wav b/app/src/main/res/raw/bell.wav new file mode 100644 index 0000000..12e3db1 Binary files /dev/null and b/app/src/main/res/raw/bell.wav differ