4 Commits
v1.5 ... main

Author SHA1 Message Date
2e1bf3907d Collapse interval bell behind underlined dropdown; 5 selectable gong sounds with preview
All checks were successful
Build & Release APK / build (push) Successful in 6m11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:38:33 -07:00
f0cdddf2d4 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>
2026-08-07 23:08:05 -07:00
688147b8ef Pull server session history into local log (merge, legacy Jhana sessions included)
All checks were successful
Build & Release APK / build (push) Successful in 6m49s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 21:58:35 -07:00
b36a9c636b Prettier adaptive launcher icon: gradient gong with boss, aura, themed-icon monochrome
All checks were successful
Build & Release APK / build (push) Successful in 6m39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 01:38:10 -07:00
14 changed files with 324 additions and 5 deletions

View File

@@ -9,7 +9,7 @@
<application
android:label="Meditation"
android:icon="@drawable/ic_gong"
android:icon="@mipmap/ic_launcher"
android:theme="@android:style/Theme.Material.NoActionBar"
android:allowBackup="true">

View File

@@ -35,6 +35,8 @@ 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.ui.text.style.TextDecoration
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -184,6 +186,9 @@ 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)) }
var gongKey by rememberSaveable { mutableStateOf(settings.getString("gong_sound", "bowl") ?: "bowl") }
val phase = timer.phase
val selectedTotalMs = durationMin * 60_000L
@@ -211,9 +216,25 @@ 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(6.dp))
GongSoundRow(
soundKey = gongKey,
onChange = {
gongKey = it
settings.edit().putString("gong_sound", it).apply()
},
)
Spacer(Modifier.height(16.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 +280,88 @@ private fun TimerTab() {
}
}
private val INTERVAL_OPTIONS = listOf(0, 5, 10, 15, 20, 30)
@Composable
private fun IntervalBellRow(intervalMin: Int, onChange: (Int) -> Unit) {
// Collapsed by default — a small underlined label that expands the options.
var expanded by rememberSaveable { mutableStateOf(false) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (intervalMin == 0) "Interval Timer" else "Interval Timer · ${intervalMin}m",
color = Gold.copy(alpha = if (expanded) 0.9f else 0.55f),
fontSize = 13.sp,
textDecoration = TextDecoration.Underline,
modifier = Modifier
.clickable { expanded = !expanded }
.padding(horizontal = 12.dp, vertical = 6.dp),
)
if (expanded) {
Spacer(Modifier.height(6.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)
expanded = false
}
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
}
}
@Composable
private fun GongSoundRow(soundKey: String, onChange: (String) -> Unit) {
var expanded by rememberSaveable { mutableStateOf(false) }
val context = LocalContext.current
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Gong Sound · ${GONG_SOUNDS.firstOrNull { it.first == soundKey }?.second ?: "Bowl"}",
color = Gold.copy(alpha = if (expanded) 0.9f else 0.55f),
fontSize = 13.sp,
textDecoration = TextDecoration.Underline,
modifier = Modifier
.clickable { expanded = !expanded }
.padding(horizontal = 12.dp, vertical = 6.dp),
)
if (expanded) {
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
GONG_SOUNDS.forEach { (key, label) ->
val selected = soundKey == key
Text(
text = label,
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(key)
// Preview the chosen gong immediately.
android.media.MediaPlayer.create(context, gongRes(key))?.apply {
setOnCompletionListener { it.release() }
start()
}
}
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
}
}
private val PRESETS = listOf(20, 45, 60, 90)
private const val MAX_MINUTES = 180
@@ -342,6 +445,9 @@ private fun HistoryTab(onEditSession: (Session) -> Unit, onOpenToken: () -> Unit
val sessions by SessionStore.sessions.collectAsState()
val pending by SessionStore.pendingSync.collectAsState()
// Pull the server history (incl. legacy Jhana-app sessions) on open.
LaunchedEffect(Unit) { SessionStore.refresh() }
Column(
Modifier
.fillMaxSize()

View File

@@ -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
@@ -55,6 +56,7 @@ object SessionStore {
publish(load())
}
syncPending()
refresh()
}
var token: String
@@ -93,6 +95,69 @@ object SessionStore {
}
}
/**
* Pull the full server history (incl. sessions from other apps/devices,
* e.g. the legacy Jhana app) and merge it into the local log. Local
* sessions still awaiting push (synced=false) always win over the server
* copy; everything else takes the server's version.
*/
fun refresh() {
val tok = token
if (tok.isEmpty()) return
scope.launch {
val remote = fetchSessions(tok) ?: return@launch
synchronized(lock) {
val local = load().associateBy { it.id }
val merged = LinkedHashMap(local)
for (r in remote) {
val l = local[r.id]
if (l == null || l.synced) {
// Keep any locally-entered rating/note if the server has none.
merged[r.id] = r.copy(
rating = r.rating ?: l?.rating,
note = r.note ?: l?.note,
synced = true,
)
}
}
persist(merged.values.toList())
}
}
}
private fun fetchSessions(tok: String): List<Session>? = try {
val conn = URL("$BASE_URL/v1/sessions?limit=1000").openConnection() as HttpURLConnection
conn.requestMethod = "GET"
conn.setRequestProperty("Authorization", "Bearer $tok")
conn.connectTimeout = 10_000
conn.readTimeout = 10_000
val body = if (conn.responseCode in 200..299) {
conn.inputStream.bufferedReader().use { it.readText() }
} else null
conn.disconnect()
body?.let {
val arr = JSONObject(it).getJSONArray("sessions")
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
Session(
id = o.getString("id"),
startedAt = o.getString("startedAt"),
endedAt = o.optString("endedAt").ifEmpty { null },
plannedMin = o.optInt("plannedMin", 0),
actualMin = o.optInt("actualMin", 0),
completed = o.optBoolean("completed", false),
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,
)
}
}
} catch (_: Exception) {
null
}
private fun postSession(s: Session, tok: String): Boolean = try {
val conn = URL("$BASE_URL/v1/sessions").openConnection() as HttpURLConnection
conn.requestMethod = "POST"
@@ -111,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
@@ -136,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),
)
}
@@ -157,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)
})
}

View File

@@ -0,0 +1,18 @@
package com.marcus.meditationtimer
/** Selectable session-gong sounds (key → label). Key persisted in settings. */
val GONG_SOUNDS = listOf(
"bowl" to "Bowl",
"deep" to "Deep",
"bright" to "Bright",
"bell" to "Bell",
"twin" to "Twin",
)
fun gongRes(key: String): Int = when (key) {
"deep" -> R.raw.gong_deep
"bright" -> R.raw.gong_bright
"bell" -> R.raw.gong_bell
"twin" -> R.raw.gong_twin
else -> R.raw.gong
}

View File

@@ -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,9 @@ class TimerService : Service() {
private var remainingMs = 0L
private var startedAtIso = ""
private var pauseCount = 0
private var intervalMin = 0
private var nextBellElapsedMs = 0L
private var gongResId = R.raw.gong
override fun onBind(intent: Intent?) = null
@@ -83,6 +88,11 @@ 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
gongResId = gongRes(
getSharedPreferences("settings", MODE_PRIVATE).getString("gong_sound", "bowl") ?: "bowl"
)
startForeground(NOTIF_ID, buildNotification(remainingMs, paused = false))
acquireWakeLock(totalMs)
playGong()
@@ -130,6 +140,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,14 +173,23 @@ 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 {
player = MediaPlayer.create(this, gongResId)?.apply {
setOnCompletionListener {
it.release()
if (player == it) player = null

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M0,0h108v108H0z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="54" android:startY="0"
android:endX="54" android:endY="108"
android:startColor="#1D3550"
android:endColor="#0A1622" />
</aapt:attr>
</path>
<!-- faint aura rings behind the gong -->
<path
android:pathData="M54,54m-44,0a44,44 0,1 1,88 0a44,44 0,1 1,-88 0"
android:strokeColor="#22E3BC4B"
android:strokeWidth="1.5"
android:fillColor="#00000000" />
<path
android:pathData="M54,54m-38,0a38,38 0,1 1,76 0a38,38 0,1 1,-76 0"
android:strokeColor="#14E3BC4B"
android:strokeWidth="1"
android:fillColor="#00000000" />
</vector>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- gong rim -->
<path android:pathData="M54,54m-29,0a29,29 0,1 1,58 0a29,29 0,1 1,-58 0">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="36" android:startY="30"
android:endX="72" android:endY="80"
android:startColor="#D9AE35"
android:endColor="#A87E1C" />
</aapt:attr>
</path>
<!-- face -->
<path android:pathData="M54,54m-24.5,0a24.5,24.5 0,1 1,49 0a24.5,24.5 0,1 1,-49 0">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="38" android:startY="32"
android:endX="70" android:endY="78"
android:startColor="#F0CF66"
android:endColor="#CD9F2E" />
</aapt:attr>
</path>
<!-- hammered ring -->
<path
android:pathData="M54,54m-17.5,0a17.5,17.5 0,1 1,35 0a17.5,17.5 0,1 1,-35 0"
android:strokeColor="#B8892288"
android:strokeWidth="1.2"
android:fillColor="#00000000" />
<!-- boss (center dome) -->
<path android:pathData="M54,54m-9.5,0a9.5,9.5 0,1 1,19 0a9.5,9.5 0,1 1,-19 0">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="47" android:startY="46"
android:endX="60" android:endY="62"
android:startColor="#FCEBA8"
android:endColor="#E3BC4B" />
</aapt:attr>
</path>
<!-- specular highlight, upper-left crescent -->
<path
android:pathData="M38.5,40.5 a21,21 0 0 1 13,-7.5 a24,24 0 0 0 -16.5,10.8 z"
android:fillColor="#66FFF7D6" />
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M54,54m-27,0a27,27 0,1 1,54 0a27,27 0,1 1,-54 0"
android:strokeColor="#FFFFFF"
android:strokeWidth="5"
android:fillColor="#00000000" />
<path
android:pathData="M54,54m-9,0a9,9 0,1 1,18 0a9,9 0,1 1,-18 0"
android:fillColor="#FFFFFF" />
</vector>

View File

@@ -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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.