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
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
BIN
app/src/main/res/raw/bell.wav
Normal file
BIN
app/src/main/res/raw/bell.wav
Normal file
Binary file not shown.
Reference in New Issue
Block a user