Interval bell setting: Off/5/10/15/20/30 min, distinct soft bell, intervalMin synced
All checks were successful
Build & Release APK / build (push) Successful in 2m54s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 23:08:05 -07:00
parent 688147b8ef
commit f0cdddf2d4
4 changed files with 71 additions and 3 deletions

View File

@@ -35,6 +35,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -184,6 +185,8 @@ private fun TimerTab() {
val context = LocalContext.current val context = LocalContext.current
val timer by TimerService.state.collectAsState() val timer by TimerService.state.collectAsState()
var durationMin by rememberSaveable { mutableIntStateOf(20) } 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 phase = timer.phase
val selectedTotalMs = durationMin * 60_000L val selectedTotalMs = durationMin * 60_000L
@@ -211,9 +214,17 @@ private fun TimerTab() {
if (phase == Phase.Idle || phase == Phase.Done) { if (phase == Phase.Idle || phase == Phase.Done) {
MinutePicker(durationMin = durationMin, onChange = { durationMin = it }) 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( Button(
onClick = { TimerService.start(context, selectedTotalMs) }, onClick = { TimerService.start(context, selectedTotalMs, intervalMin) },
colors = ButtonDefaults.buttonColors(containerColor = Gold, contentColor = DeepBlue), colors = ButtonDefaults.buttonColors(containerColor = Gold, contentColor = DeepBlue),
) { ) {
Text(if (phase == Phase.Done) "Again" else "Begin", fontSize = 18.sp) 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 val PRESETS = listOf(20, 45, 60, 90)
private const val MAX_MINUTES = 180 private const val MAX_MINUTES = 180

View File

@@ -22,6 +22,7 @@ data class Session(
val pauseCount: Int, val pauseCount: Int,
val rating: Int? = null, val rating: Int? = null,
val note: String? = null, val note: String? = null,
val intervalMin: Int? = null,
val synced: Boolean = false, val synced: Boolean = false,
) { ) {
val completionPct: Int val completionPct: Int
@@ -148,6 +149,7 @@ object SessionStore {
pauseCount = o.optInt("pauseCount", 0), pauseCount = o.optInt("pauseCount", 0),
rating = if (o.isNull("rating")) null else o.optInt("rating"), rating = if (o.isNull("rating")) null else o.optInt("rating"),
note = o.optString("note").ifEmpty { null }, note = o.optString("note").ifEmpty { null },
intervalMin = if (o.isNull("intervalMin")) null else o.optInt("intervalMin"),
synced = true, synced = true,
) )
} }
@@ -174,6 +176,7 @@ object SessionStore {
put("pauseCount", s.pauseCount) put("pauseCount", s.pauseCount)
s.rating?.let { put("rating", it) } s.rating?.let { put("rating", it) }
s.note?.let { put("note", it) } s.note?.let { put("note", it) }
s.intervalMin?.let { put("intervalMin", it) }
} }
conn.outputStream.use { it.write(body.toString().toByteArray()) } conn.outputStream.use { it.write(body.toString().toByteArray()) }
val ok = conn.responseCode in 200..299 val ok = conn.responseCode in 200..299
@@ -199,6 +202,7 @@ object SessionStore {
pauseCount = o.optInt("pauseCount", 0), pauseCount = o.optInt("pauseCount", 0),
rating = if (o.has("rating")) o.getInt("rating") else null, rating = if (o.has("rating")) o.getInt("rating") else null,
note = o.optString("note").ifEmpty { null }, note = o.optString("note").ifEmpty { null },
intervalMin = if (o.has("intervalMin")) o.getInt("intervalMin") else null,
synced = o.optBoolean("synced", false), synced = o.optBoolean("synced", false),
) )
} }
@@ -220,6 +224,7 @@ object SessionStore {
put("pauseCount", s.pauseCount) put("pauseCount", s.pauseCount)
s.rating?.let { put("rating", it) } s.rating?.let { put("rating", it) }
s.note?.let { put("note", it) } s.note?.let { put("note", it) }
s.intervalMin?.let { put("intervalMin", it) }
put("synced", s.synced) put("synced", s.synced)
}) })
} }

View File

@@ -40,14 +40,16 @@ class TimerService : Service() {
const val ACTION_RESUME = "resume" const val ACTION_RESUME = "resume"
const val ACTION_STOP = "stop" const val ACTION_STOP = "stop"
const val EXTRA_DURATION_MS = "duration_ms" const val EXTRA_DURATION_MS = "duration_ms"
const val EXTRA_INTERVAL_MIN = "interval_min"
val state = MutableStateFlow(TimerState()) val state = MutableStateFlow(TimerState())
fun start(context: Context, durationMs: Long) { fun start(context: Context, durationMs: Long, intervalMin: Int = 0) {
context.startForegroundService( context.startForegroundService(
Intent(context, TimerService::class.java) Intent(context, TimerService::class.java)
.setAction(ACTION_START) .setAction(ACTION_START)
.putExtra(EXTRA_DURATION_MS, durationMs) .putExtra(EXTRA_DURATION_MS, durationMs)
.putExtra(EXTRA_INTERVAL_MIN, intervalMin)
) )
} }
@@ -65,6 +67,8 @@ class TimerService : Service() {
private var remainingMs = 0L private var remainingMs = 0L
private var startedAtIso = "" private var startedAtIso = ""
private var pauseCount = 0 private var pauseCount = 0
private var intervalMin = 0
private var nextBellElapsedMs = 0L
override fun onBind(intent: Intent?) = null override fun onBind(intent: Intent?) = null
@@ -83,6 +87,8 @@ class TimerService : Service() {
endAt = SystemClock.elapsedRealtime() + totalMs endAt = SystemClock.elapsedRealtime() + totalMs
startedAtIso = Instant.now().toString() startedAtIso = Instant.now().toString()
pauseCount = 0 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)) startForeground(NOTIF_ID, buildNotification(remainingMs, paused = false))
acquireWakeLock(totalMs) acquireWakeLock(totalMs)
playGong() playGong()
@@ -130,6 +136,13 @@ class TimerService : Service() {
} }
remainingMs = left remainingMs = left
state.value = TimerState(Phase.Running, totalMs, 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) delay(200L)
} }
} }
@@ -156,11 +169,20 @@ class TimerService : Service() {
actualMin = (elapsedMs / 60_000.0).roundToInt().coerceAtLeast(1), actualMin = (elapsedMs / 60_000.0).roundToInt().coerceAtLeast(1),
completed = completed, completed = completed,
pauseCount = pauseCount, pauseCount = pauseCount,
intervalMin = if (intervalMin > 0) intervalMin else null,
) )
SessionStore.record(session) SessionStore.record(session)
SessionStore.lastSessionId.value = session.id 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) { private fun playGong(onComplete: (() -> Unit)? = null) {
player?.release() player?.release()
player = MediaPlayer.create(this, R.raw.gong)?.apply { player = MediaPlayer.create(this, R.raw.gong)?.apply {

Binary file not shown.