Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0cdddf2d4 | |||
| 688147b8ef | |||
| b36a9c636b |
@@ -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">
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ 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
|
||||
@@ -184,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
|
||||
@@ -211,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)
|
||||
@@ -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 const val MAX_MINUTES = 180
|
||||
|
||||
@@ -342,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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
29
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
29
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
51
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
51
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
15
app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
15
app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal 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>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
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