hydration: spoken 'hydrate now' alert, per-activity sweat, drink reconciliation
- 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.
This commit is contained in:
parent
1df97d3e73
commit
1101016ef4
3 changed files with 100 additions and 40 deletions
|
|
@ -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() {
|
||||
if (localStorage.getItem("baldav_hydr_on") === "0") return; // ON by default unless turned off
|
||||
try {
|
||||
// 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 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)));
|
||||
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 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");
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}") }
|
||||
|
|
|
|||
|
|
@ -550,10 +550,12 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result<String, Str
|
|||
// start_deficit_ml: how far behind on water you were at session start (from logged intake vs
|
||||
// need-so-far), computed in JS and seeded into the native deficit. From the settings screen it is 0.
|
||||
#[tauri::command]
|
||||
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) -> Result<String, String> {
|
||||
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<u64>) -> Result<String, String> {
|
||||
// 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue