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