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:
parent
9a8ef04230
commit
762221d186
7 changed files with 126 additions and 27 deletions
|
|
@ -25,7 +25,7 @@
|
|||
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points_<ms>_s<id>.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Настраиваемый цвет линии трека (чтобы отличать от цветных дорог). Навигационный маркер позиции с треугольником-направленцем (по компасу или GPS-курсу); порядок слоёв тайлы<дороги<маршрут<трек<маркер. Импорт тайлов по регионам (SQLite, роутер LAN). Онлайн-долечивание пустых тайлов: раз в 30с, когда OSM доступен, БалДав дофетчивает недостающие тайлы (текущий зум) по видимой области и вдоль проеханного маршрута, кэширует и перерисовывает - дырки, оставшиеся от плохого сигнала в поездке, закрываются сами, когда связь есть (бережно к OSM: троттлинг, лимит на цикл; в режиме «офлайн карты» не фетчит).
|
||||
- **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. Подключение **только вручную** (Настройки -> «Подключить»), в т.ч. во время записи - пульс пишется в трек с момента подключения; автопоиска при старте записи нет (экономия батареи). BT выключен -> кнопка просит включить (системный диалог). Браслет не найден в скане -> сервис не висит в коннекте.
|
||||
- **Зарядка (пульс)** — режим записи БЕЗ GPS для домашней тренировки: геолокация не включается и не пишется, только пульс с датчика + таймер. Экран без карты (крупный пульс, время, живые ккал, средний/макс). Нагрузка (ккал) считается по пульсу формулой Keytel (нетто, как и остальная активность) и идёт в дневной баланс. Деталь - без карты, график пульса по HR-зонам. Хранится в `gps_sessions` (`activity_type="workout"`, без точек, ряд пульса в `hr_samples`). Режим **«Руки/VR (пульс)»** (`activity_type="vr"`) - то же самое для махательных/танцевальных/VR-тренировок, трекается отдельно от зарядки.
|
||||
- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. Crr на грунтах уточняется по вибрации акселерометра (до 0.020 на траве/колее). Ходьба с велосипедом в горку (только односкоростной) отделяется автоматически по скорости ниже каденса 30 об/мин и не засчитывается как езда. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м); абсолютная точность набора ограничена сырым GPS-альтиметром (порядка десятков процентов, без модели рельефа не разрешается). Известное ограничение: `max(физика, MET)` не складывает накладные расходы, поэтому подъёмы по ккал слегка недосчитываются. Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. Вес водителя для ккал берётся из **последнего взвешивания** (настройки - запас); каждая новая поездка **хранит вес на момент записи** (`gps_sessions.rider_weight_kg`), поэтому пересчёт старого трека считает по тогдашнему весу, а не текущему (старые треки без сохранённого веса - по последнему взвешиванию). Поле веса в настройках авто-синкается с последним взвешиванием. Баланс ккал вверху, тренд «Среднее за 7 дн.» и графики (вес/баланс, окна 30/90) считаются от **выбранного дня** (окно кончается на нём), а не всегда от «сегодня». Опциональный **TEF** (термический эффект пищи, галочка в профиле, 10% прихода): вводится с сохранением поддержки - база = BMR*actLevel*(1-0.1), затем +0.1*приход, так что на поддержке баланс не меняется, а в дефиците расход чуть ниже (ел меньше - переварил меньше). В сводке дня строка «расход: база + переваривание + активность».
|
||||
- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. Crr на грунтах уточняется по вибрации акселерометра (до 0.020 на траве/колее). Ходьба с велосипедом в горку (только односкоростной) отделяется автоматически по скорости ниже каденса 30 об/мин и не засчитывается как езда. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м); абсолютная точность набора ограничена сырым GPS-альтиметром (порядка десятков процентов, без модели рельефа не разрешается). Известное ограничение: `max(физика, MET)` не складывает накладные расходы, поэтому подъёмы по ккал слегка недосчитываются. Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. Вес водителя для ккал берётся из **последнего взвешивания** (настройки - запас); каждая новая поездка **хранит вес на момент записи** (`gps_sessions.rider_weight_kg`), поэтому пересчёт старого трека считает по тогдашнему весу, а не текущему (старые треки без сохранённого веса - по последнему взвешиванию). Поле веса в настройках авто-синкается с последним взвешиванием. Баланс ккал вверху, **банк за 7 дн.** (накопительная сумма дневных балансов + перевод в кг - «сколько доесть/недоесть»), тренд «Среднее за 7 дн.» и графики (вес/баланс, окна 30/90) считаются от **выбранного дня** (окно кончается на нём), а не всегда от «сегодня». График «вес/баланс»: ось баланса флотирует симметрично вокруг 0 (пол ±500, без фикс-потолка), чтобы жёсткий день не сплющивал линию. Опциональный **TEF** (термический эффект пищи, галочка в профиле, 10% прихода): вводится с сохранением поддержки - база = BMR*actLevel*(1-0.1), затем +0.1*приход, так что на поддержке баланс не меняется, а в дефиците расход чуть ниже (ел меньше - переварил меньше). В сводке дня строка «расход: база + переваривание + активность».
|
||||
- **Дистанция на разрывах GPS** — на «слепых» участках (интервал между фиксами > `gapThresholdSec`) путь считается по скорости (speed×dt, с ограничением по макс. скорости), а не прямой хордой — иначе разрывы занижают/искажают длину. Телепорт-скачки без временного разрыва отбрасываются. Расчёт зависит только от времени и скорости, поэтому live-счётчик, «Стоп» и пересчёт дают одинаковую длину. Порог `gapThresholdSec` настраивается; при изменении и пересчёте применяется сразу.
|
||||
- **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу.
|
||||
- **Нутриенты** — 37 микронутриентов (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
# 2026-10-26 - Гидратация (починка), банк калорий, фикс шкалы графика, разбор заезда 118км
|
||||
|
||||
## Гидратация: почему не сработала на 118км и что исправлено
|
||||
Сигнал не прозвучал за весь заезд. Причина №1: фича была ВЫКЛЮЧЕНА - требовала найти скрытую
|
||||
галку в профиле, о которой пользователь не знал. Исправлено:
|
||||
- `Hydration.kt`: `enabled=true` по умолчанию (нет конфига -> ON). `main.js`: дефолт-конфиг
|
||||
пишется на старте приложения; галка в профиле отмечена по умолчанию (выключить можно).
|
||||
- Диагностика: `hydration_log.jsonl` в корне приложения (строка на тик/бип: enabled, accruedMl,
|
||||
alertMl, startMl, beepOk/beepError) - чтобы после заезда видеть, а не гадать.
|
||||
- Звук укреплён: вместо ToneGenerator - реальный сигнал будильника через `MediaPlayer` на
|
||||
`STREAM_ALARM` + временный подъём громкости будильника до макс (раньше молчало из-за низкой
|
||||
громкости КАНАЛА БУДИЛЬНИКА, не медиа). Вибро + уведомление.
|
||||
- Логика (проверена по коду): тик каждые 10с из `GpsRecorderService` (велозаезд, приоритет) и
|
||||
HR-сервиса, дедуп общим синглтоном; дефицит = стартовый (из лога: норма ко времени − выпитое) +
|
||||
пот за активность; сигнал при дефиците >= alertMl, повтор reAlertMin. Кнопки «+вода» НЕТ -
|
||||
вода берётся из залогированной жидкости (решение пользователя).
|
||||
- НЕ проверено на реальном заезде (ждёт следующей поездки); компилируется, поставлено на телефон.
|
||||
|
||||
## Банк калорий (новый виджет)
|
||||
В сводке дня: «Банк за N дн.: ±X ккал (±Y кг)» + «Среднее» - накопительная сумма дневных балансов
|
||||
за окно (~7700 ккал = 1 кг). Отвечает на «сколько доесть/недоесть» (цель асимптотически к 75 кг).
|
||||
Переиспользует уже считающиеся подневные балансы в блоке тренда (`main.js`).
|
||||
|
||||
## Фикс шкалы графика «Вес и баланс калорий»
|
||||
Ось баланса (y1) уходила в пол при жёстком дне (заезд 118км -> глубокий дневной баланс растягивал
|
||||
ось). Сделал ось симметричной вокруг 0 и ФЛОТИРУЮЩЕЙ под данные: подгон по макс |баланса|
|
||||
(округление до 500), пол ±500, БЕЗ фиксированного потолка. Маленькие значения - маленькая шкала;
|
||||
большой день - ось ровно под него. Сам расчёт баланса не трогал (по просьбе).
|
||||
|
||||
## Разбор заезда 25.10 (117.6км, 9.4ч, ЧСС из gps_points.heart_rate)
|
||||
- ЧСС: средняя 136, макс 175 (рано, 13:19, на 17-19км/ч ровно - усилие, не спуск), мин 86.
|
||||
В точке 52км/ч пульс всего 112-119 (быстро и легко). Время ≥170 - всего 174с.
|
||||
- Траектория: первые 2.5ч 137-149 (тепло 15-17C); последние 3ч провал до 118-126 (холод 10-12C,
|
||||
встречный восточный ветер 4-6 м/с, после брода, 7ч+ усталости, жёсткий дефицит).
|
||||
- Погода: Open-Meteo по координатам заезда (темп/ветер за 25.10). Ветер с востока, возврат на
|
||||
восток = в лицо. Подтверждено данными.
|
||||
- Физиология (с исследованиями): низкий поздний пульс = острая разрядка (гликоген + холод +
|
||||
усталость), НЕ низкая база; холод давит пульс (вагус) и ЧСС недосчитывает трату. Макс от
|
||||
тренировок не растёт (скорее слегка вниз) - 175 это НЕ его потолок (заниженный на убитом заезде),
|
||||
настоящий макс ≥175 ~180. Разброс 48↔170 - норма и признак формы (широкий резерв). Передачи
|
||||
(синглспид!) - реальный рычаг: вынужденный гринд на горке = высокий пульс + убитые ноги.
|
||||
База вниз не пересматривается.
|
||||
|
||||
## TODO (записано в память)
|
||||
- Разобрать и доработать поведение brouter (завёл в поля/через ледяной брод); профиль trekking.
|
||||
- Память: графики баланса показывают дневное, НЕ «сколько есть» - это не баг, не чинить (но шкалу
|
||||
чинили - см. выше).
|
||||
|
|
@ -115,6 +115,14 @@ function winPopTop() {
|
|||
|
||||
async function init() {
|
||||
applyDarkMap(); // restore dark-map tile filter preference before any map renders
|
||||
// Hydration reminder is ON by default — write the config so the native service has it (the
|
||||
// native side also defaults ON when the file is missing; this keeps file + UI consistent).
|
||||
try {
|
||||
let on = localStorage.getItem("baldav_hydr_on") !== "0";
|
||||
let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600;
|
||||
window.__TAURI__?.core?.invoke("set_hydration_config",
|
||||
{ enabled: on, alertMl, sweatMlPerHour: 600, reAlertMin: 15, startDeficitMl: 0 });
|
||||
} catch (e) {}
|
||||
if (typeof DB === "undefined" || typeof DB.getFullProfile !== "function") {
|
||||
app.innerHTML = `<div class="loading" style="color:#cf6679">
|
||||
Ошибка: db.js не загрузился.<br>
|
||||
|
|
@ -573,11 +581,19 @@ async function renderToday(container) {
|
|||
balances.push(dayBalance);
|
||||
}
|
||||
if (balances.length === 0) return;
|
||||
let avg = Math.round(balances.reduce((a, b) => a + b, 0) / balances.length);
|
||||
let sum = balances.reduce((a, b) => a + b, 0);
|
||||
let avg = Math.round(sum / balances.length);
|
||||
// Накопительный "банк": сумма дневных балансов за окно. Отвечает на "сколько доесть/недоесть"
|
||||
// (цель асимптотически к весовой). ~7700 ккал = 1 кг жира.
|
||||
let bank = Math.round(sum);
|
||||
let bankKg = (bank / 7700).toFixed(2);
|
||||
let cls = avg <= 0 ? "trend-deficit" : "trend-surplus";
|
||||
let bankCls = bank <= 0 ? "trend-deficit" : "trend-surplus";
|
||||
trendEl.innerHTML = `
|
||||
<span class="balance-trend-label">Среднее за ${balances.length} дн.:</span>
|
||||
<span class="balance-trend-val ${cls}">${avg >= 0 ? "+" : ""}${avg} ккал/день</span>`;
|
||||
<div><span class="balance-trend-label">Банк за ${balances.length} дн.:</span>
|
||||
<span class="balance-trend-val ${bankCls}">${bank >= 0 ? "+" : ""}${bank} ккал (${bankKg} кг)</span></div>
|
||||
<div><span class="balance-trend-label">Среднее:</span>
|
||||
<span class="balance-trend-val ${cls}">${avg >= 0 ? "+" : ""}${avg} ккал/день</span></div>`;
|
||||
} catch (e) { console.error("[7d trend]", e); }
|
||||
})();
|
||||
|
||||
|
|
@ -936,6 +952,13 @@ async function renderStatsCharts(container, days, foodTotals, bmr, refDate) {
|
|||
balanceData.push(window7.length > 0 ? Math.round(window7.reduce((a, b) => a + b, 0) / window7.length) : null);
|
||||
}
|
||||
|
||||
// Шкала баланса (y1): симметрична вокруг 0 и ФЛОТИРУЕТ под реальные данные - подгоняется по
|
||||
// максимуму |баланса|, округляя вверх до 500, с полом ±500 (чтобы мелкие колебания не были
|
||||
// плоской линией). Без фиксированного потолка: нет больших значений - нет и большой шкалы.
|
||||
// Симметрия держит читаемость даже при жёстком дне (дефицит у нижнего края, норма у центра).
|
||||
let bAbs = Math.max(500, ...balanceData.filter(v => v != null).map(v => Math.abs(v)), 0);
|
||||
let bMax = Math.ceil(bAbs / 500) * 500;
|
||||
|
||||
let corrLabels = allDates.map(d => {
|
||||
let parts = d.split("-");
|
||||
return parts[2] + "." + parts[1];
|
||||
|
|
@ -1003,6 +1026,7 @@ async function renderStatsCharts(container, days, foodTotals, bmr, refDate) {
|
|||
},
|
||||
y1: {
|
||||
position: "right",
|
||||
min: -bMax, max: bMax,
|
||||
title: { display: true, text: "ккал", color: "#81c784" },
|
||||
ticks: { color: "#81c784" },
|
||||
grid: { drawOnChartArea: false },
|
||||
|
|
@ -2787,7 +2811,7 @@ let gpsMarker = null;
|
|||
// 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") !== "1") return;
|
||||
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();
|
||||
|
|
@ -8206,7 +8230,7 @@ function showProfileModal() {
|
|||
</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" : ""}>
|
||||
<input id="m-hydr-on" type="checkbox" ${localStorage.getItem("baldav_hydr_on") !== "0" ? "checked" : ""}>
|
||||
Следить за питьём на тренировке
|
||||
</label>
|
||||
<div style="font-size:11px;color:#9aa4ae;margin-top:2px">Во время заезда/VR копится потребность в воде (пот), а выпитое берётся из того, что ты логируешь в баланс (жидкость по water_pct). Молчит, пока успеваешь пить; громкий сигнал (канал будильника, пробивает громкость) - только когда отстаёшь сверх порога.</div>
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.520"
|
||||
version = "0.1.522"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"btleplug",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.520"
|
||||
version = "0.1.522"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue