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