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 {
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,8 +26,10 @@ 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
@@ -128,6 +134,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 +146,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 +154,7 @@ private fun App() {
onEditSession = { ratingFor = it },
onOpenToken = { showTokenDialog = true },
)
2 -> ReadingTab()
}
}
@@ -330,6 +342,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 +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
@Composable

View File

@@ -55,6 +55,7 @@ object SessionStore {
publish(load())
}
syncPending()
refresh()
}
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 {
val conn = URL("$BASE_URL/v1/sessions").openConnection() as HttpURLConnection
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>