Hydration reminder: level-based drink alerts during a ride or VR
A shared Hydration singleton (ticked by the GPS ride service and the HR/VR service, deduped in one process) accrues the body's water need over an active session and subtracts what you logged via the "+вода" button. It stays silent while you keep up — can be quiet for hours — and only beeps LOUD on the alarm stream (cuts through media volume) once the running deficit passes a threshold, then re-beeps on a cooldown. Not a fixed timer. Rust set_hydration_config / hydration_drink bridge JS settings and the "+вода" button to hydration.json / water_drink.txt that the native service reads.
This commit is contained in:
parent
4637846839
commit
6183f0c529
13 changed files with 271 additions and 6 deletions
|
|
@ -52,7 +52,8 @@
|
|||
| Файл | Строк | Назначение |
|
||||
|------|-------|------------|
|
||||
| `MainActivity.kt` | ~300 | FileObserver IPC, seed DB, safe insets, SAF, BLE commands, OTA check |
|
||||
| `GpsRecorderService.kt` | ~530 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС; `bindSessionIfPending()` дописывает id сессии в имя файла `_s<id>` по `gps_session.txt`), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files |
|
||||
| `GpsRecorderService.kt` | ~530 | Foreground service GPS, WakeLock, JSONL (один файл на сессию до Стоп, идемпотентный onStartCommand при START_STICKY-перезапуске ОС; `bindSessionIfPending()` дописывает id сессии в имя файла `_s<id>` по `gps_session.txt`), 50Hz акселерометр/гироскоп, dead reckoning, raw sensor files. Тикает `Hydration` каждые 10с |
|
||||
| `Hydration.kt` | ~110 | Уровневое напоминание пить (синглтон на процесс, дедуп GPS+HR сервисов). `reset()` при старте сессии, `tick()` копит потребность (`sweatMlPerHour`), минус выпитое из `water_drink.txt`; сигнал (ALARM-канал) + вибро + уведомление при дефиците >= `alertMl`, повтор через `reAlertMin`. Конфиг `hydration.json`. Rust: `set_hydration_config`, `hydration_drink(ml)` |
|
||||
| `BleHrService.kt` | ~390 | Foreground service BLE HR, FitPro protocol, GATT queue, scan/connect. START_NOT_STICKY; скан не нашёл адрес -> stopSelf (не держит радио). Подключение инициируется ТОЛЬКО вручную (кнопка в настройках), не при старте записи. Выключение BT в любой момент не роняет app: единый `handler` (все postDelayed, снимаются разом), `BroadcastReceiver` на `ACTION_STATE_CHANGED` -> на STATE_OFF чистая остановка (disconnect+stopSelf), все нативные BLE-вызовы (scan/stopScan/connectGatt/writeCharacteristic/discoverServices) в try/catch. Каждый замер ЧСС дописывается в `ble_hr_log.jsonl` (append) прямо в сервисе — запись истории переживает гаснущий экран (JS-поллинг в фоне заморожен Android); воркаут при остановке пересобирает сэмплы из этого лога (`GPS._readHrLogSamples`), а не из JS-массива. Авто-реконнект: обрыв УЖЕ установленного соединения (connectedOnce) переподключается через скан (до 3 попыток, 8с), сброс бюджета при успехе; если скан не нашёл ремень или это stopping (ACTION_STOP/BT-off/onDestroy) - stopSelf, не долбится в отсутствующий/не запущенный вручную ремень. Наработка ремня: `accrueUptime`/`writeUptimeFile` копят время реальной отдачи ЧСС (только промежутки без разрывов) + шаги заряда в `strap_uptime.json` (персистентно, не чистится per-session, flush ~20с и при обрыве) |
|
||||
| `generated/*.kt` | ~1100 | Tauri-сгенерированные: WebView, IPC, permissions |
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
- **Дистанция на разрывах GPS** — на «слепых» участках (интервал между фиксами > `gapThresholdSec`) путь считается по скорости (speed×dt, с ограничением по макс. скорости), а не прямой хордой — иначе разрывы занижают/искажают длину. Телепорт-скачки без временного разрыва отбрасываются. Расчёт зависит только от времени и скорости, поэтому live-счётчик, «Стоп» и пересчёт дают одинаковую длину. Порог `gapThresholdSec` настраивается; при изменении и пересчёте применяется сразу.
|
||||
- **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу.
|
||||
- **Нутриенты** — 37 микронутриентов (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду.
|
||||
- **Трекинг жидкости** — water_pct в продуктах, прогресс-бар, настраиваемая норма.
|
||||
- **Трекинг жидкости** — water_pct в продуктах, прогресс-бар, настраиваемая норма. На тренировке (велозаезд/VR) - уровневое напоминание пить: нативный сервис копит потребность в воде (пот) за время активности, кнопка «+вода» на записи гасит отставание; громкий сигнал на канале будильника (пробивает громкость) только когда отстаёшь сверх порога. Молчит, пока успеваешь пить.
|
||||
- **Графики** — скорость, высота, ЧСС тренировки с зумом и pan. Тренд веса. Донат макронутриентов. Баланс ккал (приход/расход).
|
||||
- **OTA обновления** — проверка BUILD_NUMBER на роутере (LAN) или VPS, скачивание через браузер.
|
||||
- **Ручные маршруты** — рисование трека на карте тапами, elevation из прошлых треков или open-meteo API.
|
||||
|
|
|
|||
31
docs/session-logs/2026-10-24_hydration-reminder.md
Normal file
31
docs/session-logs/2026-10-24_hydration-reminder.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# 2026-10-24 - Уровневое напоминание пить на тренировке
|
||||
|
||||
## Что
|
||||
Напоминание пить во время велозаезда и VR. НЕ фиксированный таймер (в норме молчит часами) -
|
||||
уровневая модель: во время активности копится потребность в воде (пот), кнопка «+вода» гасит
|
||||
отставание; громкий сигнал только когда дефицит превышает порог.
|
||||
|
||||
## Реализация
|
||||
- `Hydration.kt` (новый) - синглтон на процесс (оба сервиса в одном процессе -> дедуп, нет двойных
|
||||
бипов на заезде с ремнём). `tick()` копит `sweatMlPerHour` за время, вычитает выпитое из
|
||||
`water_drink.txt`, сигналит (ToneGenerator STREAM_ALARM - пробивает громкость + вибро +
|
||||
уведомление) при дефиците >= `alertMl`, повтор через `reAlertMin`. Конфиг `hydration.json`.
|
||||
- `GpsRecorderService.kt` (велозаезд) и `BleHrService.kt` (VR) тикают `Hydration` (reset при старте).
|
||||
- Rust: `set_hydration_config(enabled, alert_ml, sweat_ml_per_hour, re_alert_min)`,
|
||||
`hydration_drink(ml)` (пишет в `water_drink.txt`, оба пути корня). Манифест: VIBRATE.
|
||||
- JS: настройка в профиле (вкл, сигнал при отставании мл, +вода за нажатие мл); кнопка «+вода» на
|
||||
экране записи -> `hydration_drink` + `DB.logWaterDrink` (ищет 100%-воду, идёт в дневной итог).
|
||||
|
||||
## Эволюция (несколько итераций по уточнениям)
|
||||
- Сначала привязал к HR-сервису (VR). Исправлено: главное - велик, значит GPS-сервис.
|
||||
- Сначала фиксированный интервал. Исправлено: не таймер, а УРОВЕНЬ (дефицит), молчит пока пьёшь.
|
||||
|
||||
## Статус
|
||||
Собрано (0.1.519), JS/Rust/Kotlin компилируются. Залито на роутер
|
||||
(http://192.168.1.1:88/baldav/baldav.apk) и VPS (https://4aiman.top/baldav/baldav.apk) - build 519.
|
||||
НА УСТРОЙСТВЕ НЕ ПРОВЕРЕНО (телефон был отключён). Проверка: профиль -> включить, старт «Вело»,
|
||||
подождать, «+вода» гасит.
|
||||
|
||||
## Дальше
|
||||
- Кнопка «+вода» на экране VR (сейчас только на записи-карте).
|
||||
- Возможно вынести sweatMlPerHour/reAlertMin в настройки (сейчас 600/15 по умолчанию).
|
||||
|
|
@ -507,6 +507,20 @@ async function deleteFoodEntry(id) {
|
|||
await execute("DELETE FROM food_log WHERE id = ?1", [id]);
|
||||
}
|
||||
|
||||
// Quick "+вода": log a plain-water drink so the daily water total/bar reflects it. Finds a
|
||||
// ~100% water product at runtime (no hardcoded id). The hydration deficit is handled separately
|
||||
// (Rust hydration_drink -> native service). Returns true if it could log to the daily total.
|
||||
async function logWaterDrink(ml) {
|
||||
const now = new Date();
|
||||
const date = now.toISOString().slice(0, 10);
|
||||
const time = now.toTimeString().slice(0, 5);
|
||||
const rows = await select("SELECT id FROM products WHERE water_pct >= 99 ORDER BY water_pct DESC LIMIT 1");
|
||||
if (!rows.length) return false;
|
||||
await addFoodEntry(date, time, rows[0].id, ml, "вода", null, null);
|
||||
return true;
|
||||
}
|
||||
window.DB.logWaterDrink = logWaterDrink;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activity log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -4976,6 +4976,21 @@ async function renderRecordingInline(container) {
|
|||
darkBtn.onclick = () => toggleDarkMap();
|
||||
btnWrap.appendChild(darkBtn);
|
||||
|
||||
// +вода: записать выпитое — гасит отставание (сигнал гидратации замолкает) и идёт в дневной итог.
|
||||
let waterBtn = document.createElement("button");
|
||||
waterBtn.className = "rec-map-btn rec-water-btn";
|
||||
waterBtn.textContent = "💧";
|
||||
waterBtn.title = "Записать воду";
|
||||
waterBtn.onclick = async () => {
|
||||
let ml = parseInt(localStorage.getItem("baldav_hydr_drink")) || 500;
|
||||
try { await window.__TAURI__.core.invoke("hydration_drink", { ml }); } catch (e) { console.error("drink", e); }
|
||||
try { await DB.logWaterDrink(ml); } catch (e) { console.error("logWater", e); }
|
||||
showToast(`+${ml} мл воды`);
|
||||
waterBtn.classList.add("active");
|
||||
setTimeout(() => waterBtn.classList.remove("active"), 800);
|
||||
};
|
||||
btnWrap.appendChild(waterBtn);
|
||||
|
||||
// Nogo: mark the current spot impassable while riding (the planner + on-the-fly reroute
|
||||
// will avoid it). Shows existing nogo circles too.
|
||||
let recNogoLayer = null;
|
||||
|
|
@ -8182,6 +8197,15 @@ function showProfileModal() {
|
|||
<div class="form-group">
|
||||
<label>Норма воды (мл/день)</label>
|
||||
<input id="m-water-norm" type="number" inputmode="numeric" value="${p.daily_water_ml ?? "2000"}" placeholder="2000" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input id="m-hydr-on" type="checkbox" ${localStorage.getItem("baldav_hydr_on") === "1" ? "checked" : ""}>
|
||||
Следить за питьём на тренировке
|
||||
</label>
|
||||
<div style="font-size:11px;color:#9aa4ae;margin-top:2px">Во время заезда/VR копится потребность в воде (пот). Молчит, пока успеваешь пить; громкий сигнал (канал будильника, пробивает громкость) - только когда отстаёшь. Кнопка «+вода» на записи гасит отставание.</div>
|
||||
<label style="font-size:13px;display:block;margin-top:4px">Сигнал при отставании, мл <input id="m-hydr-alert" type="number" inputmode="numeric" min="100" max="3000" value="${localStorage.getItem("baldav_hydr_alert") || "600"}" style="width:80px"></label>
|
||||
<label style="font-size:13px;display:block">«+вода» за нажатие, мл <input id="m-hydr-drink" type="number" inputmode="numeric" min="50" max="2000" value="${localStorage.getItem("baldav_hydr_drink") || "500"}" style="width:80px"></label>
|
||||
</div>`,
|
||||
async overlay => {
|
||||
let weight = parseFloat(overlay.querySelector("#m-weight").value) || null;
|
||||
|
|
@ -8199,6 +8223,13 @@ function showProfileModal() {
|
|||
if (tefEl) { GPS.config.tefFrac = tefEl.checked ? 0.10 : 0; GPS.saveConfig(); }
|
||||
let waterNorm = parseInt(overlay.querySelector("#m-water-norm").value) || 2000;
|
||||
await DB.setProfileValue("daily_water_ml", waterNorm);
|
||||
let hydrOn = !!overlay.querySelector("#m-hydr-on")?.checked;
|
||||
let hydrAlert = Math.min(3000, Math.max(100, parseInt(overlay.querySelector("#m-hydr-alert")?.value) || 600));
|
||||
let hydrDrink = Math.min(2000, Math.max(50, parseInt(overlay.querySelector("#m-hydr-drink")?.value) || 500));
|
||||
localStorage.setItem("baldav_hydr_on", hydrOn ? "1" : "0");
|
||||
localStorage.setItem("baldav_hydr_alert", String(hydrAlert));
|
||||
localStorage.setItem("baldav_hydr_drink", String(hydrDrink));
|
||||
try { await window.__TAURI__.core.invoke("set_hydration_config", { enabled: hydrOn, alertMl: hydrAlert, sweatMlPerHour: 600, reAlertMin: 15 }); } catch (e) { console.error("hydration cfg", e); }
|
||||
|
||||
state.profile = { ...state.profile,
|
||||
weight_kg: weight ? weight : state.profile.weight_kg,
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.515"
|
||||
version = "0.1.519"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"btleplug",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.515"
|
||||
version = "0.1.519"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ class BleHrService : Service() {
|
|||
// (a stray callback touching a GATT/scanner after the adapter is off crashes the native BT stack).
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Hydration reminder loop — delegates to the shared Hydration singleton (also driven by the GPS
|
||||
// ride service; the singleton dedups so a ride WITH the strap doesn't double-beep).
|
||||
private var hydrationTick: Runnable? = null
|
||||
|
||||
// When the user turns Bluetooth off, stop touching GATT immediately and shut the service down.
|
||||
private val btStateReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
|
|
@ -306,6 +310,7 @@ class BleHrService : Service() {
|
|||
"fitpro" -> setupFitPro(g)
|
||||
}
|
||||
updateNotification("Пульсометр подключён")
|
||||
startHydrationReminder() // workout is on (strap connected) → arm the drink reminder
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, char: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
|
|
@ -613,8 +618,29 @@ class BleHrService : Service() {
|
|||
nm.notify(NOTIFICATION_ID, buildNotification(text))
|
||||
}
|
||||
|
||||
// --- hydration reminder: tick the shared Hydration singleton every 30 s while recording ------
|
||||
private fun startHydrationReminder() {
|
||||
Hydration.reset()
|
||||
hydrationTick?.let { handler.removeCallbacks(it) }
|
||||
val r = object : Runnable {
|
||||
override fun run() {
|
||||
if (stopping || !isRunning) return
|
||||
Hydration.tick(this@BleHrService, handler)
|
||||
handler.postDelayed(this, 30_000L)
|
||||
}
|
||||
}
|
||||
hydrationTick = r
|
||||
handler.postDelayed(r, 30_000L)
|
||||
}
|
||||
|
||||
private fun stopHydrationReminder() {
|
||||
hydrationTick?.let { handler.removeCallbacks(it) }
|
||||
hydrationTick = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopping = true
|
||||
stopHydrationReminder()
|
||||
try { unregisterReceiver(btStateReceiver) } catch (e: Exception) { /* not registered / already gone */ }
|
||||
disconnect()
|
||||
super.onDestroy()
|
||||
|
|
|
|||
|
|
@ -222,9 +222,12 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
|
|||
wakeLock?.acquire(6 * 60 * 60 * 1000L)
|
||||
Log.w(TAG, "WakeLock was released — re-acquired")
|
||||
}
|
||||
// Drink reminder (shared singleton; dedups with the HR service).
|
||||
if (isRunning) Hydration.tick(applicationContext, flushHandler)
|
||||
if (isRunning) scheduleFlush()
|
||||
}, 10000)
|
||||
}
|
||||
Hydration.reset() // full first interval from the ride's start
|
||||
scheduleFlush()
|
||||
|
||||
// Dead reckoning: if GPS silent 60+ sec but accel shows movement → emit estimated point
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
package dev.baldav.app
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.media.ToneGenerator
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
// Drink reminder shared by GpsRecorderService (rides) and BleHrService (VR/workout). LEVEL-based,
|
||||
// not a fixed timer: during an active session the body's water need accrues (sweat); the quick
|
||||
// "+вода" button (water_drink.txt) subtracts what you drank. It stays silent while you keep up —
|
||||
// can be quiet for hours — and only beeps LOUD (ALARM stream) when the running deficit exceeds the
|
||||
// threshold, then re-beeps on a cooldown until you drink. Both services run in one process, so this
|
||||
// singleton dedups. Config in hydration.json (app root).
|
||||
object Hydration {
|
||||
private const val TAG = "Hydration"
|
||||
private const val CH = "baldav_hydration"
|
||||
private var accruedMl = 0.0 // need accrued since session start, minus what was drunk
|
||||
private var lastTickMs = 0L
|
||||
private var lastBeepMs = 0L
|
||||
private var lastCfgMs = 0L
|
||||
private var enabled = false
|
||||
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 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)
|
||||
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
|
||||
} else enabled = false
|
||||
} catch (e: Exception) { Log.e(TAG, "cfg: ${e.message}"); enabled = false }
|
||||
}
|
||||
|
||||
// Call at session start: clear the deficit so a fresh ride/workout starts from zero.
|
||||
fun reset() { accruedMl = 0.0; lastTickMs = 0L; lastBeepMs = 0L }
|
||||
|
||||
// "+вода" writes the drunk ml here (Rust hydration_drink); subtract and consume.
|
||||
private fun drainDrinks(ctx: Context) {
|
||||
try {
|
||||
val f = File(ctx.filesDir.parentFile ?: ctx.filesDir, "water_drink.txt")
|
||||
if (f.exists()) {
|
||||
val ml = f.readText().trim().toDoubleOrNull() ?: 0.0
|
||||
f.delete()
|
||||
accruedMl -= ml
|
||||
if (accruedMl < 0) accruedMl = 0.0
|
||||
}
|
||||
} catch (e: Exception) { Log.e(TAG, "drink: ${e.message}") }
|
||||
}
|
||||
|
||||
// Call often (every 10-30 s) from any running service. Accrues need, honours drinks, beeps only
|
||||
// when behind. At most one beep per re-alert window across all callers (process-wide singleton).
|
||||
fun tick(ctx: Context, handler: Handler) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastCfgMs > 30_000L) { readCfg(ctx); lastCfgMs = now }
|
||||
if (!enabled) return
|
||||
if (lastTickMs == 0L) { lastTickMs = now; return }
|
||||
val dtMin = (now - lastTickMs) / 60_000.0
|
||||
lastTickMs = now
|
||||
accruedMl += sweatMlPerMin * dtMin
|
||||
drainDrinks(ctx)
|
||||
if (accruedMl >= alertMl && now - lastBeepMs >= reAlertMs) {
|
||||
lastBeepMs = now
|
||||
beep(handler); vibrate(ctx); notify(ctx, accruedMl.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
// Three loud beeps on the ALARM stream — play regardless of media/ring volume (music, VR audio).
|
||||
private fun beep(handler: Handler) {
|
||||
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)
|
||||
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}") }
|
||||
}
|
||||
|
||||
private fun vibrate(ctx: Context) {
|
||||
try {
|
||||
val v = ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator ?: return
|
||||
val pattern = longArrayOf(0, 400, 200, 400)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
v.vibrate(VibrationEffect.createWaveform(pattern, -1))
|
||||
else @Suppress("DEPRECATION") v.vibrate(pattern, -1)
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
|
||||
private fun notify(ctx: Context, deficitMl: Int) {
|
||||
try {
|
||||
val nm = ctx.getSystemService(NotificationManager::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CH) == null) {
|
||||
nm.createNotificationChannel(NotificationChannel(CH, "Напоминание пить", NotificationManager.IMPORTANCE_HIGH))
|
||||
}
|
||||
val n = Notification.Builder(ctx, CH)
|
||||
.setContentTitle("Попей воды!")
|
||||
.setContentText("Отстаёшь примерно на $deficitMl мл — попей")
|
||||
.setSmallIcon(android.R.drawable.ic_menu_compass)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
nm.notify(776601, n)
|
||||
} catch (e: Exception) { Log.e(TAG, "notify: ${e.message}") }
|
||||
}
|
||||
}
|
||||
|
|
@ -496,6 +496,45 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result<String, Str
|
|||
Ok(format!("sent:{}", cmd))
|
||||
}
|
||||
|
||||
// Write hydration.json to the app root, where the Hydration singleton reads it: the level-based
|
||||
// drink reminder (accrue sweat during a session, beep when the deficit exceeds alert_ml).
|
||||
#[tauri::command]
|
||||
fn set_hydration_config(app: tauri::AppHandle, enabled: bool, alert_ml: u32, sweat_ml_per_hour: u32, re_alert_min: u32) -> Result<String, String> {
|
||||
let json = format!(
|
||||
"{{\"enabled\":{},\"alertMl\":{},\"sweatMlPerHour\":{},\"reAlertMin\":{}}}",
|
||||
enabled, alert_ml.clamp(100, 3000), sweat_ml_per_hour.clamp(60, 2000), re_alert_min.clamp(2, 120)
|
||||
);
|
||||
write_app_root_file(&app, "hydration.json", &json);
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
// "+вода": add drunk ml to water_drink.txt (read-add-write), which the Hydration singleton consumes
|
||||
// on its next tick to reduce the deficit. Lands in both candidate app-root paths so Kotlin finds it.
|
||||
#[tauri::command]
|
||||
fn hydration_drink(app: tauri::AppHandle, ml: u32) -> Result<String, String> {
|
||||
// Read the current pending amount from the explicit app root (= Kotlin filesDir.parentFile),
|
||||
// the same file the Hydration singleton deletes, so the running total stays consistent.
|
||||
let explicit_root = app.path().app_data_dir().ok()
|
||||
.and_then(|d| d.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()));
|
||||
let cur: f64 = explicit_root.as_ref()
|
||||
.and_then(|r| std::fs::read_to_string(r.join("water_drink.txt")).ok())
|
||||
.and_then(|s| s.trim().parse().ok()).unwrap_or(0.0);
|
||||
let total = cur + ml as f64;
|
||||
write_app_root_file(&app, "water_drink.txt", &format!("{}", total));
|
||||
Ok(format!("{}", total))
|
||||
}
|
||||
|
||||
// Write a small file to the app root — both where native_root resolves and the explicit
|
||||
// app_data_dir/../.. (= Kotlin filesDir.parentFile) — so the Kotlin side always finds it.
|
||||
fn write_app_root_file(app: &tauri::AppHandle, name: &str, contents: &str) {
|
||||
let _ = std::fs::write(native_root(app).join(name), contents);
|
||||
if let Ok(data_dir) = app.path().app_data_dir() {
|
||||
if let Some(root) = data_dir.parent().and_then(|p| p.parent()) {
|
||||
let _ = std::fs::write(root.join(name), contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Offline routing via the embedded BRouter engine (Kotlin runs btools.router). Same
|
||||
// file bridge as GPS: write the request + a "route:<id>" command where MainActivity's
|
||||
// FileObserver watches, then poll for brouter_result_<id>.json. `lonlats` is a JSON
|
||||
|
|
@ -1183,7 +1222,7 @@ pub fn run() {
|
|||
.add_migrations("sqlite:baldav.db", migrations)
|
||||
.build(),
|
||||
)
|
||||
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_read_hr_log, ble::ble_read_strap_uptime, ble::ble_reset_strap_uptime, ble::ble_request_enable, read_state_file])
|
||||
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, set_hydration_config, hydration_drink, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, export_db, import_db, restart_app, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr, ble::ble_read_hr_log, ble::ble_read_strap_uptime, ble::ble_reset_strap_uptime, ble::ble_request_enable, read_state_file])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
|
||||
"productName": "BalDav",
|
||||
"version": "0.1.515",
|
||||
"version": "0.1.519",
|
||||
"identifier": "dev.baldav.app",
|
||||
"build": {
|
||||
"frontendDist": "../frontend"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue