4 Commits
v1.3 ... v1.7

Author SHA1 Message Date
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
11 changed files with 256 additions and 1 deletions

View File

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

View File

@@ -9,7 +9,7 @@
<application <application
android:label="Meditation" android:label="Meditation"
android:icon="@drawable/ic_gong" android:icon="@mipmap/ic_launcher"
android:theme="@android:style/Theme.Material.NoActionBar" android:theme="@android:style/Theme.Material.NoActionBar"
android:allowBackup="true"> android:allowBackup="true">
@@ -27,6 +27,16 @@
android:name=".TimerService" android:name=".TimerService"
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaPlayback" /> 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> </application>
</manifest> </manifest>

Binary file not shown.

Binary file not shown.

View File

@@ -1,10 +1,14 @@
package com.marcus.meditationtimer package com.marcus.meditationtimer
import android.Manifest import android.Manifest
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.core.content.FileProvider
import java.io.File
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -22,8 +26,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -128,6 +134,10 @@ private fun App() {
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(DeepBlue) .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( TabRow(
selectedTabIndex = tab, selectedTabIndex = tab,
@@ -136,6 +146,7 @@ private fun App() {
) { ) {
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Timer") }) Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Timer") })
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("History") }) Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("History") })
Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Reading") })
} }
when (tab) { when (tab) {
0 -> TimerTab() 0 -> TimerTab()
@@ -143,6 +154,7 @@ private fun App() {
onEditSession = { ratingFor = it }, onEditSession = { ratingFor = it },
onOpenToken = { showTokenDialog = true }, onOpenToken = { showTokenDialog = true },
) )
2 -> ReadingTab()
} }
} }
@@ -330,6 +342,9 @@ private fun HistoryTab(onEditSession: (Session) -> Unit, onOpenToken: () -> Unit
val sessions by SessionStore.sessions.collectAsState() val sessions by SessionStore.sessions.collectAsState()
val pending by SessionStore.pendingSync.collectAsState() val pending by SessionStore.pendingSync.collectAsState()
// Pull the server history (incl. legacy Jhana-app sessions) on open.
LaunchedEffect(Unit) { SessionStore.refresh() }
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -430,6 +445,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 // ----------------------------------------------------------------- Dialogs
@Composable @Composable

View File

@@ -55,6 +55,7 @@ object SessionStore {
publish(load()) publish(load())
} }
syncPending() syncPending()
refresh()
} }
var token: String var token: String
@@ -93,6 +94,68 @@ 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 },
synced = true,
)
}
}
} catch (_: Exception) {
null
}
private fun postSession(s: Session, tok: String): Boolean = try { private fun postSession(s: Session, tok: String): Boolean = try {
val conn = URL("$BASE_URL/v1/sessions").openConnection() as HttpURLConnection val conn = URL("$BASE_URL/v1/sessions").openConnection() as HttpURLConnection
conn.requestMethod = "POST" conn.requestMethod = "POST"

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>

View File

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