Hydration on by default + diagnostics + reliable alarm; calorie bank; balance chart scale
Hydration reminder: ON by default (native defaults on when hydration.json is missing; JS writes the default config at startup; the profile toggle is checked by default) so it works out of the box on a ride instead of needing a hidden toggle. Adds hydration_log.jsonl diagnostics and swaps the ToneGenerator for the real alarm ringtone via MediaPlayer on STREAM_ALARM, with the alarm volume forced to max for the alert, so it is actually audible. Calorie bank: cumulative sum of daily balance over the window (+kg) in the day summary, answering "how much to eat up/under". Balance chart (weight vs balance) y-axis now floats symmetrically about 0 (±500 floor, no fixed ceiling) so one hard day no longer flattens the line.
This commit is contained in:
parent
9a8ef04230
commit
762221d186
7 changed files with 126 additions and 27 deletions
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.520"
|
||||
version = "0.1.522"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"btleplug",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.520"
|
||||
version = "0.1.522"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import android.app.Notification
|
|||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioManager
|
||||
import android.media.ToneGenerator
|
||||
import android.media.MediaPlayer
|
||||
import android.media.RingtoneManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.VibrationEffect
|
||||
|
|
@ -29,23 +31,25 @@ object Hydration {
|
|||
private var lastTickMs = 0L
|
||||
private var lastBeepMs = 0L
|
||||
private var lastCfgMs = 0L
|
||||
private var enabled = false
|
||||
private var enabled = true // ON by default — works out of the box, no toggle to find
|
||||
private var sweatMlPerMin = 10.0 // need rate during an active session (600 ml/h default)
|
||||
private var alertMl = 600.0 // beep once the deficit reaches this
|
||||
private var reAlertMs = 15 * 60 * 1000L
|
||||
private var lastLogMs = 0L
|
||||
private var savedAlarmVol = -1 // alarm volume to restore after an alert
|
||||
|
||||
private fun readCfg(ctx: Context) {
|
||||
try {
|
||||
val f = File(ctx.filesDir.parentFile ?: ctx.filesDir, "hydration.json")
|
||||
if (f.exists()) {
|
||||
val j = JSONObject(f.readText())
|
||||
enabled = j.optBoolean("enabled", false)
|
||||
enabled = j.optBoolean("enabled", true)
|
||||
sweatMlPerMin = j.optInt("sweatMlPerHour", 600).coerceIn(60, 2000) / 60.0
|
||||
alertMl = j.optInt("alertMl", 600).coerceIn(100, 3000).toDouble()
|
||||
reAlertMs = j.optInt("reAlertMin", 15).coerceIn(2, 120) * 60_000L
|
||||
startDeficitMl = j.optInt("startDeficitMl", 0).coerceIn(0, 5000).toDouble()
|
||||
} else enabled = false
|
||||
} catch (e: Exception) { Log.e(TAG, "cfg: ${e.message}"); enabled = false }
|
||||
} else enabled = true // no config yet -> ON by default
|
||||
} catch (e: Exception) { Log.e(TAG, "cfg: ${e.message}"); enabled = true }
|
||||
}
|
||||
|
||||
// Call at session start: next tick re-reads the config and seeds the deficit from the food log.
|
||||
|
|
@ -58,29 +62,53 @@ object Hydration {
|
|||
fun tick(ctx: Context, handler: Handler) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastCfgMs > 30_000L) { readCfg(ctx); lastCfgMs = now }
|
||||
if (!enabled) return
|
||||
if (!enabled) { if (now - lastLogMs > 120_000L) { logState(ctx, "disabled"); lastLogMs = now }; return }
|
||||
if (pendingStart) { accruedMl = startDeficitMl; pendingStart = false }
|
||||
if (lastTickMs == 0L) { lastTickMs = now; return }
|
||||
if (lastTickMs == 0L) { lastTickMs = now; logState(ctx, "start"); lastLogMs = now; return }
|
||||
val dtMin = (now - lastTickMs) / 60_000.0
|
||||
lastTickMs = now
|
||||
accruedMl += sweatMlPerMin * dtMin
|
||||
if (accruedMl >= alertMl && now - lastBeepMs >= reAlertMs) {
|
||||
lastBeepMs = now
|
||||
beep(handler); vibrate(ctx); notify(ctx, accruedMl.toInt())
|
||||
}
|
||||
val ok = beep(ctx, handler); vibrate(ctx); notify(ctx, accruedMl.toInt())
|
||||
logState(ctx, if (ok) "beepOk" else "beepError"); lastLogMs = now
|
||||
} else if (now - lastLogMs > 120_000L) { logState(ctx, "tick"); lastLogMs = now }
|
||||
}
|
||||
|
||||
// Three loud beeps on the ALARM stream — play regardless of media/ring volume (music, VR audio).
|
||||
private fun beep(handler: Handler) {
|
||||
// Diagnostics so a failed ride can be understood instead of guessed. One JSONL line per event.
|
||||
private fun logState(ctx: Context, event: String) {
|
||||
try {
|
||||
val tg = ToneGenerator(AudioManager.STREAM_ALARM, 100)
|
||||
tg.startTone(ToneGenerator.TONE_CDMA_HIGH_L, 500)
|
||||
handler.postDelayed({ try { tg.startTone(ToneGenerator.TONE_CDMA_HIGH_L, 500) } catch (e: Exception) {} }, 700)
|
||||
val f = File(ctx.filesDir.parentFile ?: ctx.filesDir, "hydration_log.jsonl")
|
||||
f.appendText("{\"ts\":${System.currentTimeMillis() / 1000},\"ev\":\"$event\"," +
|
||||
"\"enabled\":$enabled,\"accruedMl\":${accruedMl.toInt()},\"alertMl\":${alertMl.toInt()}," +
|
||||
"\"startMl\":${startDeficitMl.toInt()}}\n")
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
|
||||
// Play the real alarm ringtone on the ALARM stream for a few seconds, with the alarm volume
|
||||
// forced to max for the duration (restored after) — audible even if the user kept alarm volume
|
||||
// low, and far more reliable than ToneGenerator from a background service. Returns false on error.
|
||||
private fun beep(ctx: Context, handler: Handler): Boolean {
|
||||
return try {
|
||||
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
if (savedAlarmVol < 0) savedAlarmVol = am.getStreamVolume(AudioManager.STREAM_ALARM)
|
||||
am.setStreamVolume(AudioManager.STREAM_ALARM, am.getStreamMaxVolume(AudioManager.STREAM_ALARM), 0)
|
||||
val uri = RingtoneManager.getActualDefaultRingtoneUri(ctx, RingtoneManager.TYPE_ALARM)
|
||||
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
|
||||
val mp = MediaPlayer()
|
||||
mp.setAudioAttributes(AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
|
||||
mp.setDataSource(ctx, uri)
|
||||
mp.isLooping = true
|
||||
mp.prepare()
|
||||
mp.start()
|
||||
handler.postDelayed({
|
||||
try { tg.startTone(ToneGenerator.TONE_CDMA_HIGH_L, 500) } catch (e: Exception) {}
|
||||
handler.postDelayed({ try { tg.release() } catch (e: Exception) {} }, 700)
|
||||
}, 1400)
|
||||
} catch (e: Exception) { Log.e(TAG, "beep: ${e.message}") }
|
||||
try { mp.stop(); mp.release() } catch (e: Exception) {}
|
||||
try { if (savedAlarmVol >= 0) { am.setStreamVolume(AudioManager.STREAM_ALARM, savedAlarmVol, 0); savedAlarmVol = -1 } } catch (e: Exception) {}
|
||||
}, 4000)
|
||||
true
|
||||
} catch (e: Exception) { Log.e(TAG, "beep: ${e.message}"); false }
|
||||
}
|
||||
|
||||
private fun vibrate(ctx: Context) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
|
||||
"productName": "BalDav",
|
||||
"version": "0.1.520",
|
||||
"version": "0.1.522",
|
||||
"identifier": "dev.baldav.app",
|
||||
"build": {
|
||||
"frontendDist": "../frontend"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue