m09-phone-core + m10-phone-ui (итерация 4): телефон Android — Room LocalDb, WorkoutCore (in_plan/допуск/перерывы manual), SyncQueue/ApiClient, Compose UI (8 экранов, тёмная M3); unit 36/36, APK 12 МБ, живые смоки sync; errata контрактов: client_guid-UUID (+guid daily_log/profile), GET active camelCase, program_version flat+version, sex m/f

This commit is contained in:
virus-home 2026-10-19 09:39:03 +05:00
parent d61499025c
commit e4be7ceb31
62 changed files with 5202 additions and 5 deletions

View File

@ -43,8 +43,8 @@ JSON-тела (версии программ, факты .fit) — `jsonb` вн
| m06-fit-import | `Server.WatchBody.Import` + `--mode import`: FitParser (FIT SDK) → workout-facts/1, сопоставление ±15 мин, дедуп fit_file_id, пакет программы, TCX/GPX, архив | 2 | ✅ (2026-10-18: Garmin.FIT.Sdk 21.218.0, FitParser/WorkoutImporter/ProgramPackageImporter/ImportService, тесты Web 93/93 + Core 56/56, psql 123/123) | | m06-fit-import | `Server.WatchBody.Import` + `--mode import`: FitParser (FIT SDK) → workout-facts/1, сопоставление ±15 мин, дедуп fit_file_id, пакет программы, TCX/GPX, архив | 2 | ✅ (2026-10-18: Garmin.FIT.Sdk 21.218.0, FitParser/WorkoutImporter/ProgramPackageImporter/ImportService, тесты Web 93/93 + Core 56/56, psql 123/123) |
| m07-fit-fetch | `Server.WatchBody.Fetch` + `--mode fetch`: GarminClient (SSO по контракту garminconnect), FitFetcher (дедуп 2 рубежей), разбор конвейером m06 | 2 | ✅ (2026-10-18: SSO-флоу + FakeGarminHandler, тесты Web 98/98 + Core 56/56; против живого Garmin — ручная валидация прототипа, fallback — m06) | | m07-fit-fetch | `Server.WatchBody.Fetch` + `--mode fetch`: GarminClient (SSO по контракту garminconnect), FitFetcher (дедуп 2 рубежей), разбор конвейером m06 | 2 | ✅ (2026-10-18: SSO-флоу + FakeGarminHandler, тесты Web 98/98 + Core 56/56; против живого Garmin — ручная валидация прототипа, fallback — m06) |
| m08-admin-web | Admin-консоль: UI — Flutter Web (`3-app\3-web\admin-web`), бэкенд — admin REST API (`/api/admin/*`) в `Server.WatchBody.Web`: логин/PBKDF2→Bearer, атлеты, тренировки+CSV, программы, метрики, план/факт | 3 | ✅ (2026-10-19: Bearer-контур + 6 контроллеров + Flutter UI (SDK 3.47.7, build → wwwroot/admin), тесты Web 105/105 + Core 56/56, HTTP-смок пройден) | | m08-admin-web | Admin-консоль: UI — Flutter Web (`3-app\3-web\admin-web`), бэкенд — admin REST API (`/api/admin/*`) в `Server.WatchBody.Web`: логин/PBKDF2→Bearer, атлеты, тренировки+CSV, программы, метрики, план/факт | 3 | ✅ (2026-10-19: Bearer-контур + 6 контроллеров + Flutter UI (SDK 3.47.7, build → wwwroot/admin), тесты Web 105/105 + Core 56/56, HTTP-смок пройден) |
| m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ⬜ | | m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ✅ (2026-10-19: Room 9 таблиц + WorkoutCore + SyncWorker/ApiClient + DataStore; APK 4,2 МБ, unit 18/18; смок sync 5 сущностей против живого сервера пройден, errata client_guid-UUID + guid-колонки daily_log/profile) |
| m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ⬜ | | m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ✅ (2026-10-19: 8 экранов+VM, Compose Material 3 тёмная, unit 36/36, APK 12 МБ, живой смок контура программы; errata контрактов camelCase/flat program_version/sex m-f; запуск на устройстве — приёмка владельца) |
| m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ⬜ | | m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ⬜ |
| m12-watch-channel | `WatchChannel`: companion-канал Connect IQ (прототип ОВ-3 — итерация 1, чек-лист 7.1) | 5 | ⬜ | | m12-watch-channel | `WatchChannel`: companion-канал Connect IQ (прототип ОВ-3 — итерация 1, чек-лист 7.1) | 5 | ⬜ |
| m13-fenix-data-field | `FenixDataField` (Monkey C, таргет `fenix6x`): пульс/зоны/отдых | 5 | ⬜ | | m13-fenix-data-field | `FenixDataField` (Monkey C, таргет `fenix6x`): пульс/зоны/отдых | 5 | ⬜ |

View File

@ -11,12 +11,18 @@
- `200`: - `200`:
```json ```json
{ "program_id": 3, "version_id": "uuid", "version": 5, { "programId": 3, "versionId": "uuid", "version": 5,
"based_on_version": 4, "author": "rules", "status": "active", "basedOnVersion": 4, "author": "rules", "status": "active",
"body": { "...схема LLM-планирования разд. 2, как в БД..." }, "body": { "...схема LLM-планирования разд. 2, как в БД..." },
"rationale": "«...»", "created_at": "..." } "rationale": "«...»", "createdAt": "..." }
``` ```
Errata (m10, 2026-10-19, сверка живым запросом): ключи — camelCase
(серверная политика `JsonNamingPolicy.CamelCase`, `WebAppStartup`);
ранняя редакция примера показывала snake_case. Полезное телефону:
`versionId`, `version`, `body` (программа с `weeks` на верхнем
уровне).
- `404` — активной версии нет (программа не создана или все версии archived). - `404` — активной версии нет (программа не создана или все версии archived).
- `401` — middleware. - `401` — middleware.

View File

@ -0,0 +1,20 @@
---
name: m09-phone-core
description: Phone.WatchBody (Android, Kotlin) — Room LocalDb, WorkoutCore (тренировка/допуск/дневник/перерывы), SyncQueue (client_guid, ≤50, идемпотентно)
---
# m09-phone-core
Телефонное ядро `3-app\4-phone\Phone.WatchBody` (Gradle, Kotlin,
minSdk 26): `LocalDb` (Room — зеркала серверных таблиц с UUID-PK
телефона + `sync_pending`), `WorkoutCore` (единственный писатель:
жизненный цикл тренировки с `session_kind`, подходы с in_plan
(план ± 2,5 кг / ± 0 повторов), журнал команд, дневник-минимум,
предстартовый допуск по `Safety.*` (Р3-А), перерывы source=manual),
`SyncQueue` (WorkManager: пакеты ≤ 50, `POST /api/v1/sync` с
`X-Api-Key`, ретрай 1→64 мин, WifiOnly), `ApiClient`. Настройки —
DataStore. Unit-тесты — pure JVM (без эмулятора).
- Требования: [module_reqs.md](module_reqs.md)
- План работ: [readme.md](readme.md)
- Спецификации: [api_specs/](api_specs/)

View File

@ -0,0 +1,45 @@
# Спецификация 01: LocalDb (Room) — схема
Источники: архитектура 1.2 (LocalDb), 2-db src-таблицы (зеркала),
tech-req SyncQueue, Р2-Б (UUID-PK генерирует телефон).
## 1. База
`AppDatabase` (Room, version = 1): один атлет на устройство
(athlete_id на сервер подставляет ключ — Ф-7.2). Все PK — UUID
(String) кроме отмеченного. Timestamps — epoch millis (Long);
даты (daily_log/breaks) — ISO `yyyy-MM-dd` (String).
## 2. Таблицы
| Entity | Колонки (ключевые) | Примечание |
|---|---|---|
| `exercise` | id PK, name (UNIQUE), group_code, is_base, is_active | справочник атлета (Ф-4) |
| `program_version` | id PK, version, status, author, based_on_version?, body JSON, rationale, created_at | локальная копия (с сервера + локальные draft) |
| `workout` | id PK, program_version_id?, started_at, ended_at?, status (active/finished/cancelled), session_kind, avg_hr? | |
| `workout_set` | id PK, workout_id FK→workout(cascade), exercise_id?, seq, weight_kg, reps, done_at, source (voice/watch/edit), in_plan, rpe?, knee_pain? | |
| `command_log` | id PK, workout_id FK, t_offset_s, recognized_text, semantics JSON, accepted | голос m11; в m09 — ручная заготовка |
| `daily_log` | date PK (yyyy-MM-dd), **guid** (стабильный UUID записи), bp_sys?, bp_dias?, weight_kg?, hr_resting?, wellbeing? | день уникален (Ф-13.1); guid — client_guid для sync (errata 2026-10-19: сервер требует UUID у всех сущностей) |
| `breaks` | id PK, kind?, source (manual), started_on, planned_return?, ended_on?, note | Ф-14.2 |
| `profile` | singleton (id=0), **guid** (стабильный UUID записи), sex?, birth_year?, weight_kg?, hr_max? | Ф-6; guid — client_guid для sync (errata 2026-10-19) |
| `sync_pending` | client_guid PK, entity, payload JSON, created_at | очередь (spec 03) |
Индексы: `workout_set.workout_id`, `sync_pending.created_at`,
`exercise.name`.
## 3. DAO (по одному на группу)
- `WorkoutDao`: активная тренировка (status=active), upsert, список
истории (finished/cancelled по убыванию started_at), подходы
тренировки;
- `ExerciseDao` / `ProgramDao` / `DailyLogDao` / `BreakDao` /
`ProfileDao` — upsert/select;
- `SyncDao`: `pending(limit)` по возрастанию created_at, `deleteAll(guids)`,
`count()`.
## 4. Правила
- 🛡 Пишет только `WorkoutCore` (и `SyncQueue` — свой статус);
- миграции: version 1, `fallbackToDestructiveMigration` запрещён
после первого релиза (сейчас допустим — до m10);
- body/semantics/payload — строки JSON (kotlinx-serialization).

View File

@ -0,0 +1,66 @@
# Спецификация 02: WorkoutCore — тренировка, допуск, дневник, перерывы
Источники: Ф-3 (экран — статус берёт отсюда), Ф-11.2 (in_plan),
Ф-13.1–13.2 (дневник, допуск), Ф-14.2 (объявление break),
архитектура 1.2 (единственный писатель 🛡2).
## 1. Жизненный цикл тренировки
```kotlin
class WorkoutCore(db, settings, clock: Clock)
fun startWorkout(programVersionId: UUID?, sessionKind: String = "normal"): UUID
fun finishWorkout(id: UUID, avgHr: Int? = null)
fun cancelWorkout(id: UUID) // Ф-3.5: помечается cancelled, остаётся в истории
fun logSet(workoutId, exerciseId, weightKg: BigDecimal, reps: Int, rpe: Int?, kneePain: Int?)
fun logCommand(workoutId, text, semanticsJson, accepted) // заготовка m11
```
- `startWorkout`: не более одной `status=active` (второй старт →
ошибка `IllegalStateException`); UUID генерирует телефон (🛡3);
`programVersionId` — активная версия LocalDb (null — свободная
тренировка).
- `finishWorkout`/`cancelWorkout`: ended_at = clock; обе записи —
в историю и sync.
- `logSet`: seq = max+1 по тренировке; done_at = clock;
**in_plan** — см. §2; source = "voice" (m11 подставит своё);
каждая запись → очередь sync немедленно (Ф-7.2).
## 2. in_plan (Ф-11.2, допуск отклонения)
План упражнения = первая сессия дня недели из body активной
версии (та же логика, что m04 PlannedSets; ротация — v2):
- плановый вес `w` и повторы `r` первого сета упражнения;
- `in_plan = |weight − w| ≤ 2.5 && reps == r`;
- плана/упражнения нет → `in_plan = false`;
- расчёт — чистая функция `PlanMatch.inPlan(body, dayOfWeek,
exerciseName, weight, reps)` (unit-тест).
## 3. Предстартовый допуск (Ф-13.2, Р3-А — пороги из Safety)
```kotlin
enum class Permission { ALLOW, CARDIO_ONLY, CANCEL, POSTPONE }
fun checkStartPermission(bpSys: Int?, bpDias: Int?, hrSitting: Int?): Permission
```
Порядок (первое сработавшее): АД > `BpCancelSys/Dias` (150/90) →
`CANCEL`; АД ≥ `BpCardioSys/Dias` (140/90, валидация «≤ BpCancel» —
настройки телефона) → `CARDIO_ONLY`;
`hrSitting > RestingHrSitting` (90) → `POSTPONE`; иначе `ALLOW`.
Число отсутствует → его проверка пропускается. Значения — из
DataStore `Safety.*` (телефонные умолчания — tech-req 4.1). 🛡 Обход (`startAnyway`) — запись в command_log
(`accepted=false`, semantics `{"override":"permission"}`) — Ф-13.5.
## 4. Дневник и перерывы
- `saveDailyLog(date, поля…)` — upsert дня (spec 01), всегда в sync;
- `declareBreak(kind, plannedReturn?, note)` — вставка `breaks`
(source=**manual** — 🛡 авто-детект на сервере, Ф-14.1/14.2);
- `closeBreakIfReturning(workoutId)` — открытый break с
`started_on < дата тренировки` → `ended_on = дата` (sync
обновит сервер; `break_update` путь — тот же client_guid);
## 5. Синхронизация каждой записи
Каждая мутация → `SyncQueue.enqueue(entity, clientGuid, payload)`
(сериализация — spec 04). Чтения — прямые DAO (suspend).

View File

@ -0,0 +1,51 @@
# Спецификация 03: SyncQueue и ApiClient
Источники: Ф-7, tech-req 5 (SyncQueue: пакеты ≤ 50, WorkManager,
ретрай 1→64 мин, WifiOnly), m02 spec 03 (POST /api/v1/sync).
## 1. Очередь
- `sync_pending(client_guid PK, entity, payload JSON, created_at)`;
запись — только из WorkoutCore (spec 02 §5);
- **Batcher** (чистая функция, unit-тест):
`fun batch(rows: List<Pending>, max: Int = 50): List<List<Pending>>`
— по возрастанию created_at, порциями ≤ max.
## 2. SyncWorker (WorkManager, Expedited/OneTime)
1. Прочитать порции Batcher'ом;
2. для каждой: `POST {BaseUrl}/api/v1/sync` c `X-Api-Key`, тело —
JSON-массив конвертов (spec 04 §1);
3. ответ 200 `{"accepted":[guid…]}` → `deleteAll(accepted)`
(частично принятый пакет — удалить принятые, rest останется);
4. 401/409 — **не ретраить автоматически**: 401 — флаг
`authError` (баннер m10 «ключ неверен»), 409 — конфликт версии
программы (пользовательское разрешение — m10; запись остаётся);
5. сеть/5xx — Result.retry() c backoff WorkManager
(`setInitialDelay` экспоненциально 1→64 мин, policy LINEAR по
умолчанию меняем на ручной расчёт от attemptCount);
6. `Sync.WifiOnly=true` → constraint UNMETERED;
7. триггеры: enqueue записи, восстановление сети (constraint),
запуск приложения (m10 подпишется).
## 3. ApiClient (Retrofit + OkHttp + kotlinx-serialization)
```kotlin
interface WatchBodyApi {
@POST("api/v1/sync") suspend fun sync(@Body items: List<SyncItem>): SyncResponse
@GET("api/v1/programs/active") suspend fun activeProgram(): ActiveProgramResponse?
}
```
- BaseUrl из DataStore (`Server.BaseUrl`), интерцептор `X-Api-Key`;
- `SyncResponse { accepted: List<String> }`;
- `activeProgram` — «тяни и держи»: ответ маппится в
`program_version` LocalDb (status=active, локальный uuid клиента —
серверный versionId приходит в теле; хранение по нему), вызов —
при старте приложения/после sync (m10); 404 → активной нет.
## 4. Инварианты 🛡
- сеть только здесь (офлайн-first, 🛡1);
- удаление из очереди — только по подтверждению сервера (🛡4);
- API-ключ не логируется (🛡5; OkHttp-лог выключен в release).

View File

@ -0,0 +1,54 @@
# Спецификация 04: payload-контракт sync (фикстуры)
Источники: tech-req 2.1/2.2 (конверт, составы data), m02 spec 03
(серверный приёмник). Тест сериализации — каждый payload
сравнивается с фикстурой (ключи/типы snake_case).
## 1. Конверт
```json
{"client_guid": "<uuid>", "entity": "workout_set", "op": "upsert",
"data": { …поля сущности… }}
```
## 2. data по сущностям (обязательные/опциональные поля)
| entity | data | Примечание |
|---|---|---|
| `workout` | `program_version_id?`, `started_at` (ISO UTC), `ended_at?`, `status`, `session_kind`, `avg_hr?` | status: active/finished/cancelled |
| `workout_set` | `workout_id`, `exercise_id?`, `seq`, `weight_kg`, `reps`, `done_at`, `source`, `in_plan`, `rpe?`, `knee_pain?` | вес — число с точкой |
| `command_log` | `workout_id`, `t_offset_s`, `recognized_text`, `semantics` (объект), `accepted` | |
| `daily_log` | `date` (yyyy-MM-dd), `bp_sys?`, `bp_dias?`, `weight_kg?`, `hr_resting?`, `wellbeing?` | день уникален — сервер upsert |
| `break` | `kind?`, `source` (**только manual**), `started_on`, `planned_return?`, `ended_on?`, `note` | 🛡 авто — сервер |
| `exercise` | `name`, `group` (push/pull/legs/core/cardio), `is_base`, `is_active` | id = client_guid (серверу не передаётся) |
| `program_version` | `version` (число, **обязательно** — значение считает БД), `based_on_version?`, `author` (user), `rationale?` + **поля программы (weeks…) на верхнем уровне** | errata m10 2026-10-19: контракт m02 spec 03 — data хранится сервером целиком как body; вложенного `body` НЕТ; локальные draft (в v1 телефона нет писателя — решение гейта 1.3 m10) |
| `profile` | `sex?`, `birth_year?`, `weight_kg?`, `hr_max?` | singleton |
Уточнения против m02 spec 03: `exercise_id` у `workout_set` —
локальный UUID упражнения (Р2-Б, строкой); `program_version_id` —
UUID версии; `break.kind` ∈ illness/vacation/workload/injury/other.
`profile.sex` ∈ `m`/`f` (валидатор m02), null — не передаётся.
**Errata 2026-10-19 (смок против живого сервера):** `client_guid` —
UUID v4 для ВСЕХ сущностей, включая `daily_log` и `profile` (даты и
строки-слова сервер отвергает — «client_guid отсутствует или не
uuid»); поэтому в LocalDb у daily_log/profile есть стабильная
колонка `guid` (spec 01) — WorkoutCore сохраняет существующий guid
при повторных правках, `client_guid = guid`. Примечание: сервер
(m02 spec 06: «лишние поля игнорируются») не разбирает
`break.ended_on` — локальное закрытие перерыва в v1 на сервер не
уходит (дату возврата выводит серверный BreakEngine по тренировкам);
кандидат v2.
## 3. Даты/время
- `started_at`/`done_at`/`ended_at` — `yyyy-MM-dd'T'HH:mm:ss'Z'` UTC
(сервер парсит UtcDate);
- `date`/`started_on`/`planned_return`/`ended_on` — `yyyy-MM-dd`.
## 4. Фикстуры теста
Проверки полей — инлайн в тест-классе `SyncPayloadTest` (app/src/test):
`PayloadFactory.*` → JsonObject → assert ключей/типов/значений
snake_case, optional-поля — только при наличии; отдельно недопустимые
варианты: `break.source="auto"` → исключение фабрики (🛡);
конверт `envelope(Pending)` — форма `client_guid/entity/op/data`.

View File

@ -0,0 +1,89 @@
# Требования к модулю: m09-phone-core
> Модуль — телефонное ядро **Phone.WatchBody** (Android, Kotlin,
> архитектура разд. 1.2): `WorkoutCore` (тренировка/допуск/дневник/
> перерывы — единственный писатель `LocalDb`), `LocalDb` (Room/SQLite,
> офлайн-first), `SyncQueue` (очередь с `client_guid`, пакеты ≤ 50,
> идемпотентная отправка `POST /api/v1/sync`). UI — m10 (Compose),
> голосовой ввод — m11, канал часов — m12. Самодостаточная копия
> требований (d-dev-6).
## 1. Назначение и границы
**В границах модуля:**
- Gradle-проект `3-app\4-phone\Phone.WatchBody` (app-модуль
`:app`, Kotlin, minSdk 26, targetSdk 36; Compose-зависимость
подключается в m10 — здесь только ядро и unit-тесты);
- `LocalDb` (Room): таблицы-зеркала сервера с **UUID-PK телефона**
(Р2-Б): `exercise`, `program_version`, `workout`, `workout_set`,
`command_log`, `daily_log`, `breaks`, `profile` (singleton),
плюс `sync_pending(guid, entity, payload, created_at)`;
настройки — DataStore (`Server.BaseUrl`, `Server.ApiKey`,
`Sync.WifiOnly`, `Rest.DefaultSec`, `Safety.*` — Р3-А, телефонные
пороги допуска);
- `WorkoutCore`: старт/финиш/отмена тренировки (`session_kind`,
привязка активной версии программы); фиксация подхода с пометкой
«по плану» (план сессии дня: вес ± 2,5 кг, повторения ± 0 —
Ф-11.2); журнал команд (заглушка ручного ввода — голос m11);
дневник самоконтроля (день уникален, upsert); **предстартовый
допуск** (АД > 150/90 → отмена; 140–150/90–95 → только кардио;
пульс сидя > 90 → перенос — пороги `Safety.*`, 🛡 блокировка
обходится логированием, Ф-13.2); перерывы: объявление
`source=manual` (тип/план. возврат), закрытие первой сессией
(данные для правил сервера); 🛡 единственный писатель LocalDb;
- `SyncQueue`: каждая запись получает `client_guid` (UUID v4) и
payload по контракту sync (tech-req 2.1/2.2, snake_case);
WorkManager-воркер: пакеты ≤ 50 по времени создания, отправка
`X-Api-Key`, подтверждённые GUID удаляются; ретрай 1→64 мин
экспоненциально; ограничение `Sync.WifiOnly`; тихий статус
(баннеры — m10);
- `ApiClient`: HTTP-клиент `/api/v1/sync` (+ `GET /api/v1/workouts`,
`GET /api/v1/programs/active` — тянет активную программу);
- unit-тесты (JUnit, pure JVM): WorkoutCore (in_plan, допуск,
статусы), batcher очереди, сериализация payload (контракт
snake_case против фикстур сервера).
**Вне границ модуля:** экраны (m10 TrainingUi), STT/TTS и грамматика
(m11), companion-канал (m12), LocalDb↔сервер маппинг серверных
вычислений (метрики/правила — на сервере), восстановление пульса
пропущенного периода (m12 — по каналу часов).
## 2. Контракт sync (tech-req 2.1/2.2)
- конверт: `{"client_guid", "entity", "op": "upsert", "data"}`;
- entity ∈ workout | workout_set | command_log | daily_log | break |
exercise | program_version | profile; поля — snake_case, как
серверные sync-сущности (m02 spec 03);
- `workout_set.exercise_id`, `workout.program_version_id` — локальные
UUID (Р2-Б: маппинг id не нужен);
- `break.source` телефон передаёт только `manual` (🛡 авто-детект —
сервер, Ф-14.1).
## 3. Инварианты (🛡 — нарушать запрещено)
1. **Офлайн-first** — сеть только на пути `SyncQueue`; запись в
LocalDb никогда не ждёт сети (КУ-3).
2. **Единственный писатель** — LocalDb пишет только WorkoutCore
(+ SyncQueue свой статус); UI/m10 — через WorkoutCore.
3. **UUID генерирует телефон** для всех клиентских сущностей (Р2-Б);
`client_guid` = PK записи.
4. **Идемпотентность** — повторная отправка не меняет данные (сервер
считает по GUID; очередь удаляет только подтверждённые).
5. **Тайны** — API-ключ только в DataStore, в логи не пишется (З-6).
6. **Допуск** — пороги только из `Safety.*` (Р3-А); обход блокировки
(«всё равно начать») логируется.
## 4. Критерии приёмки модуля
1. Unit-тесты зелёные: in_plan (вес ± 2,5 кг / повторы ± 0 от плана),
допуск (отмена/кардио/перенос/норма), жизненный цикл тренировки,
batcher (порция ≤ 50, порядок по created_at, удаление accepted).
2. Payload всех 8 сущностей соответствует фикстурам контракта
(snake_case, обязательные поля).
3. Сборка проекта Gradle проходит (`assembleDebug` без UI — модуль
app-библиотеки-каркаса m10); при отсутствии локального Android SDK —
сборка у владельца по инструкции (решение гейта 1.3).
4. Интеграция со стеклянным (docker) сервером: сценарий «запись
офлайн → sync → данные в `src.*`» проверен вручную curl-фикстурой
payload-ов (при доступном SDK — инструментальным тестом).

View File

@ -0,0 +1,71 @@
# m09-phone-core — план работ (d-dev)
> Модуль: Phone.WatchBody — ядро телефона (Room LocalDb, WorkoutCore,
> SyncQueue, ApiClient); UI — m10.
> Алгоритм: `std1\_rules\d\d-dev.md`. Требования:
> [module_reqs.md](module_reqs.md).
> Зависимости: m02 (REST sync-контракт, DONE), m03/m04/m05 (серверные
> вычисления — телефон только пишет/читает).
| Шаг | Что делаем | Статус |
|---|---|---|
| 0 | Модульные документы: module_reqs.md / readme.md / SKILL.md | ✅ 2026-10-19 |
| 1 | Список единиц (классы/методы/конфиг/тесты) | ✅ 2026-10-19 |
| 1.3 | **Пользователь!** утверждение списка (+ решение по SDK) | ✅ 2026-10-19 |
| 2 | Спецификации: api_specs (LocalDb-схема, WorkoutCore, SyncQueue, контракт) | ✅ 2026-10-19 |
| 2.4 | **Пользователь!** утверждение спецификаций | ✅ 2026-10-19 |
| 3 | Реализация + тесты | ✅ 2026-10-19 |
| 3.4 | **Пользователь!** утверждение реализации (закрытие модуля) | ✅ 2026-10-19 |
| 4 | Завершение: план, примечания | ✅ 2026-10-19 |
Статусы: ⬜ не начат · 🔄 в работе · ✅ готов.
## Примечания по ходу работ
1. **Toolchain** (установлен агентом 2026-10-19 в `C:\tools`): JDK 21
(Temurin), Android SDK (platforms;android-36, build-tools 36.0.0),
Gradle — wrapper 8.11.1 в проекте. Сборка:
`JAVA_HOME`/`ANDROID_HOME` + `./gradlew :app:assembleDebug
:app:testDebugUnitTest`. Итог: BUILD SUCCESSFUL, APK 4,2 МБ,
unit-тесты **18/18** (CoreLogicTest 10 + SyncPayloadTest 8).
2. **local.properties**: `sdk.dir` — только прямые слэши
(`C:/tools/android-sdk`); экранированные бэкслэши дают IOException
«Синтаксическая ошибка в имени файла» в SdkLocator (Windows).
3. **Room**: snake_case-имена колонок — через `@ColumnInfo`
(31 аннотация, включая PK `sync_pending.client_guid`); Kotlin-поля
camelCase, индексы/FK ссылаются на snake_case.
4. **Errata контракта (смок 2026-10-19 против живого сервера,
порт 5000, тест-ключ test-key-ivan-0001)**: сервер требует
`client_guid`-UUID у ВСЕХ сущностей — daily_log («2026-10-19») и
profile («profile») отвергнуты («client_guid отсутствует или не
uuid»). Фикс: стабильные `guid`-колонки daily_log/profile
(WorkoutCore хранит существующий guid при правках, в конверт идёт
он); specs 01/04 синхронизированы, фикстуры тестов обновлены.
5. **Смок итог**: пакет 5 сущностей (exercise/workout/workout_set/
daily_log/break manual) → `accepted` все 5; повтор того же пакета —
идемпотентен (workout_set — 1 строка, daily_log — upsert по дате с
заменой id на guid); все строки проверены в docker-БД `watchbody-pg`
(метрики workout посчитаны: тоннаж 640). Попутно: `break.kind` —
только illness/vacation/workload/injury/other («rest» отклонён
400); `break.ended_on` сервер не разбирает — локальное закрытие
перерыва в v1 не синхронизируется (возврат выводит серверный
BreakEngine), кандидат v2 (spec 04).
6. **Расширение модулем m10** (решение гейта 1.3 m10, утверждено
2026-10-19): `AppSettings`/`SettingsRepository` дополняются ключами
`hrMaxBpm` (Hr.MaxBpm) и `hrStrengthMax` (Safety.HrStrengthMax) —
оба из tech-req 4.1, в m09 не были заведены; в `SyncWorker`
добавляется `scheduleNow(context)` (немедленная отправка без
ограничения Wi-Fi — кнопка «Синхронизировать сейчас»; уникальное
имя work и ретраи не меняются). Поведение ядра m09 не меняется.
По ходу шага 3 m10 добавлены: DAO-чтения для UI
(`activeWorkoutFlow`/`setsFlow`/`lastSetForExercise`/
`exercisesFlow`/`activeProgramFlow`/`versions`/`dailyLogFlow`/
`profileFlow`/`countFlow` — чтение напрямую UI разрешено, писатель
один); `logSet` — параметр `source` (форма m10 = `edit`, было
зашито `voice`); `renameExercise(id, newName)` (🛡 UUID не меняется,
Ф-4.3); **errata контракта** — `PayloadFactory.programVersion`
переписана под m02 spec 03: поля программы на верхнем уровне data
(вложенного `body` нет) + обязательное числовое `version`
(значение считает БД); зафиксировано `profile.sex ∈ m|f` (валидатор
m02). Живой смок m10 подтвердил оба исправления (accepted +
GET active с `weeks` на верхнем уровне).

View File

@ -0,0 +1,18 @@
---
name: m10-phone-ui
description: TrainingUi (Jetpack Compose, Material 3, тёмная тема) — экраны телефона Phone.WatchBody поверх ядра m09
---
# m10-phone-ui
Экраны `Phone.WatchBody` (Compose, minSdk 26): Сегодня (дневник),
Допуск (предстартовый, 4 исхода), Тренировка (план сессии дня,
пикеры 2,5 кг, таймер отдыха цвет+текст), История (план/факт),
Программы (просмотр активной версии), Профиль (зоны Танаки),
Библиотека (Ф-4, логическое удаление), Настройки (tech-req 4.1,
https-валидация), баннеры синхронизации (Ф-7.3/7.5). UI пишет
только через `WorkoutCore` (🛡), офлайн-first.
- Требования: [module_reqs.md](module_reqs.md)
- План работ: [readme.md](readme.md)
- Спецификации: [api_specs/](api_specs/)

View File

@ -0,0 +1,62 @@
# Спецификация 01: каркас приложения (shell)
Источники: архитектура 1.2 (TrainingUi), Ф-3.6, Ф-7.3/7.5,
tech-req 4.1/6, ui-ux разд. 3. Единица 1 (каркас) + баннеры (10).
## 1. Каркас
- `WatchBodyApp` (Application) — ручной DI-контейнер (без Hilt в v1):
`AppDatabase` (m09 build), `WorkoutCore`, `SettingsRepository`,
ленивый `ApiClient` (создаётся по актуальным настройкам);
- `MainActivity` — единственная activity; `WatchBodyTheme` — тёмная
colorScheme Material 3, типографика ≥ 16 sp;
- нижняя навигация `NavigationBar` — 5 вкладок: Сегодня / Тренировка
/ История / Программы / Профиль; состояние — один `NavController`
(compose-navigation), вкладки — `popUpTo(start) saveState/restore`;
- «Библиотека» и «Настройки» — экраны-переходы (иконки в appbar
«Сегодня» и «Профиля»); системный Back — к вкладке.
## 2. Первый запуск
Если `baseUrl` или `apiKey` пусты — поверх вкладок показывается
экран «Настройки» (без него остальное неактивно); после сохранения
валидных значений — возврат на «Сегодня». Повторно гейт не срабатывает.
## 3. Баннеры синхронизации (Ф-7.3/7.5)
Источник состояния: `WorkManager.getWorkInfosForUniqueWorkFlow("sync")`
+ `SyncDao.count()` (Flow). Правила отображения (верх экрана, тихий
цветовой код + текст — цвет дублируется текстом 🛡):
| Состояние | Баннер |
|---|---|
| очередь > 0, work нет/не RUNNING | «В очереди на отправку: N» |
| work RUNNING | «Синхронизация…» |
| SUCCEEDED и очередь стала 0 | «Данные успешно синхронизированы» (скрывается через ~5 с, показ однократно на смену состояния) |
| FAILED флаг `authError` (401) | «Ключ API неверен — проверьте настройки» (постоянно до успешной синхронизации) |
| FAILED флаг `conflict` (409) | «Конфликт версии программы — обратитесь к владельцу» |
| FAILED прочее (ретрай/сеть) | «Синхронизация временно недоступна — повторим автоматически» |
Индикатор сети в статус-строке тренировки: `ConnectivityManager`
(онлайн/офлайн) — информационный, ничего не блокирует (офлайн-first).
## 4. Расписание отправки
- после каждого действия пользователя через `WorkoutCore`
(дневник/подход/старт/финиш/профиль/упражнение/перерыв) —
`SyncWorker.schedule(context, wifiOnly)` (m09, unique KEEP —
повторные вызовы не плодят работы);
- кнопка «Синхронизировать сейчас» (Настройки) — новый
`SyncWorker.scheduleNow(context)`: те же worker/имя/ретраи,
ограничение только CONNECTED (явное действие — Wi-Fi-ограничение
не применяется; расширение m09, readme m09 п. 6).
## 5. Правила
- 🛡 UI не обращается к DAO/Api напрямую — только `WorkoutCore`,
`SettingsRepository`, DAO-чтения (Flow) для списков истории/
очереди (чтение напрямую разрешено — единственный писатель
WorkoutCore, m09);
- ViewModels — `ViewModel` + viewModelFactory из контейнера;
состояние экранов — `StateFlow`;
- строковые ресурсы — ru (локализация одного языка в v1).

View File

@ -0,0 +1,73 @@
# Спецификация 02: экран «Сегодня» и поток «Допуск»
Источники: Ф-13.1 (дневник-минимум), Ф-13.2 (предстартовый допуск),
Ф-14.5 (session_kind), m09 `WorkoutCore`/`AdmissionChecker`/spec 02.
Единицы 2–3.
## 1. «Сегодня» (дневник)
`TodayViewModel`: `dailyLog(today)` (Flow), `pendingCount` (Flow),
сохранение — `WorkoutCore.saveDailyLog` (merge, день уникален).
Поля (ввод за 2–3 касания — заполненные поля не переспрашиваются,
пустые необязательны, кроме самочувствия-подсказки):
| Поле | Ввод | Диапазон |
|---|---|---|
| АД систола | пикер шаг 5 | 70–250 |
| АД диастола | пикер шаг 5 | 40–150 |
| Вес тела | пикер шаг 0,5 кг | 30–300 |
| Пульс покоя | пикер шаг 1 | 30–130 |
| Самочувствие | сегмент 1–5 (подписи: 1 плохо … 5 отлично) | 1–5 |
Кнопка «Сохранить» (нижняя треть) — доступна всегда; после — тихое
подтверждение «Сохранено». Кнопка «Начать тренировку» → «Допуск».
Счётчик «в очереди: N» — из баннер-состояния (spec 01).
## 2. «Допуск» (модальный экран перед стартом силовой)
`AdmissionViewModel`: читает дневник дня (АД), поля: «АД из дневника
за сегодня» (если пусто — пикеры прямо здесь, сохранение в дневник),
«Пульс сидя» (пикер 30–180, вручную — с часов подтянется в m12).
Расчёт: `AdmissionChecker.check(safety, bpSys?, bpDias?, hrSitting?)`
(порядок CANCEL → CARDIO_ONLY → POSTPONE → ALLOW; null пропускает
проверку — m09 spec 02). Если и АД, и пульс не введены — проверка не
выполняется, исход ALLOW с пометкой «данные не введены».
Экран исхода (спокойные формулировки, статус дублируется текстом):
| Исход | Заголовок/текст | Кнопки |
|---|---|---|
| ALLOW | «Допуск получен» | «Начать тренировку» (+ выбор вида) |
| CARDIO_ONLY | «Давление повышено — сегодня только лёгкое кардио или дыхание. Силовые заблокированы.» | «Закрыть»; тихая ссылка «Всё равно начать силовую» (2 тапа) 🛡 |
| CANCEL | «Давление высокое — тренировку не начинаем. Рекомендуется прогулка.» | «Отменить» (закрыть); тихая ссылка «Всё равно начать» (2 тапа) 🛡 |
| POSTPONE | «Пульс сиди высокий — лучше перенести тренировку.» | «Перенести» (закрыть); тихая ссылка «Всё равно начать» (2 тапа) 🛡 |
🛡 Обход блокировки (любой из трёх): «Всё равно начать» → подтверждение
вторым тапом → `WorkoutCore.startAnyway(programVersionId)` — в журнал
команд пишется `accepted=false, semantics={"override":"permission"}`
(Ф-13.5, m09). Обход всегда виден в журнале.
Выбор вида тренировки (при «Начать»): сегмент — обычная (normal,
умолчание) / возврат (return) / делоад (deload) / тест (test) —
Ф-14.5. Старт: `startWorkout(activeVersionId, kind)` → переход на
вкладку «Тренировка». Активная версия отсутствует — старт без
привязки (`startWorkout(null, kind)`), на экране тренировки пометка
«вне программы».
## 3. Правила
- пороги — только из `Safety.*` настроек (Р3-А), в потоке допуска не
редактируются;
- п. 1 «дневник» и п. 2 «допуск» пишут через `saveDailyLog` (merge) —
повторное сохранение не затирает утренние значения, введённые
раньше (одна запись на день);
- после старта — `SyncWorker.schedule` (spec 01 п. 4).
## 4. Тесты (unit, pure JVM)
| # | Проверка | Ожидание |
|---|---|---|
| 1 | Слияние дневника: утро (АД 120/80) + допуск ввёл пульс | запись дня одна: 120/80 + пульс |
| 2 | Допуск без данных (все null) | ALLOW, пометка «данные не введены» |
| 3 | Исходы CANCEL/CARDIO_ONLY/POSTPONE → «всё равно» | вызов startAnyway, лог override (по m09 — уже покрыто тестами ядра; здесь — маппинг исход→кнопка) |

View File

@ -0,0 +1,79 @@
# Спецификация 03: экран «Тренировка»
Источники: Ф-3 (экран активной тренировки), Ф-11.1–11.2 (план/факт),
Ф-13.3 (RPE/боль по сетам), m09 `WorkoutCore.logSet`/`PlanMatch`,
ui-ux разд. 3. Единицы 4 + 11 (ProgramView).
## 1. Состояние экрана
`WorkoutViewModel`: `activeWorkout` (Flow, m09 DAO), `sets(workoutId)`
(Flow), план сессии — `ProgramView.sessionFor(activeBody,
dayOfWeekIso(startedAt))` (spec 04), последний вес упражнения —
последний `workout_set` этого упражнения в истории (DAO-чтение).
Нет активной тренировки — заглушка «Нет активной тренировки» +
кнопка «К экрану "Сегодня"».
## 2. Статус-строка (Ф-3.1/3.2)
| Элемент | v1 (m10) | m12 |
|---|---|---|
| Связь с часами | серый индикатор + текст «часы: нет связи» | зелёный/красный |
| Заряд часов | «—» | % |
| Пульс | крупный «—» (прочерк) | живое значение |
| Сеть | онлайн/офлайн (текст) | — |
## 3. Блок упражнения и форма подхода
- «Текущее упражнение» — первый элемент плана сессии с незакрытыми
подходами (план: вес × повторы × осталось), иначе последний
записанный; селектор упражнения — план сессии + активная
библиотека (сохранённый выбор запоминается до смены);
- форма (нижняя треть, ≤ 4 активных элемента одновременно):
- вес — пикер шаг **2,5 кг**, диапазон 0–500; умолчание — вес из
плана для этого упражнения, иначе последний фактический;
- повторы — пикер шаг 1 (0–50), умолчание — план;
- RPE — сегмент 1–10, необязательно (пропуск = null);
- боль в колене — сегмент 0–10, показывается только для группы
«ноги» (Ф-13.3), необязательно;
- «Записать подход» → `WorkoutCore.logSet` (seq, in_plan, дата —
считает ядро m09);
- список подходов тренировки (компакт): упражнение, вес × повторы,
метка «по плану» / «отклонение» (текст, не только цвет 🛡),
RPE если есть.
## 4. Таймер отдыха (Ф-3.3)
- кнопка «Отдых (Rest.DefaultSec с)» — старт обратного отсчёта
(значение из настроек);
- фазы: «идёт отдых» (фон карточки красный-приглушённый + текст),
«последние 10 с» (жёлтый + текст), «пора начинать» (зелёный +
текст; остаётся до следующего действия) — цвет всегда дублирован
текстом 🛡;
- повторный тап — рестарт; сбрасывается при записи подхода;
- автостарт после подхода в v1 не делается (старт — кнопка или
голосовая команда «Отдых S» в m11 — Ф-3.3).
## 5. Завершение
- «Закончить» → `finishWorkout(id, avgHr=null)` (пульс появится
в m12) → переход в историю (карточка);
- «Сбросить» → диалог подтверждения («Тренировка сохранится
в истории как отменённая») → `cancelWorkout(id)` (Ф-3.5);
- кнопка-микрофон — открывает форму подхода (заглушка ручного
ввода; STT-контур — m11).
## 6. Правила
- 🛡 запись подхода только через `WorkoutCore.logSet`; экран не
пишет в DAO;
- метка «по плану» — только расчёт ядра (± 2,5 кг / ± 0 повторов,
Ф-11.2), UI метку не вычисляет и не подделывает;
- экран полностью работоспособен офлайн.
## 7. Тесты (unit, pure JVM)
| # | Проверка | Ожидание |
|---|---|---|
| 1 | Умолчания формы: план сессии существует | вес/повторы = план; плана нет — последний фактический |
| 2 | Группа «ноги» → сегмент боли виден; «жим» — скрыт | видимость по groupCode |
| 3 | Фазы таймера по остатку (>10 / ≤10 / 0) | подписи фаз соответствуют |

View File

@ -0,0 +1,72 @@
# Спецификация 04: экраны «История» и «Программы», сервис ProgramView
Источники: Ф-5 (история), Ф-11.3 (отчёт план/факт), Ф-10 (просмотр),
m09 `PlanMatch`/DAO. Единицы 5–6 + сервис 11.
## 1. ProgramView (чистая функция над body активной версии)
Вход: `body` (JSON-строка), `dayOfWeekIso` (1=понедельник …
7=воскресенье, m09 PlanMatch). Выход:
```
SessionView(day: String, focus: String?,
items: List<ItemView>)
ItemView(exerciseName: String, planSets: List<SetPlan>)
SetPlan(weightKg: Double, reps: Int)
```
Правила: первая сессия дня недели из `weeks[].sessions[]` (та же
семантика, что `PlanMatch.planFor` — m09); порядок недель — по
номеру, упражнения — как в JSON; подходы упражнения — все из
`sets[]`; битый/пустой body или нет сессии дня — пустой список
(экран — «план на сегодня не задан»); дни недели — ISO-имена
(`monday…sunday`). Отдельно `allWeeks(body)` для экрана «Программы»
(полная структура недель без привязки к дню).
## 2. «История» (Ф-5)
`HistoryViewModel`: `history` (Flow: finished/cancelled по
убыванию started_at — m09 DAO). Список: дата/время, длительность
(endedAt − startedAt; незавершённая — «—»), число подходов, веха
вида (normal/return/deload/test). Карточка тренировки (по выбору):
- шапка: дата, длительность, статус (завершена/отменена), вид;
- список подходов: упражнение, вес × повторы, источник («правка»
в v1; «голос/часы» — словарь m09 `source`), метка «по плану»/
«отклонение», RPE, боль в колене (если есть);
- отчёт план/факт (Ф-11.3, локально): доля подходов `in_plan` =
inPlan / all (процент + текст «из N подходов по плану»); средний
пульс — если записан (v1 обычно «—», .fit-факты — сервер);
- всё из LocalDb, без сети (Ф-5.3).
## 3. «Программы» (Ф-10, просмотр)
`ProgramsViewModel`: `activeProgram` (Flow, m09 DAO), список локально
известных версий (`program_version` таблица: номер, статус, автор,
дата). Экран:
- сводка активной: номер версии, статус active, автор, дата;
- «План на сегодня» — `ProgramView.sessionFor(activeBody, today)`:
упражнения с планом вес × повторы (тот же блок, что на тренировке);
нет сессии — «сегодня по плану отдыха»;
- полная структура — `allWeeks`: неделя № → сессии (день, фокус) →
упражнения → план подходов; сворачиваемые карточки недель;
- 🛡 только чтение: активация/применение rules-draft — консоль
владельца (решение гейта 1.3 m10; Ф-12.3 телефонный путь —
кандидат v2);
- нет активной версии — заглушка «Программа не назначена —
обратитесь к владельцу» + кнопка «Обновить» (GET programs/active,
m09 ApiClient → запись в LocalDb + schedule sync).
Словарь групп (общий для экранов): push «жим», pull «тяга»,
legs «ноги», core «кор», cardio «кардио».
## 4. Тесты (unit, pure JVM)
| # | Проверка | Ожидание |
|---|---|---|
| 1 | `sessionFor` — среда, две сессии в дне | первая сессия, подходы по порядку |
| 2 | `sessionFor` — день без сессий / пустой body / битый JSON | пустой список, исключений нет |
| 3 | `sessionFor` согласован с `PlanMatch.planFor` | упражнение/вес/повторы первой сессии совпадают |
| 4 | `allWeeks` — порядок недель/упражнений | как в JSON |
| 5 | План/факт карточки: 2 in_plan из 5 | 40 %, текст «из 5 подходов по плану 2» |

View File

@ -0,0 +1,75 @@
# Спецификация 05: «Профиль», «Библиотека», «Настройки», ZoneCalculator
Источники: Ф-6 (профиль/зоны), Ф-4 (библиотека), tech-req 4.1
(настройки+валидация), ОВ-1 (Танака). Единицы 7–9 + сервис 11.
## 1. «Профиль» (Ф-6)
`ProfileViewModel`: `profile` (Flow), сохранение —
`WorkoutCore.saveProfile(sex, birthYear, weightKg, hrMax)` →
sync-сущность `profile` (guid-стабильность — m09).
| Поле | Ввод | Диапазон |
|---|---|---|
| Пол | сегмент м/ж/не указан | — |
| Год рождения | пикер | 1930–2010 |
| Вес | пикер шаг 0,5 кг | 30–300 |
| Макс. пульс | пикер 120–240 или «считать по формуле» (Танака) | 120–240 / авто |
`ZoneCalculator` (чистая): `maxHr = hrMax ?: 208 − 0,7 × возраст`
(округление до целого; возраст = текущий год − birthYear; без
birthYear и hrMax — зоны не показываются); зоны по Ф-6.2:
z1 < 50 %, z2 50–69 %, z3 70–84 %, z4 ≥ 85 % (границы в уд/мин,
таблица; те же единицы, что на часах). Зоны — показ; на часы — m12.
## 2. «Библиотека» (Ф-4)
`LibraryViewModel`: `exercises` (Flow), переключатель «показать
исключённые». Операции:
- «Добавить» — диалог: название (уникальность проверяет БД,
UNIQUE `exercise.name` — сообщение «уже есть»), группа (сегмент
из 5), базовое/изоляция (переключатель);
- «Переименовать» — диалог с текущим названием (UUID не меняется 🛡
Ф-4.3);
- «Исключить» — подтверждение «История тренировок сохранится;
упражнение исчезнёт из выбора» → `deactivateExercise` (🛡 только
логическое удаление, Ф-4.2; восстановления в v1 нет — решение
З-41/m02);
Запись — `WorkoutCore.saveExercise`; is_active=false уходит на сервер
повторной отправкой упражнения (контур З-41(а)).
## 3. «Настройки» (tech-req 4.1)
`SettingsViewModel`: чтение/сохранение `AppSettings` (расширение
m09: + `hrMaxBpm`, `hrStrengthMax`); валидация перед сохранением
(«Сохранить» неактивна, ошибки — построчно):
| Поле | Правило |
|---|---|
| `baseUrl` | абсолютный `https://` URL (host непустой); http допустим только для отладки — предупреждение текстом (docker-сервер) |
| `apiKey` | непустой; поле-пароль (маска), 🛡 не логируется и не выводится полностью |
| `wifiOnly` | — |
| `hrMaxBpm` | 120–240 или пусто (авто-Танака) |
| `restDefaultSec` | 30–600 |
| `bpCancelSys/bpCancelDias` | 120–200 / 70–130 |
| `bpCardioSys/bpCardioDias` | 120–200 / 70–130; ≤ соответствующего Cancel (tech-req 4.1) |
| `restingHrSitting` | 40–120 (умолчание 90) |
| `hrStrengthMax` | 100–180 (умолчание 120, З-20; применяется в m12) |
Секция «Синхронизация»: счётчик очереди, кнопка «Синхронизировать
сейчас» → `SyncWorker.scheduleNow` (spec 01 п. 4), результат —
баннер (spec 01 п. 3). Первый запуск — экран открывается гейтом
(spec 01 п. 2).
## 4. Тесты (unit, pure JVM)
| # | Проверка | Ожидание |
|---|---|---|
| 1 | `ZoneCalculator`: 1980 г.р. в 2026, без hrMax | maxHr 176 (208 − 0,7×46 = 175,8, математическое округление) |
| 2 | `ZoneCalculator`: ручной hrMax 190 | границы 95/133/162; ручной приоритетнее формулы |
| 3 | `ZoneCalculator`: нет данных | зоны не показываются (null) |
| 4 | Валидация baseUrl: `https://host/path` ок; `http://…` предупреждение; `ftp://`, `host`, пусто | ошибка/предупреждение по правила |
| 5 | Валидация Safety: bpCardioSys > bpCancelSys | ошибка «кардио-порог выше порога отмены» |
| 6 | Валидация диапазонов (restDefaultSec 10; hrMaxBpm 250; hrStrengthMax 90) | построчные ошибки |

View File

@ -0,0 +1,148 @@
# Требования к модулю: m10-phone-ui
> Модуль — **TrainingUi** телефона `Phone.WatchBody` (архитектура
> разд. 1.2): Jetpack Compose + Material 3 поверх ядра m09
> (`WorkoutCore`, `LocalDb`, `SyncQueue`, `ApiClient`,
> `SettingsRepository`). Голосовой ввод — m11 (кнопка микрофона —
> заглушка ручного ввода), канал часов — m12 (пульс/связь — прочерки).
> Самодостаточная копия требований (d-dev-6). Источники: Ф-3, Ф-4,
> Ф-5, Ф-6, Ф-10 (просмотр), Ф-11.1–11.3, Ф-13.1–13.2/13.5, Ф-14.2
> (manual), tech-req 4.1/6, ui-ux-recommendations разд. 3/5.
## 1. Назначение и границы
**В границах модуля:**
- Compose-каркас: `MainActivity`, тёмная тема Material 3, нижняя
навигация 5 вкладок — **Сегодня / Тренировка / История / Программы /
Профиль**; экраны «Библиотека» и «Настройки» — переходами из
«Сегодня»/«Профиля» (иконки в appbar);
- **экран «Сегодня»** (Ф-13.1): ввод дневника за 2–3 касания — АД
(сист./диаст.), вес, пульс покоя, самочувствие 1–5 (сегмент);
показ записи дня; кнопка «Начать тренировку» → поток допуска;
счётчик очереди sync;
- **поток «Допуск»** (Ф-13.2, модальный экран перед стартом силовой):
подтягивает АД из сегодняшнего дневника, ввод пульса сидя вручную;
`AdmissionChecker` (m09) → исходы: ALLOW («Начать»), CARDIO_ONLY
(силовые заблокированы — сообщение, прогулка), CANCEL (предложение
прогулки, кнопка «Отменить» 🛡 логируется), POSTPONE (перенос;
старт — явное «Всё равно начать» → `startAnyway` 🛡 лог);
выбор `session_kind` при старте (сегмент: обычная/возврат/делоад/
тест, умолчание normal — Ф-14.5);
- **экран «Тренировка»** (Ф-3): статус-строка (связь с часами — серый
прочерк до m12; сеть онлайн/офлайн), пульс «—» (канал — m12), блок
упражнения (текущее из плана сессии дня + рабочий вес), форма
подхода: пикер веса с шагом 2,5 кг (умолчание — последний вес
упражнения), пикер повторов, RPE 1–10 сегмент (Ф-13.3), боль
в колене 0–10 сегмент — только для группы «ноги» (Ф-13.3),
метка «по плану/отклонение» (считает WorkoutCore); таймер отдыха
(обратный отсчёт `Rest.DefaultSec`, фон красный/жёлтый (последние
10 с)/зелёный + текстовая подпись состояния); кнопки: «Отдых»
(старт таймера), микрофон (заглушка — ручной ввод подхода, m11
заменит), «Закончить», «Сбросить» (отмена с подтверждением —
статус `cancelled`, Ф-3.5); ≤ 4 активных элемента одновременно
(Ф-3.6);
- **экран «История»** (Ф-5): список finished/cancelled (дата,
длительность, число подходов; средний пульс — если есть),
карточка тренировки: подходы (упражнение, вес × повторения,
источник «голос/часы/правка» — в m10 «правка», метка плана,
RPE), отчёт план/факт по тренировке (доля подходов «по плану» —
Ф-11.3, локально по in_plan);
- **экран «Программы»** (Ф-10/Ф-11): активная версия (недели →
сессии → упражнения → план подходов вес × повторы, target RPE/
отдых — если заданы), план на сегодня (сессия дня недели);
список локально известных версий; 🛡 версии только читаются —
активация/применение rules-draft в v1 — консоль владельца (m08;
решение гейта 1.3);
- **экран «Профиль»** (Ф-6): пол, год рождения, вес, макс. пульс
(`Hr.MaxBpm` вручную или расчёт Танаки 208 − 0,7 × возраст);
зоны 50/70/85 % (показ; на часы — m12); сохранение →
`saveProfile` → sync `profile`;
- **экран «Библиотека»** (Ф-4): список упражнений (название, группа,
базовое/изоляция, активные/исключённые), добавить, переименовать,
исключить (`is_active=false` 🛡 логическое, Ф-4.2); выбор
упражнения при вводе подхода — из плана сессии + библиотека;
- **экран «Настройки»** (tech-req 4.1): `Server.BaseUrl`
(валидация — абсолютный https:// URL, иначе «Сохранить» неактивна),
`Server.ApiKey` (поле-пароль, 🛡 не отображается в открытом виде
и не логируется), `Sync.WifiOnly`, `Hr.MaxBpm` (120–240/пусто),
`Rest.DefaultSec` (30–600), `Safety.*` (BpCancel*, BpCardio*,
RestingHrSitting, HrStrengthMax 100–180) — расширение
`AppSettings` m09 (+ `hrMaxBpm`, `hrStrengthMax`); кнопка
«Синхронизировать сейчас» (+結果-баннер); первый запуск —
экран настройки подключения показывается до остальных вкладок;
- **баннеры синхронизации** (Ф-7.3/7.5): тихий статус — «в очереди
N записей», «Синхронизация временно недоступна» (сбой сети/
ретрай), «Данные успешно синхронизированы» (первая успешная),
ошибки авторизации/конфликта из флагов SyncWorker (401 — «ключ
неверен», 409 — «конфликт версии»); состояние — WorkInfo
unique-work «sync» + `SyncDao.count()`;
- сервисы UI: `ProgramView` — разбор body активной версии
(чистая функция поверх `PlanMatch`: сессия дня → список
упражнений с сетами); `ZoneCalculator` (Танака/ручной, зоны
50/70/85); расширение `AppSettings`/`SettingsRepository`;
расписание sync — UI вызывает `SyncWorker.schedule` после
действий WorkoutCore и по кнопке (WorkoutCore ядро остаётся без
Android-контекста);
- unit-тесты (pure JVM): `ProgramView` (сессия дня, пустой/
битый body), `ZoneCalculator`, валидация настроек (URL/диапазоны).
**Вне границ модуля:** STT/TTS и голосовые команды (m11 — кнопка
микрофона остаётся заглушкой ручного ввода), пульс/связь с часами
и предупреждение `HrStrengthMax` во время тренировки (m12 — канал;
настройка вводится в m10), редактор программ и создание версий
`author=user` (решение гейта 1.3 — v1 просмотр; создание — консоль),
применение rules-draft подтверждением атлета (Ф-12.3 — в v1
консоль владельца; эндпоинт активации для атлета — кандидат v2),
ответ атлета на авто-break (Ф-14.2 — серверного пути записи ответа
нет, кандидат v2), домашний поддерживающий комплекс (Ф-14.6, v2),
восстановление пульса пропущенного периода (m12), админ-функции.
## 2. Решения интерфейса (tech-req 6, ui-ux разд. 3/5)
- Тёмная тема по умолчанию, Material 3; шрифт ≥ 16 pt, контраст
WCAG AA, цели касания ≥ 48 dp;
- ключевые действия — нижняя треть экрана (одна рука);
- числовой ввод — пикеры (вес шаг 2,5 кг; повторы шаг 1), не
клавиатура; RPE/самочувствие/боль — сегмент-контролы, не слайдеры;
- цветовые сигналы дублируются текстом/иконкой (таймер отдыха,
статусы допуска, баннеры);
- предупреждения — спокойные формулировки («Пульс выше порога.
Отдохните или завершите»), без тревожной лексики;
- уведомления только значимые (SUS ≥ 80 — порог релиза; приёмка
вручную владельцем, эмулятора в среде нет).
## 3. Инварианты (🛡 — нарушать запрещено)
1. **UI не пишет в LocalDb** — только вызовы `WorkoutCore`
(единственный писатель, инвариант m09); ViewModel хранит только
UI-состояние.
2. **Офлайн-first** — каждый экран работает без сети (КУ-3); сеть —
только SyncQueue по действиям пользователя (кнопка/фоновое
расписание).
3. **Тайны** — API-ключ не отображается открыто и не логируется
(З-6); в UI — замаскированное поле.
4. **Допуск** — блокировки по `Safety.*` снимаются только явными
действиями с логированием (`startAnyway` / отмена) — Ф-13.2/13.5.
5. **Версии программ на телефоне неизменяемы и только читаются**
(Ф-10.1); активная версия фиксируется у тренировки при старте
(Ф-10.3 — уже в m09).
6. **Удаление упражнения — только логическое** (`is_active=false`,
Ф-4.2); история читаема.
## 4. Критерии приёмки модуля
1. `assembleDebug` собирается, APK запускается (вручную владельцем);
unit-тесты зелёные (ProgramView/зоны/валидация настроек).
2. Ручной смок сценария на устройстве/эмуляторе владельца: первый
запуск (настройки) → «Сегодня» (дневник) → допуск (все 4 исхода
по порогам) → тренировка (2–3 подхода с меткой плана, таймер
отдыха) → закончить → история (карточка с план/факт) → программы
(план на сегодня) → профиль → библиотека (добавить/исключить).
3. Смок синхронизации на живом (docker) сервере: действия сценария
п. 2 уходят пакетами `/api/v1/sync`, подтверждённые GUID
удаляются из очереди (счётчик «Сегодня» → 0), строки видны
в `src.*` (банее-статусы соответствуют).
4. Экраны соответствуют UI/UX-стандарту (разд. 2): тёмная тема,
пикеры/сегменты, дублирование цвета текстом — проверка владельцем
при приёмке.

View File

@ -0,0 +1,62 @@
# m10-phone-ui — план работ (d-dev)
> Модуль: TrainingUi — экраны телефона (Compose поверх ядра m09).
> Алгоритм: `std1\_rules\d\d-dev.md`. Требования:
> [module_reqs.md](module_reqs.md).
> Зависимости: m09 (WorkoutCore/LocalDb/SyncQueue/ApiClient/
> SettingsRepository — DONE); голос — m11, часы — m12 (заглушки).
| Шаг | Что делаем | Статус |
|---|---|---|
| 0 | Модульные документы: module_reqs.md / readme.md / SKILL.md | ✅ 2026-10-19 |
| 1 | Список единиц (экраны/VM/сервисы/тесты) | ✅ 2026-10-19 |
| 1.3 | **Пользователь!** утверждение списка (+ решения по объёму v1) | ✅ 2026-10-19 |
| 2 | Спецификации: api_specs (экраны, состояние, сервисы) | ✅ 2026-10-19 |
| 2.4 | **Пользователь!** утверждение спецификаций | ⬜ |
| 3 | Реализация + тесты + сборка | ✅ 2026-10-19 |
| 3.4 | **Пользователь!** утверждение реализации (закрытие модуля) | ✅ 2026-10-19 |
| 4 | Завершение: план, примечания | ✅ 2026-10-19 |
Статусы: ⬜ не начат · 🔄 в работе · ✅ готов.
## Примечания по ходу работ
1. **Состав реализации** (`app/src/main/java/com/watchbody/phone/`):
`WatchBodyApp` (Application + ручной DI-контейнер), `ui/`:
MainActivity + навигация 5 вкладок, Theme (тёмная Material 3),
Components (StepperPicker/IntPicker с «—» = «не измерено» —
merge дневника не затирает пустое; SegmentControl), SyncBanner
(WorkInfo + countFlow), экраны Today/Admission/Workout/History/
Programs/Profile/Library/Settings с ViewModel-ями;
`core/`: ProgramView, ZoneCalculator, SettingsValidator,
RestTimer, PlanFact (чистые). Compose-плагин Kotlin 2.1.10,
BOM 2024.12.01, navigation-compose 2.8.5.
2. **Сборка/тесты**: BUILD SUCCESSFUL; APK 12 МБ; unit-тесты
**36/36** (m09 CoreLogic 10 + SyncPayload 8; m10: ProgramView 5,
ZoneCalculator 4, SettingsValidator 5, RestTimer 2, PlanFact 2).
3. **Живой смок контура программы** (2026-10-19, сервер 5000,
тест-ключ test-key-ivan-0001, админ test-admin): sync flat-draft
program_version → accepted → admin activate (`?athleteId=286`) →
GET `/api/v1/programs/active` — 200, camelCase-ключи, `weeks`
на верхнем уровне body. Ошибочная вложенная форма из первого
захода отвергнута контрактом (см. п. 4).
4. **Errata контракта, вскрыты смоком** (исправлено, спеки
синхронизированы): (а) `GET programs/active` — ключи camelCase,
пример m02 spec 05 показывал snake_case (исправлен + errata);
(б) `program_version` sync-контракт: поля программы на верхнем
уровне data, обязательное числовое `version` — `PayloadFactory.
programVersion` m09 переписана (была вложенная `body`); (в)
`profile.sex` ∈ `m|f` (валидатор m02) — экран профиля исправлен
с `male/female`. Всё — в readme m09 п. 6 и спеках.
5. **UI-решения по ходу**: переименование упражнения — новый
`WorkoutCore.renameExercise` (🛡 UUID не меняется, Ф-4.3;
`saveExercise` для переименования создавал бы новый UUID, а
REPLACE по UNIQUE(name) затёр бы старую строку — проверка имени
в ViewModel до записи); вес в форме — пикер с шагом 2,5 кг от
плана/последнего фактического; поля дневника «—» = не измерено
(merge, Ф-13.1); таймер отдыха — фазы цвет+текст 🛡, автостарт
не делается (старт — кнопка/голос m11, Ф-3.3).
6. **Не проверено в среде** (нет эмулятора/устройства): запуск APK
и ручной сценарий владельца (критерий 2 приёмки) — на стороне
владельца; сетевые вызовы программы проверены curl-ом против
живого сервера (п. 3).

View File

@ -0,0 +1,5 @@
.gradle/
build/
local.properties
*.iml
.idea/

View File

@ -0,0 +1,69 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp")
}
android {
namespace = "com.watchbody.phone"
compileSdk = 36
defaultConfig {
applicationId = "com.watchbody.phone"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
// LocalDb (архитектура 1.2) — Room + KSP
implementation("androidx.room:room-runtime:2.7.1")
implementation("androidx.room:room-ktx:2.7.1")
ksp("androidx.room:room-compiler:2.7.1")
// очередь синхронизации — WorkManager (tech-req 5)
implementation("androidx.work:work-runtime-ktx:2.10.0")
// настройки — DataStore (Preferences)
implementation("androidx.datastore:datastore-preferences:1.1.4")
// API-клиент: Retrofit + OkHttp + kotlinx-serialization
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
// UI (m10): Compose + Material 3, тёмная тема (tech-req ОВ-6)
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.compose.ui:ui-tooling-preview")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1")
}

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name="com.watchbody.phone.WatchBodyApp"
android:label="Феникс 6X"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity
android:name="com.watchbody.phone.ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,31 @@
package com.watchbody.phone
import android.app.Application
import com.watchbody.phone.core.WorkoutCore
import com.watchbody.phone.db.AppDatabase
import com.watchbody.phone.settings.SettingsRepository
import com.watchbody.phone.sync.ApiClient
/** Ручной DI-контейнер (v1 без Hilt, spec 01 §1). */
class AppContainer(context: android.content.Context) {
val appContext: android.content.Context = context.applicationContext
val db: AppDatabase by lazy { AppDatabase.build(appContext) }
val settings = SettingsRepository(appContext)
val core: WorkoutCore by lazy { WorkoutCore(db) }
/** Клиент по актуальным настройкам — создаётся на каждое обращение. */
suspend fun api(): com.watchbody.phone.sync.WatchBodyApi {
val s = settings.get()
return ApiClient.create(s.baseUrl, s.apiKey)
}
}
class WatchBodyApp : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}

View File

@ -0,0 +1,34 @@
package com.watchbody.phone.core
/** Исходы предстартового допуска (Ф-13.2, spec 02 §3). */
enum class Permission { ALLOW, CARDIO_ONLY, CANCEL, POSTPONE }
/** Телефонные пороги Safety.* (Р3-А: офлайн-источник — DataStore). */
data class SafetySettings(
val bpCancelSys: Int = 150,
val bpCancelDias: Int = 90,
val bpCardioSys: Int = 140, // телефонные умолчания — tech-req 4.1
val bpCardioDias: Int = 90,
val restingHrSitting: Int = 90,
)
/**
* Предстартовый допуск: первое сработавшее условие (пороги — только
* из настроек, Р3-А). Отсутствующее значение — его проверка
* пропускается. 🛡 Обход — WorkoutCore.startAnyway (логируется).
*/
object AdmissionChecker {
fun check(
safety: SafetySettings,
bpSys: Int?, bpDias: Int?, hrSitting: Int?,
): Permission {
if (bpSys != null && bpSys > safety.bpCancelSys ||
bpDias != null && bpDias > safety.bpCancelDias
) return Permission.CANCEL
if (bpSys != null && bpSys >= safety.bpCardioSys ||
bpDias != null && bpDias >= safety.bpCardioDias
) return Permission.CARDIO_ONLY
if (hrSitting != null && hrSitting > safety.restingHrSitting) return Permission.POSTPONE
return Permission.ALLOW
}
}

View File

@ -0,0 +1,14 @@
package com.watchbody.phone.core
/**
* Локальный отчёт план/факт по тренировке (Ф-11.3): доля подходов
* с меткой in_plan (считает WorkoutCore, UI только агрегирует).
*/
data class PlanFactReport(val total: Int, val inPlan: Int) {
val pct: Int get() = if (total == 0) 0 else inPlan * 100 / total
}
object PlanFact {
fun of(sets: List<Boolean>): PlanFactReport =
PlanFactReport(total = sets.size, inPlan = sets.count { it })
}

View File

@ -0,0 +1,58 @@
package com.watchbody.phone.core
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* Пометка «по плану» (Ф-11.2, spec 02 §2): план упражнения — первая
* сессия дня недели активной версии (ротация недель — v2);
* в_plan = |вес − план| ≤ 2,5 && повторения == план.
*/
object PlanMatch {
const val WEIGHT_TOLERANCE_KG = 2.5
private val dayNames = listOf(
"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
)
/** Плановые вес/повторы упражнения в сессии дня; null — плана нет. */
fun planFor(bodyJson: String, dayOfWeekIso: Int, exerciseName: String): Pair<Double, Int>? {
val day = dayNames.getOrElse(dayOfWeekIso - 1) { return null } // 1=monday…7=sunday
return try {
val weeks = Json.parseToJsonElement(bodyJson).jsonObject["weeks"]?.jsonArray
?: return null
for (week in weeks) {
val sessions = week.jsonObject["sessions"]?.jsonArray ?: continue
for (session in sessions) {
val sessionDay = session.jsonObject["day"]?.jsonPrimitive?.content
if (!sessionDay.equals(day, ignoreCase = true)) continue
val exercises = session.jsonObject["exercises"]?.jsonArray ?: continue
for (exercise in exercises) {
val name = exercise.jsonObject["exercise_id"]?.jsonPrimitive?.content
?: continue
if (!name.equals(exerciseName, ignoreCase = true)) continue
val first = exercise.jsonObject["sets"]?.jsonArray
?.firstOrNull()?.jsonObject ?: continue
val weight = first["weight_kg"]?.jsonPrimitive?.content?.toDoubleOrNull()
val reps = first["reps"]?.jsonPrimitive?.content?.toIntOrNull()
if (weight != null && reps != null) return weight to reps
}
return null // первая сессия дня рассмотрена
}
}
null
} catch (e: Exception) {
null // кривой body — метка без плана
}
}
fun inPlan(bodyJson: String?, dayOfWeekIso: Int, exerciseName: String?,
weightKg: Double, reps: Int): Boolean {
if (bodyJson == null || exerciseName == null) return false
val plan = planFor(bodyJson, dayOfWeekIso, exerciseName) ?: return false
return kotlin.math.abs(weightKg - plan.first) <= WEIGHT_TOLERANCE_KG && reps == plan.second
}
}

View File

@ -0,0 +1,62 @@
package com.watchbody.phone.core
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* Разбор body активной версии для экранов (spec 04 §1): семантика
* первой сессии дня — та же, что [PlanMatch.planFor] (ротация
* недель — v2); битый/пустой body — пустой результат, без исключений.
*/
data class SetPlan(val weightKg: Double, val reps: Int)
data class ItemView(val exerciseName: String, val planSets: List<SetPlan>)
data class SessionView(val day: String, val focus: String?, val items: List<ItemView>)
data class WeekView(val week: Int, val sessions: List<SessionView>)
object ProgramView {
private val dayNames = listOf(
"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
)
/** Сессия дня недели (первая по порядку недель); null — плана нет. */
fun sessionFor(bodyJson: String?, dayOfWeekIso: Int): SessionView? {
val day = dayNames.getOrNull(dayOfWeekIso - 1) ?: return null // 1=monday…7=sunday
return allWeeks(bodyJson)
.asSequence()
.flatMap { it.sessions }
.firstOrNull { it.day.equals(day, ignoreCase = true) }
}
fun allWeeks(bodyJson: String?): List<WeekView> {
if (bodyJson == null) return emptyList()
return try {
val weeks = Json.parseToJsonElement(bodyJson).jsonObject["weeks"]?.jsonArray
?: return emptyList()
weeks.mapNotNull { week ->
val number = week.jsonObject["week"]?.jsonPrimitive?.content?.toIntOrNull()
?: return@mapNotNull null
val sessions = week.jsonObject["sessions"]?.jsonArray?.mapNotNull { s ->
val sDay = s.jsonObject["day"]?.jsonPrimitive?.content ?: return@mapNotNull null
val focus = s.jsonObject["focus"]?.jsonPrimitive?.content
val items = s.jsonObject["exercises"]?.jsonArray?.mapNotNull { e ->
val name = e.jsonObject["exercise_id"]?.jsonPrimitive?.content
?: return@mapNotNull null
val sets = e.jsonObject["sets"]?.jsonArray?.mapNotNull { set ->
val w = set.jsonObject["weight_kg"]?.jsonPrimitive?.content?.toDoubleOrNull()
val r = set.jsonObject["reps"]?.jsonPrimitive?.content?.toIntOrNull()
if (w != null && r != null) SetPlan(w, r) else null
}.orEmpty()
ItemView(name, sets)
}.orEmpty()
SessionView(sDay, focus, items)
}.orEmpty()
WeekView(number, sessions)
}
} catch (e: Exception) {
emptyList() // кривой body — «план не читается»
}.sortedBy { it.week } // порядок недель — по номеру (spec 04)
}
}

View File

@ -0,0 +1,18 @@
package com.watchbody.phone.core
/** Фазы таймера отдыха (Ф-3.3, spec 03 §4) — цвет всегда дублируется текстом 🛡. */
enum class RestPhase(val label: String) {
RESTING("идёт отдых"),
LAST10("последние 10 секунд"),
READY("пора начинать"),
}
object RestTimer {
/** Секунды до конца → фаза; таймер не запущен — обрабатывает UI (null). */
fun phase(secondsLeft: Int): RestPhase = when {
secondsLeft > 10 -> RestPhase.RESTING
secondsLeft in 1..10 -> RestPhase.LAST10
else -> RestPhase.READY
}
}

View File

@ -0,0 +1,56 @@
package com.watchbody.phone.core
import com.watchbody.phone.settings.AppSettings
import java.net.URI
/**
* Валидация настроек телефона перед сохранением (tech-req 4.1,
* spec 05 §3): «Сохранить» доступна только при отсутствии ошибок;
* http — предупреждение (docker-сервер отладки), не ошибка.
*/
object SettingsValidator {
data class Result(
val errors: Map<String, String>,
val httpWarning: Boolean,
) {
val ok: Boolean get() = errors.isEmpty()
}
fun validate(s: AppSettings): Result {
val errors = linkedMapOf<String, String>()
val url = runCatching { URI(s.baseUrl.trim()) }.getOrNull()
when {
s.baseUrl.isBlank() -> errors["baseUrl"] = "адрес сервера не задан"
url == null || url.host.isNullOrBlank() ||
url.scheme !in setOf("http", "https") ->
errors["baseUrl"] = "нужен абсолютный http(s)://адрес"
else -> {}
}
if (s.apiKey.isBlank()) errors["apiKey"] = "ключ API не задан"
if (s.restDefaultSec !in 30..600) {
errors["restDefaultSec"] = "отдых 30–600 с"
}
if (s.hrMaxBpm != null && s.hrMaxBpm !in 120..240) {
errors["hrMaxBpm"] = "макс. пульс 120–240 или «авто»"
}
val bp = s.safety
if (bp.bpCancelSys !in 120..200) errors["bpCancelSys"] = "систола отмены 120–200"
if (bp.bpCancelDias !in 70..130) errors["bpCancelDias"] = "диастола отмены 70–130"
if (bp.bpCardioSys !in 120..200) errors["bpCardioSys"] = "систола кардио 120–200"
if (bp.bpCardioDias !in 70..130) errors["bpCardioDias"] = "диастола кардио 70–130"
if (bp.bpCardioSys > bp.bpCancelSys || bp.bpCardioDias > bp.bpCancelDias) {
errors["bpCardio"] = "порог «только кардио» выше порога отмены"
}
if (bp.restingHrSitting !in 40..120) {
errors["restingHrSitting"] = "пульс сидя 40–120"
}
if (s.hrStrengthMax !in 100..180) {
errors["hrStrengthMax"] = "порог силовой 100–180"
}
return Result(errors, httpWarning = url?.scheme == "http")
}
}

View File

@ -0,0 +1,227 @@
package com.watchbody.phone.core
import com.watchbody.phone.db.AppDatabase
import com.watchbody.phone.db.Break
import com.watchbody.phone.db.CommandLog
import com.watchbody.phone.db.DailyLog
import com.watchbody.phone.db.Exercise
import com.watchbody.phone.db.Pending
import com.watchbody.phone.db.Profile
import com.watchbody.phone.db.Workout
import com.watchbody.phone.db.WorkoutSet
import com.watchbody.phone.payload.PayloadFactory
import com.watchbody.phone.sync.SyncWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.UUID
/**
* Единственный писатель LocalDb (архитектура 1.2, 🛡2): тренировка,
* подходы (in_plan по плану дня), журнал команд, дневник, перерывы.
* Каждая мутация → sync_pending (Ф-7.2) и будит SyncWorker.
*/
class WorkoutCore(
private val db: AppDatabase,
private val wifiOnly: () -> Boolean = { false },
private val clock: () -> Long = { System.currentTimeMillis() },
) {
// ---------- тренировка (spec 02 §1) ----------
suspend fun startWorkout(programVersionId: String?, sessionKind: String = "normal"): String {
require(db.workoutDao().activeWorkout() == null) {
"тренировка уже идёт — завершите или отмените (Ф-3)"
}
val id = UUID.randomUUID().toString() // 🛡3 UUID генерирует телефон
val workout = Workout(
id = id,
programVersionId = programVersionId,
startedAt = clock(),
endedAt = null,
status = "active",
sessionKind = sessionKind,
avgHr = null,
)
db.workoutDao().upsert(workout)
enqueue("workout", id, PayloadFactory.workout(workout))
return id
}
/** 🛡 Обход блокировки допуска — только с записью в журнал (Ф-13.5). */
suspend fun startAnyway(programVersionId: String?): String {
val id = startWorkout(programVersionId)
logCommand(
workoutId = id,
text = "обход блокировки допуска",
semanticsJson = """{"override":"permission"}""",
accepted = false,
)
return id
}
suspend fun finishWorkout(id: String, avgHr: Int? = null) =
closeWorkout(id, "finished", avgHr)
suspend fun cancelWorkout(id: String) = closeWorkout(id, "cancelled", null)
private suspend fun closeWorkout(id: String, status: String, avgHr: Int?) {
val workout = db.workoutDao().get(id) ?: error("тренировка $id не найдена")
val closed = workout.copy(endedAt = clock(), status = status, avgHr = avgHr)
db.workoutDao().upsert(closed)
enqueue("workout", id, PayloadFactory.workout(closed))
closeBreakIfReturning(id)
}
suspend fun logSet(
workoutId: String,
exerciseId: String?,
weightKg: Double,
reps: Int,
rpe: Int? = null,
kneePain: Int? = null,
source: String = "voice", // voice|watch|edit — m10 форма = edit (Ф-5.2)
) {
val workout = db.workoutDao().get(workoutId) ?: error("тренировка $workoutId не найдена")
val exerciseName = exerciseId?.let { id ->
db.libraryDao().exercises().firstOrNull { it.id == id }?.name
}
val body = workout.programVersionId?.let { pid ->
db.libraryDao().activeProgram()?.takeIf { it.id == pid }?.body
}
val dayOfWeek = Instant.ofEpochMilli(workout.startedAt)
.atOffset(ZoneOffset.UTC).dayOfWeek.value // 1=monday…7=sunday
val inPlan = PlanMatch.inPlan(body, dayOfWeek, exerciseName, weightKg, reps)
val id = UUID.randomUUID().toString()
val set = WorkoutSet(
id = id,
workoutId = workoutId,
exerciseId = exerciseId,
seq = (db.workoutDao().maxSeq(workoutId) ?: 0) + 1,
weightKg = weightKg,
reps = reps,
doneAt = clock(),
source = source,
inPlan = inPlan,
rpe = rpe,
kneePain = kneePain,
)
db.workoutDao().upsertSet(set)
enqueue("workout_set", id, PayloadFactory.workoutSet(set))
}
suspend fun logCommand(workoutId: String, text: String, semanticsJson: String, accepted: Boolean) {
val workout = db.workoutDao().get(workoutId) ?: error("тренировка $workoutId не найдена")
val id = UUID.randomUUID().toString()
val command = CommandLog(
id = id,
workoutId = workoutId,
tOffsetS = ((clock() - workout.startedAt) / 1000).toInt(),
recognizedText = text,
semantics = semanticsJson,
accepted = accepted,
)
db.workoutDao().upsertCommand(command)
enqueue("command_log", id, PayloadFactory.commandLog(command))
}
// ---------- дневник и перерывы (spec 02 §4) ----------
suspend fun saveDailyLog(
date: LocalDate? = null,
bpSys: Int? = null, bpDias: Int? = null, weightKg: Double? = null,
hrResting: Int? = null, wellbeing: Int? = null,
) {
val day = date ?: LocalDate.now(ZoneOffset.UTC)
val existing = db.healthDao().dailyLog(day.toString())
val log = DailyLog(
date = day.toString(),
guid = existing?.guid ?: UUID.randomUUID().toString(),
bpSys = bpSys ?: existing?.bpSys,
bpDias = bpDias ?: existing?.bpDias,
weightKg = weightKg ?: existing?.weightKg,
hrResting = hrResting ?: existing?.hrResting,
wellbeing = wellbeing ?: existing?.wellbeing,
)
db.healthDao().upsertDailyLog(log)
enqueue("daily_log", log.guid, PayloadFactory.dailyLog(log))
}
/** Объявление перерыва (Ф-14.2) — только manual (🛡 Ф-14.1). */
suspend fun declareBreak(kind: String?, plannedReturn: LocalDate?, note: String): String {
val id = UUID.randomUUID().toString()
val breakItem = Break(
id = id,
kind = kind,
source = "manual",
startedOn = LocalDate.now(ZoneOffset.UTC).toString(),
plannedReturn = plannedReturn?.toString(),
endedOn = null,
note = note,
)
db.healthDao().upsertBreak(breakItem)
enqueue("break", id, PayloadFactory.breakItem(breakItem))
return id
}
/** Первая сессия после возврата закрывает break (ended_on). */
suspend fun closeBreakIfReturning(workoutId: String) {
val workout = db.workoutDao().get(workoutId) ?: return
val open = db.healthDao().openBreak() ?: return
val workoutDay = Instant.ofEpochMilli(workout.startedAt)
.atOffset(ZoneOffset.UTC).toLocalDate().toString()
if (open.startedOn >= workoutDay) return // ещё не было пропуска до этой сессии
val closed = open.copy(endedOn = workoutDay)
db.healthDao().upsertBreak(closed)
enqueue("break", open.id, PayloadFactory.breakItem(closed))
}
// ---------- библиотека/профиль (тонко, UI m10) ----------
suspend fun saveExercise(name: String, groupCode: String, isBase: Boolean): String {
val id = UUID.randomUUID().toString()
val exercise = Exercise(id, name, groupCode, isBase, true)
db.libraryDao().upsertExercise(exercise)
enqueue("exercise", id, PayloadFactory.exercise(exercise))
return id
}
/** Переименование — 🛡 UUID не меняется (Ф-4.3), уходит повторной отправкой. */
suspend fun renameExercise(id: String, newName: String) {
val exercise = db.libraryDao().exercises().firstOrNull { it.id == id } ?: return
val renamed = exercise.copy(name = newName)
db.libraryDao().upsertExercise(renamed)
enqueue("exercise", id, PayloadFactory.exercise(renamed))
}
suspend fun deactivateExercise(id: String) {
val exercise = db.libraryDao().exercises().firstOrNull { it.id == id } ?: return
val deactivated = exercise.copy(isActive = false)
db.libraryDao().upsertExercise(deactivated)
enqueue("exercise", id, PayloadFactory.exercise(deactivated))
}
suspend fun saveProfile(sex: String?, birthYear: Int?, weightKg: Double?, hrMax: Int?) {
val existing = db.healthDao().profile()
val profile = Profile(0, existing?.guid ?: UUID.randomUUID().toString(), sex, birthYear, weightKg, hrMax)
db.healthDao().upsertProfile(profile)
enqueue("profile", profile.guid, PayloadFactory.profile(profile))
}
// ---------- очередь (spec 03) ----------
private suspend fun enqueue(entity: String, id: String, payload: kotlinx.serialization.json.JsonObject, guid: String = id) {
withContext(Dispatchers.IO) {
db.syncDao().enqueue(
Pending(
clientGuid = guid,
entity = entity,
payload = payload.toString(),
createdAt = clock(),
),
)
}
}
}

View File

@ -0,0 +1,26 @@
package com.watchbody.phone.core
import kotlin.math.roundToInt
/**
* Зоны пульса (Ф-6.2, ОВ-1): максимум — ручной ввод либо формула
* Танаки 208 − 0,7 × возраст; границы зон — старты 50/70/85 %.
*/
object ZoneCalculator {
/** Ручной максимум приоритетнее формулы; нет данных — null. */
fun maxHr(birthYear: Int?, manualHrMax: Int?, currentYear: Int): Int? {
manualHrMax?.let { return it }
val age = birthYear?.let { currentYear - it } ?: return null
if (age <= 0) return null
return (208 - 0.7 * age).roundToInt()
}
data class Zones(val z2: Int, val z3: Int, val z4: Int) // уд/мин, старты зон
fun zones(maxHr: Int): Zones = Zones(
z2 = (maxHr * 0.5).roundToInt(),
z3 = (maxHr * 0.7).roundToInt(),
z4 = (maxHr * 0.85).roundToInt(),
)
}

View File

@ -0,0 +1,26 @@
package com.watchbody.phone.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [
Exercise::class, ProgramVersion::class, Workout::class, WorkoutSet::class,
CommandLog::class, DailyLog::class, Break::class, Profile::class, Pending::class,
],
version = 1,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun workoutDao(): WorkoutDao
abstract fun libraryDao(): LibraryDao
abstract fun healthDao(): HealthDao
abstract fun syncDao(): SyncDao
companion object {
fun build(context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "watchbody.db").build()
}
}

View File

@ -0,0 +1,115 @@
package com.watchbody.phone.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface WorkoutDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(workout: Workout)
@Query("SELECT * FROM workout WHERE status = 'active' LIMIT 1")
suspend fun activeWorkout(): Workout?
@Query("SELECT * FROM workout WHERE status = 'active' LIMIT 1")
fun activeWorkoutFlow(): Flow<Workout?>
@Query("SELECT * FROM workout WHERE id = :id")
suspend fun get(id: String): Workout?
@Query("SELECT * FROM workout ORDER BY started_at DESC")
fun history(): Flow<List<Workout>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertSet(set: WorkoutSet)
@Query("SELECT * FROM workout_set WHERE workout_id = :workoutId ORDER BY seq")
suspend fun sets(workoutId: String): List<WorkoutSet>
@Query("SELECT * FROM workout_set WHERE workout_id = :workoutId ORDER BY seq")
fun setsFlow(workoutId: String): Flow<List<WorkoutSet>>
@Query("SELECT MAX(seq) FROM workout_set WHERE workout_id = :workoutId")
suspend fun maxSeq(workoutId: String): Int?
@Query("SELECT * FROM workout_set WHERE exercise_id = :exerciseId ORDER BY done_at DESC LIMIT 1")
suspend fun lastSetForExercise(exerciseId: String): WorkoutSet?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertCommand(command: CommandLog)
}
@Dao
interface LibraryDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertExercise(exercise: Exercise)
@Query("SELECT * FROM exercise ORDER BY name")
suspend fun exercises(): List<Exercise>
@Query("SELECT * FROM exercise ORDER BY name")
fun exercisesFlow(): Flow<List<Exercise>>
@Query("SELECT * FROM exercise WHERE name = :name LIMIT 1")
suspend fun exerciseByName(name: String): Exercise?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertProgram(version: ProgramVersion)
@Query("SELECT * FROM program_version WHERE status = 'active' LIMIT 1")
suspend fun activeProgram(): ProgramVersion?
@Query("SELECT * FROM program_version WHERE status = 'active' LIMIT 1")
fun activeProgramFlow(): Flow<ProgramVersion?>
@Query("SELECT * FROM program_version ORDER BY version DESC")
fun versions(): Flow<List<ProgramVersion>>
}
@Dao
interface HealthDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertDailyLog(log: DailyLog)
@Query("SELECT * FROM daily_log WHERE date = :date LIMIT 1")
suspend fun dailyLog(date: String): DailyLog?
@Query("SELECT * FROM daily_log WHERE date = :date LIMIT 1")
fun dailyLogFlow(date: String): Flow<DailyLog?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertBreak(breakItem: Break)
@Query("SELECT * FROM breaks WHERE ended_on IS NULL ORDER BY started_on DESC LIMIT 1")
suspend fun openBreak(): Break?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertProfile(profile: Profile)
@Query("SELECT * FROM profile WHERE id = 0")
suspend fun profile(): Profile?
@Query("SELECT * FROM profile WHERE id = 0")
fun profileFlow(): Flow<Profile?>
}
@Dao
interface SyncDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun enqueue(row: Pending)
@Query("SELECT * FROM sync_pending ORDER BY created_at ASC LIMIT :limit")
suspend fun pending(limit: Int): List<Pending>
@Query("DELETE FROM sync_pending WHERE client_guid IN (:guids)")
suspend fun deleteAll(guids: List<String>)
@Query("SELECT COUNT(*) FROM sync_pending")
suspend fun count(): Int
@Query("SELECT COUNT(*) FROM sync_pending")
fun countFlow(): Flow<Int>
}

View File

@ -0,0 +1,114 @@
package com.watchbody.phone.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
// Зеркала серверных таблиц с UUID-PK телефона (Р2-Б, spec 01 m09).
// Timestamps — epoch millis; даты — yyyy-MM-dd (строки).
@Entity(tableName = "exercise", indices = [Index(value = ["name"], unique = true)])
data class Exercise(
@PrimaryKey val id: String,
val name: String,
@ColumnInfo(name = "group_code") val groupCode: String, // push|pull|legs|core|cardio
@ColumnInfo(name = "is_base") val isBase: Boolean,
@ColumnInfo(name = "is_active") val isActive: Boolean,
)
@Entity(tableName = "program_version")
data class ProgramVersion(
@PrimaryKey val id: String, // server versionId (тянет ApiClient) либо локальный uuid (draft)
val version: Int,
val status: String, // draft|active|archived
val author: String, // user|admin|rules
@ColumnInfo(name = "based_on_version") val basedOnVersion: Int?,
val body: String, // json
val rationale: String,
@ColumnInfo(name = "created_at") val createdAt: Long,
)
@Entity(tableName = "workout")
data class Workout(
@PrimaryKey val id: String,
@ColumnInfo(name = "program_version_id") val programVersionId: String?,
@ColumnInfo(name = "started_at") val startedAt: Long,
@ColumnInfo(name = "ended_at") val endedAt: Long?,
val status: String, // active|finished|cancelled (Ф-3.5)
@ColumnInfo(name = "session_kind") val sessionKind: String, // normal|return|deload|test (Ф-14.5)
@ColumnInfo(name = "avg_hr") val avgHr: Int?,
)
@Entity(
tableName = "workout_set",
foreignKeys = [ForeignKey(
entity = Workout::class, parentColumns = ["id"], childColumns = ["workout_id"],
onDelete = ForeignKey.CASCADE,
)],
indices = [Index("workout_id")],
)
data class WorkoutSet(
@PrimaryKey val id: String,
@ColumnInfo(name = "workout_id") val workoutId: String,
@ColumnInfo(name = "exercise_id") val exerciseId: String?,
val seq: Int,
@ColumnInfo(name = "weight_kg") val weightKg: Double, // план-пикер — шаг 2,5 кг (UI/UX)
val reps: Int,
@ColumnInfo(name = "done_at") val doneAt: Long,
val source: String, // voice|watch|edit
@ColumnInfo(name = "in_plan") val inPlan: Boolean, // ±2,5 кг / ±0 повторов от плана (Ф-11.2)
val rpe: Int?,
@ColumnInfo(name = "knee_pain") val kneePain: Int?,
)
@Entity(tableName = "command_log")
data class CommandLog(
@PrimaryKey val id: String,
@ColumnInfo(name = "workout_id") val workoutId: String,
@ColumnInfo(name = "t_offset_s") val tOffsetS: Int,
@ColumnInfo(name = "recognized_text") val recognizedText: String,
val semantics: String, // json
val accepted: Boolean,
)
@Entity(tableName = "daily_log")
data class DailyLog(
@PrimaryKey val date: String, // yyyy-MM-dd; день уникален (Ф-13.1)
val guid: String, // стабильный UUID записи (client_guid sync)
@ColumnInfo(name = "bp_sys") val bpSys: Int?,
@ColumnInfo(name = "bp_dias") val bpDias: Int?,
@ColumnInfo(name = "weight_kg") val weightKg: Double?,
@ColumnInfo(name = "hr_resting") val hrResting: Int?,
val wellbeing: Int?,
)
@Entity(tableName = "breaks")
data class Break(
@PrimaryKey val id: String,
val kind: String?, // illness|vacation|workload|injury|other
val source: String, // телефон передаёт только manual (🛡 Ф-14.1)
@ColumnInfo(name = "started_on") val startedOn: String,
@ColumnInfo(name = "planned_return") val plannedReturn: String?,
@ColumnInfo(name = "ended_on") val endedOn: String?,
val note: String,
)
@Entity(tableName = "profile")
data class Profile(
@PrimaryKey val id: Int = 0, // singleton (Ф-6)
val guid: String, // стабильный UUID записи (client_guid sync)
val sex: String?,
@ColumnInfo(name = "birth_year") val birthYear: Int?,
@ColumnInfo(name = "weight_kg") val weightKg: Double?,
@ColumnInfo(name = "hr_max") val hrMax: Int?,
)
@Entity(tableName = "sync_pending", indices = [Index("created_at")])
data class Pending(
@PrimaryKey @ColumnInfo(name = "client_guid") val clientGuid: String,
val entity: String,
val payload: String, // json data-сущности (spec 04)
@ColumnInfo(name = "created_at") val createdAt: Long,
)

View File

@ -0,0 +1,128 @@
package com.watchbody.phone.payload
import com.watchbody.phone.db.Break
import com.watchbody.phone.db.CommandLog
import com.watchbody.phone.db.DailyLog
import com.watchbody.phone.db.Exercise
import com.watchbody.phone.db.Pending
import com.watchbody.phone.db.Profile
import com.watchbody.phone.db.Workout
import com.watchbody.phone.db.WorkoutSet
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.UUID
/**
* data-поля payload-ов sync (spec 04 m09): snake_case, UUID —
* client_guid записи. 🛡 break.source — только manual (Ф-14.1:
* авто-детект принадлежит серверу).
*/
object PayloadFactory {
private val json = Json { encodeDefaults = true }
fun workout(w: Workout): JsonObject = buildJsonObject {
w.programVersionId?.let { put("program_version_id", it) }
put("started_at", isoUtc(w.startedAt))
w.endedAt?.let { put("ended_at", isoUtc(it)) }
put("status", w.status)
put("session_kind", w.sessionKind)
w.avgHr?.let { put("avg_hr", it) }
}
fun workoutSet(s: WorkoutSet): JsonObject = buildJsonObject {
put("workout_id", s.workoutId)
s.exerciseId?.let { put("exercise_id", it) }
put("seq", s.seq)
put("weight_kg", s.weightKg)
put("reps", s.reps)
put("done_at", isoUtc(s.doneAt))
put("source", s.source)
put("in_plan", s.inPlan)
s.rpe?.let { put("rpe", it) }
s.kneePain?.let { put("knee_pain", it) }
}
fun commandLog(c: CommandLog): JsonObject = buildJsonObject {
put("workout_id", c.workoutId)
put("t_offset_s", c.tOffsetS)
put("recognized_text", c.recognizedText)
put("semantics", json.parseToJsonElement(c.semantics))
put("accepted", c.accepted)
}
fun dailyLog(d: DailyLog): JsonObject = buildJsonObject {
put("date", d.date)
d.bpSys?.let { put("bp_sys", it) }
d.bpDias?.let { put("bp_dias", it) }
d.weightKg?.let { put("weight_kg", it) }
d.hrResting?.let { put("hr_resting", it) }
d.wellbeing?.let { put("wellbeing", it) }
}
fun breakItem(b: Break): JsonObject {
require(b.source == "manual") { "break.source только manual (🛡 Ф-14.1)" }
return buildJsonObject {
b.kind?.let { put("kind", it) }
put("source", b.source)
put("started_on", b.startedOn)
b.plannedReturn?.let { put("planned_return", it) }
b.endedOn?.let { put("ended_on", it) }
put("note", b.note)
}
}
fun exercise(e: Exercise): JsonObject = buildJsonObject {
put("name", e.name)
put("group", e.groupCode)
put("is_base", e.isBase)
put("is_active", e.isActive)
}
/**
* Контракт m02 spec 03: поля программы (weeks…) — на ВЕРХНЕМ уровне
* data (сервер хранит data целиком как body); version — число по схеме
* (значение считает БД). Errata m10 2026-10-19: было вложенным body.
*/
fun programVersion(version: Int, author: String, body: String, rationale: String,
basedOnVersion: Int?): JsonObject {
val program = json.parseToJsonElement(body).jsonObject
return buildJsonObject {
put("version", version)
basedOnVersion?.let { put("based_on_version", it) }
put("author", author)
program.forEach { (k, v) -> put(k, v) }
put("rationale", rationale)
}
}
fun profile(p: Profile): JsonObject = buildJsonObject {
p.sex?.let { put("sex", it) }
p.birthYear?.let { put("birth_year", it) }
p.weightKg?.let { put("weight_kg", it) }
p.hrMax?.let { put("hr_max", it) }
}
fun isoUtc(epochMillis: Long): String =
Instant.ofEpochMilli(epochMillis).atOffset(ZoneOffset.UTC)
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"))
fun todayIso(): String = LocalDate.now(ZoneOffset.UTC).toString()
}
/** Конверт одной записи (spec 04 §1). */
fun envelope(row: Pending): String = Json.encodeToString(
kotlinx.serialization.json.JsonObject.serializer(),
buildJsonObject {
put("client_guid", row.clientGuid)
put("entity", row.entity)
put("op", "upsert")
put("data", Json.parseToJsonElement(row.payload))
},
)

View File

@ -0,0 +1,79 @@
package com.watchbody.phone.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.watchbody.phone.core.SafetySettings
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
/** Все настройки телефона (tech-req 4.1; 🛡 ключ — только здесь, З-6). */
data class AppSettings(
val baseUrl: String = "",
val apiKey: String = "",
val wifiOnly: Boolean = false,
val restDefaultSec: Int = 90,
val hrMaxBpm: Int? = null, // 120–240 или null — расчёт по Танаке (Ф-6.2)
val safety: SafetySettings = SafetySettings(),
val hrStrengthMax: Int = 120, // предупреждение во время силовой (Ф-13.7, применяется в m12)
)
class SettingsRepository(private val context: Context) {
private object Keys {
val baseUrl = stringPreferencesKey("server.base_url")
val apiKey = stringPreferencesKey("server.api_key")
val wifiOnly = booleanPreferencesKey("sync.wifi_only")
val restDefaultSec = intPreferencesKey("rest.default_sec")
val hrMaxBpm = intPreferencesKey("hr.max_bpm")
val bpCancelSys = intPreferencesKey("safety.bp_cancel_sys")
val bpCancelDias = intPreferencesKey("safety.bp_cancel_dias")
val bpCardioSys = intPreferencesKey("safety.bp_cardio_sys")
val bpCardioDias = intPreferencesKey("safety.bp_cardio_dias")
val restingHrSitting = intPreferencesKey("safety.resting_hr_sitting")
val hrStrengthMax = intPreferencesKey("safety.hr_strength_max")
}
suspend fun get(): AppSettings = context.dataStore.data.first().toSettings()
suspend fun save(settings: AppSettings) {
context.dataStore.edit { prefs ->
prefs[Keys.baseUrl] = settings.baseUrl
prefs[Keys.apiKey] = settings.apiKey
prefs[Keys.wifiOnly] = settings.wifiOnly
prefs[Keys.restDefaultSec] = settings.restDefaultSec
settings.hrMaxBpm?.let { prefs[Keys.hrMaxBpm] = it } ?: prefs.remove(Keys.hrMaxBpm)
prefs[Keys.bpCancelSys] = settings.safety.bpCancelSys
prefs[Keys.bpCancelDias] = settings.safety.bpCancelDias
prefs[Keys.bpCardioSys] = settings.safety.bpCardioSys
prefs[Keys.bpCardioDias] = settings.safety.bpCardioDias
prefs[Keys.restingHrSitting] = settings.safety.restingHrSitting
prefs[Keys.hrStrengthMax] = settings.hrStrengthMax
}
}
private fun Preferences.toSettings(): AppSettings {
val defaults = AppSettings()
return AppSettings(
baseUrl = this[Keys.baseUrl] ?: "",
apiKey = this[Keys.apiKey] ?: "",
wifiOnly = this[Keys.wifiOnly] ?: false,
restDefaultSec = this[Keys.restDefaultSec] ?: defaults.restDefaultSec,
hrMaxBpm = this[Keys.hrMaxBpm],
safety = SafetySettings(
bpCancelSys = this[Keys.bpCancelSys] ?: defaults.safety.bpCancelSys,
bpCancelDias = this[Keys.bpCancelDias] ?: defaults.safety.bpCancelDias,
bpCardioSys = this[Keys.bpCardioSys] ?: defaults.safety.bpCardioSys,
bpCardioDias = this[Keys.bpCardioDias] ?: defaults.safety.bpCardioDias,
restingHrSitting = this[Keys.restingHrSitting] ?: defaults.safety.restingHrSitting,
),
hrStrengthMax = this[Keys.hrStrengthMax] ?: defaults.hrStrengthMax,
)
}
}

View File

@ -0,0 +1,49 @@
package com.watchbody.phone.sync
import com.watchbody.phone.db.Pending
import com.watchbody.phone.payload.envelope
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
@Serializable
data class SyncResponse(val accepted: List<String> = emptyList())
/** POST /api/v1/sync + GET /api/v1/programs/active (spec 03 §3). */
interface WatchBodyApi {
@POST("api/v1/sync")
suspend fun sync(@Body items: List<String>): SyncResponse
@GET("api/v1/programs/active")
suspend fun activeProgram(): Map<String, kotlinx.serialization.json.JsonElement>
}
object ApiClient {
fun create(baseUrl: String, apiKey: String): WatchBodyApi {
val json = Json { ignoreUnknownKeys = true }
val client = OkHttpClient.Builder()
.addInterceptor { chain ->
// 🛡 ключ — только в заголовке, не в логах (spec 03 §4)
chain.proceed(
chain.request().newBuilder()
.header("X-Api-Key", apiKey)
.build(),
)
}
.build()
return Retrofit.Builder()
.baseUrl(if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/")
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(WatchBodyApi::class.java)
}
fun requestBody(batch: List<Pending>): List<String> = batch.map { envelope(it) }
}

View File

@ -0,0 +1,14 @@
package com.watchbody.phone.sync
import com.watchbody.phone.db.Pending
/** Порции очереди ≤ 50 по времени создания (Ф-7.2, spec 03 §1). */
object Batcher {
const val MAX_BATCH = 50
fun batch(rows: List<Pending>, max: Int = MAX_BATCH): List<List<Pending>> {
require(max > 0) { "max должен быть > 0" }
val sorted = rows.sortedBy { it.createdAt }
return sorted.chunked(max)
}
}

View File

@ -0,0 +1,111 @@
package com.watchbody.phone.sync
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.watchbody.phone.db.AppDatabase
import com.watchbody.phone.settings.SettingsRepository
import java.util.concurrent.TimeUnit
/**
* Отправка очереди (spec 03 §2): порции ≤ 50 по created_at,
* принятые GUID удаляются; 401/409 — не ретраим (флаги для UI m10);
* сеть/5xx — retry с экспоненциальной задержкой 1→64 мин.
*/
class SyncWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val settings = SettingsRepository(applicationContext).get()
if (settings.apiKey.isEmpty() || settings.baseUrl.isEmpty()) return Result.success()
val api = ApiClient.create(settings.baseUrl, settings.apiKey)
val dao = AppDatabase.build(applicationContext).syncDao()
var authError = false
var conflict = false
while (true) {
val batch = dao.pending(Batcher.MAX_BATCH)
if (batch.isEmpty()) break
try {
val response = api.sync(ApiClient.requestBody(batch))
dao.deleteAll(response.accepted)
if (response.accepted.size < batch.size) {
// пакет атомарен на сервере (m02): частичное
// подтверждение — страховка от зацикливания
break
}
} catch (e: retrofit2.HttpException) {
when (e.code()) {
401 -> {
authError = true; break
}
409 -> {
conflict = true; break
}
else -> return retryOrGiveUp()
}
} catch (e: Exception) {
return retryOrGiveUp()
}
}
return if (authError || conflict) {
Result.failure(
workDataOf(
KEY_AUTH_ERROR to authError,
KEY_CONFLICT to conflict,
),
)
} else {
Result.success()
}
}
private fun retryOrGiveUp(): Result =
if (runAttemptCount < MAX_ATTEMPTS) Result.retry() else Result.failure()
companion object {
const val KEY_AUTH_ERROR = "auth_error"
const val KEY_CONFLICT = "conflict"
const val MAX_ATTEMPTS = 7 // 1→64 мин экспоненциально
/** Очередь отправки: на запись, сеть, старт приложения (m10). */
fun schedule(context: Context, wifiOnly: Boolean) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(
if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED,
)
.build()
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork("sync", ExistingWorkPolicy.KEEP, request)
}
/**
* «Синхронизировать сейчас» (m10, spec 01 §4): явное действие
* пользователя — ограничение Wi-Fi не применяется; ждущий
* запрос с UNMETERED заменяется.
*/
fun scheduleNow(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork("sync", ExistingWorkPolicy.REPLACE, request)
}
}
}

View File

@ -0,0 +1,217 @@
package com.watchbody.phone.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.watchbody.phone.AppContainer
import com.watchbody.phone.core.AdmissionChecker
import com.watchbody.phone.core.Permission
import com.watchbody.phone.core.SafetySettings
import com.watchbody.phone.sync.SyncWorker
import kotlinx.coroutines.launch
/**
* Предстартовый допуск (Ф-13.2, spec 02 §2): АД из дневника дня,
* пульс сидя вручную (с часов — m12); исходы AdmissionChecker;
* 🛡 обход — только «всё равно начать» с подтверждением (лог в ядре).
*/
class AdmissionViewModel(private val container: AppContainer) : ViewModel() {
var safety: SafetySettings = SafetySettings()
private set
var bpFromLog: Pair<Int?, Int?> = null to null
private set
var activeVersionId: String? = null
private set
init {
viewModelScope.launch {
safety = container.settings.get().safety
val log = container.db.healthDao().dailyLog(java.time.LocalDate.now(java.time.ZoneOffset.UTC).toString())
bpFromLog = log?.bpSys to log?.bpDias
activeVersionId = container.db.libraryDao().activeProgram()?.id
}
}
fun check(bpSys: Int?, bpDias: Int?, hrSitting: Int?): Permission =
AdmissionChecker.check(safety, bpSys, bpDias, hrSitting)
/** Старт (обычный) с выбранным видом (Ф-14.5). */
fun start(kind: String, onStarted: () -> Unit) {
viewModelScope.launch {
container.core.startWorkout(activeVersionId, kind)
SyncWorker.schedule(container.appContext, container.settings.get().wifiOnly)
onStarted()
}
}
/** 🛡 Обход блокировки — ядро пишет override в журнал команд (Ф-13.5). */
fun startAnyway(onStarted: () -> Unit) {
viewModelScope.launch {
container.core.startAnyway(activeVersionId)
SyncWorker.schedule(container.appContext, container.settings.get().wifiOnly)
onStarted()
}
}
/** Пульс, введённый на допуске, — в дневник дня (merge, spec 02 §3). */
fun saveBpAndHr(bpSys: Int?, bpDias: Int?, hrSitting: Int?) {
viewModelScope.launch {
if (bpSys != null || bpDias != null || hrSitting != null) {
container.core.saveDailyLog(bpSys = bpSys, bpDias = bpDias, hrResting = hrSitting)
SyncWorker.schedule(container.appContext, container.settings.get().wifiOnly)
}
}
}
}
@Composable
fun AdmissionScreen(container: AppContainer, onStarted: () -> Unit) {
val vm = containerVM(container) { AdmissionViewModel(it) }
var bpSys by remember { mutableStateOf<Int?>(null) }
var bpDias by remember { mutableStateOf<Int?>(null) }
var hrSitting by remember { mutableStateOf<Int?>(null) }
var result by remember { mutableStateOf<Permission?>(null) }
var noData by remember { mutableStateOf(false) }
var kind by remember { mutableStateOf("normal") }
var overrideAsk by remember { mutableStateOf(false) }
val initialBp = vm.bpFromLog
if (bpSys == null && initialBp.first != null) bpSys = initialBp.first
if (bpDias == null && initialBp.second != null) bpDias = initialBp.second
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("Допуск к тренировке", style = MaterialTheme.typography.titleLarge)
if (result == null) {
Text("Пульс сидя и давление за сегодня (АД — из дневника, если заполнено).")
IntPicker("АД систола", bpSys, { bpSys = it }, min = 70, max = 250, step = 5, start = 120, suffix = " мм рт.ст.")
IntPicker("АД диастола", bpDias, { bpDias = it }, min = 40, max = 150, step = 5, start = 80, suffix = " мм рт.ст.")
IntPicker("Пульс сидя", hrSitting, { hrSitting = it }, min = 30, max = 180, start = 70, suffix = " уд/мин")
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
noData = bpSys == null && bpDias == null && hrSitting == null
vm.saveBpAndHr(bpSys, bpDias, hrSitting)
result = vm.check(bpSys, bpDias, hrSitting)
},
) { Text("Проверить") }
} else {
OutcomeContent(
permission = result!!,
noData = noData,
kind = kind,
onKind = { kind = it },
onStart = { vm.start(kind, onStarted) },
onClose = { result = null },
onOverrideAsk = { overrideAsk = true },
)
}
}
if (overrideAsk) {
AlertDialog(
onDismissRequest = { overrideAsk = false },
title = { Text("Начать вопреки рекомендации?") },
text = { Text("Блокировка допуска обходится только вами; обход записывается в журнал (Ф-13.5).") },
confirmButton = {
TextButton(onClick = { vm.startAnyway(onStarted) }) { Text("Всё равно начать") }
},
dismissButton = { TextButton(onClick = { overrideAsk = false }) { Text("Назад") } },
)
}
}
@Composable
private fun OutcomeContent(
permission: Permission,
noData: Boolean,
kind: String,
onKind: (String) -> Unit,
onStart: () -> Unit,
onClose: () -> Unit,
onOverrideAsk: () -> Unit,
) {
when (permission) {
Permission.ALLOW -> {
Text(
if (noData) "Данные не введены — проверка не выполнялась" else "Допуск получен",
style = MaterialTheme.typography.titleMedium,
)
val kinds = listOf(
"normal" to "обычная", "return" to "возврат",
"deload" to "делоад", "test" to "тест",
)
SegmentControl(
"Вид тренировки",
options = kinds.mapIndexed { i, (_, label) -> i to label },
selected = kinds.indexOfFirst { it.first == kind }.takeIf { it >= 0 },
onSelect = { idx -> onKind(kinds.getOrElse(idx ?: 0) { kinds.first() }.first) },
)
Button(onClick = onStart, modifier = Modifier.fillMaxWidth()) { Text("Начать тренировку") }
}
Permission.CARDIO_ONLY -> {
Outcome(
title = "Давление повышено",
text = "Сегодня только лёгкое кардио или дыхание. Силовые заблокированы.",
onClose = onClose,
onOverrideAsk = onOverrideAsk,
)
}
Permission.CANCEL -> {
Outcome(
title = "Давление высокое",
text = "Тренировку не начинаем. Рекомендуется прогулка.",
onClose = onClose,
onOverrideAsk = onOverrideAsk,
)
}
Permission.POSTPONE -> {
Outcome(
title = "Пульс сидя высокий",
text = "Лучше перенести тренировку на другой день.",
onClose = onClose,
onOverrideAsk = onOverrideAsk,
)
}
}
}
@Composable
private fun Outcome(title: String, text: String, onClose: () -> Unit, onOverrideAsk: () -> Unit) {
Text(title, style = MaterialTheme.typography.titleLarge)
Text(text, textAlign = TextAlign.Start)
OutlinedButton(onClick = onClose, modifier = Modifier.fillMaxWidth()) {
Text("Закрыть")
}
TextButton(onClick = onOverrideAsk, modifier = Modifier.fillMaxWidth()) {
Text("Всё равно начать силовую")
}
}

View File

@ -0,0 +1,124 @@
package com.watchbody.phone.ui
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.util.Locale
import kotlin.math.abs
/**
* Числовой пикер «− значение +» (ui-ux разд. 3: не клавиатура):
* null — «не измерено» (merge дневника не затирает пустое, Ф-13.1);
* первое касание стартует от [start]; цели касания ≥ 48 dp.
*/
@Composable
fun StepperPicker(
label: String,
value: Double?,
onValue: (Double?) -> Unit,
step: Double,
min: Double,
max: Double,
start: Double = min,
format: (Double) -> String = { formatKg(it) },
suffix: String = "",
) {
Column(Modifier.padding(vertical = 4.dp)) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = { onValue(clamp((value ?: start) - step, min, max)) },
modifier = Modifier.heightIn(min = 48.dp),
) { Text("−", style = MaterialTheme.typography.titleLarge) }
Text(
value?.let { format(it) + suffix } ?: "—",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(horizontal = 8.dp),
)
OutlinedButton(
onClick = { onValue(clamp((value ?: start) + step, min, max)) },
modifier = Modifier.heightIn(min = 48.dp),
) { Text("+", style = MaterialTheme.typography.titleLarge) }
}
}
}
/** Целочисленный пикер (АД/пульс — шаг 5/1). */
@Composable
fun IntPicker(
label: String,
value: Int?,
onValue: (Int?) -> Unit,
min: Int,
max: Int,
step: Int = 1,
start: Int = min,
suffix: String = "",
) {
StepperPicker(
label = label,
value = value?.toDouble(),
onValue = { onValue(it?.toInt()) },
step = step.toDouble(),
min = min.toDouble(),
max = max.toDouble(),
start = start.toDouble(),
format = { it.toInt().toString() },
suffix = suffix,
)
}
/** Сегмент-контрол (RPE/самочувствие/боль — не слайдеры, ui-ux разд. 3). */
@Composable
fun SegmentControl(
label: String,
options: List<Pair<Int, String>>,
selected: Int?,
onSelect: (Int?) -> Unit,
) {
Column(Modifier.padding(vertical = 4.dp)) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Row(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
options.forEach { (value, title) ->
FilterChip(
selected = selected == value,
onClick = { onSelect(if (selected == value) null else value) },
label = { Text(title) },
)
}
}
}
}
fun clamp(v: Double, min: Double, max: Double): Double = v.coerceIn(min, max)
/** Вес: один знак после запятой, без хвостовых нулей (82,5 / 80). */
fun formatKg(v: Double): String {
val rounded = (v * 2).let { Math.round(it) } / 2.0 // к сетке 0,5
return if (abs(rounded - rounded.toInt()) < 1e-9) {
rounded.toInt().toString()
} else {
String.format(Locale.ROOT, "%.1f", rounded)
}
}

View File

@ -0,0 +1,159 @@
package com.watchbody.phone.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.watchbody.phone.AppContainer
import com.watchbody.phone.core.PlanFact
import com.watchbody.phone.db.Workout
import com.watchbody.phone.db.WorkoutSet
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
/** «История» (Ф-5): список + карточка с план/факт, всё из LocalDb (офлайн). */
class HistoryViewModel(private val container: AppContainer) : ViewModel() {
val history: StateFlow<List<Workout>> = container.db.workoutDao().history()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
var opened: Pair<Workout, List<WorkoutSet>>? by mutableStateOf(null)
private set
fun open(workout: Workout) {
viewModelScope.launch {
opened = workout to container.db.workoutDao().sets(workout.id)
}
}
fun close() {
opened = null
}
}
private val dateTime = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm").withZone(ZoneOffset.UTC)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(container: AppContainer) {
val vm = containerVM(container) { HistoryViewModel(it) }
val history by vm.history.collectAsState()
Scaffold(
topBar = { TopAppBar(title = { Text("История") }) },
) { padding ->
val closed = history.filter { it.status != "active" } // Ф-5.1: завершённые
if (closed.isEmpty()) {
Column(
Modifier.padding(padding).fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
) { Text("Тренировок пока нет") }
return@Scaffold
}
LazyColumn(
Modifier.padding(padding).fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val card = vm.opened
if (card != null) {
item {
WorkoutCard(card.first, card.second) { vm.close() }
}
}
items(closed, key = { it.id }) { w ->
Card(onClick = { vm.open(w) }) {
Column(Modifier.padding(12.dp)) {
Text(
dateTime.format(Instant.ofEpochMilli(w.startedAt)) +
" · " + statusLabel(w.status) + " · " + kindLabelHistory(w.sessionKind),
style = MaterialTheme.typography.titleSmall,
)
Text(
"длительность ${durationLabel(w)} · пульс ${w.avgHr?.toString() ?: "—"}",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WorkoutCard(w: Workout, sets: List<WorkoutSet>, onClose: () -> Unit) {
val report = PlanFact.of(sets.map { it.inPlan })
Card(onClick = onClose) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"${dateTime.format(Instant.ofEpochMilli(w.startedAt))} · ${statusLabel(w.status)}",
style = MaterialTheme.typography.titleMedium,
)
Text(
"длительность ${durationLabel(w)} · средний пульс ${w.avgHr?.toString() ?: "—"}",
)
Text(
"План/факт: ${report.pct} % — из ${report.total} подходов по плану ${report.inPlan}",
style = MaterialTheme.typography.titleSmall,
)
sets.forEach { s ->
Text(
"${s.seq}. ${sourceLabel(s.source)} — ${formatKg(s.weightKg)} кг × ${s.reps}" +
(if (s.inPlan) " · по плану" else " · отклонение") +
(s.rpe?.let { " · RPE $it" } ?: "") +
(s.kneePain?.let { " · боль $it" } ?: ""),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
private fun durationLabel(w: Workout): String {
val end = w.endedAt ?: return "—"
val min = ((end - w.startedAt) / 60000).toInt()
return if (min >= 60) "${min / 60} ч ${min % 60} мин" else "$min мин"
}
private fun statusLabel(status: String) = when (status) {
"finished" -> "завершена"
"cancelled" -> "отменена"
else -> status
}
private fun kindLabelHistory(kind: String) = when (kind) {
"normal" -> "обычная"
"return" -> "возврат"
"deload" -> "делоад"
"test" -> "тест"
else -> kind
}
private fun sourceLabel(source: String) = when (source) {
"voice" -> "голос"
"watch" -> "часы"
"edit" -> "правка"
else -> source
}

View File

@ -0,0 +1,226 @@
package com.watchbody.phone.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.watchbody.phone.AppContainer
import com.watchbody.phone.db.Exercise
import com.watchbody.phone.sync.SyncWorker
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/** «Библиотека» (Ф-4): 🛡 удаление только логическое (is_active=false). */
class LibraryViewModel(private val container: AppContainer) : ViewModel() {
val exercises: StateFlow<List<Exercise>> = container.db.libraryDao().exercisesFlow()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** Ошибка уникальности имени (UNIQUE exercise.name, Ф-4.1). */
val nameError = MutableStateFlow<String?>(null)
fun add(name: String, groupCode: String, isBase: Boolean) {
viewModelScope.launch {
val clean = name.trim()
if (container.db.libraryDao().exerciseByName(clean) != null) {
nameError.value = "«$clean» уже есть"
return@launch
}
container.core.saveExercise(clean, groupCode, isBase)
schedule()
nameError.value = null
}
}
fun rename(id: String, newName: String) {
viewModelScope.launch {
val clean = newName.trim()
val clash = container.db.libraryDao().exerciseByName(clean)
if (clash != null && clash.id != id) {
nameError.value = "«$clean» уже есть"
return@launch
}
container.core.renameExercise(id, clean) // 🛡 UUID не меняется (Ф-4.3)
schedule()
nameError.value = null
}
}
fun deactivate(id: String) {
viewModelScope.launch {
container.core.deactivateExercise(id)
schedule()
}
}
private suspend fun schedule() {
SyncWorker.schedule(container.appContext, container.settings.get().wifiOnly)
}
}
private val groups = listOf(
"push" to "жим", "pull" to "тяга", "legs" to "ноги",
"core" to "кор", "cardio" to "кардио",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryScreen(container: AppContainer) {
val vm = containerVM(container) { LibraryViewModel(it) }
val exercises by vm.exercises.collectAsState()
val nameError by vm.nameError.collectAsState()
var showInactive by remember { mutableStateOf(false) }
var addOpen by remember { mutableStateOf(false) }
var renameTarget by remember { mutableStateOf<Exercise?>(null) }
var deactivateTarget by remember { mutableStateOf<Exercise?>(null) }
val visible = exercises.filter { showInactive || it.isActive }
Scaffold(
topBar = { TopAppBar(title = { Text("Библиотека") }) },
) { padding ->
Column(Modifier.padding(padding).fillMaxSize().padding(horizontal = 16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Показать исключённые")
Checkbox(checked = showInactive, onCheckedChange = { showInactive = it })
}
Button(onClick = { addOpen = true }, modifier = Modifier.fillMaxWidth()) {
Text("Добавить упражнение")
}
nameError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
items(visible, key = { it.id }) { ex ->
Column {
Text(
"${ex.name} · ${groupLabel(ex.groupCode)} · " +
(if (ex.isBase) "базовое" else "изоляция") +
(if (!ex.isActive) " · исключено" else ""),
style = MaterialTheme.typography.bodyLarge,
)
Row {
if (ex.isActive) {
TextButton(onClick = { renameTarget = ex }) { Text("Переименовать") }
TextButton(onClick = { deactivateTarget = ex }) { Text("Исключить") }
}
}
}
}
}
}
}
if (addOpen) {
EditDialog(
title = "Новое упражнение",
initialName = "",
onDismiss = { addOpen = false },
onConfirm = { name, group, isBase ->
vm.add(name, group, isBase)
addOpen = false
},
)
}
renameTarget?.let { target ->
EditDialog(
title = "Переименовать «${target.name}»",
initialName = target.name,
fixedGroup = target.groupCode,
fixedBase = target.isBase,
onDismiss = { renameTarget = null },
onConfirm = { name, _, _ ->
vm.rename(target.id, name)
renameTarget = null
},
)
}
deactivateTarget?.let { target ->
AlertDialog(
onDismissRequest = { deactivateTarget = null },
title = { Text("Исключить «${target.name}»?") },
text = { Text("История тренировок сохранится; упражнение исчезнет из выбора (Ф-4.2).") },
confirmButton = {
TextButton(onClick = { vm.deactivate(target.id); deactivateTarget = null }) { Text("Исключить") }
},
dismissButton = { TextButton(onClick = { deactivateTarget = null }) { Text("Отмена") } },
)
}
}
@Composable
private fun EditDialog(
title: String,
initialName: String,
fixedGroup: String? = null,
fixedBase: Boolean? = null,
onDismiss: () -> Unit,
onConfirm: (name: String, group: String, isBase: Boolean) -> Unit,
) {
var name by remember { mutableStateOf(initialName) }
var groupIdx by remember {
val idx = fixedGroup?.let { f -> groups.indexOfFirst { it.first == f } } ?: -1
mutableStateOf(if (idx >= 0) idx else 0)
}
var isBase by remember { mutableStateOf(fixedBase ?: true) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Название") })
if (fixedGroup == null) {
SegmentControl(
"Группа",
options = groups.mapIndexed { i, (_, label) -> i to label },
selected = groupIdx,
onSelect = { groupIdx = it ?: 0 },
)
}
if (fixedBase == null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Базовое")
Checkbox(checked = isBase, onCheckedChange = { isBase = it })
}
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(name, groups[groupIdx].first, isBase) },
enabled = name.isNotBlank(),
) { Text("Сохранить") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
)
}
private fun groupLabel(code: String): String = groups.firstOrNull { it.first == code }?.second ?: code

View File

@ -0,0 +1,182 @@
package com.watchbody.phone.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.watchbody.phone.AppContainer
import com.watchbody.phone.WatchBodyApp
/** Точки навигации (spec 01 §1). */
object Routes {
const val TODAY = "today"
const val WORKOUT = "workout"
const val HISTORY = "history"
const val PROGRAMS = "programs"
const val PROFILE = "profile"
const val LIBRARY = "library"
const val SETTINGS = "settings"
const val ADMISSION = "admission"
val tabs = listOf(TODAY, WORKOUT, HISTORY, PROGRAMS, PROFILE)
val barVisible = tabs + LIBRARY + SETTINGS
fun tabIcon(route: String): ImageVector = when (route) {
TODAY -> Icons.Filled.Home
WORKOUT -> Icons.Filled.PlayArrow
HISTORY -> Icons.Filled.List
PROGRAMS -> Icons.Filled.Star
else -> Icons.Filled.Person
}
fun tabTitle(route: String): String = when (route) {
TODAY -> "Сегодня"
WORKOUT -> "Тренировка"
HISTORY -> "История"
PROGRAMS -> "Программы"
else -> "Профиль"
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val container = (application as WatchBodyApp).container
setContent {
WatchBodyTheme { AppRoot(container) }
}
}
}
/** Общий фабричный хелпер ViewModel (ручной DI, spec 01 §5). */
@Composable
inline fun <reified VM : ViewModel> containerVM(
container: AppContainer,
crossinline create: (AppContainer) -> VM,
): VM = viewModel<VM>(
factory = viewModelFactory { initializer { create(container) } },
)
@Composable
fun AppRoot(container: AppContainer) {
// Первый запуск: пустые baseUrl/apiKey → гейт настроек (spec 01 §2)
val firstRun by produceState<Boolean?>(initialValue = null, container) {
val s = container.settings.get()
value = s.baseUrl.isBlank() || s.apiKey.isBlank()
}
when (firstRun) {
null -> {} // загрузка настроек
true -> SettingsGate(container)
else -> MainScaffold(container)
}
}
/** Гейт первого запуска: до сохранения валидных настроек вкладки недоступны. */
@Composable
private fun SettingsGate(container: AppContainer) {
var configured by remember { mutableStateOf(false) }
if (!configured) {
SettingsScreen(container, standalone = true, onSaved = { configured = true })
} else {
MainScaffold(container)
}
}
@Composable
private fun MainScaffold(container: AppContainer) {
val nav = rememberNavController()
val backStack by nav.currentBackStackEntryAsState()
val current = backStack?.destination?.route ?: Routes.TODAY
val bannerVm = containerVM(container) { SyncBannerViewModel(it) }
val banner by bannerVm.state.collectAsState()
Scaffold(
bottomBar = {
if (current in Routes.barVisible) {
NavigationBar {
Routes.tabs.forEach { route ->
NavigationBarItem(
selected = current == route,
onClick = {
nav.navigate(route) {
popUpTo(Routes.TODAY) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(Routes.tabIcon(route), contentDescription = Routes.tabTitle(route)) },
label = { Text(Routes.tabTitle(route)) },
)
}
}
}
},
) { padding ->
Column(Modifier.padding(padding)) {
SyncBanner(banner)
AppNavHost(container, nav)
}
}
}
@Composable
private fun AppNavHost(container: AppContainer, nav: NavHostController) {
NavHost(navController = nav, startDestination = Routes.TODAY) {
composable(Routes.TODAY) {
TodayScreen(
container = container,
onOpenSettings = { nav.navigate(Routes.SETTINGS) },
onStartAdmission = { nav.navigate(Routes.ADMISSION) },
)
}
composable(Routes.WORKOUT) { WorkoutScreen(container) }
composable(Routes.HISTORY) { HistoryScreen(container) }
composable(Routes.PROGRAMS) { ProgramsScreen(container) }
composable(Routes.PROFILE) {
ProfileScreen(container, onOpenLibrary = { nav.navigate(Routes.LIBRARY) })
}
composable(Routes.LIBRARY) { LibraryScreen(container) }
composable(Routes.SETTINGS) { SettingsScreen(container, standalone = false, onSaved = {}) }
composable(Routes.ADMISSION) {
AdmissionScreen(
container,
onStarted = {
nav.navigate(Routes.WORKOUT) {
popUpTo(Routes.TODAY)
launchSingleTop = true
}
},
)
}
}
}

View File

@ -0,0 +1,155 @@
package com.watchbody.phone.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.List
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.watchbody.phone.AppContainer
import com.watchbody.phone.core.ZoneCalculator
import com.watchbody.phone.db.Profile
import com.watchbody.phone.sync.SyncWorker
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.ZoneOffset
/** «Профиль» (Ф-6): поля + зоны; сохранение → WorkoutCore → sync. */
class ProfileViewModel(private val container: AppContainer) : ViewModel() {
val profile: StateFlow<Profile?> = container.db.healthDao().profileFlow()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun save(sex: String?, birthYear: Int?, weightKg: Double?, hrMax: Int?, onDone: () -> Unit) {
viewModelScope.launch {
container.core.saveProfile(sex, birthYear, weightKg, hrMax)
SyncWorker.schedule(container.appContext, container.settings.get().wifiOnly)
onDone()
}
}
companion object {
fun currentYear(): Int = LocalDate.now(ZoneOffset.UTC).year
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(container: AppContainer, onOpenLibrary: () -> Unit) {
val vm = containerVM(container) { ProfileViewModel(it) }
val profile by vm.profile.collectAsState()
var sexIdx by remember { mutableStateOf<Int?>(null) }
var birthYear by remember { mutableStateOf<Int?>(null) }
var weight by remember { mutableStateOf<Double?>(null) }
var hrMax by remember { mutableStateOf<Int?>(null) }
var hrMaxAuto by remember { mutableStateOf(true) }
var saved by remember { mutableStateOf(false) }
var initialized by remember { mutableStateOf(false) }
LaunchedEffect(profile) {
if (!initialized && profile != null) {
sexIdx = when (profile!!.sex) {
"m" -> 0; "f" -> 1; else -> 2
}
birthYear = profile!!.birthYear
weight = profile!!.weightKg
hrMaxAuto = profile!!.hrMax == null
hrMax = profile!!.hrMax
initialized = true
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Профиль") },
actions = {
IconButton(onClick = onOpenLibrary) {
Icon(Icons.Filled.List, contentDescription = "Библиотека упражнений")
}
},
)
},
) { padding ->
Column(
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
val sexes = listOf("m" to "муж", "f" to "жен") // сервер: sex ∈ m|f (m02 spec 06)
SegmentControl(
"Пол",
options = listOf(0 to "муж", 1 to "жен", 2 to "—"),
selected = sexIdx,
onSelect = { sexIdx = it },
)
IntPicker("Год рождения", birthYear, { birthYear = it }, min = 1930, max = 2010, start = 1980)
StepperPicker("Вес", weight, { weight = it }, step = 0.5, min = 30.0, max = 300.0, start = 80.0, suffix = " кг")
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Макс. пульс — по формуле (Танака)")
Switch(checked = hrMaxAuto, onCheckedChange = { hrMaxAuto = it; if (it) hrMax = null })
}
if (!hrMaxAuto) {
IntPicker("Макс. пульс", hrMax, { hrMax = it }, min = 120, max = 240, start = 180, suffix = " уд/мин")
}
// зоны (Ф-6.2) — показ; на часы передаст m12
val maxHr = ZoneCalculator.maxHr(birthYear, hrMax, ProfileViewModel.currentYear())
if (maxHr != null) {
val z = ZoneCalculator.zones(maxHr)
Text(
"Максимум: $maxHr уд/мин",
style = MaterialTheme.typography.titleSmall,
)
Text("зона 1 ( recovery ): < ${z.z2}")
Text("зона 2 ( лёгкая ): ${z.z2}–${z.z3 - 1}")
Text("зона 3 ( средняя ): ${z.z3}–${z.z4 - 1}")
Text("зона 4 ( высокая ): ≥ ${z.z4}")
}
if (saved) Text("Сохранено", color = MaterialTheme.colorScheme.primary)
Button(
onClick = {
vm.save(
sexIdx?.let { i -> sexes.getOrNull(i)?.first }, // «—» → null
birthYear, weight, if (hrMaxAuto) null else hrMax,
) { saved = true }
},
modifier = Modifier.fillMaxWidth(),
) { Text("Сохранить") }
}
}
}

View File

@ -0,0 +1,217 @@
package com.watchbody.phone.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.watchbody.phone.AppContainer
import com.watchbody.phone.core.ProgramView
import com.watchbody.phone.core.WeekView
import com.watchbody.phone.db.ProgramVersion
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.ZoneOffset
/**
* «Программы» (Ф-10, просмотр 🛡): активная версия + план на сегодня;
* активация/rules-draft — консоль владельца (решение гейта 1.3).
*/
class ProgramsViewModel(private val container: AppContainer) : ViewModel() {
val active: StateFlow<ProgramVersion?> = container.db.libraryDao().activeProgramFlow()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val versions: StateFlow<List<ProgramVersion>> = container.db.libraryDao().versions()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
var refreshError: String? by mutableStateOf(null)
private set
/** GET /api/v1/programs/active → LocalDb (m09 ApiClient). */
fun refresh() {
viewModelScope.launch {
try {
val body = container.api().activeProgram()
val version = parseActive(body)
if (version != null) {
container.db.libraryDao().upsertProgram(version)
refreshError = null
} else {
refreshError = "сервер не вернул активную версию"
}
} catch (e: Exception) {
refreshError = "не удалось обновить: ${e.message ?: e.javaClass.simpleName}"
}
}
}
/**
* GET /programs/active (m02): ключи camelCase (server policy),
* body — программа с weeks на верхнем уровне (контракт m02 spec 03);
* страховка от вложенного body (errata-совместимость).
*/
private fun parseActive(body: Map<String, kotlinx.serialization.json.JsonElement>): ProgramVersion? {
return try {
fun prim(key: String): kotlinx.serialization.json.JsonPrimitive? =
body[key] as? kotlinx.serialization.json.JsonPrimitive
val raw = body["body"]
val weeks = (raw as? kotlinx.serialization.json.JsonObject)?.get("weeks")
val programJson = if (weeks == null) {
(raw as? kotlinx.serialization.json.JsonObject)?.get("body") ?: raw
} else {
raw
}
ProgramVersion(
id = prim("versionId")?.content ?: return null,
version = prim("version")?.content?.toIntOrNull() ?: return null,
status = "active",
author = prim("author")?.content ?: "user",
basedOnVersion = prim("basedOnVersion")?.content?.toIntOrNull(),
body = programJson.toString(),
rationale = prim("rationale")?.content ?: "",
createdAt = prim("createdAt")?.content?.toLongOrNull() ?: System.currentTimeMillis(),
)
} catch (e: Exception) {
null
}
}
companion object {
fun todayIso(): Int = LocalDate.now(ZoneOffset.UTC).dayOfWeek.value
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProgramsScreen(container: AppContainer) {
val vm = containerVM(container) { ProgramsViewModel(it) }
val active by vm.active.collectAsState()
val versions by vm.versions.collectAsState()
Scaffold(
topBar = { TopAppBar(title = { Text("Программы") }) },
) { padding ->
LazyColumn(
Modifier.padding(padding).fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (active == null) {
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Программа не назначена — обратитесь к владельцу")
Button(onClick = { vm.refresh() }) { Text("Обновить") }
vm.refreshError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
}
} else {
val a = active!!
item {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"Активная: версия ${a.version} · ${a.author} · ${a.status}",
style = MaterialTheme.typography.titleMedium,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { vm.refresh() }) { Text("Обновить") }
}
vm.refreshError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
}
item {
TodayPlan(body = a.body)
}
val weeks = ProgramView.allWeeks(a.body)
items(weeks, key = { it.week }) { week ->
WeekCard(week)
}
item {
Text(
"Версии на устройстве: ${versions.size}. Активация и применение " +
"предложений правил — в консоли владельца (v1).",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
@Composable
private fun TodayPlan(body: String) {
val session = ProgramView.sessionFor(body, ProgramsViewModel.todayIso())
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("План на сегодня", style = MaterialTheme.typography.titleSmall)
if (session == null || session.items.isEmpty()) {
Text("сегодня по плану отдыха")
} else {
session.items.forEach { item ->
val sets = item.planSets.joinToString(", ") { "${formatKg(it.weightKg)}×${it.reps}" }
Text("• ${item.exerciseName}: $sets")
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WeekCard(week: WeekView) {
var expanded by androidx.compose.runtime.remember {
androidx.compose.runtime.mutableStateOf(false)
}
Card(onClick = { expanded = !expanded }) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Неделя ${week.week}", style = MaterialTheme.typography.titleSmall)
if (!expanded) {
Text("${week.sessions.size} сессий", style = MaterialTheme.typography.bodySmall)
} else {
week.sessions.forEach { s ->
Text(
"— ${dayLabel(s.day)}${s.focus?.let { " · $it" } ?: ""}",
style = MaterialTheme.typography.bodyMedium,
)
s.items.forEach { item ->
val sets = item.planSets.joinToString(", ") { "${formatKg(it.weightKg)}×${it.reps}" }
Text(
" ${item.exerciseName}: $sets",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
}
private fun dayLabel(iso: String): String = when (iso.lowercase()) {
"monday" -> "понедельник"
"tuesday" -> "вторник"
"wednesday" -> "среда"
"thursday" -> "четверг"
"friday" -> "пятница"
"saturday" -> "суббота"
"sunday" -> "воскресенье"
else -> iso
}

View File

@ -0,0 +1,188 @@
package com.watchbody.phone.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.watchbody.phone.AppContainer
import com.watchbody.phone.core.SettingsValidator
import com.watchbody.phone.settings.AppSettings
import com.watchbody.phone.sync.SyncWorker
import kotlinx.coroutines.launch
/** «Настройки» (tech-req 4.1, spec 05 §3); 🛡 ключ — маска, не логируется. */
class SettingsViewModel(private val container: AppContainer) : ViewModel() {
var loaded by mutableStateOf<AppSettings?>(null)
private set
init {
viewModelScope.launch { loaded = container.settings.get() }
}
fun save(settings: AppSettings, onSaved: () -> Unit) {
viewModelScope.launch {
container.settings.save(settings)
onSaved()
}
}
fun syncNow() {
SyncWorker.scheduleNow(container.appContext)
}
val queueCount = container.db.syncDao().countFlow()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () -> Unit) {
val vm = containerVM(container) { SettingsViewModel(it) }
val queue by vm.queueCount.collectAsState(initial = 0)
var baseUrl by remember { mutableStateOf("") }
var apiKey by remember { mutableStateOf("") }
var wifiOnly by remember { mutableStateOf(false) }
var restSec by remember { mutableStateOf(90) }
var hrMaxBpm by remember { mutableStateOf<Int?>(null) }
var hrMaxAuto by remember { mutableStateOf(true) }
var bpCancelSys by remember { mutableStateOf(150) }
var bpCancelDias by remember { mutableStateOf(90) }
var bpCardioSys by remember { mutableStateOf(140) }
var bpCardioDias by remember { mutableStateOf(90) }
var restingHrSitting by remember { mutableStateOf(90) }
var hrStrengthMax by remember { mutableStateOf(120) }
LaunchedEffect(vm.loaded) { // одна загрузка в форму
vm.loaded?.let { s ->
baseUrl = s.baseUrl; apiKey = s.apiKey; wifiOnly = s.wifiOnly
restSec = s.restDefaultSec
hrMaxAuto = s.hrMaxBpm == null; hrMaxBpm = s.hrMaxBpm
bpCancelSys = s.safety.bpCancelSys; bpCancelDias = s.safety.bpCancelDias
bpCardioSys = s.safety.bpCardioSys; bpCardioDias = s.safety.bpCardioDias
restingHrSitting = s.safety.restingHrSitting
hrStrengthMax = s.hrStrengthMax
}
}
val draft = AppSettings(
baseUrl = baseUrl, apiKey = apiKey, wifiOnly = wifiOnly,
restDefaultSec = restSec, hrMaxBpm = if (hrMaxAuto) null else hrMaxBpm,
safety = com.watchbody.phone.core.SafetySettings(
bpCancelSys, bpCancelDias, bpCardioSys, bpCardioDias, restingHrSitting,
),
hrStrengthMax = hrStrengthMax,
)
val validation = SettingsValidator.validate(draft)
Scaffold(
topBar = {
TopAppBar(title = { Text(if (standalone) "Первичная настройка" else "Настройки") })
},
) { padding ->
Column(
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("Подключение", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it },
label = { Text("Адрес сервера (https://…)") },
modifier = Modifier.fillMaxWidth(),
isError = validation.errors.containsKey("baseUrl"),
)
validation.errors["baseUrl"]?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (validation.httpWarning) {
Text("http — только для отладки (docker-сервер)", color = MaterialTheme.colorScheme.tertiary)
}
OutlinedTextField(
value = apiKey,
onValueChange = { apiKey = it },
label = { Text("Ключ API") },
visualTransformation = PasswordVisualTransformation(), // 🛡 не показывать
modifier = Modifier.fillMaxWidth(),
isError = validation.errors.containsKey("apiKey"),
)
validation.errors["apiKey"]?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Только Wi-Fi (без лимитных сетей)")
Switch(checked = wifiOnly, onCheckedChange = { wifiOnly = it })
}
Text("Тренировка", style = MaterialTheme.typography.titleMedium)
IntPicker("Отдых по умолчанию", restSec, { restSec = it ?: 90 }, min = 30, max = 600, start = 90, suffix = " с")
validation.errors["restDefaultSec"]?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Макс. пульс — по формуле")
Switch(checked = hrMaxAuto, onCheckedChange = { hrMaxAuto = it; if (it) hrMaxBpm = null })
}
if (!hrMaxAuto) {
IntPicker("Макс. пульс", hrMaxBpm, { hrMaxBpm = it }, min = 120, max = 240, start = 180, suffix = " уд/мин")
validation.errors["hrMaxBpm"]?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
Text("Допуск (Safety, Р3-А)", style = MaterialTheme.typography.titleMedium)
IntPicker("Отмена: систола", bpCancelSys, { bpCancelSys = it ?: 150 }, min = 120, max = 200, start = 150)
IntPicker("Отмена: диастола", bpCancelDias, { bpCancelDias = it ?: 90 }, min = 70, max = 130, start = 90)
IntPicker("Только кардио: систола", bpCardioSys, { bpCardioSys = it ?: 140 }, min = 120, max = 200, start = 140)
IntPicker("Только кардио: диастола", bpCardioDias, { bpCardioDias = it ?: 90 }, min = 70, max = 130, start = 90)
validation.errors["bpCardio"]?.let { Text(it, color = MaterialTheme.colorScheme.error) }
IntPicker("Пульс сидя (перенос)", restingHrSitting, { restingHrSitting = it ?: 90 }, min = 40, max = 120, start = 90, suffix = " уд/мин")
IntPicker("Порог в силовой", hrStrengthMax, { hrStrengthMax = it ?: 120 }, min = 100, max = 180, start = 120, suffix = " уд/мин")
validation.errors["hrStrengthMax"]?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (!standalone) {
Text(
"Очередь отправки: $queue записей",
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(onClick = { vm.syncNow() }, modifier = Modifier.fillMaxWidth()) {
Text("Синхронизировать сейчас")
}
}
Button(
onClick = { vm.save(draft, onSaved) },
enabled = validation.ok,
modifier = Modifier.fillMaxWidth(),
) { Text("Сохранить") }
if (standalone) {
Text(
"Без подключения к серверу приложение работает офлайн; настройки можно изменить позже.",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}

View File

@ -0,0 +1,84 @@
package com.watchbody.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.watchbody.phone.AppContainer
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
/**
* Состояние синхронизации для баннеров (spec 01 §3): WorkInfo
* unique «sync» + счётчик очереди; текст и цвет дублируют друг друга 🛡.
*/
sealed interface SyncBannerState {
data object None : SyncBannerState
data object Running : SyncBannerState
data class Queued(val count: Int) : SyncBannerState
data object Success : SyncBannerState
data object AuthError : SyncBannerState
data object Conflict : SyncBannerState
data object Unavailable : SyncBannerState
}
class SyncBannerViewModel(container: AppContainer) : ViewModel() {
val state: StateFlow<SyncBannerState> = combine(
WorkManager.getInstance(container.appContext)
.getWorkInfosForUniqueWorkFlow("sync"),
container.db.syncDao().countFlow(),
) { infos, count ->
val last = infos.firstOrNull()
toState(last, count)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SyncBannerState.None)
private fun toState(info: WorkInfo?, count: Int): SyncBannerState = when {
info?.state == WorkInfo.State.RUNNING -> SyncBannerState.Running
info?.state == WorkInfo.State.ENQUEUED && count > 0 -> SyncBannerState.Queued(count)
info?.state == WorkInfo.State.FAILED -> when {
info.outputData.getBoolean(SYNC_KEY_AUTH, false) -> SyncBannerState.AuthError
info.outputData.getBoolean(SYNC_KEY_CONFLICT, false) -> SyncBannerState.Conflict
else -> SyncBannerState.Unavailable
}
count > 0 -> SyncBannerState.Queued(count)
info?.state == WorkInfo.State.SUCCEEDED -> SyncBannerState.Success
else -> SyncBannerState.None
}
}
private const val SYNC_KEY_AUTH = "auth_error"
private const val SYNC_KEY_CONFLICT = "conflict"
@Composable
fun SyncBanner(state: SyncBannerState) {
val (text, color) = when (state) {
SyncBannerState.None -> return
SyncBannerState.Running -> "Синхронизация…" to Color(0xFF2E4A2E)
is SyncBannerState.Queued -> "В очереди на отправку: ${state.count}" to Color(0xFF3A3A26)
SyncBannerState.Success -> "Данные успешно синхронизированы" to Color(0xFF1E4620)
SyncBannerState.AuthError -> "Ключ API неверен — проверьте настройки" to Color(0xFF5C2B2B)
SyncBannerState.Conflict -> "Конфликт версии программы — обратитесь к владельцу" to Color(0xFF5C2B2B)
SyncBannerState.Unavailable -> "Синхронизация временно недоступна — повторим автоматически" to Color(0xFF4A3A1E)
}
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.background(color)
.padding(horizontal = 16.dp, vertical = 8.dp),
color = Color(0xFFEDEDED),
)
}

View File

@ -0,0 +1,23 @@
package com.watchbody.phone.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/** Тёмная тема всегда (tech-req ОВ-6, зальное использование); акцент — спокойный зелёный. */
private val DarkScheme = darkColorScheme(
primary = Color(0xFF7DD87D),
onPrimary = Color(0xFF003900),
secondary = Color(0xFF9FCBEF),
tertiary = Color(0xFFFFB74D),
error = Color(0xFFCF6679),
background = Color(0xFF121212),
surface = Color(0xFF1E1E1E),
surfaceVariant = Color(0xFF2A2A2A),
)
@Composable
fun WatchBodyTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = DarkScheme, content = content)
}

View File

@ -0,0 +1,146 @@
package com.watchbody.phone.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.watchbody.phone.AppContainer
import com.watchbody.phone.db.DailyLog
import com.watchbody.phone.sync.SyncWorker
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.ZoneOffset
/** «Сегодня» (Ф-13.1): дневник за 2–3 касания + вход в допуск. */
class TodayViewModel(private val container: AppContainer) : ViewModel() {
val today: LocalDate = LocalDate.now(ZoneOffset.UTC)
val log: StateFlow<DailyLog?> = container.db.healthDao()
.dailyLogFlow(today.toString())
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun save(
bpSys: Int?, bpDias: Int?, weightKg: Double?,
hrResting: Int?, wellbeing: Int?,
onDone: () -> Unit,
) {
viewModelScope.launch {
container.core.saveDailyLog(today, bpSys, bpDias, weightKg, hrResting, wellbeing)
scheduleSync()
onDone()
}
}
fun scheduleSync() {
viewModelScope.launch {
val wifiOnly = container.settings.get().wifiOnly
SyncWorker.schedule(container.appContext, wifiOnly)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodayScreen(
container: AppContainer,
onOpenSettings: () -> Unit,
onStartAdmission: () -> Unit,
) {
val vm = containerVM(container) { TodayViewModel(it) }
val log by vm.log.collectAsState()
var bpSys by remember { mutableStateOf<Int?>(null) }
var bpDias by remember { mutableStateOf<Int?>(null) }
var weight by remember { mutableStateOf<Double?>(null) }
var hrResting by remember { mutableStateOf<Int?>(null) }
var wellbeing by remember { mutableStateOf<Int?>(null) }
var saved by remember { mutableStateOf(false) }
var initialized by remember { mutableStateOf(false) }
LaunchedEffect(log) {
if (!initialized && log != null) { // утренние значения — в форму, не переспрашивать
bpSys = log!!.bpSys
bpDias = log!!.bpDias
weight = log!!.weightKg
hrResting = log!!.hrResting
wellbeing = log!!.wellbeing
initialized = true
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Сегодня") },
actions = {
IconButton(onClick = onOpenSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Настройки")
}
},
)
},
) { padding ->
Column(
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("Дневник самоконтроля", style = MaterialTheme.typography.titleMedium)
IntPicker("АД систола", bpSys, { bpSys = it }, min = 70, max = 250, step = 5, start = 120, suffix = " мм рт.ст.")
IntPicker("АД диастола", bpDias, { bpDias = it }, min = 40, max = 150, step = 5, start = 80, suffix = " мм рт.ст.")
StepperPicker("Вес тела", weight, { weight = it }, step = 0.5, min = 30.0, max = 300.0, start = 80.0, suffix = " кг")
IntPicker("Пульс покоя", hrResting, { hrResting = it }, min = 30, max = 130, start = 60, suffix = " уд/мин")
SegmentControl(
"Самочувствие",
options = listOf(1 to "1 плохо", 2 to "2", 3 to "3", 4 to "4", 5 to "5 отлично"),
selected = wellbeing,
onSelect = { wellbeing = it },
)
if (saved) Text("Сохранено", color = MaterialTheme.colorScheme.primary)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(onClick = { vm.save(bpSys, bpDias, weight, hrResting, wellbeing) { saved = true } }) {
Text("Сохранить")
}
Button(onClick = onStartAdmission, modifier = Modifier.weight(1f)) {
Text("Начать тренировку")
}
}
}
}
}

View File

@ -0,0 +1,423 @@
package com.watchbody.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.watchbody.phone.AppContainer
import com.watchbody.phone.core.ProgramView
import com.watchbody.phone.core.RestPhase
import com.watchbody.phone.core.RestTimer
import com.watchbody.phone.core.SetPlan
import com.watchbody.phone.db.Exercise
import com.watchbody.phone.db.Workout
import com.watchbody.phone.db.WorkoutSet
import com.watchbody.phone.sync.SyncWorker
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneOffset
/** Состояние экрана тренировки (spec 03). */
data class WorkoutUiState(
val workout: Workout? = null,
val sets: List<WorkoutSet> = emptyList(),
val exercises: List<Exercise> = emptyList(),
val planItems: List<com.watchbody.phone.core.ItemView> = emptyList(),
val offline: Boolean = false,
)
@OptIn(ExperimentalCoroutinesApi::class)
class WorkoutViewModel(private val container: AppContainer) : ViewModel() {
val state: StateFlow<WorkoutUiState> = container.db.workoutDao()
.activeWorkoutFlow()
.flatMapLatest { workout ->
if (workout == null) {
flowOf(WorkoutUiState())
} else {
container.db.workoutDao().setsFlow(workout.id).flatMapLatest { sets ->
container.db.libraryDao().exercisesFlow().flatMapLatest { exercises ->
container.db.libraryDao().activeProgramFlow().flatMapLatest { program ->
flowOf(
WorkoutUiState(
workout = workout,
sets = sets,
exercises = exercises.filter { it.isActive },
planItems = program?.body?.let { body ->
ProgramView.sessionFor(body, dayOfWeek(workout.startedAt))?.items.orEmpty()
}.orEmpty(),
),
)
}
}
}
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), WorkoutUiState())
/** Умолчание формы: план упражнения, иначе последний фактический вес. */
suspend fun defaults(exerciseId: String?): Pair<Double?, Int?> {
if (exerciseId == null) return null to null
val name = state.value.exercises.firstOrNull { it.id == exerciseId }?.name ?: return null to null
val plan: SetPlan? = state.value.planItems
.firstOrNull { it.exerciseName.equals(name, ignoreCase = true) }
?.planSets?.firstOrNull()
val last = container.db.workoutDao().lastSetForExercise(exerciseId)
return (plan?.weightKg ?: last?.weightKg) to (plan?.reps ?: last?.reps)
}
fun logSet(
workoutId: String, exerciseId: String?, weightKg: Double, reps: Int,
rpe: Int?, kneePain: Int?,
) {
viewModelScope.launch {
container.core.logSet(
workoutId, exerciseId, weightKg, reps, rpe, kneePain,
source = "edit", // ручная форма (Ф-5.2); голос — m11
)
SyncWorker.schedule(container.appContext, container.settings.get().wifiOnly)
}
}
fun finish(workoutId: String, onDone: () -> Unit) {
viewModelScope.launch {
container.core.finishWorkout(workoutId, avgHr = null) // пульс — m12
SyncWorker.schedule(container.appContext, container.settings.get().wifiOnly)
onDone()
}
}
fun cancel(workoutId: String) {
viewModelScope.launch {
container.core.cancelWorkout(workoutId)
SyncWorker.schedule(container.appContext, container.settings.get().wifiOnly)
}
}
suspend fun restDefaultSec(): Int = container.settings.get().restDefaultSec
companion object {
fun dayOfWeek(epochMillis: Long): Int =
Instant.ofEpochMilli(epochMillis).atOffset(ZoneOffset.UTC).dayOfWeek.value
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WorkoutScreen(container: AppContainer) {
val vm = containerVM(container) { WorkoutViewModel(it) }
val state by vm.state.collectAsState()
// форма подхода
var formOpen by remember { mutableStateOf(false) }
var selectedExercise by remember { mutableStateOf<String?>(null) }
var weight by remember { mutableStateOf(60.0) }
var reps by remember { mutableStateOf(10) }
var rpe by remember { mutableStateOf<Int?>(null) }
var kneePain by remember { mutableStateOf<Int?>(null) }
// таймер отдыха (spec 03 §4)
var restLeft by remember { mutableIntStateOf(-1) } // −1 = не запущен
var restTotal by remember { mutableIntStateOf(90) }
LaunchedEffect(Unit) { restTotal = vm.restDefaultSec() }
LaunchedEffect(restLeft) {
if (restLeft > 0) {
kotlinx.coroutines.delay(1000)
restLeft -= 1
}
}
var confirmCancel by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Тренировка") },
actions = {
IconButton(onClick = { formOpen = true }) {
Icon(
Icons.Filled.Edit,
contentDescription = "Записать подход (ручной ввод; голос — m11)",
)
}
},
)
},
) { padding ->
val w = state.workout
if (w == null) {
Column(
Modifier.padding(padding).fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
) {
Text("Нет активной тренировки", style = MaterialTheme.typography.titleMedium)
Text("Начните с экрана «Сегодня» — допуск и старт.")
}
return@Scaffold
}
LazyColumn(
Modifier.padding(padding).fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
// статус-строка (Ф-3.1/3.2): часы/пульс — прочерки до m12
Text(
"часы: нет связи пульс: — сеть: ${
if (state.offline) "офлайн" else "онлайн"
}",
style = MaterialTheme.typography.bodySmall,
)
Text(
"вид: ${kindLabel(w.sessionKind)}" + (w.programVersionId?.let { "" } ?: " (вне программы)"),
style = MaterialTheme.typography.bodySmall,
)
}
item {
RestCard(restLeft, onStart = { restLeft = restTotal })
}
item {
PlanBlock(state.planItems, state.exercises)
}
items(state.sets, key = { it.id }) { set ->
SetRow(set, state.exercises)
}
item {
Row(
Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(
onClick = { vm.finish(w.id) {} },
modifier = Modifier.weight(1f),
) { Text("Закончить") }
OutlinedButton(
onClick = { confirmCancel = true },
modifier = Modifier.weight(1f),
) { Text("Сбросить") }
}
}
}
if (formOpen) {
SetFormDialog(
vm = vm,
workoutId = w.id,
exercises = state.exercises,
planItems = state.planItems,
selectedExercise = selectedExercise,
onSelectedExercise = { selectedExercise = it },
weight = weight,
onWeight = { weight = it },
reps = reps,
onReps = { reps = it },
rpe = rpe,
onRpe = { rpe = it },
kneePain = kneePain,
onKneePain = { kneePain = it },
onDismiss = { formOpen = false },
onLogged = { formOpen = false; restLeft = -1 },
)
}
if (confirmCancel) {
AlertDialog(
onDismissRequest = { confirmCancel = false },
title = { Text("Сбросить тренировку?") },
text = { Text("Тренировка сохранится в истории как отменённая (Ф-3.5).") },
confirmButton = {
TextButton(onClick = { vm.cancel(w.id); confirmCancel = false }) { Text("Сбросить") }
},
dismissButton = { TextButton(onClick = { confirmCancel = false }) { Text("Оставить") } },
)
}
}
}
@Composable
private fun RestCard(restLeft: Int, onStart: () -> Unit) {
val phase = if (restLeft >= 0) RestTimer.phase(restLeft) else null
val bg = when (phase) {
null -> MaterialTheme.colorScheme.surfaceVariant
RestPhase.RESTING -> Color(0xFF5A2323)
RestPhase.LAST10 -> Color(0xFF5A4A1E)
RestPhase.READY -> Color(0xFF1E5A2A)
}
Column(
Modifier
.fillMaxWidth()
.background(bg)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
phase?.let { "${it.label}: $restLeft с" } ?: "Таймер отдыха не запущен",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFEDEDED),
)
OutlinedButton(onClick = onStart) { Text("Отдых") }
}
}
@Composable
private fun PlanBlock(items: List<com.watchbody.phone.core.ItemView>, exercises: List<Exercise>) {
if (items.isEmpty()) {
Text("План на сегодня не задан — тренировка вне программы", style = MaterialTheme.typography.bodyMedium)
return
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("План сессии", style = MaterialTheme.typography.titleSmall)
items.forEach { item ->
val sets = item.planSets.joinToString(", ") { "${formatKg(it.weightKg)}×${it.reps}" }
Text("• ${item.exerciseName}: $sets", style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
private fun SetRow(set: WorkoutSet, exercises: List<Exercise>) {
val name = set.exerciseId?.let { id -> exercises.firstOrNull { it.id == id }?.name } ?: "—"
val marks = buildList {
add(if (set.inPlan) "по плану" else "отклонение") // текст, не только цвет 🛡
set.rpe?.let { add("RPE $it") }
set.kneePain?.let { add("боль $it") }
}.joinToString(" · ")
Text(
"${set.seq}. $name — ${formatKg(set.weightKg)} кг × ${set.reps}",
style = MaterialTheme.typography.bodyLarge,
)
Text(marks, style = MaterialTheme.typography.bodySmall)
}
@Composable
private fun SetFormDialog(
vm: WorkoutViewModel,
workoutId: String,
exercises: List<Exercise>,
planItems: List<com.watchbody.phone.core.ItemView>,
selectedExercise: String?,
onSelectedExercise: (String?) -> Unit,
weight: Double,
onWeight: (Double) -> Unit,
reps: Int,
onReps: (Int) -> Unit,
rpe: Int?,
onRpe: (Int?) -> Unit,
kneePain: Int?,
onKneePain: (Int?) -> Unit,
onDismiss: () -> Unit,
onLogged: () -> Unit,
) {
// умолчания при выборе упражнения (план → последний фактический)
LaunchedEffect(selectedExercise) {
if (selectedExercise != null) {
val (w, r) = vm.defaults(selectedExercise)
w?.let { onWeight(it) }
r?.let { onReps(it) }
}
}
val chosen = exercises.firstOrNull { it.id == selectedExercise }
val planNames = planItems.map { it.exerciseName }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Записать подход") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// выбор упражнения: план сессии + активная библиотека (spec 03 §3)
val options = (
planNames.map { name -> name to exercises.firstOrNull { e -> e.name.equals(name, ignoreCase = true) }?.id }
.filter { it.second != null }
) + exercises.filter { e -> planNames.none { it.equals(e.name, ignoreCase = true) } }
.map { it.name to it.id }
Text("Упражнение", style = MaterialTheme.typography.bodyMedium)
Text(
chosen?.name ?: "не выбрано",
style = MaterialTheme.typography.titleMedium,
)
SegmentControl(
"",
options = options.map { (name, _) -> options.indexOfFirst { it.first == name } to name },
selected = options.indexOfFirst { it.second == selectedExercise }.takeIf { it >= 0 },
onSelect = { idx -> onSelectedExercise(options.getOrNull(idx ?: -1)?.second) },
)
StepperPicker("Вес", weight, { onWeight(it ?: weight) }, step = 2.5, min = 0.0, max = 500.0, suffix = " кг")
IntPicker("Повторы", reps, { v -> v?.let(onReps) }, min = 0, max = 50)
SegmentControl(
"RPE",
options = (1..10).map { it to it.toString() },
selected = rpe,
onSelect = onRpe,
)
if (chosen?.groupCode == "legs") { // боль в колене — только «ноги» (Ф-13.3)
SegmentControl(
"Боль в колене",
options = (0..10).map { it to it.toString() },
selected = kneePain,
onSelect = onKneePain,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
vm.logSet(workoutId, selectedExercise, weight, reps, rpe, kneePain)
onLogged()
},
enabled = selectedExercise != null,
) { Text("Записать") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
)
}
private fun kindLabel(kind: String): String = when (kind) {
"normal" -> "обычная"
"return" -> "возврат"
"deload" -> "делоад"
"test" -> "тест"
else -> kind
}

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources />

View File

@ -0,0 +1,90 @@
package com.watchbody.phone
import com.watchbody.phone.core.AdmissionChecker
import com.watchbody.phone.core.Permission
import com.watchbody.phone.core.PlanMatch
import com.watchbody.phone.core.SafetySettings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Чистая логика WorkoutCore (specs 02 §2–3): in_plan и допуск. */
class CoreLogicTest {
private val body = """
{"weeks":[{"week":1,"sessions":[
{"day":"monday","focus":"push","exercises":[
{"exercise_id":"жим","sets":[{"weight_kg":80,"reps":8},{"weight_kg":80,"reps":8}]}]},
{"day":"thursday","focus":"legs","exercises":[
{"exercise_id":"присед","sets":[{"weight_kg":100,"reps":10}]}]}]}]}
""".trimIndent()
// --- PlanMatch (Ф-11.2: ±2,5 кг, повторы ±0) ---
@Test
fun planFor_findsFirstSessionOfDay() {
val plan = PlanMatch.planFor(body, dayOfWeekIso = 1, exerciseName = "жим")
assertEquals(80.0 to 8, plan)
}
@Test
fun planFor_otherDay() {
assertEquals(100.0 to 10, PlanMatch.planFor(body, 4, "присед"))
assertNull(PlanMatch.planFor(body, 4, "жим"))
}
@Test
fun inPlan_withinTolerance() {
assertTrue(PlanMatch.inPlan(body, 1, "жим", 80.0, 8))
assertTrue(PlanMatch.inPlan(body, 1, "жим", 82.5, 8)) // ровно +2,5
assertFalse(PlanMatch.inPlan(body, 1, "жим", 82.6, 8)) // за допуском
assertFalse(PlanMatch.inPlan(body, 1, "жим", 80.0, 7)) // повторы ±0
}
@Test
fun inPlan_noPlan() {
assertFalse(PlanMatch.inPlan(null, 1, "жим", 80.0, 8))
assertFalse(PlanMatch.inPlan(body, 3, "жим", 80.0, 8)) // ср — нет сессии
}
@Test
fun inPlan_brokenBody() {
assertFalse(PlanMatch.inPlan("\"oops\"", 1, "жим", 80.0, 8))
}
// --- AdmissionChecker (Ф-13.2; пороги Safety) ---
@Test
fun admission_cancelOnHighBp() {
assertEquals(Permission.CANCEL, AdmissionChecker.check(SafetySettings(), 160, 95, 80))
assertEquals(Permission.CANCEL, AdmissionChecker.check(SafetySettings(), 120, 91, 80))
}
@Test
fun admission_cardioOnly() {
assertEquals(Permission.CARDIO_ONLY, AdmissionChecker.check(SafetySettings(), 145, 89, 80))
assertEquals(Permission.CARDIO_ONLY, AdmissionChecker.check(SafetySettings(), 140, 90, 80))
}
@Test
fun admission_postponeOnHr() {
assertEquals(Permission.POSTPONE, AdmissionChecker.check(SafetySettings(), 120, 80, 91))
assertEquals(Permission.ALLOW, AdmissionChecker.check(SafetySettings(), 120, 80, 90))
}
@Test
fun admission_missingValuesSkipped() {
assertEquals(Permission.ALLOW, AdmissionChecker.check(SafetySettings(), null, null, null))
assertEquals(Permission.CANCEL, AdmissionChecker.check(SafetySettings(), 160, null, null))
}
@Test
fun admission_customThresholds() {
val stricter = SafetySettings(bpCancelSys = 145, bpCancelDias = 85, bpCardioSys = 135, bpCardioDias = 80, restingHrSitting = 80)
assertEquals(Permission.CANCEL, AdmissionChecker.check(stricter, 146, 70, 60))
assertEquals(Permission.CARDIO_ONLY, AdmissionChecker.check(stricter, 136, 70, 60))
assertEquals(Permission.POSTPONE, AdmissionChecker.check(stricter, 120, 70, 81))
}
}

View File

@ -0,0 +1,67 @@
package com.watchbody.phone
import com.watchbody.phone.core.PlanMatch
import com.watchbody.phone.core.ProgramView
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** ProgramView (spec 04 §4): сессия дня, согласованность с PlanMatch, битый body. */
class ProgramViewTest {
private val body = """
{"weeks":[
{"week":2,"sessions":[{"day":"friday","focus":"pull","exercises":[
{"exercise_id":"тяга","sets":[{"weight_kg":60,"reps":10}]}]}]},
{"week":1,"sessions":[{"day":"wednesday","focus":"push","exercises":[
{"exercise_id":"жим","sets":[{"weight_kg":80,"reps":8},{"weight_kg":80,"reps":8}]},
{"exercise_id":"разводка","sets":[{"weight_kg":12.5,"reps":12}]}]}]}
]}
""".trimIndent()
@Test
fun sessionFor_firstSessionOfTheDay() {
val session = ProgramView.sessionFor(body, 3) // среда
assertEquals("wednesday", session!!.day)
assertEquals("push", session.focus)
assertEquals(2, session.items.size)
assertEquals("жим", session.items[0].exerciseName)
assertEquals(2, session.items[0].planSets.size)
assertEquals(80.0, session.items[0].planSets[0].weightKg, 1e-9)
assertEquals(8, session.items[0].planSets[0].reps)
assertEquals(12.5, session.items[1].planSets[0].weightKg, 1e-9)
}
@Test
fun sessionFor_dayWithoutSession_isNull() {
assertNull(ProgramView.sessionFor(body, 7)) // воскресенье
}
@Test
fun sessionFor_brokenOrNullBody_isNull_noException() {
assertNull(ProgramView.sessionFor(null, 3))
assertNull(ProgramView.sessionFor("\"oops\"", 3))
assertNull(ProgramView.sessionFor("{}", 3))
assertNull(ProgramView.sessionFor(body, 99)) // день вне 1..7
}
@Test
fun sessionFor_agreesWithPlanMatch() {
val plan = PlanMatch.planFor(body, 3, "жим")
val view = ProgramView.sessionFor(body, 3)!!.items[0].planSets[0]
assertEquals(plan!!.first, view.weightKg, 1e-9)
assertEquals(plan.second, view.reps)
}
@Test
fun allWeeks_orderAndShape() {
val weeks = ProgramView.allWeeks(body)
assertEquals(2, weeks.size)
assertEquals(1, weeks[0].week) // порядок неделей — по номеру, не по JSON
assertEquals(2, weeks[1].week)
assertEquals("friday", weeks[1].sessions[0].day)
assertTrue(ProgramView.allWeeks(null).isEmpty())
assertTrue(ProgramView.allWeeks("broken").isEmpty())
}
}

View File

@ -0,0 +1,45 @@
package com.watchbody.phone
import com.watchbody.phone.core.PlanFact
import com.watchbody.phone.core.PlanFactReport
import com.watchbody.phone.core.RestPhase
import com.watchbody.phone.core.RestTimer
import org.junit.Assert.assertEquals
import org.junit.Test
/** Таймер отдыха (spec 03 §7): фазы по остатку. */
class RestTimerTest {
@Test
fun phases_bySecondsLeft() {
assertEquals(RestPhase.RESTING, RestTimer.phase(90))
assertEquals(RestPhase.RESTING, RestTimer.phase(11))
assertEquals(RestPhase.LAST10, RestTimer.phase(10))
assertEquals(RestPhase.LAST10, RestTimer.phase(1))
assertEquals(RestPhase.READY, RestTimer.phase(0))
assertEquals(RestPhase.READY, RestTimer.phase(-1))
}
@Test
fun labels_present_colorDuplicatedByText() {
// 🛡 цвет дублируется текстом: у каждой фазы есть подпись
RestPhase.entries.forEach { assert(it.label.isNotBlank()) }
}
}
/** Локальный план/факт (spec 04 §4): доля in_plan. */
class PlanFactTest {
@Test
fun report_twoOfFive_is40pct() {
val r = PlanFact.of(listOf(true, true, false, false, false))
assertEquals(PlanFactReport(total = 5, inPlan = 2), r)
assertEquals(40, r.pct)
}
@Test
fun empty_isZero() {
assertEquals(0, PlanFact.of(emptyList()).pct)
assertEquals(0, PlanFact.of(emptyList()).total)
}
}

View File

@ -0,0 +1,120 @@
package com.watchbody.phone
import com.watchbody.phone.db.Break
import com.watchbody.phone.db.DailyLog
import com.watchbody.phone.db.Pending
import com.watchbody.phone.db.Profile
import com.watchbody.phone.db.Workout
import com.watchbody.phone.db.WorkoutSet
import com.watchbody.phone.payload.PayloadFactory
import com.watchbody.phone.payload.envelope
import com.watchbody.phone.sync.Batcher
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.assertThrows
import org.junit.Test
/** Batcher (spec 03 §1) и payload-контракт (spec 04). */
class SyncPayloadTest {
// --- Batcher: ≤50, по created_at ---
@Test
fun batch_ordersAndChunks() {
val rows = (1..120).map {
Pending(it.toString(), "workout", "{}", createdAt = (121 - it).toLong())
}
val batches = Batcher.batch(rows)
assertEquals(3, batches.size)
assertEquals(50, batches[0].size)
assertEquals(50, batches[1].size)
assertEquals(20, batches[2].size)
assertEquals("120", batches[0].first().clientGuid) // раньше всех
assertEquals("1", batches[2].last().clientGuid)
}
@Test
fun batch_emptyAndSmall() {
assertEquals(0, Batcher.batch(emptyList()).size)
assertEquals(1, Batcher.batch(listOf(Pending("a", "workout", "{}", 1))).size)
}
// --- PayloadFactory: snake_case и обязательные поля (spec 04 §2) ---
@Test
fun payload_workout_fields() {
val started = 1_760_000_000_000L
val json = PayloadFactory.workout(
Workout("w1", "pv1", started, endedAt = started + 3_600_000,
status = "finished", sessionKind = "return", avgHr = 110),
)
assertEquals("pv1", json["program_version_id"]!!.jsonPrimitive.content)
assertEquals("finished", json["status"]!!.jsonPrimitive.content)
assertEquals("return", json["session_kind"]!!.jsonPrimitive.content)
assertEquals(110, json["avg_hr"]!!.jsonPrimitive.content.toInt())
assertTrue(json["started_at"]!!.jsonPrimitive.content.endsWith("Z"))
assertTrue(json.containsKey("ended_at"))
}
@Test
fun payload_workoutSet_fields() {
val json = PayloadFactory.workoutSet(
WorkoutSet("s1", "w1", "e1", seq = 2, weightKg = 82.5, reps = 8,
doneAt = 1_760_000_000_000, source = "voice", inPlan = true, rpe = 7, kneePain = null),
)
assertEquals("w1", json["workout_id"]!!.jsonPrimitive.content)
assertEquals("e1", json["exercise_id"]!!.jsonPrimitive.content)
assertEquals(2, json["seq"]!!.jsonPrimitive.content.toInt())
assertEquals(82.5, json["weight_kg"]!!.jsonPrimitive.content.toDouble(), 1e-9)
assertEquals(true, json["in_plan"]!!.jsonPrimitive.content.toBoolean())
assertEquals(7, json["rpe"]!!.jsonPrimitive.content.toInt())
assertTrue(!json.containsKey("knee_pain"))
}
@Test
fun payload_dailyLog_fields() {
val json = PayloadFactory.dailyLog(DailyLog("2026-10-19", "g-d1", 120, 80, 100.5, 60, 4))
assertEquals("2026-10-19", json["date"]!!.jsonPrimitive.content)
assertEquals(120, json["bp_sys"]!!.jsonPrimitive.content.toInt())
assertEquals(4, json["wellbeing"]!!.jsonPrimitive.content.toInt())
val empty = PayloadFactory.dailyLog(DailyLog("2026-10-19", "g-d1", null, null, null, null, null))
assertTrue(!empty.containsKey("bp_sys"))
}
@Test
fun payload_break_manualOnly() {
val manual = PayloadFactory.breakItem(
Break("b1", "vacation", "manual", "2026-10-01", "2026-10-15", null, "отпуск"),
)
assertEquals("manual", manual["source"]!!.jsonPrimitive.content)
assertEquals("vacation", manual["kind"]!!.jsonPrimitive.content)
// 🛡 авто-детект — сервер: фабрика отказывает
assertThrows(IllegalArgumentException::class.java) {
PayloadFactory.breakItem(Break("b1", null, "auto", "2026-10-01", null, null, ""))
}
}
@Test
fun payload_profile_fields() {
val json = PayloadFactory.profile(Profile(0, "g-p", "m", 1980, 105.0, 160))
assertEquals("m", json["sex"]!!.jsonPrimitive.content)
assertEquals(1980, json["birth_year"]!!.jsonPrimitive.content.toInt())
assertEquals(160, json["hr_max"]!!.jsonPrimitive.content.toInt())
}
// --- конверт (spec 04 §1) ---
@Test
fun envelope_shape() {
val row = Pending("g1", "workout_set", """{"workout_id":"w1"}""", 1)
val parsed = Json.parseToJsonElement(envelope(row)).jsonObject
assertEquals("g1", parsed["client_guid"]!!.jsonPrimitive.content)
assertEquals("workout_set", parsed["entity"]!!.jsonPrimitive.content)
assertEquals("upsert", parsed["op"]!!.jsonPrimitive.content)
assertEquals("w1", parsed["data"]!!.jsonObject["workout_id"]!!.jsonPrimitive.content)
}
}

View File

@ -0,0 +1,103 @@
package com.watchbody.phone
import com.watchbody.phone.core.SafetySettings
import com.watchbody.phone.core.SettingsValidator
import com.watchbody.phone.core.ZoneCalculator
import com.watchbody.phone.settings.AppSettings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** ZoneCalculator (spec 05 §4): Танака/ручной/нет данных. */
class ZoneCalculatorTest {
@Test
fun tanaka_46YearsOld_roundsTo176() {
// 1980 г.р. в 2026: 208 − 0,7×46 = 175,8 → 176
assertEquals(176, ZoneCalculator.maxHr(1980, null, 2026))
}
@Test
fun manualMaxHr_winsOverFormula() {
assertEquals(190, ZoneCalculator.maxHr(1980, 190, 2026))
}
@Test
fun noData_noZones() {
assertNull(ZoneCalculator.maxHr(null, null, 2026))
assertNull(ZoneCalculator.maxHr(2030, null, 2026)) // возраст ≤ 0 — защита
}
@Test
fun zones_boundaries50_70_85() {
val z = ZoneCalculator.zones(190)
assertEquals(95, z.z2)
assertEquals(133, z.z3)
assertEquals(162, z.z4) // 0,85×190 = 161,5 → 162
assertNotNull(z)
}
}
/** SettingsValidator (spec 05 §4): URL, диапазоны, BpCardio ≤ BpCancel. */
class SettingsValidatorTest {
private fun settings(
baseUrl: String = "https://watchbody.example",
apiKey: String = "key-1",
restSec: Int = 90,
hrMaxBpm: Int? = null,
safety: SafetySettings = SafetySettings(),
hrStrengthMax: Int = 120,
) = AppSettings(baseUrl, apiKey, false, restSec, hrMaxBpm, safety, hrStrengthMax)
@Test
fun validHttps_noErrors() {
val r = SettingsValidator.validate(settings())
assertTrue(r.ok)
assertFalse(r.httpWarning)
}
@Test
fun baseUrl_variants() {
assertTrue(SettingsValidator.validate(settings(baseUrl = "")).errors.containsKey("baseUrl"))
assertTrue(SettingsValidator.validate(settings(baseUrl = "watchbody.example")).errors.containsKey("baseUrl"))
assertTrue(SettingsValidator.validate(settings(baseUrl = "ftp://x/y")).errors.containsKey("baseUrl"))
val http = SettingsValidator.validate(settings(baseUrl = "http://127.0.0.1:5000"))
assertTrue(http.ok) // http — предупреждение, не ошибка (docker)
assertTrue(http.httpWarning)
}
@Test
fun apiKey_required() {
assertTrue(SettingsValidator.validate(settings(apiKey = "")).errors.containsKey("apiKey"))
}
@Test
fun cardioAboveCancel_isError() {
val r = SettingsValidator.validate(
settings(safety = SafetySettings(bpCardioSys = 155)),
)
assertTrue(r.errors.containsKey("bpCardio"))
assertTrue(r.errors["bpCardio"]!!.contains("выше"))
}
@Test
fun ranges_rejected() {
val r = SettingsValidator.validate(
settings(
restSec = 10,
hrMaxBpm = 250,
hrStrengthMax = 90,
safety = SafetySettings(restingHrSitting = 200),
),
)
assertTrue(r.errors.containsKey("restDefaultSec"))
assertTrue(r.errors.containsKey("hrMaxBpm"))
assertTrue(r.errors.containsKey("hrStrengthMax"))
assertTrue(r.errors.containsKey("restingHrSitting"))
assertFalse(r.ok)
}
}

View File

@ -0,0 +1,7 @@
plugins {
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.10" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10" apply false
id("com.google.devtools.ksp") version "2.1.10-1.0.31" apply false
}

View File

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
3-app/4-phone/Phone.WatchBody/gradlew vendored Normal file
View File

@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Phone.WatchBody"
include(":app")