From 1101016ef4cd059a2459de6f825a3df9e417d6f2 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 27 Oct 2026 21:47:10 +0300 Subject: [PATCH] hydration: spoken 'hydrate now' alert, per-activity sweat, drink reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signal is now a loud TTS 'hydrate now' on the ALARM stream (TTS warmed at session start), so it is unmistakably BalDav and audible when the phone is not in view (walking/VR); the old ringtone was indistinguishable from a system alarm. - sweat need scales by activity instead of a flat 600 ml/h: bike 600, walk 200, run 450, workout/vr 250 ml/h. Walking no longer nags every 15 min. - deficit split into baseDeficit (seeded from the food log) + accrued sweat; logging a drink mid-session recomputes the base and re-seeds via seedSeq, so it silences the reminder. seedSeq=0 (profile save / init) never zeroes it mid-ride. - persistent 'БалДав: попей воды (HH:MM)' notification left as a record. --- frontend/js/main.js | 47 +++++++--- .../src/main/java/dev/baldav/app/Hydration.kt | 85 +++++++++++++------ src-tauri/src/lib.rs | 8 +- 3 files changed, 100 insertions(+), 40 deletions(-) diff --git a/frontend/js/main.js b/frontend/js/main.js index 7e59ec0..f40942e 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -1364,6 +1364,7 @@ function showFoodModal() { amountG = Math.round(rawAmount * selectedUnit.grams_per_unit * 10) / 10; } await DB.addFoodEntry(state.date, time, selectedProduct.id, amountG, null, unitId, unitAmount); + hydrationOnDrink(); // if it was liquid, quiet the reminder once you've drunk } else { let desc = overlay.querySelector("#m-desc").value.trim(); if (!desc) return false; @@ -1850,6 +1851,7 @@ async function renderProducts(container) { let note = overlay.querySelector("#m-note").value.trim() || null; if (isNaN(amount) || amount <= 0) return false; await DB.addFoodEntry(state.date, time, productId, amount, note); + hydrationOnDrink(); // quiet the drink reminder if this was liquid }); } @@ -2824,23 +2826,46 @@ let gpsMarker = null; // At session start, seed the native hydration deficit from the FOOD LOG: how far behind on water // you are for the time of day (need-so-far minus logged intake). During the session the native // service adds sweat on top. Intake comes from what you log, not a button. -async function seedHydrationDeficit() { +// Sweat (extra water loss) per activity — cycling sweats far more than a walk, so a walk must +// NOT accrue the ride rate (that made it falsely hit the threshold after ~1 h of walking). +let HYDR_SWEAT = { bike: 600, walk: 200, run: 450, workout: 250, vr: 250 }; +function hydrSweatFor(type) { return HYDR_SWEAT[type] || 600; } + +// Compute the current sedentary shortfall (need-so-far for the time of day minus what you've +// logged drinking today). Shared by the session seed and the on-drink re-seed. +async function hydrBaseDeficit() { + let target = parseInt(state.profile?.daily_water_ml) || 2000; + let now = new Date(); + let nowMin = now.getHours() * 60 + now.getMinutes(); + let wakeStart = 7 * 60, wakeEnd = 23 * 60; // assume waking 07:00–23:00 + let frac = Math.max(0, Math.min(1, (nowMin - wakeStart) / (wakeEnd - wakeStart))); + let intake = await DB.getDailyWaterMl(now.toISOString().slice(0, 10)); + return Math.max(0, Math.min(5000, Math.round(target * frac - intake))); +} + +async function seedHydrationDeficit(activityType) { if (localStorage.getItem("baldav_hydr_on") === "0") return; // ON by default unless turned off try { - let target = parseInt(state.profile?.daily_water_ml) || 2000; - let now = new Date(); - let nowMin = now.getHours() * 60 + now.getMinutes(); - let wakeStart = 7 * 60, wakeEnd = 23 * 60; // assume waking 07:00–23:00 - let frac = Math.max(0, Math.min(1, (nowMin - wakeStart) / (wakeEnd - wakeStart))); - let needSoFar = target * frac; - let intake = await DB.getDailyWaterMl(now.toISOString().slice(0, 10)); - let startDeficit = Math.max(0, Math.min(5000, Math.round(needSoFar - intake))); + let startDeficit = await hydrBaseDeficit(); let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600; await window.__TAURI__.core.invoke("set_hydration_config", - { enabled: true, alertMl, sweatMlPerHour: 600, reAlertMin: 15, startDeficitMl: startDeficit }); + { enabled: true, alertMl, sweatMlPerHour: hydrSweatFor(activityType), reAlertMin: 15, startDeficitMl: startDeficit, seedSeq: Date.now() }); } catch (e) { console.error("seedHydration", e); } } +// After you log a drink DURING a session: recompute the base deficit (now lower) and push it with +// a fresh seedSeq so the native side drops baseDeficit — the reminder goes quiet once you've drunk. +async function hydrationOnDrink() { + try { + if (!(GPS.recording && GPS.recording.active)) return; // only matters during a session + if (localStorage.getItem("baldav_hydr_on") === "0") return; + let base = await hydrBaseDeficit(); + let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600; + await window.__TAURI__.core.invoke("set_hydration_config", + { enabled: true, alertMl, sweatMlPerHour: hydrSweatFor(GPS.recording.activityType), reAlertMin: 15, startDeficitMl: base, seedSeq: Date.now() }); + } catch (e) {} +} + async function renderGpsScreen(container) { // Check Bluetooth state if BLE device is paired let bleAddr = await DB.getProfileValue("ble_device_addr"); @@ -2955,7 +2980,7 @@ async function renderGpsScreen(container) { heightCm: parseFloat(state.profile?.height_cm) || 178, } : null; await GPS.startRecording(type, wkg, hrProfile); - seedHydrationDeficit(); + seedHydrationDeficit(type); renderScreen("recording"); } }; diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/Hydration.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/Hydration.kt index c645276..46a8ba2 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/Hydration.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/Hydration.kt @@ -12,8 +12,10 @@ import android.os.Build import android.os.Handler import android.os.VibrationEffect import android.os.Vibrator +import android.speech.tts.TextToSpeech import android.util.Log import java.io.File +import java.util.Locale import org.json.JSONObject // Drink reminder shared by GpsRecorderService (rides) and BleHrService (VR/workout). LEVEL-based, @@ -25,18 +27,23 @@ import org.json.JSONObject object Hydration { private const val TAG = "Hydration" private const val CH = "baldav_hydration" - private var accruedMl = 0.0 // running water deficit: start-of-session shortfall + sweat - private var startDeficitMl = 0.0 // how far behind you were at session start (from the food log) - private var pendingStart = false // apply startDeficitMl on the first tick after reset() + private var baseDeficit = 0.0 // sedentary shortfall at (re)seed: need-so-far minus intake + private var sweatMl = 0.0 // activity sweat accrued this session (grows with the session) + private var startDeficitMl = 0.0 // last seeded base from the food log; applied on (re)seed + private var seedSeq = 0L // JS bumps it on (re)seed (e.g. after you log a drink) + private var lastSeedSeq = -1L // a change re-applies startDeficitMl to baseDeficit mid-session + private var pendingStart = false // apply the seed on the first tick after reset() private var lastTickMs = 0L private var lastBeepMs = 0L private var lastCfgMs = 0L 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 sweatMlPerMin = 10.0 // need rate during an active session (set per activity by JS) 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 var tts: TextToSpeech? = null + private var ttsReady = false // the signal is a SPOKEN "hydrate now" — unmistakably BalDav private fun readCfg(ctx: Context) { try { @@ -48,6 +55,11 @@ object Hydration { 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() + seedSeq = j.optLong("seedSeq", 0L) + // a NON-ZERO new seedSeq means JS recomputed the base (session start / after a drink) + // -> re-apply it to baseDeficit mid-session; sweat keeps accruing. seedSeq=0 (profile + // save, init) must NOT zero the deficit mid-ride, so it's ignored here. + if (seedSeq != 0L && seedSeq != lastSeedSeq) { baseDeficit = startDeficitMl; lastSeedSeq = seedSeq } } else enabled = true // no config yet -> ON by default } catch (e: Exception) { Log.e(TAG, "cfg: ${e.message}"); enabled = true } } @@ -63,14 +75,15 @@ object Hydration { val now = System.currentTimeMillis() if (now - lastCfgMs > 30_000L) { readCfg(ctx); lastCfgMs = now } if (!enabled) { if (now - lastLogMs > 120_000L) { logState(ctx, "disabled"); lastLogMs = now }; return } - if (pendingStart) { accruedMl = startDeficitMl; pendingStart = false } - if (lastTickMs == 0L) { lastTickMs = now; logState(ctx, "start"); lastLogMs = now; return } + if (pendingStart) { baseDeficit = startDeficitMl; sweatMl = 0.0; lastSeedSeq = seedSeq; pendingStart = false } + if (lastTickMs == 0L) { lastTickMs = now; ensureTts(ctx); logState(ctx, "start"); lastLogMs = now; return } val dtMin = (now - lastTickMs) / 60_000.0 lastTickMs = now - accruedMl += sweatMlPerMin * dtMin - if (accruedMl >= alertMl && now - lastBeepMs >= reAlertMs) { + sweatMl += sweatMlPerMin * dtMin + val deficit = baseDeficit + sweatMl + if (deficit >= alertMl && now - lastBeepMs >= reAlertMs) { lastBeepMs = now - val ok = beep(ctx, handler); vibrate(ctx); notify(ctx, accruedMl.toInt()) + val ok = beep(ctx, handler); vibrate(ctx); notify(ctx, deficit.toInt()) logState(ctx, if (ok) "beepOk" else "beepError"); lastLogMs = now } else if (now - lastLogMs > 120_000L) { logState(ctx, "tick"); lastLogMs = now } } @@ -80,8 +93,8 @@ object Hydration { try { 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") + "\"enabled\":$enabled,\"accruedMl\":${(baseDeficit + sweatMl).toInt()},\"sweatMl\":${sweatMl.toInt()}," + + "\"baseMl\":${baseDeficit.toInt()},\"alertMl\":${alertMl.toInt()},\"startMl\":${startDeficitMl.toInt()}}\n") } catch (e: Exception) {} } @@ -93,24 +106,43 @@ object Hydration { 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() + if (tts == null) { + tts = TextToSpeech(ctx.applicationContext) { status -> + ttsReady = status == TextToSpeech.SUCCESS + if (ttsReady) { try { tts?.language = Locale.US } catch (e: Exception) {}; speakNow() } + } + } else if (ttsReady) speakNow() + // restore the alarm volume a few seconds after the phrase handler.postDelayed({ - 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) + }, 5000) true } catch (e: Exception) { Log.e(TAG, "beep: ${e.message}"); false } } + // Warm the TTS engine up at session start so the first alert isn't silent (init is async). + private fun ensureTts(ctx: Context) { + if (tts != null) return + try { + tts = TextToSpeech(ctx.applicationContext) { status -> + ttsReady = status == TextToSpeech.SUCCESS + if (ttsReady) { try { tts?.language = Locale.US } catch (e: Exception) {} } + } + } catch (e: Exception) { Log.e(TAG, "tts init: ${e.message}") } + } + + // Speak the phrase over the ALARM stream so it's loud even with media muted. + private fun speakNow() { + try { + val t = tts ?: return + t.setAudioAttributes(AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build()) + t.speak("hydrate now", TextToSpeech.QUEUE_FLUSH, null, "hyd1") + t.speak("hydrate now", TextToSpeech.QUEUE_ADD, null, "hyd2") + } catch (e: Exception) { Log.e(TAG, "speak: ${e.message}") } + } + private fun vibrate(ctx: Context) { try { val v = ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator ?: return @@ -127,11 +159,12 @@ object Hydration { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CH) == null) { nm.createNotificationChannel(NotificationChannel(CH, "Напоминание пить", NotificationManager.IMPORTANCE_HIGH)) } + val time = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date()) val n = Notification.Builder(ctx, CH) - .setContentTitle("Попей воды!") - .setContentText("Отстаёшь примерно на $deficitMl мл — попей") + .setContentTitle("БалДав: попей воды") + .setContentText("$time — отстаёшь примерно на $deficitMl мл") .setSmallIcon(android.R.drawable.ic_menu_compass) - .setAutoCancel(true) + .setAutoCancel(true) // stays in the shade as a record until you tap it .build() nm.notify(776601, n) } catch (e: Exception) { Log.e(TAG, "notify: ${e.message}") } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b7b4260..168b67e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -550,10 +550,12 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result Result { +fn set_hydration_config(app: tauri::AppHandle, enabled: bool, alert_ml: u32, sweat_ml_per_hour: u32, re_alert_min: u32, start_deficit_ml: u32, seed_seq: Option) -> Result { + // seed_seq: bump it (e.g. a timestamp) whenever the base deficit is recomputed mid-session + // (after logging a drink) so the native side re-applies start_deficit_ml to its baseDeficit. let json = format!( - "{{\"enabled\":{},\"alertMl\":{},\"sweatMlPerHour\":{},\"reAlertMin\":{},\"startDeficitMl\":{}}}", - enabled, alert_ml.clamp(100, 3000), sweat_ml_per_hour.clamp(60, 2000), re_alert_min.clamp(2, 120), start_deficit_ml.clamp(0, 5000) + "{{\"enabled\":{},\"alertMl\":{},\"sweatMlPerHour\":{},\"reAlertMin\":{},\"startDeficitMl\":{},\"seedSeq\":{}}}", + enabled, alert_ml.clamp(100, 3000), sweat_ml_per_hour.clamp(60, 2000), re_alert_min.clamp(2, 120), start_deficit_ml.clamp(0, 5000), seed_seq.unwrap_or(0) ); write_app_root_file(&app, "hydration.json", &json); Ok(json)