5 Commits
v1.3 ... v1.8

Author SHA1 Message Date
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
d9b886cdc6 Fix unclickable tabs: pad for status/nav bars under enforced edge-to-edge
All checks were successful
Build & Release APK / build (push) Successful in 6m49s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 01:29:33 -07:00
cc800e756b Add Reading tab with Jhanas Guide and Retreat Instructions PDFs
Some checks failed
Build & Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 01:23:16 -07:00
13 changed files with 327 additions and 4 deletions

View File

@@ -51,6 +51,7 @@ android {
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
implementation(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.9.3")

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">
@@ -27,6 +27,16 @@
android:name=".TimerService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.marcus.meditationtimer.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

Binary file not shown.

Binary file not shown.

View File

@@ -1,10 +1,14 @@
package com.marcus.meditationtimer
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.core.content.FileProvider
import java.io.File
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
@@ -22,13 +26,16 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
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
@@ -128,6 +135,10 @@ private fun App() {
modifier = Modifier
.fillMaxSize()
.background(DeepBlue)
// app targets SDK 35 → edge-to-edge is forced; without these the tab
// row sits under the status bar and taps never reach it
.statusBarsPadding()
.navigationBarsPadding()
) {
TabRow(
selectedTabIndex = tab,
@@ -136,6 +147,7 @@ private fun App() {
) {
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Timer") })
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("History") })
Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Reading") })
}
when (tab) {
0 -> TimerTab()
@@ -143,6 +155,7 @@ private fun App() {
onEditSession = { ratingFor = it },
onOpenToken = { showTokenDialog = true },
)
2 -> ReadingTab()
}
}
@@ -172,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
@@ -199,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)
@@ -247,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
@@ -330,6 +383,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()
@@ -430,6 +486,67 @@ private fun SessionRow(s: Session, onClick: () -> Unit) {
}
}
// -------------------------------------------------------------- Reading tab
private data class Doc(val title: String, val subtitle: String, val asset: String)
private val DOCS = listOf(
Doc("Pragmatic Jhanas Guide", "v4.3", "jhanas-guide.pdf"),
Doc("Retreat Instructions", "v6.0.3 · Day 1 & 2", "retreat-instructions.pdf"),
)
@Composable
private fun ReadingTab() {
val context = LocalContext.current
Column(
Modifier
.fillMaxSize()
.padding(20.dp)
) {
DOCS.forEach { doc ->
Column(
Modifier
.fillMaxWidth()
.clickable { openPdf(context, doc) }
.background(Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp))
.padding(20.dp)
) {
Text(doc.title, color = Color.White.copy(alpha = 0.9f), fontSize = 16.sp)
Spacer(Modifier.height(4.dp))
Text(doc.subtitle, color = Dim, fontSize = 12.sp)
}
Spacer(Modifier.height(12.dp))
}
Text(
"Opens in the system PDF viewer.",
color = Dim,
fontSize = 11.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
private fun openPdf(context: Context, doc: Doc) {
try {
val dir = File(context.cacheDir, "reading").apply { mkdirs() }
val outFile = File(dir, doc.asset)
if (!outFile.exists() || outFile.length() == 0L) {
context.assets.open("reading/${doc.asset}").use { input ->
outFile.outputStream().use { input.copyTo(it) }
}
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", outFile)
context.startActivity(
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/pdf")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
)
} catch (e: Exception) {
Toast.makeText(context, "Could not open PDF: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
// ----------------------------------------------------------------- Dialogs
@Composable

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

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

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.

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="reading" path="reading/" />
</paths>