Meditation timer: gong service, wheel picker, history + health sync, Gitea CI
Some checks failed
Build & Release APK / build (push) Failing after 46s
Some checks failed
Build & Release APK / build (push) Failing after 46s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
58
app/build.gradle.kts
Normal file
58
app/build.gradle.kts
Normal file
@@ -0,0 +1,58 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.marcus.meditationtimer"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.marcus.meditationtimer"
|
||||
minSdk = 29
|
||||
targetSdk = 35
|
||||
versionCode = System.getenv("VERSION_CODE")?.toIntOrNull() ?: 2
|
||||
versionName = System.getenv("VERSION_NAME") ?: "1.1-dev"
|
||||
}
|
||||
|
||||
val releaseKeystore = System.getenv("KEYSTORE_FILE")
|
||||
if (releaseKeystore != null) {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(releaseKeystore)
|
||||
storePassword = System.getenv("KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("KEY_ALIAS") ?: "meditation"
|
||||
keyPassword = System.getenv("KEY_PASSWORD") ?: System.getenv("KEYSTORE_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
if (releaseKeystore != null) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
|
||||
implementation(composeBom)
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
}
|
||||
32
app/src/main/AndroidManifest.xml
Normal file
32
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:label="Meditation"
|
||||
android:icon="@drawable/ic_gong"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar"
|
||||
android:allowBackup="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".TimerService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
543
app/src/main/java/com/marcus/meditationtimer/MainActivity.kt
Normal file
543
app/src/main/java/com/marcus/meditationtimer/MainActivity.kt
Normal file
@@ -0,0 +1,543 @@
|
||||
package com.marcus.meditationtimer
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
private val Gold = Color(0xFFE3BC4B)
|
||||
private val DeepBlue = Color(0xFF10202A)
|
||||
private val Dim = Color.White.copy(alpha = 0.35f)
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val notifPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
SessionStore.init(this)
|
||||
handleTokenIntent(intent)
|
||||
if (Build.VERSION.SDK_INT >= 33 &&
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notifPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
setContent {
|
||||
MaterialTheme(
|
||||
colorScheme = darkColorScheme(
|
||||
primary = Gold,
|
||||
background = DeepBlue,
|
||||
surface = DeepBlue,
|
||||
)
|
||||
) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleTokenIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleTokenIntent(intent: Intent?) {
|
||||
intent?.getStringExtra("sync_token")?.let { SessionStore.token = it }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun App() {
|
||||
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||
var showTokenDialog by remember { mutableStateOf(false) }
|
||||
var ratingFor by remember { mutableStateOf<Session?>(null) }
|
||||
|
||||
val lastId by SessionStore.lastSessionId.collectAsState()
|
||||
LaunchedEffect(lastId) {
|
||||
val id = lastId ?: return@LaunchedEffect
|
||||
SessionStore.sessions.value.find { it.id == id && it.completed }?.let { ratingFor = it }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(DeepBlue)
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = tab,
|
||||
containerColor = DeepBlue,
|
||||
contentColor = Gold,
|
||||
) {
|
||||
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Timer") })
|
||||
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("History") })
|
||||
}
|
||||
when (tab) {
|
||||
0 -> TimerTab()
|
||||
1 -> HistoryTab(
|
||||
onEditSession = { ratingFor = it },
|
||||
onOpenToken = { showTokenDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showTokenDialog) {
|
||||
TokenDialog(onDismiss = { showTokenDialog = false })
|
||||
}
|
||||
ratingFor?.let { session ->
|
||||
RatingDialog(
|
||||
session = session,
|
||||
onSave = { rating, note ->
|
||||
SessionStore.updateRating(session.id, rating, note)
|
||||
SessionStore.lastSessionId.value = null
|
||||
ratingFor = null
|
||||
},
|
||||
onDismiss = {
|
||||
SessionStore.lastSessionId.value = null
|
||||
ratingFor = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Timer tab
|
||||
|
||||
@Composable
|
||||
private fun TimerTab() {
|
||||
val context = LocalContext.current
|
||||
val timer by TimerService.state.collectAsState()
|
||||
var durationMin by rememberSaveable { mutableIntStateOf(20) }
|
||||
|
||||
val phase = timer.phase
|
||||
val selectedTotalMs = durationMin * 60_000L
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = when (phase) {
|
||||
Phase.Idle -> "Meditation"
|
||||
Phase.Running -> "Breathe"
|
||||
Phase.Paused -> "Paused"
|
||||
Phase.Done -> "Session complete"
|
||||
},
|
||||
color = Gold.copy(alpha = 0.8f),
|
||||
fontSize = 20.sp,
|
||||
letterSpacing = 3.sp,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
if (phase == Phase.Idle || phase == Phase.Done) {
|
||||
MinutePicker(durationMin = durationMin, onChange = { durationMin = it })
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(
|
||||
onClick = { TimerService.start(context, selectedTotalMs) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Gold, contentColor = DeepBlue),
|
||||
) {
|
||||
Text(if (phase == Phase.Done) "Again" else "Begin", fontSize = 18.sp)
|
||||
}
|
||||
} else {
|
||||
val progress by animateFloatAsState(
|
||||
targetValue = (timer.remainingMs.toFloat() / timer.totalMs.coerceAtLeast(1L)).coerceIn(0f, 1f),
|
||||
animationSpec = tween(250),
|
||||
label = "progress",
|
||||
)
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(260.dp),
|
||||
color = Gold,
|
||||
trackColor = Color.White.copy(alpha = 0.08f),
|
||||
strokeWidth = 6.dp,
|
||||
)
|
||||
Text(
|
||||
text = formatTime(timer.remainingMs),
|
||||
color = Color.White,
|
||||
fontSize = 56.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(40.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
if (phase == Phase.Running) {
|
||||
OutlinedButton(onClick = { TimerService.send(context, TimerService.ACTION_PAUSE) }) {
|
||||
Text("Pause", color = Gold)
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { TimerService.send(context, TimerService.ACTION_RESUME) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Gold, contentColor = DeepBlue),
|
||||
) { Text("Resume") }
|
||||
}
|
||||
OutlinedButton(onClick = { TimerService.send(context, TimerService.ACTION_STOP) }) {
|
||||
Text("End", color = Color.White.copy(alpha = 0.6f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val PRESETS = listOf(20, 45, 60, 90)
|
||||
private const val MAX_MINUTES = 180
|
||||
|
||||
@Composable
|
||||
private fun MinutePicker(durationMin: Int, onChange: (Int) -> Unit) {
|
||||
val itemHeight = 44.dp
|
||||
val listState = rememberLazyListState(initialFirstVisibleItemIndex = durationMin - 1)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val centeredIndex by remember {
|
||||
derivedStateOf {
|
||||
val info = listState.layoutInfo
|
||||
if (info.visibleItemsInfo.isEmpty()) durationMin - 1
|
||||
else {
|
||||
val center = (info.viewportStartOffset + info.viewportEndOffset) / 2
|
||||
info.visibleItemsInfo.minByOrNull {
|
||||
kotlin.math.abs((it.offset + it.size / 2) - center)
|
||||
}?.index ?: (durationMin - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { centeredIndex }.collect { onChange(it + 1) }
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(150.dp)
|
||||
.height(itemHeight)
|
||||
.background(Gold.copy(alpha = 0.10f), RoundedCornerShape(12.dp))
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
flingBehavior = rememberSnapFlingBehavior(listState),
|
||||
modifier = Modifier
|
||||
.height(itemHeight * 3)
|
||||
.width(90.dp),
|
||||
contentPadding = PaddingValues(vertical = itemHeight),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
items((1..MAX_MINUTES).toList()) { min ->
|
||||
val selected = min - 1 == centeredIndex
|
||||
Box(Modifier.height(itemHeight), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "$min",
|
||||
fontSize = if (selected) 32.sp else 20.sp,
|
||||
fontWeight = if (selected) FontWeight.Medium else FontWeight.Light,
|
||||
color = if (selected) Gold else Dim,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("min", color = Dim, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PRESETS.forEach { min ->
|
||||
FilterChip(
|
||||
selected = durationMin == min,
|
||||
onClick = { scope.launch { listState.animateScrollToItem(min - 1) } },
|
||||
label = { Text("$min") },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = Gold,
|
||||
selectedLabelColor = DeepBlue,
|
||||
labelColor = Color.White.copy(alpha = 0.7f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- History tab
|
||||
|
||||
@Composable
|
||||
private fun HistoryTab(onEditSession: (Session) -> Unit, onOpenToken: () -> Unit) {
|
||||
val sessions by SessionStore.sessions.collectAsState()
|
||||
val pending by SessionStore.pendingSync.collectAsState()
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 20.dp)
|
||||
) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
val totalMin = sessions.sumOf { it.actualMin }
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
Stat("${streak(sessions)}", "day streak")
|
||||
Stat("${sessions.size}", "sits")
|
||||
Stat("${totalMin / 60}h ${totalMin % 60}m", "total")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
SessionStore.token.isEmpty() -> "Sync not configured"
|
||||
pending > 0 -> "$pending unsynced"
|
||||
else -> "All synced"
|
||||
},
|
||||
color = if (pending > 0 || SessionStore.token.isEmpty()) Gold.copy(alpha = 0.8f) else Dim,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
Row {
|
||||
if (pending > 0 && SessionStore.token.isNotEmpty()) {
|
||||
TextButton(onClick = { SessionStore.syncPending() }) {
|
||||
Text("Sync now", color = Gold, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onOpenToken) {
|
||||
Text("Token", color = Dim, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = Color.White.copy(alpha = 0.08f))
|
||||
|
||||
if (sessions.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No sits recorded yet", color = Dim)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(sessions, key = { it.id }) { s ->
|
||||
SessionRow(s, onClick = { onEditSession(s) })
|
||||
HorizontalDivider(color = Color.White.copy(alpha = 0.06f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Stat(value: String, label: String) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(value, color = Gold, fontSize = 24.sp, fontWeight = FontWeight.Medium)
|
||||
Text(label.uppercase(), color = Dim, fontSize = 10.sp, letterSpacing = 1.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionRow(s: Session, onClick: () -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 12.dp, horizontal = 4.dp)
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatDate(s.startedAt), color = Color.White.copy(alpha = 0.85f), fontSize = 14.sp)
|
||||
Text(
|
||||
text = if (s.rating != null) "●".repeat(s.rating) + "○".repeat(5 - s.rating) else "",
|
||||
color = Gold,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 2.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = "${s.actualMin} min" + if (s.completed) "" else " of ${s.plannedMin} (${s.completionPct}%)",
|
||||
color = if (s.completed) Gold else Color(0xFFF5C542),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
if (!s.synced) Text("unsynced", color = Dim, fontSize = 11.sp)
|
||||
}
|
||||
s.note?.let {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(it, color = Dim, fontSize = 12.sp, fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- Dialogs
|
||||
|
||||
@Composable
|
||||
private fun RatingDialog(session: Session, onSave: (Int?, String?) -> Unit, onDismiss: () -> Unit) {
|
||||
var rating by remember { mutableStateOf(session.rating) }
|
||||
var note by remember { mutableStateOf(session.note ?: "") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("How deep was the sit?") },
|
||||
text = {
|
||||
Column {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
(1..5).forEach { i ->
|
||||
Text(
|
||||
text = if (rating != null && i <= rating!!) "●" else "○",
|
||||
color = Gold,
|
||||
fontSize = 34.sp,
|
||||
modifier = Modifier
|
||||
.clickable { rating = if (rating == i) null else i }
|
||||
.padding(horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = note,
|
||||
onValueChange = { note = it },
|
||||
label = { Text("Note (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSave(rating, note) }) { Text("Save", color = Gold) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Skip", color = Dim) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TokenDialog(onDismiss: () -> Unit) {
|
||||
var token by remember { mutableStateOf(SessionStore.token) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Sync token") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"Bearer token for meditation.rehbock.xyz. Stored only on this device.",
|
||||
fontSize = 12.sp,
|
||||
color = Dim,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = { token = it },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
SessionStore.token = token
|
||||
onDismiss()
|
||||
}) { Text("Save", color = Gold) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel", color = Dim) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Helpers
|
||||
|
||||
private fun formatTime(ms: Long): String {
|
||||
val totalSec = (ms + 999) / 1000
|
||||
return "%d:%02d".format(totalSec / 60, totalSec % 60)
|
||||
}
|
||||
|
||||
private val DATE_FMT = DateTimeFormatter.ofPattern("EEE d MMM · HH:mm")
|
||||
|
||||
private fun formatDate(iso: String): String = try {
|
||||
DATE_FMT.format(Instant.parse(iso).atZone(ZoneId.systemDefault()))
|
||||
} catch (_: Exception) {
|
||||
iso.take(16).replace("T", " ")
|
||||
}
|
||||
|
||||
private fun streak(sessions: List<Session>): Int {
|
||||
val days = sessions.mapNotNull {
|
||||
try {
|
||||
Instant.parse(it.startedAt).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}.toSet()
|
||||
var cursor = LocalDate.now()
|
||||
if (cursor !in days) cursor = cursor.minusDays(1)
|
||||
var n = 0
|
||||
while (cursor in days) {
|
||||
n++
|
||||
cursor = cursor.minusDays(1)
|
||||
}
|
||||
return n
|
||||
}
|
||||
171
app/src/main/java/com/marcus/meditationtimer/SessionStore.kt
Normal file
171
app/src/main/java/com/marcus/meditationtimer/SessionStore.kt
Normal file
@@ -0,0 +1,171 @@
|
||||
package com.marcus.meditationtimer
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
data class Session(
|
||||
val id: String,
|
||||
val startedAt: String,
|
||||
val endedAt: String?,
|
||||
val plannedMin: Int,
|
||||
val actualMin: Int,
|
||||
val completed: Boolean,
|
||||
val pauseCount: Int,
|
||||
val rating: Int? = null,
|
||||
val note: String? = null,
|
||||
val synced: Boolean = false,
|
||||
) {
|
||||
val completionPct: Int
|
||||
get() = if (plannedMin > 0) minOf(100, Math.round(actualMin * 100f / plannedMin)) else 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Local session log (SharedPreferences-backed) that syncs to the health API
|
||||
* at meditation.rehbock.xyz. The bearer token is never shipped in the APK —
|
||||
* it is entered in-app (or seeded via `adb shell am start ... --es sync_token X`)
|
||||
* and lives only in local app storage.
|
||||
*/
|
||||
object SessionStore {
|
||||
private const val PREFS = "sessions"
|
||||
private const val KEY_LIST = "list"
|
||||
private const val KEY_TOKEN = "sync_token"
|
||||
private const val BASE_URL = "https://meditation.rehbock.xyz"
|
||||
|
||||
private lateinit var prefs: SharedPreferences
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val lock = Any()
|
||||
|
||||
val sessions = MutableStateFlow<List<Session>>(emptyList())
|
||||
val pendingSync = MutableStateFlow(0)
|
||||
val lastSessionId = MutableStateFlow<String?>(null)
|
||||
|
||||
fun init(context: Context) {
|
||||
synchronized(lock) {
|
||||
if (::prefs.isInitialized) return
|
||||
prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
publish(load())
|
||||
}
|
||||
syncPending()
|
||||
}
|
||||
|
||||
var token: String
|
||||
get() = synchronized(lock) { prefs.getString(KEY_TOKEN, "") ?: "" }
|
||||
set(value) {
|
||||
synchronized(lock) { prefs.edit().putString(KEY_TOKEN, value.trim()).apply() }
|
||||
syncPending()
|
||||
}
|
||||
|
||||
fun record(session: Session) {
|
||||
synchronized(lock) { persist(load() + session) }
|
||||
syncPending()
|
||||
}
|
||||
|
||||
fun updateRating(id: String, rating: Int?, note: String?) {
|
||||
synchronized(lock) {
|
||||
persist(load().map {
|
||||
if (it.id == id) it.copy(rating = rating, note = note?.ifBlank { null }, synced = false) else it
|
||||
})
|
||||
}
|
||||
syncPending()
|
||||
}
|
||||
|
||||
fun syncPending() {
|
||||
val tok = token
|
||||
if (tok.isEmpty()) return
|
||||
scope.launch {
|
||||
val pending = synchronized(lock) { load().filter { !it.synced } }
|
||||
for (s in pending) {
|
||||
if (postSession(s, tok)) {
|
||||
synchronized(lock) {
|
||||
persist(load().map { if (it.id == s.id) it.copy(synced = true) else it })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun postSession(s: Session, tok: String): Boolean = try {
|
||||
val conn = URL("$BASE_URL/v1/sessions").openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Authorization", "Bearer $tok")
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.connectTimeout = 10_000
|
||||
conn.readTimeout = 10_000
|
||||
conn.doOutput = true
|
||||
val body = JSONObject().apply {
|
||||
put("id", s.id)
|
||||
put("startedAt", s.startedAt)
|
||||
s.endedAt?.let { put("endedAt", it) }
|
||||
put("plannedMin", s.plannedMin)
|
||||
put("actualMin", s.actualMin)
|
||||
put("completed", s.completed)
|
||||
put("pauseCount", s.pauseCount)
|
||||
s.rating?.let { put("rating", it) }
|
||||
s.note?.let { put("note", it) }
|
||||
}
|
||||
conn.outputStream.use { it.write(body.toString().toByteArray()) }
|
||||
val ok = conn.responseCode in 200..299
|
||||
conn.disconnect()
|
||||
ok
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
private fun load(): List<Session> {
|
||||
val raw = prefs.getString(KEY_LIST, "[]") ?: "[]"
|
||||
return try {
|
||||
val arr = JSONArray(raw)
|
||||
(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.getInt("plannedMin"),
|
||||
actualMin = o.getInt("actualMin"),
|
||||
completed = o.getBoolean("completed"),
|
||||
pauseCount = o.optInt("pauseCount", 0),
|
||||
rating = if (o.has("rating")) o.getInt("rating") else null,
|
||||
note = o.optString("note").ifEmpty { null },
|
||||
synced = o.optBoolean("synced", false),
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist(list: List<Session>) {
|
||||
val arr = JSONArray()
|
||||
list.forEach { s ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("id", s.id)
|
||||
put("startedAt", s.startedAt)
|
||||
s.endedAt?.let { put("endedAt", it) }
|
||||
put("plannedMin", s.plannedMin)
|
||||
put("actualMin", s.actualMin)
|
||||
put("completed", s.completed)
|
||||
put("pauseCount", s.pauseCount)
|
||||
s.rating?.let { put("rating", it) }
|
||||
s.note?.let { put("note", it) }
|
||||
put("synced", s.synced)
|
||||
})
|
||||
}
|
||||
prefs.edit().putString(KEY_LIST, arr.toString()).apply()
|
||||
publish(list)
|
||||
}
|
||||
|
||||
private fun publish(list: List<Session>) {
|
||||
sessions.value = list.sortedByDescending { it.startedAt }
|
||||
pendingSync.value = list.count { !it.synced }
|
||||
}
|
||||
}
|
||||
220
app/src/main/java/com/marcus/meditationtimer/TimerService.kt
Normal file
220
app/src/main/java/com/marcus/meditationtimer/TimerService.kt
Normal file
@@ -0,0 +1,220 @@
|
||||
package com.marcus.meditationtimer
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.MediaPlayer
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
enum class Phase { Idle, Running, Paused, Done }
|
||||
|
||||
data class TimerState(
|
||||
val phase: Phase = Phase.Idle,
|
||||
val totalMs: Long = 0L,
|
||||
val remainingMs: Long = 0L,
|
||||
)
|
||||
|
||||
class TimerService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "meditation_session"
|
||||
private const val NOTIF_ID = 1
|
||||
const val ACTION_START = "start"
|
||||
const val ACTION_PAUSE = "pause"
|
||||
const val ACTION_RESUME = "resume"
|
||||
const val ACTION_STOP = "stop"
|
||||
const val EXTRA_DURATION_MS = "duration_ms"
|
||||
|
||||
val state = MutableStateFlow(TimerState())
|
||||
|
||||
fun start(context: Context, durationMs: Long) {
|
||||
context.startForegroundService(
|
||||
Intent(context, TimerService::class.java)
|
||||
.setAction(ACTION_START)
|
||||
.putExtra(EXTRA_DURATION_MS, durationMs)
|
||||
)
|
||||
}
|
||||
|
||||
fun send(context: Context, action: String) {
|
||||
context.startService(Intent(context, TimerService::class.java).setAction(action))
|
||||
}
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var tickJob: Job? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var player: MediaPlayer? = null
|
||||
private var endAt = 0L
|
||||
private var totalMs = 0L
|
||||
private var remainingMs = 0L
|
||||
private var startedAtIso = ""
|
||||
private var pauseCount = 0
|
||||
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Meditation session", NotificationManager.IMPORTANCE_LOW)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
totalMs = intent.getLongExtra(EXTRA_DURATION_MS, 10 * 60_000L)
|
||||
remainingMs = totalMs
|
||||
endAt = SystemClock.elapsedRealtime() + totalMs
|
||||
startedAtIso = Instant.now().toString()
|
||||
pauseCount = 0
|
||||
startForeground(NOTIF_ID, buildNotification(remainingMs, paused = false))
|
||||
acquireWakeLock(totalMs)
|
||||
playGong()
|
||||
startTicking()
|
||||
}
|
||||
ACTION_PAUSE -> {
|
||||
tickJob?.cancel()
|
||||
pauseCount++
|
||||
remainingMs = (endAt - SystemClock.elapsedRealtime()).coerceAtLeast(0)
|
||||
releaseWakeLock()
|
||||
state.value = TimerState(Phase.Paused, totalMs, remainingMs)
|
||||
notify(buildNotification(remainingMs, paused = true))
|
||||
}
|
||||
ACTION_RESUME -> {
|
||||
endAt = SystemClock.elapsedRealtime() + remainingMs
|
||||
acquireWakeLock(remainingMs)
|
||||
notify(buildNotification(remainingMs, paused = false))
|
||||
startTicking()
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
if (tickJob?.isActive == true) {
|
||||
remainingMs = (endAt - SystemClock.elapsedRealtime()).coerceAtLeast(0)
|
||||
}
|
||||
recordSession(completed = false)
|
||||
player?.release()
|
||||
player = null
|
||||
finish(Phase.Idle)
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startTicking() {
|
||||
tickJob?.cancel()
|
||||
tickJob = scope.launch {
|
||||
while (true) {
|
||||
val left = endAt - SystemClock.elapsedRealtime()
|
||||
if (left <= 0L) {
|
||||
remainingMs = 0L
|
||||
state.value = TimerState(Phase.Done, totalMs, 0L)
|
||||
recordSession(completed = true)
|
||||
// keep the service (and wake lock) alive until the gong finishes
|
||||
playGong { finish(Phase.Done) }
|
||||
return@launch
|
||||
}
|
||||
remainingMs = left
|
||||
state.value = TimerState(Phase.Running, totalMs, left)
|
||||
delay(200L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun finish(phase: Phase) {
|
||||
tickJob?.cancel()
|
||||
releaseWakeLock()
|
||||
state.value = TimerState(phase, totalMs, 0L)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun recordSession(completed: Boolean) {
|
||||
val elapsedMs = if (completed) totalMs else totalMs - remainingMs
|
||||
// ignore sub-30s sits (gong tests, accidental starts)
|
||||
if (elapsedMs < 30_000L) return
|
||||
SessionStore.init(applicationContext)
|
||||
val session = Session(
|
||||
id = UUID.randomUUID().toString(),
|
||||
startedAt = startedAtIso,
|
||||
endedAt = Instant.now().toString(),
|
||||
plannedMin = (totalMs / 60_000L).toInt(),
|
||||
actualMin = (elapsedMs / 60_000.0).roundToInt().coerceAtLeast(1),
|
||||
completed = completed,
|
||||
pauseCount = pauseCount,
|
||||
)
|
||||
SessionStore.record(session)
|
||||
SessionStore.lastSessionId.value = session.id
|
||||
}
|
||||
|
||||
private fun playGong(onComplete: (() -> Unit)? = null) {
|
||||
player?.release()
|
||||
player = MediaPlayer.create(this, R.raw.gong)?.apply {
|
||||
setOnCompletionListener {
|
||||
it.release()
|
||||
if (player == it) player = null
|
||||
onComplete?.invoke()
|
||||
}
|
||||
start()
|
||||
}
|
||||
if (player == null) onComplete?.invoke()
|
||||
}
|
||||
|
||||
private fun buildNotification(remaining: Long, paused: Boolean): Notification {
|
||||
val tapIntent = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val builder = Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notif)
|
||||
.setContentTitle(if (paused) "Meditation paused" else "Meditation in progress")
|
||||
.setContentIntent(tapIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
if (!paused) {
|
||||
builder.setWhen(System.currentTimeMillis() + remaining)
|
||||
.setShowWhen(true)
|
||||
.setUsesChronometer(true)
|
||||
.setChronometerCountDown(true)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun notify(notification: Notification) {
|
||||
getSystemService(NotificationManager::class.java).notify(NOTIF_ID, notification)
|
||||
}
|
||||
|
||||
private fun acquireWakeLock(ms: Long) {
|
||||
releaseWakeLock()
|
||||
wakeLock = (getSystemService(POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "MeditationTimer:session")
|
||||
.apply { acquire(ms + 20_000L) }
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
wakeLock?.let { if (it.isHeld) it.release() }
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
tickJob?.cancel()
|
||||
scope.cancel()
|
||||
releaseWakeLock()
|
||||
player?.release()
|
||||
player = null
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
19
app/src/main/res/drawable/ic_gong.xml
Normal file
19
app/src/main/res/drawable/ic_gong.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?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="M0,0h108v108H0z"
|
||||
android:fillColor="#1B2B34" />
|
||||
<path
|
||||
android:pathData="M54,54m-34,0a34,34 0,1 1,68 0a34,34 0,1 1,-68 0"
|
||||
android:fillColor="#C9A227" />
|
||||
<path
|
||||
android:pathData="M54,54m-24,0a24,24 0,1 1,48 0a24,24 0,1 1,-48 0"
|
||||
android:fillColor="#E3BC4B" />
|
||||
<path
|
||||
android:pathData="M54,54m-9,0a9,9 0,1 1,18 0a9,9 0,1 1,-18 0"
|
||||
android:fillColor="#F5DE8C" />
|
||||
</vector>
|
||||
15
app/src/main/res/drawable/ic_notif.xml
Normal file
15
app/src/main/res/drawable/ic_notif.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="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000" />
|
||||
<path
|
||||
android:pathData="M12,12m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0"
|
||||
android:fillColor="#FFFFFF" />
|
||||
</vector>
|
||||
BIN
app/src/main/res/raw/gong.wav
Normal file
BIN
app/src/main/res/raw/gong.wav
Normal file
Binary file not shown.
Reference in New Issue
Block a user