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:
4aiman 2026-10-26 15:05:31 +03:00
commit 762221d186
7 changed files with 126 additions and 27 deletions

2
src-tauri/Cargo.lock generated
View file

@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.520"
version = "0.1.522"
dependencies = [
"base64 0.22.1",
"btleplug",

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.520"
version = "0.1.522"
edition = "2021"
[lib]

View file

@ -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) {

View file

@ -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"