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:
60
.gitea/workflows/release.yml
Normal file
60
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Build & Release APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: eclipse-temurin:21-jdk
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq unzip curl >/dev/null
|
||||
mkdir -p "$HOME/android-sdk/cmdline-tools"
|
||||
curl -sLo /tmp/ct.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d /tmp
|
||||
mv /tmp/cmdline-tools "$HOME/android-sdk/cmdline-tools/latest"
|
||||
yes | "$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$HOME/android-sdk" --licenses >/dev/null 2>&1 || true
|
||||
"$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$HOME/android-sdk" \
|
||||
"platform-tools" "platforms;android-35" "build-tools;35.0.0" >/dev/null
|
||||
|
||||
- name: Decode signing keystore
|
||||
env:
|
||||
KEYSTORE_B64: ${{ secrets.KEYSTORE_B64 }}
|
||||
run: echo "$KEYSTORE_B64" | base64 -d > /tmp/release.jks
|
||||
|
||||
- name: Build signed release APK
|
||||
env:
|
||||
KEYSTORE_FILE: /tmp/release.jks
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: meditation
|
||||
VERSION_CODE: ${{ github.run_number }}
|
||||
VERSION_NAME: 1.${{ github.run_number }}
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
echo "sdk.dir=$ANDROID_HOME" > local.properties
|
||||
./gradlew assembleRelease --no-daemon --console=plain
|
||||
|
||||
- name: Publish Gitea release with APK
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
TAG="v1.${{ github.run_number }}"
|
||||
APK=app/build/outputs/apk/release/app-release.apk
|
||||
test -f "$APK"
|
||||
curl -sf -X POST "$API/releases" \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated build of commit ${{ github.sha }}\",\"target_commitish\":\"${{ github.sha }}\"}" \
|
||||
> /tmp/release.json
|
||||
RID=$(grep -o '"id":[0-9]*' /tmp/release.json | head -1 | cut -d: -f2)
|
||||
echo "release id: $RID"
|
||||
curl -sf -X POST "$API/releases/$RID/assets?name=meditation-timer-$TAG.apk" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$APK;type=application/vnd.android.package-archive" >/dev/null
|
||||
echo "published $TAG"
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
.kotlin/
|
||||
.idea/
|
||||
*.iml
|
||||
38
README.md
Normal file
38
README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Meditation Timer
|
||||
|
||||
Jhana meditation timer for GrapheneOS. Start/end gong, scroll-wheel duration
|
||||
picker (presets 20/45/60/90), session history with depth rating + notes, and
|
||||
sync to the meditation dashboard at health.rehbock.xyz/meditation.html.
|
||||
|
||||
## Pipeline
|
||||
|
||||
Push to `main` on [git.rehbock.xyz](https://git.rehbock.xyz/marcus/meditation-timer)
|
||||
→ Gitea Actions builds a signed release APK → published as a Gitea release
|
||||
→ Obtainium on the phone picks up the new version.
|
||||
|
||||
## Sync
|
||||
|
||||
Sessions POST to `https://meditation.rehbock.xyz/v1/sessions` (health-api).
|
||||
The bearer token is **not** in this repo or the APK — set it once in-app
|
||||
(History → Token) or seed it via:
|
||||
|
||||
```
|
||||
adb shell am start -n com.marcus.meditationtimer/.MainActivity --es sync_token "<token>"
|
||||
```
|
||||
|
||||
Sessions shorter than 30 seconds are not recorded (gong tests).
|
||||
|
||||
## Local build
|
||||
|
||||
```
|
||||
./gradlew assembleDebug # needs ANDROID_HOME or local.properties
|
||||
```
|
||||
|
||||
Release signing (CI): secrets `KEYSTORE_B64` + `KEYSTORE_PASSWORD`; key alias
|
||||
`meditation`. Keystore master copy: `~/.android-keys/` on the desktop.
|
||||
|
||||
## Gong
|
||||
|
||||
`app/src/main/res/raw/gong.wav` is synthesized (singing-bowl partials,
|
||||
detuned fundamental pair for beating). Regenerate with the script in the
|
||||
repo history / scratchpad if the tone needs tweaking.
|
||||
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.
5
build.gradle.kts
Normal file
5
build.gradle.kts
Normal file
@@ -0,0 +1,5 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||
}
|
||||
3
gradle.properties
Normal file
3
gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
9
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
9
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
248
gradlew
vendored
Executable file
248
gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
82
gradlew.bat
vendored
Normal file
82
gradlew.bat
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
16
settings.gradle.kts
Normal file
16
settings.gradle.kts
Normal file
@@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "MeditationTimer"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user