Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0571ae44e | |||
| ae14cee07b | |||
| 4eb8519d9d | |||
| 458f7cc9ef | |||
| b3372a80e4 | |||
| 45ba0bdc62 | |||
| a0f3ac59f9 | |||
| 5cd1023af1 | |||
| d531ecae24 | |||
| 0bb259d12d | |||
| 386693379e | |||
| a4f859f854 | |||
| 2548c6207b | |||
| a3a9c7652c | |||
| 09cddfc8ee | |||
| d6d95080b4 | |||
| a40d9bc076 | |||
| 0f0ecc3d23 |
7
.gitignore
vendored
7
.gitignore
vendored
@ -5,6 +5,13 @@ build/
|
|||||||
# Local configuration (machine-specific, never commit)
|
# Local configuration (machine-specific, never commit)
|
||||||
local.properties
|
local.properties
|
||||||
|
|
||||||
|
# Release signing (v1.9.8) — created by scripts/make-release-keystore.sh.
|
||||||
|
# NEVER commit: the keystore + its passwords are the identity of the app
|
||||||
|
# (leaking them = anyone can ship an APK that upgrades over yours).
|
||||||
|
keystore.properties
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
|
||||||
# IntelliJ / Android Studio
|
# IntelliJ / Android Studio
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
|
|||||||
305
README.en.md
Normal file
305
README.en.md
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
# HormoneTrack
|
||||||
|
|
||||||
|
> **Language**: [Français](README.md) · **English** (this file)
|
||||||
|
|
||||||
|
> **🤖 AI-assisted development**: this project was designed and coded with an
|
||||||
|
> AI assistant. The human contribution was **essential**: continuous feedback,
|
||||||
|
> user reports (testing on a real phone, bug reports with real data exports),
|
||||||
|
> improvement suggestions and validation of every release. The session-by-session
|
||||||
|
> detail is documented in
|
||||||
|
> [docs/DEVELOPPEMENT.md §2](docs/DEVELOPPEMENT.md) (French).
|
||||||
|
|
||||||
|
Hormone therapy (HRT) tracker on Android, with **hour-by-hour** estimated
|
||||||
|
curves of estradiol (E2) and testosterone (T), calibration against blood
|
||||||
|
tests, reminders mirrored on a smartwatch (Huawei Watch GT 3 via Gadgetbridge
|
||||||
|
or Huawei Health) and JSON backup. **100% local, no account, no server.**
|
||||||
|
|
||||||
|
> **⚠️ Medical disclaimer**: the curves are **pharmacokinetic estimates**
|
||||||
|
> for informational purposes — they are not measurements. Always rely on your
|
||||||
|
> blood tests and on your endocrinologist's guidance.
|
||||||
|
|
||||||
|
- **Status**: v1.13.0 — Android build ✅, **lint clean** ✅, **284 unit tests** ✅
|
||||||
|
(250 without the local test data; 7 regressions pinned on real data
|
||||||
|
**not committed to the repo**), **14 Compose UI tests** ✅ (emulator),
|
||||||
|
smartwatch integration = notifications ✅, **private Gitea repo + releases
|
||||||
|
with APK** ✅
|
||||||
|
- **Changelog**: [docs/CHANGELOG.md](docs/CHANGELOG.md) (French)
|
||||||
|
- **User guide**: [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) (French)
|
||||||
|
- **Development docs** (architecture, math, decisions, bugs): [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) (French)
|
||||||
|
- **Watch / Gadgetbridge**: [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md) (French)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Hour-by-hour estimated curves**: E2 (pg/mL) and T (ng/mL), 24 h / 7 d / 30 d views,
|
||||||
|
**dose markers** (forecast and actual), **configurable chart timezone** (v1.4.5),
|
||||||
|
**pan** (drag to scroll back in time), **zoom** (pinch or − / + buttons,
|
||||||
|
6 h → 300 d, adaptive sampling), displayable **peaks & troughs** with their
|
||||||
|
**estimated values** (▲▼ triangles at local extrema, toggleable)
|
||||||
|
- **Two PK models, superimposable**: **Estrannaise (EstraNase)** (hourly tables
|
||||||
|
from the `.ods` spreadsheet) and **Transfem Science** — since v1.4.0 the TFS
|
||||||
|
model is the **official 3-compartment meta-analysis** (exact closed form,
|
||||||
|
parameters from the [TFS simulator](https://transfemscience.org/misc/injectable-e2-simulator/),
|
||||||
|
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
||||||
|
covering **7 esters** (EV, EU, EEn, EB, EC oil, EC suspension, PEP) — WHSAH
|
||||||
|
covers 6 (no PEP). Displayed side by side with independent toggles (3 colors) —
|
||||||
|
**pre-checked according to your treatments' models** (v1.4.7) and **each model
|
||||||
|
calibrated separately** by your labs (v1.4.8 — scale factors adapt to each profile)
|
||||||
|
- **"Lab track" curve (v1.5.0, extended in v1.6.0)**: the curve anchored on your
|
||||||
|
blood tests (`Lab track` chip, off by default) — the PK model's shape between
|
||||||
|
labs, with the amplitude rescaled to pass EXACTLY through every E2 lab value;
|
||||||
|
log-linear transition from one lab to the next, keeps out-of-action-window labs
|
||||||
|
(#61); **`Extend` chip (v1.6.0)**: the curve extends beyond the last lab
|
||||||
|
(model × last lab ratio, horizon = model extinction, estimated part drawn
|
||||||
|
faded + dedicated legend) — the curve is a COMPARISON reference, never an
|
||||||
|
estimate of action (it never feeds the home screen or the alerts)
|
||||||
|
- **Analytical Estrannaise model (v1.9.0)**: the ESE model now uses the 3C
|
||||||
|
closed form published by estrannaise.js (fidelity to the old tables pinned at
|
||||||
|
RMS 0) — **MCMC uncertainty cloud** (`Cloud` chip, ESE-exclusive: 32 posterior
|
||||||
|
curves showing the imprecision range, like the estrannaise website); **6
|
||||||
|
injectable esters** (incl. EUCS, exclusive); the historical ODS is no longer
|
||||||
|
used at runtime (fidelity = tests only); analytical terminal t½
|
||||||
|
(blood draw recommendation)
|
||||||
|
- **Configurable Bateman model** (time to peak, half-life, bioavailability) for
|
||||||
|
gel, patch and oral routes
|
||||||
|
- **Forecast simulation**: set a **Dosage interval** (days) on a treatment →
|
||||||
|
upcoming doses are projected on the chart (never saved); **enabling forecast
|
||||||
|
does not move the chart start** but EXTENDS the window to the next dose
|
||||||
|
(v1.4.4), and the projection is browsed by dragging left, up to **1 year**
|
||||||
|
- **Inactive treatments**: an archived treatment disappears from input and
|
||||||
|
reminders, but **its whole history stays simulated and calibrated** — useful
|
||||||
|
for a valerate → enanthate transition; **grouped at the very bottom** of the
|
||||||
|
Treatments page under a dedicated header, dimmed cards (v1.11.0);
|
||||||
|
**no future projection anymore** (v1.12.0 — its slots disappear from the
|
||||||
|
Forecast chart)
|
||||||
|
- **Dose log** with exact date/time, dose in mg, **per-injection ester**
|
||||||
|
(EV↔EU↔EEn switch like in the spreadsheet), **editable** (tap a row in Doses),
|
||||||
|
**interval in days between doses** displayed; **route icon per dose row**
|
||||||
|
(oral / IM-SC injection / gel / patch, v1.12.0) and **same-day blood test
|
||||||
|
markers** (🧪↑ before the dose, 🧪↓ after)
|
||||||
|
- **Blood tests**: **E2 + T in a single entry** (each optional), displayed
|
||||||
|
**side by side** when they share the same date/time, editable (tap → pick the
|
||||||
|
entry); T units: ng/mL, ng/dL, ng/L, nmol/L
|
||||||
|
- **Suggested next blood draw (v1.8.0, extended v1.8.1 to the Labs and Home pages)**:
|
||||||
|
the Labs page recommends the **estimated trough just before the next injection**
|
||||||
|
(the most comparable moment), **at the first trough where your regimen is
|
||||||
|
stabilized** (~5 half-lives after your last change — dose, ester or interval) —
|
||||||
|
derived from the curves and the Dosage interval; requires an active injectable
|
||||||
|
E2 with a Dosage (a prompt suggests setting one otherwise); estimates, never
|
||||||
|
medical advice;
|
||||||
|
**v1.13.0**: the card shows the **expected E2 at that trough** (when
|
||||||
|
auto-calibration is ON) and compares it to your personal **trough target**
|
||||||
|
(opt-in — distinct from the alert thresholds, never notifies)
|
||||||
|
- **Calibration**: per-treatment scale factor = median(lab ÷ model prediction),
|
||||||
|
computed automatically (the `.ods` "Scale factor", automated) — or **permanent
|
||||||
|
automatic calibration** (option, off by default) which calibrates **each ester
|
||||||
|
with the labs of its own period** (valerate labs → valerate doses, enanthate
|
||||||
|
labs → enanthate doses) and recalibrates the T model
|
||||||
|
- **Empirical T estimation** `T = floor + (base − floor) ÷ (1 + k·E2)`, with
|
||||||
|
**k calibrated per ester period** (T suppression differs valerate vs enanthate),
|
||||||
|
against the already-calibrated E2 — accepted T units: ng/mL, ng/dL, ng/L, nmol/L
|
||||||
|
- **Daily reminders** with **"Taken" / "Snooze 1 h"** actions right in the
|
||||||
|
notification; notifications are mirrored to the GT 3 watch (Gadgetbridge or
|
||||||
|
Huawei Health); **reminders follow the Dosage interval** (v1.4.0): a treatment
|
||||||
|
injected every 7 days only rings on injection day, not every day; **a dose
|
||||||
|
already logged on the day skips the notification** (v1.10.0 — the next
|
||||||
|
reminder resumes at the following slot)
|
||||||
|
- **Configurable alert thresholds** (v1.4.2): E2 (pg/mL) and T (ng/mL) high/low
|
||||||
|
limits in Settings → warning card on the home screen + **notification every
|
||||||
|
15 min even with the app closed** (WorkManager, anti-spam, dedicated channel) —
|
||||||
|
evaluated on the **estimated** level, opt-in
|
||||||
|
- **Full JSON backup**: treatments + doses + labs + T settings
|
||||||
|
**+ parameters (language, auto-calibration, alert thresholds)** (v1.4.2)
|
||||||
|
- **Daily automatic backup (v1.7.0, opt-in)**: IN ADDITION to the manual export —
|
||||||
|
every day, a full JSON backup (same format, directly importable) is written to
|
||||||
|
**the folder you choose** (e.g. a synchronized Owncloud folder). No storage
|
||||||
|
permission (SAF, folder picked once, persistent revocable permission),
|
||||||
|
configurable retention (1–30 copies, default 7 — timestamped file per run,
|
||||||
|
never overwritten; manual exports and foreign files **never touched**), last
|
||||||
|
run status in Settings, first backup immediately on activation
|
||||||
|
- **What's new on every update**: automatic changelog dialog (dismissed =
|
||||||
|
won't show again before the next version)
|
||||||
|
- **Time on HRT** displayed at the top of the Doses page (since the first dose);
|
||||||
|
**next dose in days** on the home screen when it is more than 24 h away (v1.4.1)
|
||||||
|
- **Exportable diagnostic logs** (Settings) — useful for support
|
||||||
|
- **Calendar events**: dose reminders can create a recurring (posology) event in
|
||||||
|
a "HormoneTrack" calendar on your phone
|
||||||
|
- **Full JSON backup/restore** (treatments + doses + labs + T settings + parameters, v1.4.2)
|
||||||
|
- **FR + EN** (per-app language, independent of the system)
|
||||||
|
- Jetpack Compose UI (BOM 2026.08, Material You); 100% local, no account
|
||||||
|
|
||||||
|
## Quick start (building from source)
|
||||||
|
|
||||||
|
Prerequisites: JDK 17+ (Java 21 OK), Android SDK (platform 37 will be
|
||||||
|
auto-downloaded by AGP if the licenses are signed). The wrapper downloads
|
||||||
|
Gradle 9.7.1.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo> && cd HormoneTrack
|
||||||
|
echo "sdk.dir=/path/to/android-sdk" > local.properties # or ANDROID_HOME
|
||||||
|
./gradlew assembleDebug # APK: app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
./gradlew testDebugUnitTest # 284 tests (250 without local test data)
|
||||||
|
./gradlew connectedDebugAndroidTest # 14 UI tests (emulator/device required)
|
||||||
|
./gradlew lint # clean lint required before a release
|
||||||
|
```
|
||||||
|
|
||||||
|
Installing on a phone: developer mode + USB debugging, then Android Studio
|
||||||
|
(**Run ▶️**) or `adb install -r app/build/outputs/apk/debug/app-debug.apk`.
|
||||||
|
No Play Store: the app is sideloaded. Step-by-step details (French):
|
||||||
|
[docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md).
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
Repositories: **gitea.cloudyfy.fr** and **gitea.farewell.dev** (mirror) —
|
||||||
|
`Siphonight/HormoneTrack` on both (private), with **tagged releases**
|
||||||
|
(`v1.1.0` → `v1.10.0`) and **two APKs per release** (since v1.2.5)
|
||||||
|
(downloadable without building, cf
|
||||||
|
[docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)):
|
||||||
|
`-release.apk` (**recommended**, R8-optimized, 2.7 MB) and `-debug.apk` (20 MB):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag # list releases
|
||||||
|
git log --oneline # layer-by-layer history (toolchain / engine / UI / docs)
|
||||||
|
git push -u origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web port**: since v1.4.10, the browser version lives in its own repository
|
||||||
|
`Siphonight/HormoneTrack-web` (same instances) — **aligned versions**
|
||||||
|
(web v1.4.10 = port of Android v1.4.10), synced releases (checklist §16 step
|
||||||
|
4.bis), separate changelogs.
|
||||||
|
|
||||||
|
Every release commit passes `./gradlew testDebugUnitTest` (green required) and
|
||||||
|
is an annotated tag. See [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
|
||||||
|
|
||||||
|
**Release signing**: the release APK is signed with the debug key as long as
|
||||||
|
no `keystore.properties` exists at the repo root; once created via
|
||||||
|
`scripts/make-release-keystore.sh` (both files gitignored), `assembleRelease`
|
||||||
|
signs with that keystore — phone migration documented in
|
||||||
|
[docs/DEVELOPPEMENT.md §16.quater](docs/DEVELOPPEMENT.md).
|
||||||
|
|
||||||
|
## The models in short
|
||||||
|
|
||||||
|
Each injection contributes `dose_mg × profile(dt)` where `profile` is the
|
||||||
|
normalized response (pg/mL per mg); contributions superimpose. **Three
|
||||||
|
superimposable models**:
|
||||||
|
|
||||||
|
- **Estrannaise** = hourly tables from the `.ods` (8001 h), EV/EU/EEn
|
||||||
|
- **Transfem Science** = 3-compartment meta-analysis (V3C, closed form,
|
||||||
|
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
||||||
|
7 esters (EV, EU, EEn, EB, EC, EC suspension, PEP)
|
||||||
|
- **WHSAH** (v1.4.6) = "license-free" fit of the [WHSAH Collective via
|
||||||
|
Mona](https://github.com/mona-hrt/mona) — same mathematical family but
|
||||||
|
independent parameters with explicit bioavailability F < 1: faster rise
|
||||||
|
at D+1 (EEn ~70 pg/mL at D+1 for 5 mg vs ~22 for TFS) and longer decay
|
||||||
|
(EEn t½ 7.3 d vs 4.5 d). 6 esters (no PEP)
|
||||||
|
|
||||||
|
Reference peaks (pg/mL per mg):
|
||||||
|
|
||||||
|
| Profile | Model | Peak (pg/mL/mg) | Tmax | Terminal t½ |
|
||||||
|
|----------|------------------|-----------------|--------|-------------|
|
||||||
|
| EV | Estrannaise | 61.1 | ~45 h | — |
|
||||||
|
| EU | Estrannaise | 3.4 | ~55 h (long plateau) | — |
|
||||||
|
| EEn | Estrannaise | 31.4 | ~152 h | — |
|
||||||
|
| EV | Transfem Science | 59.0 | ~51 h | 3.0 d |
|
||||||
|
| EU | Transfem Science | 10.1 | ~198 h | — |
|
||||||
|
| EEn | Transfem Science | 32.0 | ~156 h | 4.5 d |
|
||||||
|
| EB | Transfem Science | 194.2 | ~16 h | 1.2 d |
|
||||||
|
| EC (oil) | Transfem Science | 31.1 | ~103 h | 6.7 d |
|
||||||
|
| EC (susp.) | Transfem Science | 48.2 | ~29 h | 5.1 d |
|
||||||
|
| PEP | Transfem Science | 1.03 (~6.5× dose) | ~18 d | 28.4 d |
|
||||||
|
| EV | WHSAH | 73.5 | ~41 h | 3.1 d |
|
||||||
|
| EEn | WHSAH | 37.6 | ~120 h | 7.3 d |
|
||||||
|
| EB | WHSAH | 260.1 | ~12 h | 1.3 d |
|
||||||
|
| EC (oil) | WHSAH | 25.0 | ~81 h | 7.9 d |
|
||||||
|
| EC (susp.) | WHSAH | 53.5 | ~16 h | 7.1 d |
|
||||||
|
| EU | WHSAH | 4.9 | ~67 h | 31.7 d |
|
||||||
|
|
||||||
|
**Calibration (v1.4.8)**: the scale factor applies per ESTER and per
|
||||||
|
injection PERIOD (median of lab ÷ prediction ratios, like the original
|
||||||
|
spreadsheet's "Scale factor" column) — and per MODEL: every displayed curve
|
||||||
|
(Estrannaise / Transfem Science / WHSAH) is calibrated with ITS OWN model's
|
||||||
|
prediction → they all stick to your labs, whatever their shape.
|
||||||
|
Auto-calibration is optional (off by default).
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
- **The app is 100% local**: Room database on the phone, no server, no
|
||||||
|
telemetry; Gadgetbridge-compatible (FOSS) with no Huawei Health dependency
|
||||||
|
- **The repository contains no health data**: code and docs are generic; the
|
||||||
|
regression tests that use real exports load their data from
|
||||||
|
`local-test-data/` (**gitignored**, outside the repo — and the history was
|
||||||
|
cleaned before the first push, cf
|
||||||
|
[docs/DEVELOPPEMENT.md §8.bis](docs/DEVELOPPEMENT.md))
|
||||||
|
- Backups = JSON files you store wherever you want (Owncloud, etc.)
|
||||||
|
- **The daily auto-backup (v1.7.0) only writes to the folder YOU picked
|
||||||
|
yourself** (system SAF picker, revocable permission) — nothing ever leaves
|
||||||
|
the phone, no server
|
||||||
|
- `allowBackup=false` (sensitive data); biometric lock planned for Phase 2
|
||||||
|
- The repository is **private**: release APKs download while logged in;
|
||||||
|
making the repo public would make the APKs downloadable without an account
|
||||||
|
(no data risk, see above)
|
||||||
|
|
||||||
|
## Repository structure
|
||||||
|
|
||||||
|
```
|
||||||
|
HormoneTrack/
|
||||||
|
├── README.md ← this file's French twin
|
||||||
|
├── docs/
|
||||||
|
│ ├── GUIDE_INSTALLATION.md user guide (phone + watch, French)
|
||||||
|
│ ├── DEVELOPPEMENT.md full dev docs (architecture, math, bugs, tests, French)
|
||||||
|
│ ├── CHANGELOG.md detailed version history (French)
|
||||||
|
│ └── MONTRE-GADGETBRIDGE.md Huawei GT 3 watch: options + limits (French)
|
||||||
|
├── scripts/
|
||||||
|
│ ├── gitea-release.py publishes a release body (CHANGELOG + APK)
|
||||||
|
│ ├── publish-release.py publishes a tag's 2 APKs (verified by download)
|
||||||
|
│ ├── seed-emulator.py injects a JSON backup into an emulator DB
|
||||||
|
│ └── make-release-keystore.sh generates the release keystore + properties (v1.9.8)
|
||||||
|
├── local-test-data/ ← gitignored: real backups for regression
|
||||||
|
│ tests (NEVER in the repo, cf §8.bis)
|
||||||
|
├── keystore.properties ← gitignored: release signing (optional, §16.quater)
|
||||||
|
├── build.gradle.kts root Gradle config (AGP/Kotlin/KSP pinned)
|
||||||
|
├── settings.gradle.kts
|
||||||
|
├── gradle.properties
|
||||||
|
├── gradle/wrapper/ Gradle 9.7.1 wrapper (jar + properties)
|
||||||
|
├── gradlew / gradlew.bat
|
||||||
|
└── app/
|
||||||
|
├── build.gradle.kts dependencies (Compose, Room, DataStore, Gson…)
|
||||||
|
├── proguard-rules.pro
|
||||||
|
└── src/
|
||||||
|
├── main/
|
||||||
|
│ ├── AndroidManifest.xml
|
||||||
|
│ ├── assets/pk_profiles.json ← hourly tables (Estrannaise/TFS)
|
||||||
|
│ ├── java/com/hormonetrack/
|
||||||
|
│ │ ├── data/ (Room: models, DAOs, repository, backup)
|
||||||
|
│ │ ├── pk/ (PK engine + profiles: Estrannaise/TFS/WHSAH, alerts, export)
|
||||||
|
│ │ ├── reminder/ (exact alarms, notifs + actions, boot, alert worker)
|
||||||
|
│ │ ├── settings/ (DataStore: TConfig, language)
|
||||||
|
│ │ ├── ui/ (Compose: screens, components, theme)
|
||||||
|
│ │ │ └── screens/settings/ ← Settings cards (v1.9.8 split)
|
||||||
|
│ │ ├── HormoneTrackApp.kt
|
||||||
|
│ │ └── MainActivity.kt
|
||||||
|
│ └── res/ (FR/EN strings, theme, icons)
|
||||||
|
├── test/java/com/hormonetrack/ ← JVM unit tests
|
||||||
|
│ ├── pk/ (engine, ESE/TFS/WHSAH profiles, per-model
|
||||||
|
│ │ calibration, reminders, data-driven regressions)
|
||||||
|
│ ├── data/backup/ (Gson round-trip + parameters)
|
||||||
|
│ ├── ui/ (chart window, source guard)
|
||||||
|
│ ├── util/ (export file names, durations)
|
||||||
|
│ └── reminder/ + settings/ (calendar RRULE, changelog)
|
||||||
|
└── androidTest/java/com/hormonetrack/ ← Compose UI tests (v1.9.8)
|
||||||
|
└── uitest/ (navigation, changelog dialog, settings)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [x] v1: E2/T curves, dose log, labs + calibration, reminders, JSON backup, FR/EN
|
||||||
|
- [x] Compose UI tests (13) + dedicated release signing infrastructure (v1.9.8)
|
||||||
|
- [ ] Biometric lock, widget, CSV export
|
||||||
|
- [ ] Watch Phase 2: custom watchface and/or Lite Wearable mini-app (see
|
||||||
|
[docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md))
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
**GPL-3.0** — see [LICENSE](LICENSE). Consistent with the Gadgetbridge
|
||||||
|
ecosystem. The PK models belong to their respective authors
|
||||||
|
([Estrannaise](https://estrannaise.github.io/),
|
||||||
|
[Transfem Science](https://transfemscience.org)).
|
||||||
59
README.md
59
README.md
@ -1,5 +1,7 @@
|
|||||||
# HormoneTrack
|
# HormoneTrack
|
||||||
|
|
||||||
|
> **Langue** : Français (ce fichier) · [English](README.en.md)
|
||||||
|
|
||||||
> **🤖 Développé avec l'IA** : ce projet a été conçu et codé avec un assistant IA.
|
> **🤖 Développé avec l'IA** : ce projet a été conçu et codé avec un assistant IA.
|
||||||
> La contribution humaine a été **essentielle** : feedback continu, retours
|
> La contribution humaine a été **essentielle** : feedback continu, retours
|
||||||
> utilisateur (tests sur vrai téléphone, rapports de bugs accompagnés d'exports
|
> utilisateur (tests sur vrai téléphone, rapports de bugs accompagnés d'exports
|
||||||
@ -16,11 +18,12 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
|
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
|
||||||
> sang et aux consignes de ton endocrinologue.
|
> sang et aux consignes de ton endocrinologue.
|
||||||
|
|
||||||
- **Statut** : v1.9.4 — build Android ✅, **lint vert** ✅, **243 tests unitaires** ✅ (213 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
|
- **Statut** : v1.13.0 — build Android ✅, **lint vert** ✅, **284 tests unitaires** ✅ (250 sans les données de test locales ; 7 régressions épinglées sur données réelles **non versionnées**), **14 tests UI Compose** ✅ (émulateur), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
|
||||||
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
||||||
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
|
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
|
||||||
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
|
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
|
||||||
- **Montre / Gadgetbridge** : [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md)
|
- **Montre / Gadgetbridge** : [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md)
|
||||||
|
- **README anglais** : [README.en.md](README.en.md)
|
||||||
|
|
||||||
## Fonctionnalités
|
## Fonctionnalités
|
||||||
|
|
||||||
@ -64,10 +67,13 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
vers la gauche, jusqu'à **1 an**
|
vers la gauche, jusqu'à **1 an**
|
||||||
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
|
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
|
||||||
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
|
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
|
||||||
**intervalle en jours entre dosages** affiché
|
**intervalle en jours entre dosages** affiché ; **icône de forme de prise**
|
||||||
|
(oral / injection IM-SC / gel / patch, v1.12.0) et **encart « prise de sang
|
||||||
|
le même jour »** (🧪↑ avant la dose, 🧪↓ après)
|
||||||
- **Traitements inactifs** : un traitement archivé n'apparaît plus dans la saisie
|
- **Traitements inactifs** : un traitement archivé n'apparaît plus dans la saisie
|
||||||
ni dans les rappels, mais **tout son historique reste simulé et calibré** —
|
ni dans les rappels, mais **tout son historique reste simulé et calibré** —
|
||||||
utile pour une transition valerate → enanthate
|
utile pour une transition valerate → enanthate ; **regroupés tout en bas** de la
|
||||||
|
page Traitements sous un en-tête dédié, cartes atténuées (v1.11.0)
|
||||||
- **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées
|
- **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées
|
||||||
**côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de
|
**côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de
|
||||||
l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
|
l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
|
||||||
@ -76,7 +82,10 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
**au premier creux où ton régime est stabilisé** (~5 demi-vies après ton dernier
|
**au premier creux où ton régime est stabilisé** (~5 demi-vies après ton dernier
|
||||||
changement — dose, ester ou intervalle) — déduit des courbes et de la Posologie ; requiert un
|
changement — dose, ester ou intervalle) — déduit des courbes et de la Posologie ; requiert un
|
||||||
injectable E2 actif à Posologie (une invite propose de la renseigner sinon) ;
|
injectable E2 actif à Posologie (une invite propose de la renseigner sinon) ;
|
||||||
estimations, jamais un avis médical
|
estimations, jamais un avis médical ;
|
||||||
|
**v1.13.0** : la carte affiche la **valeur E2 attendue au creux** (si l'auto-calibration
|
||||||
|
est ON) et la compare à ta **cible de creux** personnelle (opt-in — distinct des
|
||||||
|
seuils d'alerte, jamais de notification)
|
||||||
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
|
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
|
||||||
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
|
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
|
||||||
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
|
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
|
||||||
@ -88,7 +97,9 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
|
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
|
||||||
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ;
|
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ;
|
||||||
**les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les
|
**les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les
|
||||||
7 jours ne sonne que le jour d'injection, pas tous les jours
|
7 jours ne sonne que le jour d'injection, pas tous les jours ; **une dose déjà
|
||||||
|
loguée dans la journée saute la notification** (v1.10.0 — le rappel suivant
|
||||||
|
repart au créneau suivant)
|
||||||
- **Seuils d'alerte configurables** (v1.4.2) : limites hautes/basses E2 (pg/mL)
|
- **Seuils d'alerte configurables** (v1.4.2) : limites hautes/basses E2 (pg/mL)
|
||||||
et T (ng/mL) dans Paramètres → carte d'avertissement sur l'accueil +
|
et T (ng/mL) dans Paramètres → carte d'avertissement sur l'accueil +
|
||||||
**notification toutes les 15 min même app fermée** (WorkManager, anti-spam,
|
**notification toutes les 15 min même app fermée** (WorkManager, anti-spam,
|
||||||
@ -123,7 +134,8 @@ par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
|
|||||||
git clone <repo> && cd HormoneTrack
|
git clone <repo> && cd HormoneTrack
|
||||||
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
||||||
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
||||||
./gradlew testDebugUnitTest # 243 tests (213 sans les données locales)
|
./gradlew testDebugUnitTest # 284 tests (250 sans les données locales)
|
||||||
|
./gradlew connectedDebugAndroidTest # 14 tests UI (émulateur/appareil requis)
|
||||||
./gradlew lint # lint vert obligatoire avant release
|
./gradlew lint # lint vert obligatoire avant release
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -135,9 +147,9 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
|
|||||||
|
|
||||||
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
|
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
|
||||||
`Siphonight/HormoneTrack` sur les deux (privé), avec
|
`Siphonight/HormoneTrack` sur les deux (privé), avec
|
||||||
**releases taguées** (`v1.1.0` → `v1.8.1`) et **deux APK par release** (depuis v1.2.5)
|
**releases taguées** (`v1.1.0` → `v1.10.0`) et **deux APK par release** (depuis v1.2.5)
|
||||||
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
|
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
|
||||||
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :
|
`-release.apk` (**recommandé**, optimisé R8, 2,7 Mo) et `-debug.apk` (20 Mo) :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag # lister les releases
|
git tag # lister les releases
|
||||||
@ -153,6 +165,12 @@ dépôt `Siphonight/HormoneTrack-web` (mêmes instances) — versions **alignée
|
|||||||
Chaque commit de release passe `./gradlew testDebugUnitTest` (vert obligatoire) et est
|
Chaque commit de release passe `./gradlew testDebugUnitTest` (vert obligatoire) et est
|
||||||
taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
|
taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
|
||||||
|
|
||||||
|
**Signature release** : l'APK release est signé avec la clé debug tant qu'aucun
|
||||||
|
`keystore.properties` n'existe à la racine ; dès sa création via
|
||||||
|
`scripts/make-release-keystore.sh` (fichiers gitignorés), `assembleRelease`
|
||||||
|
signe avec ce keystore — migration téléphone documentée dans
|
||||||
|
[docs/DEVELOPPEMENT.md §16.quater](docs/DEVELOPPEMENT.md).
|
||||||
|
|
||||||
## Les modèles en bref
|
## Les modèles en bref
|
||||||
|
|
||||||
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée
|
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée
|
||||||
@ -217,7 +235,7 @@ forme. L'auto-calibration est optionnelle (désactivée par défaut).
|
|||||||
|
|
||||||
```
|
```
|
||||||
HormoneTrack/
|
HormoneTrack/
|
||||||
├── README.md ← ce fichier
|
├── README.md ← ce fichier (README.en.md = version anglaise)
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── GUIDE_INSTALLATION.md guide utilisateur (téléphone + montre)
|
│ ├── GUIDE_INSTALLATION.md guide utilisateur (téléphone + montre)
|
||||||
│ ├── DEVELOPPEMENT.md doc de dev complète (architecture, maths, bugs, tests)
|
│ ├── DEVELOPPEMENT.md doc de dev complète (architecture, maths, bugs, tests)
|
||||||
@ -226,9 +244,11 @@ HormoneTrack/
|
|||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── gitea-release.py publie le corps d'une release (CHANGELOG + APK)
|
│ ├── gitea-release.py publie le corps d'une release (CHANGELOG + APK)
|
||||||
│ ├── publish-release.py publie les 2 APK d'un tag (vérification par téléchargement)
|
│ ├── publish-release.py publie les 2 APK d'un tag (vérification par téléchargement)
|
||||||
│ └── seed-emulator.py injecte un backup JSON dans la DB d'un émulateur (cf §16.ter)
|
│ ├── seed-emulator.py injecte un backup JSON dans la DB d'un émulateur (cf §16.ter)
|
||||||
|
│ └── make-release-keystore.sh génère le keystore de signature release (v1.9.8, §16.quater)
|
||||||
├── local-test-data/ ← gitignoré : backups réels pour les tests
|
├── local-test-data/ ← gitignoré : backups réels pour les tests
|
||||||
│ de régression (JAMAIS dans le dépôt, cf §8.bis)
|
│ de régression (JAMAIS dans le dépôt, cf §8.bis)
|
||||||
|
├── keystore.properties ← gitignoré : signature release (optionnel, §16.quater)
|
||||||
├── build.gradle.kts config Gradle racine (AGP/Kotlin/KSP épinglés)
|
├── build.gradle.kts config Gradle racine (AGP/Kotlin/KSP épinglés)
|
||||||
├── settings.gradle.kts
|
├── settings.gradle.kts
|
||||||
├── gradle.properties
|
├── gradle.properties
|
||||||
@ -247,22 +267,25 @@ HormoneTrack/
|
|||||||
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes)
|
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes)
|
||||||
│ │ ├── settings/ (DataStore : TConfig, langue)
|
│ │ ├── settings/ (DataStore : TConfig, langue)
|
||||||
│ │ ├── ui/ (Compose : screens, components, theme)
|
│ │ ├── ui/ (Compose : screens, components, theme)
|
||||||
|
│ │ │ └── screens/settings/ ← cartes des Paramètres (découpage v1.9.8)
|
||||||
│ │ ├── HormoneTrackApp.kt
|
│ │ ├── HormoneTrackApp.kt
|
||||||
│ │ └── MainActivity.kt
|
│ │ └── MainActivity.kt
|
||||||
│ └── res/ (strings FR/EN, thème, icônes)
|
│ └── res/ (strings FR/EN, thème, icônes)
|
||||||
└── test/java/com/hormonetrack/ ← tests unitaires JVM
|
├── test/java/com/hormonetrack/ ← tests unitaires JVM
|
||||||
├── pk/ (moteur, profils ESE/TFS/WHSAH, calibration
|
│ ├── pk/ (moteur, profils ESE/TFS/WHSAH, calibration
|
||||||
│ par modèle, rappels, régressions data-driven)
|
│ │ par modèle, rappels, régressions data-driven)
|
||||||
├── data/backup/ (round-trip Gson + paramètres)
|
│ ├── data/backup/ (round-trip Gson + paramètres)
|
||||||
├── ui/ (fenêtre graphique, garde de source)
|
│ ├── ui/ (fenêtre graphique, garde de source)
|
||||||
├── util/ (noms de fichiers d'export, durées)
|
│ ├── util/ (noms de fichiers d'export, durées)
|
||||||
├── reminder/ + settings/ (RRULE agenda, changelog)
|
│ └── reminder/ + settings/ (RRULE agenda, changelog)
|
||||||
|
└── androidTest/java/com/hormonetrack/ ← tests UI Compose (v1.9.8)
|
||||||
|
└── uitest/ (navigation, dialog changelog, Paramètres)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Feuille de route
|
## Feuille de route
|
||||||
|
|
||||||
- [x] v1 : courbes E2/T, log doses, labs + calibration, rappels, backup JSON, FR/EN
|
- [x] v1 : courbes E2/T, log doses, labs + calibration, rappels, backup JSON, FR/EN
|
||||||
- [ ] Tests UI Compose + compilation release signée
|
- [x] Tests UI Compose (13) + infrastructure de compilation release signée (v1.9.8)
|
||||||
- [ ] Verrou biométrique, widget, export CSV
|
- [ ] Verrou biométrique, widget, export CSV
|
||||||
- [ ] Phase 2 montre : watchface personnalisée et/ou mini-app Lite Wearable (voir [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md))
|
- [ ] Phase 2 montre : watchface personnalisée et/ou mini-app Lite Wearable (voir [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md))
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,25 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
id("com.google.devtools.ksp")
|
id("com.google.devtools.ksp")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Signature RELEASE (v1.9.8) ──────────────────────────────────────────────
|
||||||
|
// keystore.properties (RACINE du dépôt, GITIGNORÉ) décrit le vrai keystore :
|
||||||
|
// storeFile=release.keystore (chemin relatif à la racine du dépôt)
|
||||||
|
// storePassword=…
|
||||||
|
// keyAlias=…
|
||||||
|
// keyPassword=…
|
||||||
|
// Tant que ce fichier est ABSENT, la build release reste signée avec la clé
|
||||||
|
// DEBUG (comportement historique : installation par-dessus l'APK debug sans
|
||||||
|
// perte de données, cf §16.quater). Dès qu'il existe (créé par
|
||||||
|
// scripts/make-release-keystore.sh), la build release l'utilise — ⚠️ signature
|
||||||
|
// différente ⇒ migration obligatoire : export JSON → désinstallation →
|
||||||
|
// réinstallation → réimport (documenté §16.quater).
|
||||||
|
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.hormonetrack"
|
namespace = "com.hormonetrack"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
@ -17,8 +33,8 @@ android {
|
|||||||
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
||||||
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
||||||
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
||||||
versionCode = 41
|
versionCode = 50
|
||||||
versionName = "1.9.4"
|
versionName = "1.13.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@ -35,12 +51,27 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
// ⚠️ Signé avec la CLÉ DEBUG volontairement : l'APK release reste
|
// Signature : VRAI keystore si keystore.properties existe (racine du
|
||||||
// installable PAR-DESSUS l'APK debug déjà installé (même signature),
|
// dépôt, gitignoré — créé par scripts/make-release-keystore.sh) ;
|
||||||
// sans désinstallation ni perte de données, tout en bénéficiant de
|
// sinon CLÉ DEBUG volontairement : l'APK release reste installable
|
||||||
// R8. Si un jour on distribue largement : générer un vrai keystore
|
// PAR-DESSUS l'APK debug déjà installé (même signature), sans
|
||||||
// (signature différente → migration backup→désinstall→réimport).
|
// désinstallation ni perte de données, tout en bénéficiant de R8.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// ⚠️ Passer au vrai keystore CHANGE la signature : migration
|
||||||
|
// backup→désinstall→réimport requise sur le téléphone (§16.quater).
|
||||||
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
|
val props = Properties().apply {
|
||||||
|
keystorePropertiesFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
signingConfigs.create("release") {
|
||||||
|
storeFile = rootProject.file(props.getProperty("storeFile"))
|
||||||
|
storePassword = props.getProperty("storePassword")
|
||||||
|
keyAlias = props.getProperty("keyAlias")
|
||||||
|
keyPassword = props.getProperty("keyPassword")
|
||||||
|
}
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,4 +160,16 @@ dependencies {
|
|||||||
|
|
||||||
// Tests unitaires JVM
|
// Tests unitaires JVM
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
|
||||||
|
// ── Tests UI Compose (v1.9.8, androidTest — cf docs/DEVELOPPEMENT.md §8.ter) ──
|
||||||
|
// Exécution : ./gradlew connectedDebugAndroidTest (émulateur/appareil requis).
|
||||||
|
// Le BOM est réappliqué sur la configuration androidTest pour épinner les
|
||||||
|
// mêmes versions que le runtime Compose de l'app.
|
||||||
|
androidTestImplementation(composeBom)
|
||||||
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
|
||||||
|
// JUnit explicite (org.junit.Rule/@Test) — ne pas compter sur le transitif
|
||||||
|
androidTestImplementation("junit:junit:4.13.2")
|
||||||
|
// UiDevice (pressBack pour fermer les dialogs — v1.10.0, LogDoseRequestTest)
|
||||||
|
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,108 @@
|
|||||||
|
package com.hormonetrack.uitest
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.hormonetrack.BuildConfig
|
||||||
|
import com.hormonetrack.MainActivity
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.BeforeClass
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test UI du dialog « Nouveautés » (v1.9.8) — épinglé sur les comportements
|
||||||
|
* historiques des fixes v1.3.2/v1.3.3 (docs/DEVELOPPEMENT.md §14) :
|
||||||
|
* - affiché quand la version installée est plus récente que la dernière vue ;
|
||||||
|
* - PAS affiché quand la version courante a déjà été vue ;
|
||||||
|
* - une fois FERMÉ, il ne RÉAPPARAÎT PAS au redémarrage (la version vue est
|
||||||
|
* mémorisée AVANT l'affichage — sinon le même changelog ressortait à
|
||||||
|
* chaque réouverture de l'app, bug v1.3.2).
|
||||||
|
*
|
||||||
|
* ⚠️ Technique : ces tests lancent l'activité MANUELLEMENT (ActivityScenario
|
||||||
|
* + règle Compose vide) et non via createAndroidComposeRule, car l'état
|
||||||
|
* DataStore (changelogSeenVersion) doit être réglé AVANT que
|
||||||
|
* HormoneTrackRoot ne lise la version vue dans son LaunchedEffect de
|
||||||
|
* démarrage. La règle automatique lancerait l'activité trop tôt (avant le
|
||||||
|
* @Before du test) et le dialog du test précédent fausserait l'état.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ChangelogDialogTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createEmptyComposeRule()
|
||||||
|
|
||||||
|
private var scenario: ActivityScenario<MainActivity>? = null
|
||||||
|
|
||||||
|
private fun res(id: Int, vararg args: Any): String =
|
||||||
|
UiTestEnv.targetContext.getString(id, *args)
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
scenario?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dialogShownWhenInstalledVersionIsNewerThanLastSeen() {
|
||||||
|
runBlocking {
|
||||||
|
UiTestEnv.app.container.settings.setChangelogSeenVersion("1.0.0")
|
||||||
|
}
|
||||||
|
scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dialogAbsentWhenVersionAlreadySeen() {
|
||||||
|
runBlocking {
|
||||||
|
UiTestEnv.app.container.settings.setChangelogSeenVersion(BuildConfig.VERSION_NAME)
|
||||||
|
}
|
||||||
|
scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||||
|
).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dialogDoesNotReappearAfterDismissAndRestart() {
|
||||||
|
runBlocking {
|
||||||
|
UiTestEnv.app.container.settings.setChangelogSeenVersion("1.0.0")
|
||||||
|
}
|
||||||
|
scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
// Fermeture par le bouton « OK » (identique FR/EN)
|
||||||
|
composeRule.onNodeWithText(res(R.string.ok)).performClick()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||||
|
).assertDoesNotExist()
|
||||||
|
// Redémarrage de l'activité : le dialog ne doit PAS revenir — la
|
||||||
|
// version vue a été persistée AVANT l'affichage (fix v1.3.2).
|
||||||
|
scenario!!.recreate()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||||
|
).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@BeforeClass
|
||||||
|
@JvmStatic
|
||||||
|
fun setUpApp() {
|
||||||
|
// Permission de notifications uniquement : le changelog est réglé
|
||||||
|
// PAR TEST (avant chaque lancement manuel d'activité).
|
||||||
|
UiTestEnv.grantPostNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
package com.hormonetrack.uitest
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.uiautomator.UiDevice
|
||||||
|
import com.hormonetrack.MainActivity
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.BeforeClass
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test UI de la demande « logger une dose » venue d'une NOTIFICATION
|
||||||
|
* (v1.10.0, **fix #69**) : le dialog s'ouvre UNE FOIS au lancement avec les
|
||||||
|
* extras `open_log_dose`/`treatment_id` (même contrat que ReminderReceiver,
|
||||||
|
* cf reminder/ReminderManager.kt), puis — une fois fermé — ne se rouvre
|
||||||
|
* JAMAIS tout seul. Comportement bogué épinglé : chaque retour sur l'onglet
|
||||||
|
* Accueil rouvrait le dialog indéfiniment (le paramètre d'intent vivait au
|
||||||
|
* niveau activité alors que la consommation vivait dans les `remember` de
|
||||||
|
* Home, détruits à chaque changement d'onglet).
|
||||||
|
*
|
||||||
|
* Lancement MANUEL avec intent (règle Compose vide + ActivityScenario) : le
|
||||||
|
* contrat du fix exige de régler l'état AVANT que la composition ne lise
|
||||||
|
* l'intent — et l'overload `createAndroidComposeRule(intent)` n'existe pas
|
||||||
|
* dans la ligne Compose 1.12. La fermeture passe par BACK (onDismissRequest,
|
||||||
|
* indépendant des libellés) ; le titre `add_dose` (« Log a dose ») est
|
||||||
|
* affiché dans les DEUX variantes du dialog (avec ou sans traitements en
|
||||||
|
* base) : le test reste data-indépendant.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class LogDoseRequestTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createEmptyComposeRule()
|
||||||
|
|
||||||
|
private var scenario: ActivityScenario<MainActivity>? = null
|
||||||
|
|
||||||
|
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
scenario?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun doseDialogOpensOnceAndNeverReopensAfterTabSwitch() {
|
||||||
|
val intent = Intent(UiTestEnv.targetContext, MainActivity::class.java).apply {
|
||||||
|
putExtra("open_log_dose", true)
|
||||||
|
putExtra("treatment_id", 1L)
|
||||||
|
}
|
||||||
|
scenario = ActivityScenario.launch(intent)
|
||||||
|
|
||||||
|
val dialogTitle = res(R.string.add_dose)
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(dialogTitle).assertIsDisplayed()
|
||||||
|
|
||||||
|
// Fermeture du dialog (BACK → onDismissRequest)
|
||||||
|
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(dialogTitle).assertDoesNotExist()
|
||||||
|
|
||||||
|
// Changer d'onglet puis revenir sur l'accueil : le dialog NE doit
|
||||||
|
// PAS se rouvrir (demande consommée une fois pour toutes)
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_doses)).performClick()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(dialogTitle).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@BeforeClass
|
||||||
|
@JvmStatic
|
||||||
|
fun setUpApp() = UiTestEnv.prepare()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
package com.hormonetrack.uitest
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.hormonetrack.MainActivity
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import org.junit.BeforeClass
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test UI de NAVIGATION (v1.9.8) : la barre d'onglets mène bien à chaque
|
||||||
|
* écran, et les Paramètres s'ouvrent depuis l'icône de l'accueil.
|
||||||
|
*
|
||||||
|
* Les preuves de navigation utilisent des éléments **inconditionnels**
|
||||||
|
* (indépendants des données Room, contrairement au contenu des listes) :
|
||||||
|
* - Graphiques → le chip de plage « 24 h » (toujours affiché) ;
|
||||||
|
* - Doses / Analyses / Traitements → leur FloatingActionButton « + »
|
||||||
|
* (contentDescription add_dose / add_lab / add_treatment) ;
|
||||||
|
* - Paramètres → le titre de l'écran + les sections toujours présentes.
|
||||||
|
*
|
||||||
|
* Textes résolus via targetContext (cf UiTestEnv) : indépendant de la langue
|
||||||
|
* système et du réglage in-app FR/EN.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class NavigationTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bottomBarShowsAllFiveTabs() {
|
||||||
|
listOf(
|
||||||
|
R.string.nav_home, R.string.nav_chart, R.string.nav_doses,
|
||||||
|
R.string.nav_labs, R.string.nav_treatments
|
||||||
|
).forEach { labelRes ->
|
||||||
|
composeRule.onNodeWithText(res(labelRes)).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun homeTabShowsAppTitle() {
|
||||||
|
// Retour explicite à l'accueil d'abord : l'état sauvegardé de la
|
||||||
|
// navigation peut restaurer l'onglet du test précédent.
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithText(res(R.string.app_name)).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chartTabShowsRangeChips() {
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_chart)).performClick()
|
||||||
|
composeRule.onNodeWithText(res(R.string.chart_24h)).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText(res(R.string.chart_7j)).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText(res(R.string.chart_30j)).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dosesTabShowsLogDoseFab() {
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_doses)).performClick()
|
||||||
|
composeRule.onNodeWithContentDescription(res(R.string.add_dose))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun labsTabShowsAddLabFab() {
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_labs)).performClick()
|
||||||
|
composeRule.onNodeWithContentDescription(res(R.string.add_lab))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun treatmentsTabShowsAddTreatmentFab() {
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_treatments)).performClick()
|
||||||
|
composeRule.onNodeWithContentDescription(res(R.string.add_treatment))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsOpensFromHomeGearIcon() {
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithContentDescription(res(R.string.settings)).performClick()
|
||||||
|
// Titre de l'écran + bouton retour (TopAppBar des Paramètres)
|
||||||
|
composeRule.onNodeWithText(res(R.string.settings)).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithContentDescription(res(R.string.back))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@BeforeClass
|
||||||
|
@JvmStatic
|
||||||
|
fun setUpApp() = UiTestEnv.prepare()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
package com.hormonetrack.uitest
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.hormonetrack.MainActivity
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import org.junit.BeforeClass
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test UI de l'écran Paramètres (v1.9.8) : chaque SECTION est présente après
|
||||||
|
* l'ouverture depuis l'accueil. Les sections profondes (sauvegarde, logs,
|
||||||
|
* à propos) vivent SOUS le pli de l'écran scrollable → `assertExists`
|
||||||
|
* (présence dans la hiérarchie) et non `assertIsDisplayed` (visibilité à
|
||||||
|
* l'écran) : ce test vérifie la STRUCTURE, pas la mise en page.
|
||||||
|
*
|
||||||
|
* Les chips de langue « Français » / « English » sont des LITTÉRAUX dans le
|
||||||
|
* code (chaque langue s'écrit dans sa propre langue — convention UX i18n) :
|
||||||
|
* l'assertion ne dépend d'aucune ressource.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SettingsScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
|
||||||
|
|
||||||
|
private fun openSettings() {
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithContentDescription(res(R.string.settings)).performClick()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun languageSectionShowsLocalizedChips() {
|
||||||
|
openSettings()
|
||||||
|
composeRule.onNodeWithText(res(R.string.language)).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Français").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("English").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allSettingsSectionsArePresent() {
|
||||||
|
openSettings()
|
||||||
|
listOf(
|
||||||
|
R.string.auto_calibrate, // Calibration automatique
|
||||||
|
R.string.t_model_title, // Estimation T
|
||||||
|
R.string.alert_section, // Seuils d'alerte
|
||||||
|
R.string.timezone_section, // Fuseau du graphique
|
||||||
|
R.string.reminders_section, // Rappels / alarmes exactes
|
||||||
|
R.string.backup_section, // Sauvegarde JSON manuelle
|
||||||
|
R.string.autobackup_section, // Auto-backup quotidien
|
||||||
|
R.string.logs_section, // Logs de diagnostic
|
||||||
|
R.string.about_title // À propos / version
|
||||||
|
).forEach { sectionRes ->
|
||||||
|
composeRule.onNodeWithText(res(sectionRes)).assertExists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aboutSectionShowsInstalledVersion() {
|
||||||
|
openSettings()
|
||||||
|
// « Version : X.Y.Z » — la valeur vient de BuildConfig.VERSION_NAME
|
||||||
|
// (épinglé : les tags v1.3.0→v1.3.2 embarquaient un versionName
|
||||||
|
// jamais bumpé, bug §14 #46 — ce test l'attraperait désormais).
|
||||||
|
val expected = UiTestEnv.targetContext.getString(
|
||||||
|
R.string.settings_version, com.hormonetrack.BuildConfig.VERSION_NAME
|
||||||
|
)
|
||||||
|
composeRule.onNodeWithText(expected).assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@BeforeClass
|
||||||
|
@JvmStatic
|
||||||
|
fun setUpApp() = UiTestEnv.prepare()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
package com.hormonetrack.uitest
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.hormonetrack.BuildConfig
|
||||||
|
import com.hormonetrack.HormoneTrackApp
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environnement partagé des tests UI Compose (v1.9.8, cf docs/DEVELOPPEMENT.md
|
||||||
|
* §8.ter). Les tests instrumentés tournent CONTRE l'app réelle installée :
|
||||||
|
* ils doivent donc être **déterministes quel que soit l'état de l'appareil**
|
||||||
|
* (données Room seedées ou non, locale système, version précédemment vue).
|
||||||
|
*
|
||||||
|
* Deux prérequis réglés ici AVANT le lancement de l'activité :
|
||||||
|
* - **permission POST_NOTIFICATIONS** : MainActivity la demande au premier
|
||||||
|
* lancement (API 33+). Sans pré-grant, le dialog SYSTÈME s'afficherait par
|
||||||
|
* dessus l'app et les assertions Compose ne voient que la fenêtre de
|
||||||
|
* l'app → timeout. Le grant shell (`pm grant`) est persistant.
|
||||||
|
* - **dialog « Nouveautés » neutralisé** : HormoneTrackRoot affiche le
|
||||||
|
* changelog au lancement quand la version installée est plus récente que
|
||||||
|
* la dernière vue — un dialog MODAL qui masquerait toute la navigation.
|
||||||
|
* En marquant la version courante comme vue, il ne s'affiche plus. Les
|
||||||
|
* tests dédiés au dialog (ChangelogDialogTest) relancent l'activité
|
||||||
|
* manuellement APRÈS avoir réglé leur propre état.
|
||||||
|
*
|
||||||
|
* Les assertions de texte passent par `targetContext.getString(...)` : la
|
||||||
|
* ressource est résolue dans la MÊME configuration que celle de l'UI (les
|
||||||
|
* per-app locales d'AppCompat s'appliquent à toute l'application), ce qui
|
||||||
|
* rend les tests indépendants de la langue système OU du réglage in-app.
|
||||||
|
*/
|
||||||
|
object UiTestEnv {
|
||||||
|
|
||||||
|
val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
|
||||||
|
/** L'Application réelle du process instrumenté (même instance que l'UI). */
|
||||||
|
val app: HormoneTrackApp =
|
||||||
|
targetContext.applicationContext as HormoneTrackApp
|
||||||
|
|
||||||
|
/** Exécute une commande shell et ATTEND sa fin (lecture jusqu'à EOF). */
|
||||||
|
fun runShellCommand(command: String) {
|
||||||
|
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||||
|
automation.executeShellCommand(command).use { pfd ->
|
||||||
|
java.io.FileInputStream(pfd.fileDescriptor).use { it.readBytes() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pré-grante la permission de notifications (idempotent, API 33+). */
|
||||||
|
fun grantPostNotifications() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
runShellCommand("pm grant com.hormonetrack android.permission.POST_NOTIFICATIONS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marque le changelog de la version courante comme déjà vu. */
|
||||||
|
fun markChangelogSeen() = runBlocking {
|
||||||
|
app.container.settings.setChangelogSeenVersion(BuildConfig.VERSION_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** À appeler dans le @BeforeClass de chaque classe de test UI. */
|
||||||
|
fun prepare() {
|
||||||
|
grantPostNotifications()
|
||||||
|
markChangelogSeen()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,7 +26,13 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- launchMode singleTop (v1.10.1) : un tap sur la notification de
|
||||||
|
rappel quand l'app est DÉJÀ ouverte délivre onNewIntent (avec les
|
||||||
|
extras open_log_dose/treatment_id) SANS détruire/recréer
|
||||||
|
l'activité — l'ancien comportement (standard + CLEAR_TOP)
|
||||||
|
recréait tout, et onNewIntent était du code mort. -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.hormonetrack.ui.HormoneTrackRoot
|
import com.hormonetrack.ui.HormoneTrackRoot
|
||||||
@ -22,6 +23,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private val notificationPermissionLauncher =
|
private val notificationPermissionLauncher =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demande « logger une dose » venue d'une NOTIFICATION (extra
|
||||||
|
* `open_log_dose`/`treatment_id`). Détenu par l'ACTIVITÉ — survit aux
|
||||||
|
* changements d'onglets — et consommé UNE FOIS par HomeScreen via
|
||||||
|
* [HormoneTrackRoot] (**fix #69 v1.10.0** : l'ancien code passait le
|
||||||
|
* paramètre d'intent tel quel à la composition ; quitter l'onglet
|
||||||
|
* Accueil détruisait les `remember` locaux, le paramètre lui vivait au
|
||||||
|
* niveau activité → le dialog se rouvrait à CHAQUE retour sur l'accueil).
|
||||||
|
*/
|
||||||
|
private val logDoseRequest = mutableStateOf<Long?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val container = (application as HormoneTrackApp).container
|
val container = (application as HormoneTrackApp).container
|
||||||
@ -53,15 +65,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val openLogDose = intent?.getBooleanExtra("open_log_dose", false) ?: false
|
logDoseRequest.value = consumeLogDoseExtras(intent)
|
||||||
val treatmentId = intent?.getLongExtra("treatment_id", -1L) ?: -1L
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
HormoneTrackTheme {
|
HormoneTrackTheme {
|
||||||
HormoneTrackRoot(
|
HormoneTrackRoot(logDoseRequest = logDoseRequest)
|
||||||
initialOpenLogDose = openLogDose,
|
|
||||||
initialTreatmentId = treatmentId
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -69,5 +77,22 @@ class MainActivity : AppCompatActivity() {
|
|||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
// v1.10.0 : un tap sur la notification quand l'app est DÉJÀ ouverte
|
||||||
|
// ouvre aussi le dialog (avant : setIntent seul, la demande était
|
||||||
|
// silencieusement perdue).
|
||||||
|
consumeLogDoseExtras(intent)?.let { logDoseRequest.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit les extras de demande de log puis les RETIRE de l'intent : un
|
||||||
|
* recreate de l'activité (rotation, processus restauré) ne doit JAMAIS
|
||||||
|
* rouvrir le dialog d'une notification traitée il y a longtemps.
|
||||||
|
*/
|
||||||
|
private fun consumeLogDoseExtras(intent: Intent?): Long? {
|
||||||
|
val open = intent?.getBooleanExtra("open_log_dose", false) ?: false
|
||||||
|
val id = intent?.getLongExtra("treatment_id", -1L) ?: -1L
|
||||||
|
intent?.removeExtra("open_log_dose")
|
||||||
|
intent?.removeExtra("treatment_id")
|
||||||
|
return if (open && id > 0) id else null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,7 +36,12 @@ data class UserSettings(
|
|||||||
val alertE2High: Double? = null,
|
val alertE2High: Double? = null,
|
||||||
val alertE2Low: Double? = null,
|
val alertE2Low: Double? = null,
|
||||||
val alertTHigh: Double? = null,
|
val alertTHigh: Double? = null,
|
||||||
val alertTLow: Double? = null
|
val alertTLow: Double? = null,
|
||||||
|
// v1.13.0 : cible de creux E2 (opt-in) — champs NULLABLES pour la
|
||||||
|
// rétrocompatibilité : un backup v1/v2 antérieur ne les a pas → Gson
|
||||||
|
// les laisse à null → l'import ne touche pas à la cible locale.
|
||||||
|
val troughTargetLow: Double? = null,
|
||||||
|
val troughTargetHigh: Double? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BackupData(
|
data class BackupData(
|
||||||
|
|||||||
@ -18,7 +18,8 @@ data class PKPreset(
|
|||||||
object PKPresets {
|
object PKPresets {
|
||||||
|
|
||||||
val all: List<PKPreset> = listOf(
|
val all: List<PKPreset> = listOf(
|
||||||
// --- Injections: Estrannaise models (profiles from the ODS) ---
|
// --- Injections : modèle Estrannaise ANALYTIQUE (v1.9.0 — la
|
||||||
|
// source est estrannaise.js, plus de tables ODS au runtime) ---
|
||||||
PKPreset(
|
PKPreset(
|
||||||
nameRes = R.string.preset_ev_ese,
|
nameRes = R.string.preset_ev_ese,
|
||||||
type = TreatmentType.ESTRADIOL,
|
type = TreatmentType.ESTRADIOL,
|
||||||
|
|||||||
@ -94,7 +94,8 @@ data class Treatment(
|
|||||||
val isActive: Boolean = true,
|
val isActive: Boolean = true,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
|
|
||||||
// PK: lookup-table model for injections (EV/EU/EEn from Estrannaise / Transfem Science)
|
// PK: analytical models for injections — Estrannaise (v1.9.0, 6 esters),
|
||||||
|
// Transfem Science (V3C, 7), WHSAH (6); see Esters.choicesForModel().
|
||||||
val esterType: String = Esters.NONE,
|
val esterType: String = Esters.NONE,
|
||||||
val pkModel: String = PKModels.ESTRANNAISE,
|
val pkModel: String = PKModels.ESTRANNAISE,
|
||||||
|
|
||||||
|
|||||||
@ -58,10 +58,6 @@ object EstrannaiseCloud {
|
|||||||
* @param doseLogs toutes les doses réelles
|
* @param doseLogs toutes les doses réelles
|
||||||
* @param startMs/endMs fenêtre demandée (celle du graphique)
|
* @param startMs/endMs fenêtre demandée (celle du graphique)
|
||||||
* @param stepMs pas de la grille (stepForRange)
|
* @param stepMs pas de la grille (stepForRange)
|
||||||
* @param nowMs « maintenant » (les doses futures du porteur ne sont
|
|
||||||
* pas loguées — seules les doses à horizon prévisionnel
|
|
||||||
* alimentées par le caller seraient incluses ; v1.9.0
|
|
||||||
* limite le nuage aux doses RÉELLES, comme le tracé labs)
|
|
||||||
* @param nbCurves nombre de courbes du nuage (défaut [DEFAULT_NB_CURVES])
|
* @param nbCurves nombre de courbes du nuage (défaut [DEFAULT_NB_CURVES])
|
||||||
* @return M listes de points (M = [nbCurves]) — **vide** si rien n'est
|
* @return M listes de points (M = [nbCurves]) — **vide** si rien n'est
|
||||||
* calculable : aucune dose E2 à profil injectable dont l'ester
|
* calculable : aucune dose E2 à profil injectable dont l'ester
|
||||||
@ -74,7 +70,6 @@ object EstrannaiseCloud {
|
|||||||
startMs: Long,
|
startMs: Long,
|
||||||
endMs: Long,
|
endMs: Long,
|
||||||
stepMs: Long,
|
stepMs: Long,
|
||||||
nowMs: Long,
|
|
||||||
nbCurves: Int = DEFAULT_NB_CURVES,
|
nbCurves: Int = DEFAULT_NB_CURVES,
|
||||||
/**
|
/**
|
||||||
* Calibration à appliquer au nuage (v1.9.1 — fix « le nuage ne
|
* Calibration à appliquer au nuage (v1.9.1 — fix « le nuage ne
|
||||||
|
|||||||
@ -20,8 +20,10 @@ import kotlin.math.ceil
|
|||||||
* classique des **5 demi-vies terminales** (≈ 97 % de l'équilibre). Après
|
* classique des **5 demi-vies terminales** (≈ 97 % de l'équilibre). Après
|
||||||
* un changement d'ester/dose/rythme, les prises intermédiaires reflètent
|
* un changement d'ester/dose/rythme, les prises intermédiaires reflètent
|
||||||
* une transition, pas le régime ;
|
* une transition, pas le régime ;
|
||||||
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0) : le
|
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0 ; **fix
|
||||||
* premier créneau d'injection ≥ date de stabilisation du régime courant.
|
* #68 v1.10.0** : la stabilisation se juge sur le CREUX lui-même —
|
||||||
|
* l'instant où la prise de sang serait faite — pas sur le créneau
|
||||||
|
* d'injection qui le suit).
|
||||||
*
|
*
|
||||||
* CALCUL (tout est déduit de ce que l'app connaît déjà) :
|
* CALCUL (tout est déduit de ce que l'app connaît déjà) :
|
||||||
* - **traitement porteur** : ESTRADIOL + injectable + ACTIF + Posologie
|
* - **traitement porteur** : ESTRADIOL + injectable + ACTIF + Posologie
|
||||||
@ -36,10 +38,13 @@ import kotlin.math.ceil
|
|||||||
* assumé et documenté : l'app ne trace pas l'historique des éditions de
|
* assumé et documenté : l'app ne trace pas l'historique des éditions de
|
||||||
* Posologie/dose, la première dose du traitement est le meilleur
|
* Posologie/dose, la première dose du traitement est le meilleur
|
||||||
* estimateur du début du régime (même sémantique que le « temps sous THS ») ;
|
* estimateur du début du régime (même sémantique que le « temps sous THS ») ;
|
||||||
* - **creux** = minimum local de la courbe E2 PRÉVISIONNELLE (doses réelles
|
* - **creux** = niveau PRÉ-INJECTION du créneau (dernier point de la courbe
|
||||||
* + créneaux [PharmacokineticEngine.generateForecastDoses]) entre deux
|
* E2 prévisionnelle juste avant l'injection — principe 1 ; **fix #68
|
||||||
* injections. La FORME suffit : calcul SANS calibration (le facteur
|
* v1.10.0** : l'ancien minimum de fenêtre entière tombait juste après
|
||||||
* d'échelle est multiplicatif, il ne déplace pas le minimum) ;
|
* l'injection PRÉCÉDENTE pour les esters à montée lente comme l'EEn, dont
|
||||||
|
* le pic ~J+5 avoisine l'intervalle de 7 j). La FORME suffit : calcul
|
||||||
|
* SANS calibration (le facteur d'échelle est multiplicatif, il ne
|
||||||
|
* déplace pas le minimum) ;
|
||||||
* - **filtres** : creux ≥ maintenant + [MIN_HORIZON_HOURS] (pas de
|
* - **filtres** : creux ≥ maintenant + [MIN_HORIZON_HOURS] (pas de
|
||||||
* recommandation quasi-immédiate), creux > dernière prise de sang
|
* recommandation quasi-immédiate), creux > dernière prise de sang
|
||||||
* existante (ne jamais recommander un creux déjà mesuré), créneau ≥
|
* existante (ne jamais recommander un creux déjà mesuré), créneau ≥
|
||||||
@ -72,6 +77,37 @@ object LabTiming {
|
|||||||
/** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */
|
/** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */
|
||||||
const val MIN_HORIZON_HOURS = 6L
|
const val MIN_HORIZON_HOURS = 6L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position d'une valeur par rapport à la CIBLE DE CREUX personnelle
|
||||||
|
* (v1.13.0, opt-in). ⚠️ DISTINCT des seuils d'alerte (pk/Alerts.kt,
|
||||||
|
* §9.bis) : les alertes surveillent le niveau estimé EN CONTINU et
|
||||||
|
* notifient ; la cible de creux est une RÉFÉRENCE DE LAB — « quand je
|
||||||
|
* pique au creux recommandé, je veux être dans [low, high] » — évaluée
|
||||||
|
* en lecture seule sur la carte, JAMAIS de notification.
|
||||||
|
*/
|
||||||
|
enum class TroughTargetStatus { IN_TARGET, BELOW, ABOVE }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statut de [predictedE2] par rapport à la cible [low, high].
|
||||||
|
* `null` si pas de prédiction OU cible incomplète/désactivée.
|
||||||
|
* PUR → testable en JVM ; miroir web `troughTargetStatus`
|
||||||
|
* (js/pk/lab-timing.js).
|
||||||
|
*/
|
||||||
|
fun troughTargetStatus(
|
||||||
|
predictedE2: Double?,
|
||||||
|
low: Double?,
|
||||||
|
high: Double?
|
||||||
|
): TroughTargetStatus? {
|
||||||
|
val value = predictedE2 ?: return null
|
||||||
|
val lo = low ?: return null
|
||||||
|
val hi = high ?: return null
|
||||||
|
return when {
|
||||||
|
value < lo -> TroughTargetStatus.BELOW
|
||||||
|
value > hi -> TroughTargetStatus.ABOVE
|
||||||
|
else -> TroughTargetStatus.IN_TARGET
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Résultat : tout ce que l'UI affiche sur la carte de recommandation. */
|
/** Résultat : tout ce que l'UI affiche sur la carte de recommandation. */
|
||||||
data class LabRecommendation(
|
data class LabRecommendation(
|
||||||
/** Date/heure du CREUX exact (minimum prévisionnel entre 2 injections). */
|
/** Date/heure du CREUX exact (minimum prévisionnel entre 2 injections). */
|
||||||
@ -92,7 +128,15 @@ object LabTiming {
|
|||||||
* arrive entre maintenant et le creux recommandé (texte « c'est le
|
* arrive entre maintenant et le creux recommandé (texte « c'est le
|
||||||
* premier creux fiable »).
|
* premier creux fiable »).
|
||||||
*/
|
*/
|
||||||
val wasAlreadyStabilized: Boolean
|
val wasAlreadyStabilized: Boolean,
|
||||||
|
/**
|
||||||
|
* E2 ATTENDUE à ce creux (v1.13.0) : valeur brute du modèle ×
|
||||||
|
* facteur d'échelle du ester actif — fourni par l'appelant quand
|
||||||
|
* la calibration est disponible (auto-calibration ON). `null` =
|
||||||
|
* pas de calibration fiable → la carte n'affiche PAS de valeur
|
||||||
|
* (honnêteté : une valeur brute non calibrée serait trompeuse).
|
||||||
|
*/
|
||||||
|
val predictedE2: Double? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -107,7 +151,14 @@ object LabTiming {
|
|||||||
treatments: List<Treatment>,
|
treatments: List<Treatment>,
|
||||||
doseLogs: List<DoseLog>,
|
doseLogs: List<DoseLog>,
|
||||||
labs: List<LabResult>,
|
labs: List<LabResult>,
|
||||||
nowMs: Long = System.currentTimeMillis()
|
nowMs: Long = System.currentTimeMillis(),
|
||||||
|
/**
|
||||||
|
* Facteurs d'échelle PAR ESTER (v1.13.0) — issus de l'auto-calibration
|
||||||
|
* quand elle est activée (sinon `null`) : permet de retourner la
|
||||||
|
* valeur E2 ATTENDUE au creux ([LabRecommendation.predictedE2]).
|
||||||
|
* Absent → `predictedE2 = null` (carte sans valeur).
|
||||||
|
*/
|
||||||
|
scalePerEster: Map<String, Double>? = null
|
||||||
): LabRecommendation? {
|
): LabRecommendation? {
|
||||||
// ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────
|
// ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────
|
||||||
val carrier = treatments.firstOrNull {
|
val carrier = treatments.firstOrNull {
|
||||||
@ -133,13 +184,14 @@ object LabTiming {
|
|||||||
// ── 3) Début du régime courant + date de stabilisation ──────────────
|
// ── 3) Début du régime courant + date de stabilisation ──────────────
|
||||||
val myDoses = doseLogs.filter { it.treatmentId == carrier.id }
|
val myDoses = doseLogs.filter { it.treatmentId == carrier.id }
|
||||||
if (myDoses.isEmpty()) return null
|
if (myDoses.isEmpty()) return null
|
||||||
val regimeStartMs = regimeStartMs(carrier, myDoses)
|
|
||||||
val stabilizedAtMs = regimeStartMs +
|
|
||||||
(STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong()
|
|
||||||
// Ester EFFECTIF de la dernière dose (override compris) : c'est lui
|
|
||||||
// qui gouverne les creux futurs et le texte de la carte
|
|
||||||
val lastDose = myDoses.maxByOrNull { it.timestamp }!!
|
val lastDose = myDoses.maxByOrNull { it.timestamp }!!
|
||||||
val activeEster = PharmacokineticEngine.doseEster(carrier, lastDose)
|
val activeEster = PharmacokineticEngine.doseEster(carrier, lastDose)
|
||||||
|
// v1.13.0 : régime POOLÉ (cf [pooledRegimeDoses]) — le re-parenting
|
||||||
|
// d'historique entre traitements (ex. « 6d - old » créé a posteriori,
|
||||||
|
// remontée v1.12.0) ne doit pas faire croire à un nouveau régime.
|
||||||
|
val regimeStart = regimeStartMs(carrier, pooledRegimeDoses(carrier, doseLogs, treatments))
|
||||||
|
val stabilizedAtMs = regimeStart +
|
||||||
|
(STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong()
|
||||||
|
|
||||||
// ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ──
|
// ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ──
|
||||||
val intervalDays = carrier.forecastIntervalDays!!
|
val intervalDays = carrier.forecastIntervalDays!!
|
||||||
@ -165,25 +217,47 @@ object LabTiming {
|
|||||||
val minTroughMs = nowMs + MIN_HORIZON_HOURS * 3_600_000L
|
val minTroughMs = nowMs + MIN_HORIZON_HOURS * 3_600_000L
|
||||||
var windowStart = nowMs
|
var windowStart = nowMs
|
||||||
for (slot in slots) {
|
for (slot in slots) {
|
||||||
// Le creux du créneau = minimum de la courbe entre l'injection
|
// ⚠️ FIX #68 (v1.10.0) : le creux = niveau PRÉ-INJECTION du
|
||||||
// précédente (ou maintenant) et ce créneau
|
// créneau (dernier point de courbe avant l'injection — principe 1,
|
||||||
val window = curve.filter { it.timestamp > windowStart && it.timestamp < slot.timestamp }
|
// « le moment le plus comparable »). L'ancien MINIMUM DE LA
|
||||||
val trough = window.minByOrNull { it.e2 }
|
// FENÊTRE ENTIÈRE tombait au DÉBUT de la fenêtre pour les esters
|
||||||
|
// à montée lente (EEn : pic ~J+5 ≈ intervalle 7 j → le minimum
|
||||||
|
// local est le creux d'absorption, quelques heures après
|
||||||
|
// l'injection PRÉCÉDENTE) : la date proposée précédait la
|
||||||
|
// stabilisation affichée sur la MÊME carte, et le texte
|
||||||
|
// « juste avant ton injection du … » était faux de plusieurs
|
||||||
|
// jours (remontée v1.9.8, épinglé par RegressionUserCase6Test).
|
||||||
|
// Pour les esters à t½ courte (EV), le point pré-injection EST
|
||||||
|
// le minimum de fenêtre : comportement inchangé.
|
||||||
|
val trough = curve.lastOrNull {
|
||||||
|
it.timestamp > windowStart && it.timestamp < slot.timestamp
|
||||||
|
}
|
||||||
if (trough != null &&
|
if (trough != null &&
|
||||||
trough.timestamp >= minTroughMs &&
|
trough.timestamp >= minTroughMs &&
|
||||||
trough.timestamp > lastLabMs &&
|
trough.timestamp > lastLabMs &&
|
||||||
// ON SAUTE au premier creux stabilisé (décision v1.8.0) :
|
// ON SAUTE au premier creux stabilisé (décision v1.8.0) :
|
||||||
// le creux d'un créneau est stabilisé si le CRÉNEAU l'est
|
// le creux pré-injection ≈ instant du créneau, le garde
|
||||||
slot.timestamp >= stabilizedAtMs
|
// exprime la stabilisation sur l'instant réellement proposé
|
||||||
|
// pour la prise de sang (trough < slot de ~1 pas de courbe).
|
||||||
|
trough.timestamp >= stabilizedAtMs
|
||||||
) {
|
) {
|
||||||
return LabRecommendation(
|
return LabRecommendation(
|
||||||
troughMs = trough.timestamp,
|
troughMs = trough.timestamp,
|
||||||
injectionMs = slot.timestamp,
|
injectionMs = slot.timestamp,
|
||||||
ester = activeEster,
|
ester = activeEster,
|
||||||
terminalHalfLifeDays = tHalfDays,
|
terminalHalfLifeDays = tHalfDays,
|
||||||
regimeStartMs = regimeStartMs,
|
regimeStartMs = regimeStart,
|
||||||
stabilizedAtMs = stabilizedAtMs,
|
stabilizedAtMs = stabilizedAtMs,
|
||||||
wasAlreadyStabilized = stabilizedAtMs <= nowMs
|
wasAlreadyStabilized = stabilizedAtMs <= nowMs,
|
||||||
|
// v1.13.0 : E2 attendue au creux = valeur brute du
|
||||||
|
// modèle × facteur du ester actif (lookup insensible à
|
||||||
|
// la casse — les clés de scalePerEster suivent les
|
||||||
|
// esters stockés). null si pas de calibration.
|
||||||
|
predictedE2 = scalePerEster
|
||||||
|
?.entries
|
||||||
|
?.firstOrNull { it.key.equals(activeEster, ignoreCase = true) }
|
||||||
|
?.value
|
||||||
|
?.let { scale -> trough.e2 * scale }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
windowStart = slot.timestamp
|
windowStart = slot.timestamp
|
||||||
@ -194,6 +268,61 @@ object LabTiming {
|
|||||||
/** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */
|
/** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */
|
||||||
private const val DOSE_EPS = 1e-6
|
private const val DOSE_EPS = 1e-6
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SÉQUENCE DE RÉGIME POOLÉE (v1.13.0) : toutes les doses de TOUS les
|
||||||
|
* traitements partageant l'**ester effectif** du porteur ET son **mg
|
||||||
|
* exact** (± [DOSE_EPS]), triées par timestamp.
|
||||||
|
*
|
||||||
|
* **Pourquoi** (remontée v1.12.0) : le re-parenting d'historique — créer
|
||||||
|
* un traitement « … - old » inactif et y déplacer des doses (ex. séparer
|
||||||
|
* une ère « 6 j » d'une ère « 7 j ») — créait un TROU synthétique dans
|
||||||
|
* l'historique du porteur : le régime redémarrait à la date du
|
||||||
|
* re-parenting et la stabilisation (5 × t½) repartait de plusieurs
|
||||||
|
* semaines, alors que pharmacologiquement rien n'avait changé (même
|
||||||
|
* ester, même dose).
|
||||||
|
*
|
||||||
|
* Garde-fous (épinglés par tests) :
|
||||||
|
* - **ester ≠ → pas de mutualisation** (transition EV → EEn : deux
|
||||||
|
* régimes distincts — régression n°3) ;
|
||||||
|
* - **dose ≠ → exclue du pool** (une dose « 8 mg » au milieu d'un
|
||||||
|
* régime « 5 mg » casse naturellement la séquence via le trou qui
|
||||||
|
* suit — conservateur, ~1 semaine du côté prudent).
|
||||||
|
*
|
||||||
|
* PUR → testable en JVM (DosesExtras? non — LabTimingTest +
|
||||||
|
* RegressionUserCase7Test) ; miroir web `pooledRegimeDoses`
|
||||||
|
* (js/pk/lab-timing.js).
|
||||||
|
*/
|
||||||
|
internal fun pooledRegimeDoses(
|
||||||
|
carrier: Treatment,
|
||||||
|
allDoseLogs: List<DoseLog>,
|
||||||
|
allTreatments: List<Treatment>
|
||||||
|
): List<DoseLog> {
|
||||||
|
val last = allDoseLogs
|
||||||
|
.filter { it.treatmentId == carrier.id }
|
||||||
|
.maxByOrNull { it.timestamp } ?: return emptyList()
|
||||||
|
val activeEster = PharmacokineticEngine.doseEster(carrier, last)
|
||||||
|
val amount = last.doseAmount
|
||||||
|
val ownerById = allTreatments.associateBy { it.id }
|
||||||
|
return allDoseLogs
|
||||||
|
.filter { d ->
|
||||||
|
// Ester EFFECTIF via le traitement PROPRIÉTAIRE de la dose
|
||||||
|
// (override par dose compris) — jamais via le porteur
|
||||||
|
val owner = ownerById[d.treatmentId] ?: return@filter false
|
||||||
|
PharmacokineticEngine.doseEster(owner, d) == activeEster &&
|
||||||
|
Math.abs(d.doseAmount - amount) < DOSE_EPS
|
||||||
|
}
|
||||||
|
.sortedBy { it.timestamp }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tolérance de l'écart inter-doses du régime (v1.9.7) : ± 24 h autour de
|
||||||
|
* l'intervalle de Posologie — « je m'injecte le même jour de la semaine,
|
||||||
|
* à l'heure près ». Un log à 12:30 au lieu de 12:00 ne doit pas
|
||||||
|
* repousser la stabilisation de 5 × t½ (remontée v1.9.7) ; un vrai
|
||||||
|
* changement de créneau (2 j au lieu de 7 j) reste hors fenêtre.
|
||||||
|
*/
|
||||||
|
private val GAP_TOLERANCE_MS = 24L * 3_600_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DÉBUT DU RÉGIME COURANT (v1.8.1 — **correction du proxy v1.8.0**).
|
* DÉBUT DU RÉGIME COURANT (v1.8.1 — **correction du proxy v1.8.0**).
|
||||||
*
|
*
|
||||||
@ -202,25 +331,35 @@ object LabTiming {
|
|||||||
* depuis février" » — l'ancien proxy (1ʳᵉ dose du traitement) ne voyait
|
* depuis février" » — l'ancien proxy (1ʳᵉ dose du traitement) ne voyait
|
||||||
* PAS les changements récents de dose ni d'intervalle.
|
* PAS les changements récents de dose ni d'intervalle.
|
||||||
*
|
*
|
||||||
* **NOUVELLE RÈGLE** : le régime courant = la **séquence terminale de
|
* **Critique remontée** (v1.9.7) : « la suggestion de prochaine prise de
|
||||||
* doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques
|
* sang change tout le temps à chaque injection si l'injection n'est pas
|
||||||
* à celles de la dose la plus récente, ET (b) **l'écart entre doses
|
* faite pile à la même heure » — la comparaison EXACTE de v1.8.1 (écart
|
||||||
* consécutives est constant** (= l'écart des deux doses les plus
|
* identique au ms près) cassait le régime pour un log à 12:30 au lieu de
|
||||||
* récentes). On remonte depuis la dose la plus récente tant que ces
|
* 12:00, repoussant la stabilisation de 5 × t½ à CHAQUE injection : le
|
||||||
* conditions tiennent ; le régime commence à la dose la plus ancienne
|
* creux recommandé fuyait vers l'avant sans jamais se fixer.
|
||||||
* de la séquence.
|
*
|
||||||
|
* **RÈGLE** : le régime courant = la **séquence terminale de doses**
|
||||||
|
* (v1.13.0 : **poolée** entre traitements — cf [pooledRegimeDoses] — le
|
||||||
|
* re-parenting d'historique ne doit plus redémarrer la stabilisation)
|
||||||
|
* où (a) l'**ester effectif** et la **dose (mg)** sont identiques à
|
||||||
|
* celles de la dose la plus récente, ET (b) chaque **écart entre doses
|
||||||
|
* consécutives reste dans l'INTERVALLE DE POSOLOGIE ± 24 h**
|
||||||
|
* ([GAP_TOLERANCE_MS] — « je m'injecte le même jour, à l'heure près »).
|
||||||
|
* On remonte depuis la dose la plus récente tant que ces conditions
|
||||||
|
* tiennent ; le régime commence à la dose la plus ancienne de la
|
||||||
|
* séquence.
|
||||||
*
|
*
|
||||||
* Pourquoi chaque composante :
|
* Pourquoi chaque composante :
|
||||||
* - **ester effectif** (override par dose compris) : un switch EV → EEn
|
* - **ester effectif** (override par dose compris) : un switch EV → EEn
|
||||||
* réinitialise la stabilisation ;
|
* réinitialise la stabilisation ;
|
||||||
* - **doseAmount** : 3 mg → 5 mg change le niveau d'équilibre ;
|
* - **doseAmount** : 3 mg → 5 mg change le niveau d'équilibre ;
|
||||||
* - **écart inter-doses constant** : 7 j → 2 j → 9 j (posologie
|
* - **écart ≈ intervalle de Posologie ± 24 h** : le trough n'est
|
||||||
* modifiée/irrégulière) change le creux — conservateur : une série
|
* comparable que sur un intervalle régulier (pharmacocinétique), mais
|
||||||
* d'intervalles chaotiques maintient la carte « non stabilisée », ce
|
* l'heure de log RÉELLE d'une humaine varie — un écart 7 j ± 23 h EST
|
||||||
* qui est pharmacocinétiquement vrai (le trough n'est comparable que
|
* le même régime ; 7 j + 25 h (ou un vrai passage à 2 j = 48 h, hors
|
||||||
* sur un intervalle régulier). La comparaison est EXACTE (arrondi au
|
* fenêtre) casse le régime. La fenêtre se réfère à l'INTERVALLE
|
||||||
* ms) : un écart 7 j → 7 j ± 1 h casse le régime, ce qui reflète un
|
* THÉORIQUE (Posologie), pas à l'écart précédent : des logs à 6,8 j
|
||||||
* vrai changement de créneau.
|
* puis 7,2 j ne se déstabilisent plus en cascade.
|
||||||
*
|
*
|
||||||
* @param carrier traitement porteur (injectable E2 actif à Posologie)
|
* @param carrier traitement porteur (injectable E2 actif à Posologie)
|
||||||
* @param myDoses doses du porteur (ordre indifférent)
|
* @param myDoses doses du porteur (ordre indifférent)
|
||||||
@ -231,18 +370,23 @@ object LabTiming {
|
|||||||
var idx = sorted.size - 1
|
var idx = sorted.size - 1
|
||||||
var regimeStart = sorted[idx].timestamp
|
var regimeStart = sorted[idx].timestamp
|
||||||
var current = sorted[idx]
|
var current = sorted[idx]
|
||||||
// Écart EXIGÉ pour remonter : celui des deux doses les plus récentes
|
// Fenêtre d'écart = INTERVALLE DE POSOLOGIE ± tolérance (v1.9.7 —
|
||||||
// (inconnu si une seule dose — la séquence s'arrête là de toute façon)
|
// remplace la comparaison exacte v1.8.1 qui cassait le régime à
|
||||||
var gapExpected = -1L
|
// chaque variation d'heure de log). La Posologie est un prérequis de
|
||||||
|
// la carte : absente → on s'arrête à la dernière dose (dégradé).
|
||||||
|
val intervalDays = carrier.forecastIntervalDays ?: return regimeStart
|
||||||
|
val intervalMs = (intervalDays * 24.0 * 3_600_000.0).toLong()
|
||||||
|
val gapMin = intervalMs - GAP_TOLERANCE_MS
|
||||||
|
val gapMax = intervalMs + GAP_TOLERANCE_MS
|
||||||
while (idx > 0) {
|
while (idx > 0) {
|
||||||
val prev = sorted[idx - 1]
|
val prev = sorted[idx - 1]
|
||||||
val gap = current.timestamp - prev.timestamp
|
val gap = current.timestamp - prev.timestamp
|
||||||
val sameMarker = PharmacokineticEngine.doseEster(carrier, current) == PharmacokineticEngine.doseEster(carrier, prev) &&
|
val sameMarker = PharmacokineticEngine.doseEster(carrier, current) == PharmacokineticEngine.doseEster(carrier, prev) &&
|
||||||
Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS
|
Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS
|
||||||
// Remonter seulement si même (ester, dose) ET même écart
|
// Remonter seulement si même (ester, dose) ET écart dans la
|
||||||
val sameGap = gapExpected < 0L || gap == gapExpected
|
// fenêtre (gap > 0 : un doublon de log n'est jamais un régime)
|
||||||
|
val sameGap = gap > 0 && gap in gapMin..gapMax
|
||||||
if (!sameMarker || !sameGap) break
|
if (!sameMarker || !sameGap) break
|
||||||
gapExpected = gap
|
|
||||||
regimeStart = prev.timestamp
|
regimeStart = prev.timestamp
|
||||||
current = prev
|
current = prev
|
||||||
idx--
|
idx--
|
||||||
|
|||||||
@ -178,17 +178,19 @@ object LabTrajectoryModel {
|
|||||||
if (estrogenTreatments.isEmpty()) return AnchoredCurve(emptyList(), 0)
|
if (estrogenTreatments.isEmpty()) return AnchoredCurve(emptyList(), 0)
|
||||||
|
|
||||||
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le
|
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le
|
||||||
// facteur — combiner avec le facteur stocké = double correction)
|
// facteur — combiner avec le facteur stocké = double correction).
|
||||||
|
// Contexte moteur pré-calculé UNE fois (v1.9.5, audit) : la boucle
|
||||||
|
// de grille appelle e2At par point (720 pts) — sans contexte
|
||||||
|
// partagé, chaque appel reconstruisait les maps internes.
|
||||||
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
||||||
|
val e2Ctx = PharmacokineticEngine.prepareE2Context(unscaled, doseLogs)
|
||||||
|
|
||||||
// ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ───────────
|
// ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ───────────
|
||||||
// Un couple (t_lab, ratio) par lab significatif, trié croissant.
|
// Un couple (t_lab, ratio) par lab significatif, trié croissant.
|
||||||
val anchors = mutableListOf<Pair<Long, Double>>()
|
val anchors = mutableListOf<Pair<Long, Double>>()
|
||||||
var maxPredictionSoFar = 0.0
|
var maxPredictionSoFar = 0.0
|
||||||
for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) {
|
for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) {
|
||||||
val predicted = PharmacokineticEngine.e2At(
|
val predicted = PharmacokineticEngine.e2At(ctx = e2Ctx, tMs = lab.timestamp, modelOverride = modelOverride)
|
||||||
unscaled, doseLogs, lab.timestamp, modelOverride, scalePerEster = null
|
|
||||||
)
|
|
||||||
if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue
|
if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue
|
||||||
anchors.add(lab.timestamp to (lab.value / predicted))
|
anchors.add(lab.timestamp to (lab.value / predicted))
|
||||||
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
|
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
|
||||||
@ -220,7 +222,7 @@ object LabTrajectoryModel {
|
|||||||
val points = mutableListOf<LevelPoint>()
|
val points = mutableListOf<LevelPoint>()
|
||||||
var t = t0
|
var t = t0
|
||||||
while (t <= t1) {
|
while (t <= t1) {
|
||||||
val m = PharmacokineticEngine.e2At(unscaled, doseLogs, t, modelOverride, scalePerEster = null)
|
val m = PharmacokineticEngine.e2At(e2Ctx, t, modelOverride)
|
||||||
val rho = ratioAt(anchors, t)
|
val rho = ratioAt(anchors, t)
|
||||||
points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série
|
points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série
|
||||||
t += stepMs
|
t += stepMs
|
||||||
|
|||||||
@ -164,6 +164,65 @@ object PharmacokineticEngine {
|
|||||||
// Aggregated levels
|
// Aggregated levels
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexte PRÉ-CALCULÉ d'une série d'appels [e2At] (v1.9.5, audit) :
|
||||||
|
* cutoff par traitement + Bateman paresseux + doses pré-groupées. Le
|
||||||
|
* chemin chaud du TRACÉ LABS appelle e2At par point de grille (720 pts)
|
||||||
|
* et par lab — reconstruire ce contexte à CHAQUE appel diluait
|
||||||
|
* l'optimisation v1.8.2 (720 × N traitements reconstructions de maps).
|
||||||
|
* Préparer UNE fois puis appeler [e2At] surcharge contexte — résultat
|
||||||
|
* bit-identique (mêmes boucles, même ordre de sommation flottante).
|
||||||
|
*/
|
||||||
|
class E2Context internal constructor(
|
||||||
|
internal val treatments: List<Treatment>,
|
||||||
|
internal val dosesByTreatment: Map<Long, List<DoseLog>>,
|
||||||
|
internal val cutoffByTreatment: HashMap<Long, Double>,
|
||||||
|
internal val batemanCache: HashMap<Long, BatemanParams>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Prépare un [E2Context] pour une série d'appels e2At (même entrées). */
|
||||||
|
fun prepareE2Context(treatments: List<Treatment>, doseLogs: List<DoseLog>): E2Context {
|
||||||
|
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
|
||||||
|
val cutoffByTreatment = HashMap<Long, Double>()
|
||||||
|
val batemanCache = HashMap<Long, BatemanParams>()
|
||||||
|
for (treatment in treatments) {
|
||||||
|
if (treatment.type != TreatmentType.ESTRADIOL) continue
|
||||||
|
cutoffByTreatment[treatment.id] = cutoffHours(treatment)
|
||||||
|
if (!treatment.usesProfileModel) {
|
||||||
|
batemanCache[treatment.id] = batemanParams(treatment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return E2Context(treatments, dosesByTreatment, cutoffByTreatment, batemanCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e2At sur contexte pré-calculé (voir [E2Context]) — la forme WORKHORSE. */
|
||||||
|
fun e2At(
|
||||||
|
ctx: E2Context,
|
||||||
|
tMs: Long,
|
||||||
|
modelOverride: String? = null,
|
||||||
|
scalePerEster: Map<String, Double>? = null
|
||||||
|
): Double {
|
||||||
|
var total = 0.0
|
||||||
|
for (treatment in ctx.treatments) {
|
||||||
|
if (treatment.type != TreatmentType.ESTRADIOL) continue
|
||||||
|
for (dose in ctx.dosesByTreatment[treatment.id].orEmpty()) {
|
||||||
|
if (dose.timestamp > tMs) continue
|
||||||
|
val dtH = (tMs - dose.timestamp) / 3_600_000.0
|
||||||
|
if (dtH > ctx.cutoffByTreatment.getValue(treatment.id)) continue
|
||||||
|
val c = concentrationOfDose(
|
||||||
|
treatment, dose, tMs, ctx.batemanCache[treatment.id], modelOverride
|
||||||
|
)
|
||||||
|
if (c > 0.0) {
|
||||||
|
val scale = scalePerEster?.get(doseEster(treatment, dose))
|
||||||
|
?: treatment.scaleFactor
|
||||||
|
total += c * scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e2At à appel unique : prépare le contexte puis délègue. */
|
||||||
fun e2At(
|
fun e2At(
|
||||||
treatments: List<Treatment>,
|
treatments: List<Treatment>,
|
||||||
doseLogs: List<DoseLog>,
|
doseLogs: List<DoseLog>,
|
||||||
@ -177,43 +236,7 @@ object PharmacokineticEngine {
|
|||||||
*/
|
*/
|
||||||
scalePerEster: Map<String, Double>? = null
|
scalePerEster: Map<String, Double>? = null
|
||||||
): Double {
|
): Double {
|
||||||
// Pré-calculs PAR TRAITEMENT (v1.8.2 — le chemin chaud du tracé labs
|
return e2At(prepareE2Context(treatments, doseLogs), tMs, modelOverride, scalePerEster)
|
||||||
// appelle e2At par point de grille : 720 pts × N traitements × D doses
|
|
||||||
// recalculaient cutoffHours et batemanParams à chaque itération) :
|
|
||||||
// - cutoff : déterministe par traitement → calculé UNE fois ;
|
|
||||||
// - Bateman (bisection 50 itérations + ln) : inutile pour les
|
|
||||||
// traitements à PROFIL (concentrationOfDose ne lit le paramètre
|
|
||||||
// que dans la branche Bateman, inatteignable quand
|
|
||||||
// usesProfileModel == true) → calcul paresseux.
|
|
||||||
// - doses pré-groupées par traitement (fini le scan complet de
|
|
||||||
// doseLogs par traitement) — l'ordre d'itération des doses d'UN
|
|
||||||
// traitement est préservé (groupBy préserve l'ordre relatif) →
|
|
||||||
// sommation flottante identique.
|
|
||||||
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
|
|
||||||
val cutoffByTreatment = HashMap<Long, Double>()
|
|
||||||
val batemanCache = HashMap<Long, BatemanParams>()
|
|
||||||
var total = 0.0
|
|
||||||
for (treatment in treatments) {
|
|
||||||
if (treatment.type != TreatmentType.ESTRADIOL) continue
|
|
||||||
cutoffByTreatment[treatment.id] = cutoffHours(treatment)
|
|
||||||
if (!treatment.usesProfileModel) {
|
|
||||||
batemanCache[treatment.id] = batemanParams(treatment)
|
|
||||||
}
|
|
||||||
for (dose in dosesByTreatment[treatment.id].orEmpty()) {
|
|
||||||
if (dose.timestamp > tMs) continue
|
|
||||||
val dtH = (tMs - dose.timestamp) / 3_600_000.0
|
|
||||||
if (dtH > cutoffByTreatment.getValue(treatment.id)) continue
|
|
||||||
val c = concentrationOfDose(
|
|
||||||
treatment, dose, tMs, batemanCache[treatment.id], modelOverride
|
|
||||||
)
|
|
||||||
if (c > 0.0) {
|
|
||||||
val scale = scalePerEster?.get(doseEster(treatment, dose))
|
|
||||||
?: treatment.scaleFactor
|
|
||||||
total += c * scale
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun testosteroneAt(e2Level: Double, config: TConfig): Double {
|
fun testosteroneAt(e2Level: Double, config: TConfig): Double {
|
||||||
@ -393,6 +416,14 @@ object PharmacokineticEngine {
|
|||||||
): List<DoseLog> {
|
): List<DoseLog> {
|
||||||
val intervalDays = treatment.forecastIntervalDays ?: return emptyList()
|
val intervalDays = treatment.forecastIntervalDays ?: return emptyList()
|
||||||
if (intervalDays <= 0.0) return emptyList()
|
if (intervalDays <= 0.0) return emptyList()
|
||||||
|
// ⚠️ FIX #70 (v1.12.0, demande utilisatrice) : un traitement INACTIF ne
|
||||||
|
// projette PLUS de créneaux — l'archivage arrête les prédictions
|
||||||
|
// d'injections futures (graphique Prévision, extension de fenêtre,
|
||||||
|
// horizon LabTiming). Cohérent avec les rappels (nextReminderFireFor
|
||||||
|
// garde déjà !isActive). ⚠️ §6.bis inchangé : l'INACTIF reste simulé
|
||||||
|
// pour son HISTORIQUE (computeCurve ne passe pas ici) — seul le
|
||||||
|
// FUTUR s'arrête.
|
||||||
|
if (!treatment.isActive) return emptyList()
|
||||||
val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong()
|
val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong()
|
||||||
if (intervalMs <= 0L) return emptyList()
|
if (intervalMs <= 0L) return emptyList()
|
||||||
|
|
||||||
@ -460,6 +491,8 @@ object PharmacokineticEngine {
|
|||||||
// stocké du traitement, ce qui produisait des facteurs absurdes dès
|
// stocké du traitement, ce qui produisait des facteurs absurdes dès
|
||||||
// qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH)
|
// qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH)
|
||||||
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
||||||
|
// Contexte pré-calculé UNE fois pour toute la série de labs (v1.9.5)
|
||||||
|
val ctx = prepareE2Context(unscaled, doseLogs)
|
||||||
val ratiosByEster = HashMap<String, MutableList<Double>>()
|
val ratiosByEster = HashMap<String, MutableList<Double>>()
|
||||||
var maxPredictionSoFar = 0.0
|
var maxPredictionSoFar = 0.0
|
||||||
// ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant —
|
// ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant —
|
||||||
@ -467,7 +500,7 @@ object PharmacokineticEngine {
|
|||||||
// résiduelle) produisaient des ratios aberrants dont la MÉDIANE
|
// résiduelle) produisaient des ratios aberrants dont la MÉDIANE
|
||||||
// tombait à ×2,21
|
// tombait à ×2,21
|
||||||
for (lab in e2Labs.sortedBy { it.timestamp }) {
|
for (lab in e2Labs.sortedBy { it.timestamp }) {
|
||||||
val predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride = modelOverride)
|
val predicted = e2At(ctx, lab.timestamp, modelOverride = modelOverride)
|
||||||
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue
|
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue
|
||||||
|
|
||||||
// Attribution : dernière dose E2 ≤ lab → son ester (override compris)
|
// Attribution : dernière dose E2 ≤ lab → son ester (override compris)
|
||||||
@ -711,6 +744,32 @@ object PharmacokineticEngine {
|
|||||||
return current.copy(k = medianRounded(ks, 3))
|
return current.copy(k = medianRounded(ks, 3))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une dose de CE traitement est-elle déjà loguée LE JOUR de [atMs]
|
||||||
|
* (jour calendaire LOCAL — fuseau paramétrable pour la testabilité) ?
|
||||||
|
*
|
||||||
|
* v1.10.0 (demande utilisatrice) : si la dose prévue du jour est
|
||||||
|
* saisie AVANT l'heure de rappel, la notification ne doit PAS sonner
|
||||||
|
* pour une prise déjà faite — le rappel suivant repart au créneau
|
||||||
|
* SUIVANT de la grille (le receiver re-programme dans tous les cas).
|
||||||
|
* S'applique identiquement au fallback quotidien (oral sans Posologie :
|
||||||
|
* dose loguée aujourd'hui → pas de notif aujourd'hui, demain re-part).
|
||||||
|
*
|
||||||
|
* PUR (aucune dépendance Android) → testable en JVM (ReminderLoggedDayTest).
|
||||||
|
*/
|
||||||
|
fun hasDoseLoggedOnDay(
|
||||||
|
treatmentId: Long,
|
||||||
|
doseLogs: List<DoseLog>,
|
||||||
|
atMs: Long,
|
||||||
|
zoneId: java.time.ZoneId = java.time.ZoneId.systemDefault()
|
||||||
|
): Boolean {
|
||||||
|
val day = java.time.Instant.ofEpochMilli(atMs).atZone(zoneId).toLocalDate()
|
||||||
|
return doseLogs.any {
|
||||||
|
it.treatmentId == treatmentId &&
|
||||||
|
java.time.Instant.ofEpochMilli(it.timestamp).atZone(zoneId).toLocalDate() == day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prochain déclenchement de rappel pour UN traitement (v1.4.0).
|
* Prochain déclenchement de rappel pour UN traitement (v1.4.0).
|
||||||
*
|
*
|
||||||
|
|||||||
@ -43,7 +43,14 @@ class DoseActionReceiver : BroadcastReceiver() {
|
|||||||
ReminderContract.ACTION_SNOOZE -> {
|
ReminderContract.ACTION_SNOOZE -> {
|
||||||
val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: ""
|
val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: ""
|
||||||
val dose = intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0)
|
val dose = intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0)
|
||||||
AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose)
|
// v1.10.1 : le snooze FORWARDE l'instant planifié d'origine —
|
||||||
|
// le garde du feu différé évalue le jour du CRÉNEAU, pas
|
||||||
|
// celui du snooze (sinon un snooze à cheval sur minuit
|
||||||
|
// évaluerait le mauvais jour).
|
||||||
|
val plannedAt = if (intent.hasExtra(ReminderContract.EXTRA_PLANNED_AT)) {
|
||||||
|
intent.getLongExtra(ReminderContract.EXTRA_PLANNED_AT, 0L)
|
||||||
|
} else null
|
||||||
|
AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose, plannedAt = plannedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,11 +15,11 @@ import com.hormonetrack.data.AppDatabase
|
|||||||
import com.hormonetrack.data.model.DoseLog
|
import com.hormonetrack.data.model.DoseLog
|
||||||
import com.hormonetrack.data.model.Treatment
|
import com.hormonetrack.data.model.Treatment
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
|
import com.hormonetrack.util.AppLog
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
|
|
||||||
object ReminderContract {
|
object ReminderContract {
|
||||||
const val CHANNEL_ID = "hormonetrack_reminders"
|
const val CHANNEL_ID = "hormonetrack_reminders"
|
||||||
@ -30,6 +30,17 @@ object ReminderContract {
|
|||||||
const val EXTRA_TREATMENT_NAME = "treatment_name"
|
const val EXTRA_TREATMENT_NAME = "treatment_name"
|
||||||
const val EXTRA_DOSE_AMOUNT = "dose_amount"
|
const val EXTRA_DOSE_AMOUNT = "dose_amount"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instant PLANIFIÉ de l'alarme (v1.10.1) — embarqué par
|
||||||
|
* AlarmScheduler.scheduleFor au moment du scheduling et relu par
|
||||||
|
* [ReminderReceiver]. ⚠️ Nécessaire car une alarme INEXACTE (setWindow
|
||||||
|
* sans SCHEDULE_EXACT_ALARM) ou retardée (Doze) peut fire après la
|
||||||
|
* frontière de jour : le garde « créneau déjà honoré » doit évaluer le
|
||||||
|
* jour PLANIFIÉ, pas le jour réel du feu. Absent (vieux PendingIntents)
|
||||||
|
* → le receiver retombe sur l'heure courante.
|
||||||
|
*/
|
||||||
|
const val EXTRA_PLANNED_AT = "planned_at"
|
||||||
|
|
||||||
fun reminderIntent(context: Context, treatmentId: Long): Intent =
|
fun reminderIntent(context: Context, treatmentId: Long): Intent =
|
||||||
Intent(context, ReminderReceiver::class.java).apply {
|
Intent(context, ReminderReceiver::class.java).apply {
|
||||||
action = ACTION_REMINDER
|
action = ACTION_REMINDER
|
||||||
@ -53,10 +64,16 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
|
|
||||||
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
|
||||||
private fun pendingBroadcast(treatmentId: Long, name: String?, dose: Double?): PendingIntent {
|
private fun pendingBroadcast(
|
||||||
|
treatmentId: Long,
|
||||||
|
name: String?,
|
||||||
|
dose: Double?,
|
||||||
|
plannedAt: Long? = null
|
||||||
|
): PendingIntent {
|
||||||
val intent = ReminderContract.reminderIntent(context, treatmentId)
|
val intent = ReminderContract.reminderIntent(context, treatmentId)
|
||||||
name?.let { intent.putExtra(ReminderContract.EXTRA_TREATMENT_NAME, it) }
|
name?.let { intent.putExtra(ReminderContract.EXTRA_TREATMENT_NAME, it) }
|
||||||
dose?.let { intent.putExtra(ReminderContract.EXTRA_DOSE_AMOUNT, it) }
|
dose?.let { intent.putExtra(ReminderContract.EXTRA_DOSE_AMOUNT, it) }
|
||||||
|
plannedAt?.let { intent.putExtra(ReminderContract.EXTRA_PLANNED_AT, it) }
|
||||||
return PendingIntent.getBroadcast(
|
return PendingIntent.getBroadcast(
|
||||||
context,
|
context,
|
||||||
treatmentId.toInt(),
|
treatmentId.toInt(),
|
||||||
@ -80,12 +97,27 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
val fireAt = PharmacokineticEngine.nextReminderFireFor(
|
val fireAt = PharmacokineticEngine.nextReminderFireFor(
|
||||||
treatment, doseLogs, System.currentTimeMillis()
|
treatment, doseLogs, System.currentTimeMillis()
|
||||||
) ?: return false
|
) ?: return false
|
||||||
val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount)
|
// L'instant planifié voyage dans l'intent : le garde « créneau déjà
|
||||||
|
// honoré » (v1.10.1) évalue le jour PLANIFIÉ, pas celui du feu réel
|
||||||
|
// (une alarme inexacte peut glisser après minuit — cf
|
||||||
|
// ReminderContract.EXTRA_PLANNED_AT).
|
||||||
|
val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount, fireAt)
|
||||||
return scheduleAt(fireAt, pi)
|
return scheduleAt(fireAt, pi)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scheduleSnooze(treatmentId: Long, name: String, dose: Double, delayMs: Long = 60 * 60_000L): Boolean {
|
/**
|
||||||
val pi = pendingBroadcast(treatmentId, name, dose)
|
* Alarme « Reporter 1 h ». [plannedAt] : l'instant PLANIFIÉ du créneau
|
||||||
|
* d'origine — forwardé tel quel pour que le garde du feu différé
|
||||||
|
* évalue toujours le bon jour (et non le jour du snooze).
|
||||||
|
*/
|
||||||
|
fun scheduleSnooze(
|
||||||
|
treatmentId: Long,
|
||||||
|
name: String,
|
||||||
|
dose: Double,
|
||||||
|
delayMs: Long = 60 * 60_000L,
|
||||||
|
plannedAt: Long? = null
|
||||||
|
): Boolean {
|
||||||
|
val pi = pendingBroadcast(treatmentId, name, dose, plannedAt)
|
||||||
return scheduleAt(System.currentTimeMillis() + delayMs, pi)
|
return scheduleAt(System.currentTimeMillis() + delayMs, pi)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,19 +240,41 @@ class ReminderReceiver : BroadcastReceiver() {
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
manager.notify(treatmentId.toInt(), notification)
|
|
||||||
|
|
||||||
// ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer
|
// ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer
|
||||||
// ICI le déclenchement SUIVANT (v1.4.0, fix #52) — sinon le rappel suivant
|
// ICI le déclenchement SUIVANT (v1.4.0, fix #52) — sinon le rappel suivant
|
||||||
// n'existait qu'après un reboot/une édition. Le calcul passe par le moteur
|
// n'existait qu'après un reboot/une édition. Le calcul passe par le moteur
|
||||||
// (grille Posologie si elle existe, sinon quotidien).
|
// (grille Posologie si elle existe, sinon quotidien).
|
||||||
|
// v1.10.0 (demande utilisatrice) : si la dose du jour est DÉJÀ loguée
|
||||||
|
// (prise saisie avant l'heure de rappel), la notification est SAUTÉE —
|
||||||
|
// elle ne doit pas sonner pour une prise déjà faite — et le rappel
|
||||||
|
// suivant repart au créneau suivant (re-schedule dans tous les cas).
|
||||||
|
// v1.10.1 : le jour évalué est celui du créneau PLANIFIÉ
|
||||||
|
// (EXTRA_PLANNED_AT), pas celui du feu réel — une alarme inexacte
|
||||||
|
// peut glisser après minuit sans changer de créneau.
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val plannedAtMs = if (intent.hasExtra(ReminderContract.EXTRA_PLANNED_AT)) {
|
||||||
|
intent.getLongExtra(ReminderContract.EXTRA_PLANNED_AT, 0L)
|
||||||
|
} else {
|
||||||
|
System.currentTimeMillis()
|
||||||
|
}
|
||||||
val pending = goAsync()
|
val pending = goAsync()
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
val db = AppDatabase.getInstance(context)
|
val db = AppDatabase.getInstance(context)
|
||||||
|
val doses = db.doseLogDao().getAllOnce()
|
||||||
|
if (PharmacokineticEngine.hasDoseLoggedOnDay(
|
||||||
|
treatmentId, doses, plannedAtMs
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
AppLog.log(
|
||||||
|
"reminder",
|
||||||
|
"créneau déjà honoré (dose loguée le jour planifié) → notification sautée (treatment=$treatmentId)"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
manager.notify(treatmentId.toInt(), notification)
|
||||||
|
}
|
||||||
db.treatmentDao().getById(treatmentId)?.let { tr ->
|
db.treatmentDao().getById(treatmentId)?.let { tr ->
|
||||||
AlarmScheduler(context).scheduleFor(tr, db.doseLogDao().getAllOnce())
|
AlarmScheduler(context).scheduleFor(tr, doses)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pending.finish()
|
pending.finish()
|
||||||
@ -232,23 +286,23 @@ class ReminderReceiver : BroadcastReceiver() {
|
|||||||
class BootReceiver : BroadcastReceiver() {
|
class BootReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||||
val result = goAsync()
|
// v1.10.1 : consolidation goAsync + coroutine (pattern ReminderReceiver —
|
||||||
Thread {
|
// remplace le Thread + double runBlocking de la v1.0, dette §20.bis #2) :
|
||||||
|
// une seule lecture séquentielle des DAO dans un scope IO dédié, finish()
|
||||||
|
// garanti par le finally.
|
||||||
|
val pending = goAsync()
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
val db = AppDatabase.getInstance(context)
|
val db = AppDatabase.getInstance(context)
|
||||||
// TOUS les traitements : les inactifs doivent voir leur alarme
|
// TOUS les traitements : les inactifs doivent voir leur alarme
|
||||||
// annulée au boot (sinon une alarme périmée continue de sonner) ;
|
// annulée au boot (sinon une alarme périmée continue de sonner) ;
|
||||||
// les doses alimentent la grille Posologie (fix #52)
|
// les doses alimentent la grille Posologie (fix #52)
|
||||||
val treatments = runBlocking {
|
val treatments = db.treatmentDao().getAllOnce()
|
||||||
db.treatmentDao().getAllOnce()
|
val doses = db.doseLogDao().getAllOnce()
|
||||||
}
|
|
||||||
val doses = runBlocking {
|
|
||||||
db.doseLogDao().getAllOnce()
|
|
||||||
}
|
|
||||||
AlarmScheduler(context).rescheduleAll(treatments, doses)
|
AlarmScheduler(context).rescheduleAll(treatments, doses)
|
||||||
} finally {
|
} finally {
|
||||||
result.finish()
|
pending.finish()
|
||||||
}
|
}
|
||||||
}.start()
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,6 +79,15 @@ class AppSettings(private val context: Context) {
|
|||||||
|
|
||||||
/** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */
|
/** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */
|
||||||
val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at")
|
val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at")
|
||||||
|
|
||||||
|
// ── Cible de creux E2 (v1.13.0, opt-in) ────────────────────────────
|
||||||
|
// Référence PERSONNELLE pour la prochaine prise de sang (« je veux
|
||||||
|
// être dans [low, high] au creux recommandé »). DISTINCT des seuils
|
||||||
|
// d'alerte (ALERT_E2_*), qui surveillent le niveau en continu —
|
||||||
|
// les deux bandes sont volontairement indépendantes. Champ absent =
|
||||||
|
// cible désactivée (comportement v1.12).
|
||||||
|
val TROUGH_TARGET_LOW = doublePreferencesKey("trough_target_low")
|
||||||
|
val TROUGH_TARGET_HIGH = doublePreferencesKey("trough_target_high")
|
||||||
}
|
}
|
||||||
|
|
||||||
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
|
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
|
||||||
@ -221,6 +230,30 @@ class AppSettings(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cible de creux (v1.13.0) : `null` si désactivée (un champ manquant suffit). */
|
||||||
|
val troughTarget: Flow<Pair<Double, Double>?> = context.dataStore.data.map { prefs ->
|
||||||
|
val low = prefs[Keys.TROUGH_TARGET_LOW]
|
||||||
|
val high = prefs[Keys.TROUGH_TARGET_HIGH]
|
||||||
|
if (low != null && high != null) low to high else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persiste la cible de creux (v1.13.0). `null` EFFACE les deux clés —
|
||||||
|
* « champs vides = cible désactivée ». La cohérence high > low est
|
||||||
|
* validée dans l'UI (TroughTargetCard), comme pour les seuils d'alerte.
|
||||||
|
*/
|
||||||
|
suspend fun setTroughTarget(target: Pair<Double, Double>?) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
if (target == null) {
|
||||||
|
prefs.remove(Keys.TROUGH_TARGET_LOW)
|
||||||
|
prefs.remove(Keys.TROUGH_TARGET_HIGH)
|
||||||
|
} else {
|
||||||
|
prefs[Keys.TROUGH_TARGET_LOW] = target.first
|
||||||
|
prefs[Keys.TROUGH_TARGET_HIGH] = target.second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé
|
* Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé
|
||||||
* (DataStore : remove) — « champ vide = pas d'alerte ».
|
* (DataStore : remove) — « champ vide = pas d'alerte ».
|
||||||
|
|||||||
@ -46,7 +46,7 @@ val LocalAppContainer = compositionLocalOf<AppContainer> {
|
|||||||
private data class TabItem(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector)
|
private data class TabItem(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
|
fun HormoneTrackRoot(logDoseRequest: androidx.compose.runtime.MutableState<Long?>) {
|
||||||
val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp
|
val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
|
||||||
@ -132,7 +132,11 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
|
|||||||
) {
|
) {
|
||||||
composable("home") {
|
composable("home") {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else null,
|
// Demande de notification : état détenu par l'activité
|
||||||
|
// (survit aux onglets), consommé UNE fois par HomeScreen
|
||||||
|
// (fix #69 — dialog fantôme au retour sur l'accueil)
|
||||||
|
openLogDoseForTreatmentId = logDoseRequest.value,
|
||||||
|
onLogDoseConsumed = { logDoseRequest.value = null },
|
||||||
onOpenSettings = { navController.navigate("settings") },
|
onOpenSettings = { navController.navigate("settings") },
|
||||||
onOpenCharts = {
|
onOpenCharts = {
|
||||||
// Tap sur le mini-graphique de l'accueil → écran Graphiques
|
// Tap sur le mini-graphique de l'accueil → écran Graphiques
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.hormonetrack.ui.components
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
@ -14,6 +15,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
|||||||
import androidx.compose.ui.graphics.nativeCanvas
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.data.model.DoseLog
|
import com.hormonetrack.data.model.DoseLog
|
||||||
|
import com.hormonetrack.data.model.PKModels
|
||||||
import com.hormonetrack.data.model.LabResult
|
import com.hormonetrack.data.model.LabResult
|
||||||
import com.hormonetrack.pk.LevelPoint
|
import com.hormonetrack.pk.LevelPoint
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
@ -101,12 +103,20 @@ fun xLabelTicks(
|
|||||||
cal.set(Calendar.MILLISECOND, 0)
|
cal.set(Calendar.MILLISECOND, 0)
|
||||||
while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1)
|
while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1)
|
||||||
}
|
}
|
||||||
val stepMs = stepHours * 3_600_000L
|
|
||||||
val out = mutableListOf<Long>()
|
val out = mutableListOf<Long>()
|
||||||
var t = cal.timeInMillis
|
var t = cal.timeInMillis
|
||||||
while (t <= t1) {
|
while (t <= t1) {
|
||||||
out.add(t)
|
out.add(t)
|
||||||
t += stepMs
|
// On avance via le Calendar (et non un +stepMs fixe) : une addition
|
||||||
|
// en millisecondes dérive d'1 h aux transitions DST, alors que le
|
||||||
|
// pas « 24 h » doit tomber sur minuit LOCAL tous les jours — y
|
||||||
|
// compris le jour du changement d'heure.
|
||||||
|
if (stepHours >= 24) {
|
||||||
|
cal.add(Calendar.DAY_OF_MONTH, (stepHours / 24L).toInt().coerceAtLeast(1))
|
||||||
|
} else {
|
||||||
|
cal.add(Calendar.HOUR_OF_DAY, stepHours.toInt())
|
||||||
|
}
|
||||||
|
t = cal.timeInMillis
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@ -174,12 +184,12 @@ fun niceCeil(value: Double): Double {
|
|||||||
*/
|
*/
|
||||||
fun defaultModelToggles(pkModelsUsed: Set<String>): Map<String, Boolean> {
|
fun defaultModelToggles(pkModelsUsed: Set<String>): Map<String, Boolean> {
|
||||||
val toggles = mapOf(
|
val toggles = mapOf(
|
||||||
"ESE" to pkModelsUsed.contains("ESE"),
|
PKModels.ESTRANNAISE to pkModelsUsed.contains(PKModels.ESTRANNAISE),
|
||||||
"TFS" to pkModelsUsed.contains("TFS"),
|
PKModels.TRANSFEM_SCIENCE to pkModelsUsed.contains(PKModels.TRANSFEM_SCIENCE),
|
||||||
"WHS" to pkModelsUsed.contains("WHS")
|
PKModels.WHSAH to pkModelsUsed.contains(PKModels.WHSAH)
|
||||||
)
|
)
|
||||||
// Garde-fou : aucune courbe sinon (Bateman tracé dans chaque série)
|
// Garde-fou : aucune courbe sinon (Bateman tracé dans chaque série)
|
||||||
return if (toggles.values.none { it }) toggles + ("TFS" to true) else toggles
|
return if (toggles.values.none { it }) toggles + (PKModels.TRANSFEM_SCIENCE to true) else toggles
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -310,6 +320,35 @@ fun CurveChart(
|
|||||||
val forecastMarkerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f)
|
val forecastMarkerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f)
|
||||||
val realDoseMarkerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
|
val realDoseMarkerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
|
||||||
|
|
||||||
|
// v1.9.5 (audit) : extrema mémoïsés PAR SÉRIE (index aligné sur
|
||||||
|
// `series`) — detectExtrema scanne tous les points (O(n)) et ne change
|
||||||
|
// que si les séries/options changent, pas à chaque pan/zoom (le Canvas
|
||||||
|
// se redessine à chaque geste).
|
||||||
|
val extremaBySeries = remember(series, options) {
|
||||||
|
series.map { s ->
|
||||||
|
Pair(
|
||||||
|
if (s.showExtrema) {
|
||||||
|
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
|
||||||
|
} else emptyList(),
|
||||||
|
if (s.showExtrema) {
|
||||||
|
s.tStyle?.let { PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// v1.9.5 (audit) : formatters mémoïsés (SimpleDateFormat n'est pas
|
||||||
|
// thread-safe — thread UI seul ici — et l'allouer par frame était du
|
||||||
|
// gaspillage, même standard que le fix des marqueurs v1.8.2).
|
||||||
|
val markerTimeFmt = remember(zone) {
|
||||||
|
SimpleDateFormat("HH:mm", Locale.getDefault()).apply { timeZone = zone }
|
||||||
|
}
|
||||||
|
val xAxisHourFmt = remember(zone) {
|
||||||
|
SimpleDateFormat("HH'h'", Locale.getDefault()).apply { timeZone = zone }
|
||||||
|
}
|
||||||
|
val xAxisDayFmt = remember(zone) {
|
||||||
|
SimpleDateFormat("dd/MM", Locale.getDefault()).apply { timeZone = zone }
|
||||||
|
}
|
||||||
|
|
||||||
Canvas(modifier = modifier) {
|
Canvas(modifier = modifier) {
|
||||||
val allPoints = series.flatMap { it.points }
|
val allPoints = series.flatMap { it.points }
|
||||||
if (allPoints.size < 2) return@Canvas
|
if (allPoints.size < 2) return@Canvas
|
||||||
@ -364,27 +403,19 @@ fun CurveChart(
|
|||||||
drawAxisUnit(T_UNIT, padLeft + w + 6.dp.toPx(), padTop - 6.dp.toPx(),
|
drawAxisUnit(T_UNIT, padLeft + w + 6.dp.toPx(), padTop - 6.dp.toPx(),
|
||||||
android.graphics.Paint.Align.LEFT)
|
android.graphics.Paint.Align.LEFT)
|
||||||
}
|
}
|
||||||
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w, zone)
|
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w, zone, xAxisHourFmt, xAxisDayFmt)
|
||||||
|
|
||||||
// Doses PRÉVISIONNELLES (v1.4.5) : ligne verticale pointillée + petit
|
// Doses PRÉVISIONNELLES (v1.4.5) : ligne verticale pointillée + petit
|
||||||
// triangle ▼ en haut de la zone — rend l'instant de la dose simulée
|
// triangle ▼ en haut de la zone — rend l'instant de la dose simulée
|
||||||
// VISIBLE (sinon la projection paraît décalée d'un jour, bug #55).
|
// VISIBLE (sinon la projection paraît décalée d'un jour, bug #55).
|
||||||
if (forecastDoses.isNotEmpty()) {
|
if (forecastDoses.isNotEmpty()) {
|
||||||
val markerColor = forecastMarkerColor
|
val markerColor = forecastMarkerColor
|
||||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply {
|
val paint = textPaint(markerColor, 10f, android.graphics.Paint.Align.CENTER)
|
||||||
color = android.graphics.Color.argb(
|
|
||||||
(markerColor.alpha * 255).toInt(),
|
|
||||||
(markerColor.red * 255).toInt(),
|
|
||||||
(markerColor.green * 255).toInt(),
|
|
||||||
(markerColor.blue * 255).toInt()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// SimpleDateFormat créé UNE fois (hors boucle) : le Canvas se
|
// SimpleDateFormat créé UNE fois (hors boucle) : le Canvas se
|
||||||
// redessine à chaque pan/zoom, une allocation par dose × frame
|
// redessine à chaque pan/zoom, une allocation par dose × frame
|
||||||
// était du gaspillage (la classe n'est de toute façon pas
|
// était du gaspillage (la classe n'est de toute façon pas
|
||||||
// thread-safe — ici on reste dans le DrawScope UI)
|
// thread-safe — ici on reste dans le DrawScope UI)
|
||||||
val hourFmt = SimpleDateFormat("HH:mm", Locale.getDefault())
|
val hourFmt = markerTimeFmt
|
||||||
hourFmt.timeZone = zone
|
|
||||||
for (dose in forecastDoses) {
|
for (dose in forecastDoses) {
|
||||||
if (dose.timestamp !in t0..t1) continue
|
if (dose.timestamp !in t0..t1) continue
|
||||||
val x = xOf(dose.timestamp)
|
val x = xOf(dose.timestamp)
|
||||||
@ -439,7 +470,7 @@ fun CurveChart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Une courbe E2 + une courbe T par série
|
// Une courbe E2 + une courbe T par série
|
||||||
for (s in series) {
|
for ((sIdx, s) in series.withIndex()) {
|
||||||
if (s.points.size < 2) continue
|
if (s.points.size < 2) continue
|
||||||
val e2Path = Path()
|
val e2Path = Path()
|
||||||
s.points.forEachIndexed { i, p ->
|
s.points.forEachIndexed { i, p ->
|
||||||
@ -479,17 +510,20 @@ fun CurveChart(
|
|||||||
// Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée
|
// Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée
|
||||||
// (v1.2.6 : le triangle est accompagné de la valeur estimée du pic/creux).
|
// (v1.2.6 : le triangle est accompagné de la valeur estimée du pic/creux).
|
||||||
// v1.5.0 : une série peut être exclue (Tracé labs → showExtrema=false).
|
// v1.5.0 : une série peut être exclue (Tracé labs → showExtrema=false).
|
||||||
|
// v1.9.5 (audit) : les extrema sont mémoïsés (extremaByModel, hors
|
||||||
|
// Canvas, recalculés seulement quand séries/options changent) — le
|
||||||
|
// scan local était refait PAR FRAME, x2 séries.
|
||||||
if (options.showExtrema && s.showExtrema) {
|
if (options.showExtrema && s.showExtrema) {
|
||||||
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
|
extremaBySeries[sIdx]?.let { (e2Extrema, tExtrema) ->
|
||||||
.forEach { ex ->
|
e2Extrema.forEach { ex ->
|
||||||
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
|
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
|
||||||
}
|
}
|
||||||
if (options.showT) {
|
if (options.showT) {
|
||||||
s.tStyle?.let { tStyle ->
|
s.tStyle?.let { tStyle ->
|
||||||
PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02)
|
tExtrema.orEmpty().forEach { ex ->
|
||||||
.forEach { ex ->
|
|
||||||
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
|
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -574,19 +608,9 @@ private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: Pharmacokinet
|
|||||||
}
|
}
|
||||||
path.close()
|
path.close()
|
||||||
drawPath(path, color)
|
drawPath(path, color)
|
||||||
// Valeur estimée du pic/creux, dans la couleur de la courbe
|
// Valeur estimée du pic/creux, dans la couleur de la courbe (Paint mis
|
||||||
val paint = android.graphics.Paint().apply {
|
// en cache — v1.9.5 : un Paint par extremum × frame était du gaspillage)
|
||||||
this.color = android.graphics.Color.argb(
|
val paint = textPaint(color, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
|
||||||
(color.alpha * 255).toInt(),
|
|
||||||
(color.red * 255).toInt(),
|
|
||||||
(color.green * 255).toInt(),
|
|
||||||
(color.blue * 255).toInt()
|
|
||||||
)
|
|
||||||
textSize = 9.dp.toPx()
|
|
||||||
textAlign = android.graphics.Paint.Align.CENTER
|
|
||||||
isAntiAlias = true
|
|
||||||
isFakeBoldText = true
|
|
||||||
}
|
|
||||||
drawContext.canvas.nativeCanvas.drawText(
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
formatValue(extremum.value), cx, valueY, paint
|
formatValue(extremum.value), cx, valueY, paint
|
||||||
)
|
)
|
||||||
@ -619,22 +643,42 @@ private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: F
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Paint partagé pour les labels texte (couleur gris neutre fixe). */
|
/**
|
||||||
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint {
|
* Cache de Paints TEXTE du graphique (v1.9.5, audit) : un Paint par
|
||||||
val c = Color(0xFF6B7280)
|
* (couleur, taille, align, gras) au lieu d'une allocation PAR EXTREMUM /
|
||||||
return android.graphics.Paint().apply {
|
* label / frame — le Canvas se redessine à chaque pan/zoom/tick. Tout le
|
||||||
color = android.graphics.Color.argb(
|
* dessin Compose est séquentiel sur le thread UI → un cache d'objet suffit.
|
||||||
(c.alpha * 255).toInt(),
|
* Les clés sont des value classes (Color) → pas de fuite.
|
||||||
(c.red * 255).toInt(),
|
*/
|
||||||
(c.green * 255).toInt(),
|
private val textPaintCache = HashMap<String, android.graphics.Paint>()
|
||||||
(c.blue * 255).toInt()
|
|
||||||
)
|
private fun DrawScope.textPaint(
|
||||||
textSize = 10.dp.toPx()
|
color: Color,
|
||||||
textAlign = align
|
textSizeDp: Float,
|
||||||
isAntiAlias = true
|
align: android.graphics.Paint.Align,
|
||||||
|
fakeBold: Boolean = false
|
||||||
|
): android.graphics.Paint {
|
||||||
|
val key = "$color|$textSizeDp|$align|$fakeBold"
|
||||||
|
return textPaintCache.getOrPut(key) {
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
this.color = android.graphics.Color.argb(
|
||||||
|
(color.alpha * 255).toInt(),
|
||||||
|
(color.red * 255).toInt(),
|
||||||
|
(color.green * 255).toInt(),
|
||||||
|
(color.blue * 255).toInt()
|
||||||
|
)
|
||||||
|
textSize = textSizeDp.dp.toPx()
|
||||||
|
textAlign = align
|
||||||
|
isAntiAlias = true
|
||||||
|
isFakeBoldText = fakeBold
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paint partagé pour les labels texte (couleur gris neutre fixe, 10 dp). */
|
||||||
|
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint =
|
||||||
|
textPaint(Color(0xFF6B7280), 10f, align)
|
||||||
|
|
||||||
private fun DrawScope.drawLeftYLabels(color: Color, e2Max: Double, padLeft: Float, padTop: Float, h: Float) {
|
private fun DrawScope.drawLeftYLabels(color: Color, e2Max: Double, padLeft: Float, padTop: Float, h: Float) {
|
||||||
val paint = labelPaint(android.graphics.Paint.Align.RIGHT)
|
val paint = labelPaint(android.graphics.Paint.Align.RIGHT)
|
||||||
for (i in 0..4) {
|
for (i in 0..4) {
|
||||||
@ -656,7 +700,11 @@ private fun DrawScope.drawRightYLabels(color: Color, tMax: Double, xRight: Float
|
|||||||
private fun DrawScope.drawXLabels(
|
private fun DrawScope.drawXLabels(
|
||||||
color: Color, t0: Long, t1: Long,
|
color: Color, t0: Long, t1: Long,
|
||||||
padLeft: Float, yBottom: Float, w: Float,
|
padLeft: Float, yBottom: Float, w: Float,
|
||||||
zone: java.util.TimeZone = java.util.TimeZone.getDefault()
|
zone: java.util.TimeZone = java.util.TimeZone.getDefault(),
|
||||||
|
// v1.9.5 (audit) : formatters mémoïsés du composable (plus de
|
||||||
|
// SimpleDateFormat alloué par frame)
|
||||||
|
hourFmt: SimpleDateFormat = SimpleDateFormat("HH'h'", Locale.getDefault()),
|
||||||
|
dayFmt: SimpleDateFormat = SimpleDateFormat("dd/MM", Locale.getDefault())
|
||||||
) {
|
) {
|
||||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER)
|
val paint = labelPaint(android.graphics.Paint.Align.CENTER)
|
||||||
val spanH = (t1 - t0) / 3_600_000.0
|
val spanH = (t1 - t0) / 3_600_000.0
|
||||||
@ -670,28 +718,25 @@ private fun DrawScope.drawXLabels(
|
|||||||
}
|
}
|
||||||
// v1.4.5 : le label est formaté DANS le fuseau de lecture — sinon le
|
// v1.4.5 : le label est formaté DANS le fuseau de lecture — sinon le
|
||||||
// tick « minuit local » serait étiqueté avec l'heure système.
|
// tick « minuit local » serait étiqueté avec l'heure système.
|
||||||
val fmt = if (spanH <= 25) SimpleDateFormat("HH'h'", Locale.getDefault())
|
// v1.9.5 : formatters mémoïsés passés par le composable (plus d'alloc/frame).
|
||||||
else SimpleDateFormat("dd/MM", Locale.getDefault())
|
val fmt = if (spanH <= 25) hourFmt else dayFmt
|
||||||
fmt.timeZone = zone
|
|
||||||
// v1.4.5 (fix #55) : les ticks sont alignés sur les frontières LOCALES
|
// v1.4.5 (fix #55) : les ticks sont alignés sur les frontières LOCALES
|
||||||
// (minuit local pour les pas en jours) — l'ancien alignement sur des
|
// (minuit local pour les pas en jours) — l'ancien alignement sur des
|
||||||
// multiples UTC plaçait le label « 13/09 » à 02:00 du matin FR.
|
// multiples UTC plaçait le label « 13/09 » à 02:00 du matin FR.
|
||||||
for (tick in xLabelTicks(t0, t1, stepHours.toLong())) {
|
// v1.9.5 (audit) : la ZONE de lecture est passée à xLabelTicks — l'oubli
|
||||||
|
// alignait les ticks sur le fuseau de l'appareil pendant que les labels
|
||||||
|
// sont formatés dans le fuseau choisi (heures décalées dès que les deux
|
||||||
|
// divergent, contredisant le fix #55).
|
||||||
|
for (tick in xLabelTicks(t0, t1, stepHours.toLong(), zone)) {
|
||||||
val x = padLeft + w * ((tick - t0).toFloat() / (t1 - t0).toFloat())
|
val x = padLeft + w * ((tick - t0).toFloat() / (t1 - t0).toFloat())
|
||||||
drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint)
|
drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawDotLabel(v: Double, c: Offset) {
|
private fun DrawScope.drawDotLabel(v: Double, c: Offset) {
|
||||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply {
|
// Paint en cache (v1.9.5 : une allocation par lab × frame → une par
|
||||||
color = android.graphics.Color.rgb(
|
// couleur, réutilisée entre frames)
|
||||||
(LabDot.red * 255).toInt(),
|
val paint = textPaint(LabDot, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
|
||||||
(LabDot.green * 255).toInt(),
|
|
||||||
(LabDot.blue * 255).toInt()
|
|
||||||
)
|
|
||||||
textSize = 9.dp.toPx()
|
|
||||||
isFakeBoldText = true
|
|
||||||
}
|
|
||||||
drawContext.canvas.nativeCanvas.drawText(formatValue(v), c.x, c.y, paint)
|
drawContext.canvas.nativeCanvas.drawText(formatValue(v), c.x, c.y, paint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,7 @@ import androidx.compose.ui.layout.onSizeChanged
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.data.model.PKModels
|
||||||
import com.hormonetrack.pk.LevelPoint
|
import com.hormonetrack.pk.LevelPoint
|
||||||
import com.hormonetrack.pk.EstrannaiseCloud
|
import com.hormonetrack.pk.EstrannaiseCloud
|
||||||
import com.hormonetrack.pk.LabTrajectoryModel
|
import com.hormonetrack.pk.LabTrajectoryModel
|
||||||
@ -153,9 +154,9 @@ fun ChartScreen() {
|
|||||||
val toggles = defaultModelToggles(
|
val toggles = defaultModelToggles(
|
||||||
treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet()
|
treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet()
|
||||||
)
|
)
|
||||||
showEse = toggles.getValue("ESE")
|
showEse = toggles.getValue(PKModels.ESTRANNAISE)
|
||||||
showTfs = toggles.getValue("TFS")
|
showTfs = toggles.getValue(PKModels.TRANSFEM_SCIENCE)
|
||||||
showWhs = toggles.getValue("WHS")
|
showWhs = toggles.getValue(PKModels.WHSAH)
|
||||||
modelTogglesInitialized = true
|
modelTogglesInitialized = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -192,14 +193,14 @@ fun ChartScreen() {
|
|||||||
} else {
|
} else {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
mapOf(
|
mapOf(
|
||||||
"ESE" to PharmacokineticEngine.autoCalibrated(
|
PKModels.ESTRANNAISE to PharmacokineticEngine.autoCalibrated(
|
||||||
treatments, doseLogs, labResults, tConfig, modelOverride = "ESE"
|
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.ESTRANNAISE
|
||||||
),
|
),
|
||||||
"TFS" to PharmacokineticEngine.autoCalibrated(
|
PKModels.TRANSFEM_SCIENCE to PharmacokineticEngine.autoCalibrated(
|
||||||
treatments, doseLogs, labResults, tConfig, modelOverride = "TFS"
|
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.TRANSFEM_SCIENCE
|
||||||
),
|
),
|
||||||
"WHS" to PharmacokineticEngine.autoCalibrated(
|
PKModels.WHSAH to PharmacokineticEngine.autoCalibrated(
|
||||||
treatments, doseLogs, labResults, tConfig, modelOverride = "WHS"
|
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.WHSAH
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -208,7 +209,14 @@ fun ChartScreen() {
|
|||||||
|
|
||||||
// Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné
|
// Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné
|
||||||
// [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt).
|
// [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt).
|
||||||
val maxIntervalDays = treatments.mapNotNull { it.forecastIntervalDays }.maxOrNull()
|
// ⚠️ FIX #70 (v1.12.0) : SEULS les traitements ACTIFS comptent — la
|
||||||
|
// Posologie d'un traitement archivé ne doit ni activer le chip
|
||||||
|
// Prévision ni étendre l'horizon (le moteur ne projette plus ses
|
||||||
|
// créneaux, cf generateForecastDoses).
|
||||||
|
val maxIntervalDays = treatments
|
||||||
|
.filter { it.isActive }
|
||||||
|
.mapNotNull { it.forecastIntervalDays }
|
||||||
|
.maxOrNull()
|
||||||
val forecastHorizonHours = forecastHorizonHours(maxIntervalDays)
|
val forecastHorizonHours = forecastHorizonHours(maxIntervalDays)
|
||||||
|
|
||||||
// ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ──
|
// ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ──
|
||||||
@ -263,6 +271,11 @@ fun ChartScreen() {
|
|||||||
// exposées pour être MARQUÉES sur le graphique (lignes pointillées aux
|
// exposées pour être MARQUÉES sur le graphique (lignes pointillées aux
|
||||||
// créneaux projetés, fix #55 : sans marqueur, la projection paraissait
|
// créneaux projetés, fix #55 : sans marqueur, la projection paraissait
|
||||||
// décalée d'un jour).
|
// décalée d'un jour).
|
||||||
|
// ⚠️ v1.9.5 (audit) : ce producer et le producer `curves` calculent
|
||||||
|
// chacun leurs doses prévisionnelles — VOLONTAIRE. Un partage d'état
|
||||||
|
// (curves lisant ce producer) créerait une lecture STALE d'un cycle de
|
||||||
|
// recomposition (leçon #65 : un producer doit redériver de ses vraies
|
||||||
|
// entrées) pour un calcul léger (O(traitements), pas par point de grille).
|
||||||
val forecastDoses by produceState(
|
val forecastDoses by produceState(
|
||||||
emptyList<com.hormonetrack.data.model.DoseLog>(),
|
emptyList<com.hormonetrack.data.model.DoseLog>(),
|
||||||
treatments, doseLogs, showForecast, endMs, forecastHorizonHours
|
treatments, doseLogs, showForecast, endMs, forecastHorizonHours
|
||||||
@ -338,8 +351,8 @@ fun ChartScreen() {
|
|||||||
// Calibration PAR MODÈLE (fix #60) : chaque courbe reçoit les
|
// Calibration PAR MODÈLE (fix #60) : chaque courbe reçoit les
|
||||||
// échelles/k calculés avec SA prédiction. Hors auto-calibration
|
// échelles/k calculés avec SA prédiction. Hors auto-calibration
|
||||||
// → scaleFactor stocké des traitements (comportement historique).
|
// → scaleFactor stocké des traitements (comportement historique).
|
||||||
val effTreatments = autoByModel["TFS"]?.treatments ?: treatments
|
val effTreatments = autoByModel[PKModels.TRANSFEM_SCIENCE]?.treatments ?: treatments
|
||||||
val effTConfig = autoByModel["TFS"]?.tConfig ?: tConfig
|
val effTConfig = autoByModel[PKModels.TRANSFEM_SCIENCE]?.tConfig ?: tConfig
|
||||||
// Doses réelles + doses prévisionnelles éventuelles
|
// Doses réelles + doses prévisionnelles éventuelles
|
||||||
val forecastDoses = if (showForecast) {
|
val forecastDoses = if (showForecast) {
|
||||||
effTreatments.flatMap {
|
effTreatments.flatMap {
|
||||||
@ -352,7 +365,7 @@ fun ChartScreen() {
|
|||||||
// v1.8.2 : les 3 blocs copiés (ESE/TFS/WHS) sont factorisés —
|
// v1.8.2 : les 3 blocs copiés (ESE/TFS/WHS) sont factorisés —
|
||||||
// le modèle est la seule variable (même calcul, même calibration
|
// le modèle est la seule variable (même calcul, même calibration
|
||||||
// PAR MODÈLE via autoByModel[model], fix #60)
|
// PAR MODÈLE via autoByModel[model], fix #60)
|
||||||
for ((model, enabled) in listOf("ESE" to showEse, "TFS" to showTfs, "WHS" to showWhs)) {
|
for ((model, enabled) in listOf(PKModels.ESTRANNAISE to showEse, PKModels.TRANSFEM_SCIENCE to showTfs, PKModels.WHSAH to showWhs)) {
|
||||||
if (!enabled) continue
|
if (!enabled) continue
|
||||||
val autoM = autoByModel[model]
|
val autoM = autoByModel[model]
|
||||||
val curve = PharmacokineticEngine.computeCurve(
|
val curve = PharmacokineticEngine.computeCurve(
|
||||||
@ -365,11 +378,11 @@ fun ChartScreen() {
|
|||||||
// v1.9.0 : le nuage d'incertitude est attaché à la série
|
// v1.9.0 : le nuage d'incertitude est attaché à la série
|
||||||
// ESE (exclusif à ce modèle — les fits TFS/WHSAH ne
|
// ESE (exclusif à ce modèle — les fits TFS/WHSAH ne
|
||||||
// publient pas de posterior MCMC)
|
// publient pas de posterior MCMC)
|
||||||
val cloud = if (model == "ESE" && showCloud) {
|
val cloud = if (model == PKModels.ESTRANNAISE && showCloud) {
|
||||||
// MÊME calibration que la courbe ESE (fix v1.9.1 :
|
// MÊME calibration que la courbe ESE (fix v1.9.1 :
|
||||||
// nuage brut ≠ échelle de la courbe calibrée)
|
// nuage brut ≠ échelle de la courbe calibrée)
|
||||||
EstrannaiseCloud.compute(
|
EstrannaiseCloud.compute(
|
||||||
treatments, doseLogs, startMs, endMs, step, nowMs,
|
treatments, doseLogs, startMs, endMs, step,
|
||||||
scalePerEster = autoM?.esterScales
|
scalePerEster = autoM?.esterScales
|
||||||
)
|
)
|
||||||
} else emptyList()
|
} else emptyList()
|
||||||
@ -394,9 +407,9 @@ fun ChartScreen() {
|
|||||||
// T Estrannaise = rose plein, T TFS = rose pointillé.
|
// T Estrannaise = rose plein, T TFS = rose pointillé.
|
||||||
val series = curves.map { (model, points, cloud) ->
|
val series = curves.map { (model, points, cloud) ->
|
||||||
when (model) {
|
when (model) {
|
||||||
"ESE" -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT), cloud = curves.first { it.model == "ESE" }.cloud)
|
PKModels.ESTRANNAISE -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT), cloud = cloud)
|
||||||
// v1.4.6 : WHSAH = E2 vert plein, T violet pointillé
|
// v1.4.6 : WHSAH = E2 vert plein, T violet pointillé
|
||||||
"WHS" -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true))
|
PKModels.WHSAH -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true))
|
||||||
// v1.5.0 : « Tracé labs » = E2 rose foncé pointillé, PAS de courbe T
|
// v1.5.0 : « Tracé labs » = E2 rose foncé pointillé, PAS de courbe T
|
||||||
// (la série n'a qu'une valeur E2 — tStyle null → rien de dessiné)
|
// (la série n'a qu'une valeur E2 — tStyle null → rien de dessiné)
|
||||||
// et hors pics/creux (labIsSignificant a déjà filtré, §7.10)
|
// et hors pics/creux (labIsSignificant a déjà filtré, §7.10)
|
||||||
@ -658,7 +671,7 @@ fun ChartScreen() {
|
|||||||
// une 2ᵉ fois (bug v1.5.0-trouvé-en-émulateur #63).
|
// une 2ᵉ fois (bug v1.5.0-trouvé-en-émulateur #63).
|
||||||
curves.forEach { (model, _) ->
|
curves.forEach { (model, _) ->
|
||||||
when (model) {
|
when (model) {
|
||||||
"ESE" -> {
|
PKModels.ESTRANNAISE -> {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_ese_e2),
|
stringResource(R.string.legend_ese_e2),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@ -673,7 +686,7 @@ fun ChartScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// v1.4.6 : légende WHSAH (vert / violet pointillé)
|
// v1.4.6 : légende WHSAH (vert / violet pointillé)
|
||||||
"WHS" -> {
|
PKModels.WHSAH -> {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_whs_e2),
|
stringResource(R.string.legend_whs_e2),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@ -741,7 +754,7 @@ fun ChartScreen() {
|
|||||||
}
|
}
|
||||||
// v1.9.0 : légende du nuage d'incertitude (ESE) — visible
|
// v1.9.0 : légende du nuage d'incertitude (ESE) — visible
|
||||||
// tant que le nuage est affiché (série ESE avec cloud non vide)
|
// tant que le nuage est affiché (série ESE avec cloud non vide)
|
||||||
if (showCloud && showEse && curves.any { it.model == "ESE" && it.cloud.isNotEmpty() }) {
|
if (showCloud && showEse && curves.any { it.model == PKModels.ESTRANNAISE && it.cloud.isNotEmpty() }) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.legend_cloud),
|
stringResource(R.string.legend_cloud),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
|||||||
@ -2,17 +2,24 @@ package com.hormonetrack.ui.screens
|
|||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Colorize
|
||||||
|
import androidx.compose.material.icons.filled.Healing
|
||||||
|
import androidx.compose.material.icons.filled.Medication
|
||||||
|
import androidx.compose.material.icons.filled.Science
|
||||||
|
import androidx.compose.material.icons.filled.Vaccines
|
||||||
|
import androidx.compose.material.icons.filled.WaterDrop
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@ -22,6 +29,7 @@ import androidx.compose.material3.FloatingActionButton
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -33,13 +41,20 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.data.model.AdministrationRoute
|
||||||
import com.hormonetrack.data.model.DoseLog
|
import com.hormonetrack.data.model.DoseLog
|
||||||
|
import com.hormonetrack.data.model.LabResult
|
||||||
import com.hormonetrack.ui.LocalAppContainer
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
import com.hormonetrack.ui.components.DoseDialog
|
import com.hormonetrack.ui.components.DoseDialog
|
||||||
|
import com.hormonetrack.ui.theme.LabDot
|
||||||
import com.hormonetrack.util.HrtDuration
|
import com.hormonetrack.util.HrtDuration
|
||||||
import com.hormonetrack.ui.components.formatDose
|
import com.hormonetrack.ui.components.formatDose
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@ -49,6 +64,54 @@ import java.time.Instant
|
|||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icône de FORME DE PRISE (v1.12.0, demande utilisatrice : « des icônes pour
|
||||||
|
* bien séparer les différentes formes de prises des traitements »). Mapping :
|
||||||
|
* ORAL = pilule (Medication) ; IM/SC = seringue (Vaccines — le glyphe est
|
||||||
|
* partagé, la TEinte les distingue côté UI : IM = couleur courante, SC =
|
||||||
|
* tertiary) ; GEL = goutte (WaterDrop) ; PATCH = sparadrap (Healing) ;
|
||||||
|
* OTHER/inconnu = compte-gouttes (Colorize). PUR → testable en JVM
|
||||||
|
* (DosesExtrasTest) ; miroir web `routeGlyph` (js/data/models.js).
|
||||||
|
*/
|
||||||
|
fun routeIcon(route: AdministrationRoute?): ImageVector = when (route) {
|
||||||
|
AdministrationRoute.ORAL -> Icons.Filled.Medication
|
||||||
|
AdministrationRoute.INJECTION_IM -> Icons.Filled.Vaccines
|
||||||
|
AdministrationRoute.INJECTION_SUBCUT -> Icons.Filled.Vaccines
|
||||||
|
AdministrationRoute.TRANSDERMAL_GEL -> Icons.Filled.WaterDrop
|
||||||
|
AdministrationRoute.TRANSDERMAL_PATCH -> Icons.Filled.Healing
|
||||||
|
else -> Icons.Filled.Colorize
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marqueurs d'ANALYSE DE SANG pour UNE dose (v1.12.0, demande utilisatrice :
|
||||||
|
* « une petite indication … dans le cas où un test sanguin a été effectué
|
||||||
|
* avant ou après une prise — peut-être un petit encart sur le côté »).
|
||||||
|
*
|
||||||
|
* Une analyse compte si elle tombe le MÊME JOUR calendaire LOCAL que la
|
||||||
|
* dose : au-delà, le lien clinique (labs au creux/pic autour d'une
|
||||||
|
* injection) devient ténu et chaque dose finirait marquée.
|
||||||
|
*
|
||||||
|
* @return Pair(AVANT la dose, APRÈS la dose) — une analyse exactement À
|
||||||
|
* l'heure de la dose compte comme APRÈS (convention arbitraire documentée).
|
||||||
|
* PUR → testable en JVM (DosesExtrasTest) ; miroir web `labMarkersForDose`
|
||||||
|
* (js/util/format.js).
|
||||||
|
*/
|
||||||
|
fun labMarkersForDose(
|
||||||
|
labs: List<LabResult>,
|
||||||
|
doseMs: Long,
|
||||||
|
zone: ZoneId = ZoneId.systemDefault()
|
||||||
|
): Pair<Boolean, Boolean> {
|
||||||
|
val day = Instant.ofEpochMilli(doseMs).atZone(zone).toLocalDate()
|
||||||
|
var before = false
|
||||||
|
var after = false
|
||||||
|
for (lab in labs) {
|
||||||
|
if (Instant.ofEpochMilli(lab.timestamp).atZone(zone).toLocalDate() == day) {
|
||||||
|
if (lab.timestamp < doseMs) before = true else after = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return before to after
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DosesScreen() {
|
fun DosesScreen() {
|
||||||
@ -56,6 +119,8 @@ fun DosesScreen() {
|
|||||||
val repo = container.repository
|
val repo = container.repository
|
||||||
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
|
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
|
||||||
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
|
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
|
||||||
|
// v1.12.0 : les labs alimentent les marqueurs « prise de sang avant/après »
|
||||||
|
val labResults by repo.allLabResults.collectAsState(initial = emptyList())
|
||||||
val treatmentMap = remember(treatments) { treatments.associateBy { it.id } }
|
val treatmentMap = remember(treatments) { treatments.associateBy { it.id } }
|
||||||
|
|
||||||
var showLogDialog by remember { mutableStateOf(false) }
|
var showLogDialog by remember { mutableStateOf(false) }
|
||||||
@ -154,6 +219,24 @@ fun DosesScreen() {
|
|||||||
val time = timeFmt.format(Instant.ofEpochMilli(log.timestamp).atZone(zone))
|
val time = timeFmt.format(Instant.ofEpochMilli(log.timestamp).atZone(zone))
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable { toEdit = log },
|
modifier = Modifier.clickable { toEdit = log },
|
||||||
|
// v1.12.0 : icône de FORME DE PRISE à gauche —
|
||||||
|
// oral / injection IM / injection SC / gel /
|
||||||
|
// patch / autre se repèrent d'un coup d'œil.
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
routeIcon(tr?.route),
|
||||||
|
contentDescription = tr?.let {
|
||||||
|
stringResource(routeLabelRes(it.route))
|
||||||
|
},
|
||||||
|
tint = if (tr?.route == AdministrationRoute.INJECTION_SUBCUT) {
|
||||||
|
// IM/SC partagent le glyphe seringue :
|
||||||
|
// la teinte tertiary distingue la SC
|
||||||
|
MaterialTheme.colorScheme.tertiary
|
||||||
|
} else {
|
||||||
|
LocalContentColor.current
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text("${tr?.name ?: "?"} · ${formatDose(log.doseAmount)} ${tr?.doseUnit ?: ""}")
|
Text("${tr?.name ?: "?"} · ${formatDose(log.doseAmount)} ${tr?.doseUnit ?: ""}")
|
||||||
},
|
},
|
||||||
@ -178,8 +261,16 @@ fun DosesScreen() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
IconButton(onClick = { toDelete = log }) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
|
// v1.12.0 : encart latéral « prise de sang
|
||||||
|
// le même jour » — ↑ = lab AVANT la dose,
|
||||||
|
// ↓ = lab APRÈS (cf labMarkersForDose)
|
||||||
|
val (labBefore, labAfter) = labMarkersForDose(labResults, log.timestamp, zone)
|
||||||
|
if (labBefore) LabSideMarker(up = true)
|
||||||
|
if (labAfter) LabSideMarker(up = false)
|
||||||
|
IconButton(onClick = { toDelete = log }) {
|
||||||
|
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -234,11 +325,37 @@ fun DosesScreen() {
|
|||||||
CoroutineScope(Dispatchers.IO).launch { repo.deleteDoseLog(log) }
|
CoroutineScope(Dispatchers.IO).launch { repo.deleteDoseLog(log) }
|
||||||
}) { Text(stringResource(R.string.delete)) }
|
}) { Text(stringResource(R.string.delete)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = { TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) } },
|
||||||
TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) }
|
|
||||||
},
|
|
||||||
title = { Text(stringResource(R.string.delete)) },
|
title = { Text(stringResource(R.string.delete)) },
|
||||||
text = { Text(stringResource(R.string.confirm_delete)) }
|
text = { Text(stringResource(R.string.confirm_delete)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encart latéral « prise de sang le même jour que cette dose » (v1.12.0,
|
||||||
|
* demande utilisatrice) : icône laboratoire + flèche — **↑** = analyse
|
||||||
|
* effectuée AVANT la dose, **↓** = APRÈS. La description sémantique porte
|
||||||
|
* la phrase complète (accessibilité TalkBack) ; le glyphe seul est décoratif
|
||||||
|
* (contentDescription = null sur l'icône, description portée par le Row).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LabSideMarker(up: Boolean) {
|
||||||
|
val description = stringResource(if (up) R.string.lab_before_dose else R.string.lab_after_dose)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.semantics { contentDescription = description }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Science,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = LabDot,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (up) "↑" else "↓",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = LabDot
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -78,6 +78,7 @@ private const val HOUR_MS = 3_600_000L
|
|||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
openLogDoseForTreatmentId: Long?,
|
openLogDoseForTreatmentId: Long?,
|
||||||
|
onLogDoseConsumed: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenCharts: () -> Unit,
|
onOpenCharts: () -> Unit,
|
||||||
onOpenTreatment: (Long) -> Unit
|
onOpenTreatment: (Long) -> Unit
|
||||||
@ -113,10 +114,16 @@ fun HomeScreen(
|
|||||||
var showLogDialog by remember { mutableStateOf(false) }
|
var showLogDialog by remember { mutableStateOf(false) }
|
||||||
var preselectId by remember { mutableStateOf<Long?>(null) }
|
var preselectId by remember { mutableStateOf<Long?>(null) }
|
||||||
|
|
||||||
|
// Demande de notification : ouvrir le dialog UNE SEULE FOIS puis
|
||||||
|
// CONSOMMER la demande au niveau activité (fix #69 — l'ancien code
|
||||||
|
// relançait le dialog à chaque RÉ-ENTRÉE en composition de Home :
|
||||||
|
// quitter l'onglet détruit les remember locaux, le paramètre d'intent
|
||||||
|
// lui survivait → retour sur l'accueil = dialog fantôme à chaque fois).
|
||||||
LaunchedEffect(openLogDoseForTreatmentId) {
|
LaunchedEffect(openLogDoseForTreatmentId) {
|
||||||
openLogDoseForTreatmentId?.takeIf { it > 0 }?.let {
|
openLogDoseForTreatmentId?.takeIf { it > 0 }?.let {
|
||||||
showLogDialog = true
|
showLogDialog = true
|
||||||
preselectId = it
|
preselectId = it
|
||||||
|
onLogDoseConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,14 +241,20 @@ fun HomeScreen(
|
|||||||
// (l'accueil ne fait pas doublon d'explication).
|
// (l'accueil ne fait pas doublon d'explication).
|
||||||
val labRecommendation by produceState(
|
val labRecommendation by produceState(
|
||||||
initialValue = null as LabTiming.LabRecommendation?,
|
initialValue = null as LabTiming.LabRecommendation?,
|
||||||
allTreatments, doseLogs, labResults, tick
|
allTreatments, doseLogs, labResults, tick, effectiveAuto
|
||||||
) {
|
) {
|
||||||
value = withContext(Dispatchers.Default) {
|
value = withContext(Dispatchers.Default) {
|
||||||
|
// v1.13.0 : les facteurs d'échelle de l'auto-calibration
|
||||||
|
// alimentent la prédiction « ≈ X pg/mL au creux » (null
|
||||||
|
// si l'option est OFF → carte sans valeur, inchangée)
|
||||||
LabTiming.nextBloodDrawRecommendation(
|
LabTiming.nextBloodDrawRecommendation(
|
||||||
allTreatments, doseLogs, labResults, System.currentTimeMillis()
|
allTreatments, doseLogs, labResults, System.currentTimeMillis(),
|
||||||
|
effectiveAuto?.esterScales
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
|
||||||
|
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
|
||||||
labRecommendation?.let { rec ->
|
labRecommendation?.let { rec ->
|
||||||
val fmtDate = remember {
|
val fmtDate = remember {
|
||||||
DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault())
|
DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault())
|
||||||
@ -263,6 +276,37 @@ fun HomeScreen(
|
|||||||
stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))),
|
stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))),
|
||||||
style = MaterialTheme.typography.bodyLarge
|
style = MaterialTheme.typography.bodyLarge
|
||||||
)
|
)
|
||||||
|
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si
|
||||||
|
// la calibration est disponible) + statut face à la
|
||||||
|
// cible de creux personnelle (lecture seule — jamais
|
||||||
|
// de notification, distinct des seuils d'alerte)
|
||||||
|
rec.predictedE2?.let { predicted ->
|
||||||
|
val targetStatus = LabTiming.troughTargetStatus(
|
||||||
|
predicted, troughTarget?.first, troughTarget?.second
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.labrec_predicted,
|
||||||
|
String.format(java.util.Locale.getDefault(), "%.0f", predicted)
|
||||||
|
) + when (targetStatus) {
|
||||||
|
LabTiming.TroughTargetStatus.IN_TARGET ->
|
||||||
|
" — " + stringResource(R.string.labrec_in_target)
|
||||||
|
LabTiming.TroughTargetStatus.BELOW,
|
||||||
|
LabTiming.TroughTargetStatus.ABOVE ->
|
||||||
|
" — " + stringResource(R.string.labrec_out_of_target)
|
||||||
|
null -> ""
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = when (targetStatus) {
|
||||||
|
LabTiming.TroughTargetStatus.IN_TARGET ->
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
LabTiming.TroughTargetStatus.BELOW,
|
||||||
|
LabTiming.TroughTargetStatus.ABOVE ->
|
||||||
|
MaterialTheme.colorScheme.tertiary
|
||||||
|
null -> MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.labrec_before_injection,
|
R.string.labrec_before_injection,
|
||||||
@ -458,48 +502,76 @@ private fun NowLevelCard(curve: List<LevelPoint>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ligne « prochaine prise » d'un traitement (v1.9.6 — PUR, testable JVM) :
|
||||||
|
* « Xj Yh · ven. 25 10:00 (nom) » au-delà de 24 h (les heures deviennent peu
|
||||||
|
* lisibles, v1.4.1), « XhMM · HH:mm (nom) » en dessous, « N min · HH:mm
|
||||||
|
* (nom) » dans l'heure. Utilisée par chaque ligne de [NextDoseCard].
|
||||||
|
*/
|
||||||
|
internal fun nextDoseLine(
|
||||||
|
nowMs: Long,
|
||||||
|
fireMs: Long,
|
||||||
|
name: String,
|
||||||
|
/** Template localisé du cas ≥ 24 h (string `next_dose_days`, 4 args). */
|
||||||
|
daysTemplate: String,
|
||||||
|
zone: ZoneId = ZoneId.systemDefault(),
|
||||||
|
locale: Locale = Locale.getDefault()
|
||||||
|
): String {
|
||||||
|
val deltaMs = fireMs - nowMs
|
||||||
|
val h = deltaMs / HOUR_MS
|
||||||
|
val m = (deltaMs % HOUR_MS) / 60_000L
|
||||||
|
return if (h >= 24) {
|
||||||
|
val (days, remH) = com.hormonetrack.util.HrtDuration.daysAndHours(h)
|
||||||
|
val dayTimeStr = DateTimeFormatter.ofPattern("EEE d HH:mm")
|
||||||
|
.withZone(zone)
|
||||||
|
.withLocale(locale)
|
||||||
|
.format(Instant.ofEpochMilli(fireMs))
|
||||||
|
String.format(locale, daysTemplate, days, remH, dayTimeStr, name)
|
||||||
|
} else {
|
||||||
|
val timeStr = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
.withZone(zone)
|
||||||
|
.withLocale(locale)
|
||||||
|
.format(Instant.ofEpochMilli(fireMs))
|
||||||
|
if (h > 0) {
|
||||||
|
String.format(locale, "%dh%02d · %s (%s)", h, m, timeStr, name)
|
||||||
|
} else {
|
||||||
|
String.format(locale, "%d min · %s (%s)", m, timeStr, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
|
private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
|
||||||
// v1.4.0 (fix #52) : les dates de rappel passent par le moteur — la carte
|
// v1.4.0 (fix #52) : les dates de rappel passent par le moteur — la carte
|
||||||
// suit la grille Posologie (jour du créneau uniquement) comme les alarmes.
|
// suit la grille Posologie (jour du créneau uniquement) comme les alarmes.
|
||||||
|
// v1.9.6 (demande) : la carte liste TOUTES les prochaines prises, UNE
|
||||||
|
// ligne par traitement, TRIÉES par prochaine prise — l'ancienne carte ne
|
||||||
|
// montrait que la plus proche, et une prise quotidienne (CPA) masquait
|
||||||
|
// totalement le rappel de l'injection hebdomadaire (le rappel EEn
|
||||||
|
// « n'arrivait qu'après celui de la CPA »). Le titre passe au pluriel
|
||||||
|
// dès qu'il y a au moins deux lignes.
|
||||||
val nowMs = System.currentTimeMillis()
|
val nowMs = System.currentTimeMillis()
|
||||||
val fires = treatments
|
val fires = treatments
|
||||||
.mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } }
|
.mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } }
|
||||||
|
.sortedBy { it.second }
|
||||||
if (fires.isEmpty()) return
|
if (fires.isEmpty()) return
|
||||||
val (nextTreatment, next) = fires.minByOrNull { it.second }!!
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.next_dose),
|
stringResource(if (fires.size == 1) R.string.next_dose else R.string.next_doses),
|
||||||
style = MaterialTheme.typography.labelMedium
|
style = MaterialTheme.typography.labelMedium
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
val deltaMs = next - System.currentTimeMillis()
|
// Première ligne (la plus proche) mise en avant, les suivantes en
|
||||||
val h = deltaMs / HOUR_MS
|
// style secondaire — même hiérarchie visuelle que v1.4.x.
|
||||||
val m = (deltaMs % HOUR_MS) / 60_000L
|
fires.forEachIndexed { idx, (treatment, next) ->
|
||||||
val name = nextTreatment?.name ?: ""
|
|
||||||
// v1.4.1 : au-delà de 24 h, afficher en JOURS (les heures
|
|
||||||
// deviennent peu lisibles) + le jour de la semaine dans la date.
|
|
||||||
if (h >= 24) {
|
|
||||||
val (days, remH) = com.hormonetrack.util.HrtDuration.daysAndHours(h)
|
|
||||||
val dayTimeStr = DateTimeFormatter.ofPattern("EEE d HH:mm")
|
|
||||||
.withZone(ZoneId.systemDefault())
|
|
||||||
.format(Instant.ofEpochMilli(next))
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.next_dose_days, days, remH, dayTimeStr, name),
|
nextDoseLine(
|
||||||
style = MaterialTheme.typography.titleMedium
|
nowMs, next, treatment?.name ?: "",
|
||||||
)
|
stringResource(R.string.next_dose_days)
|
||||||
} else {
|
),
|
||||||
val timeStr = DateTimeFormatter.ofPattern("HH:mm")
|
style = if (idx == 0) MaterialTheme.typography.titleMedium
|
||||||
.withZone(ZoneId.systemDefault())
|
else MaterialTheme.typography.bodyMedium
|
||||||
.format(Instant.ofEpochMilli(next))
|
|
||||||
Text(
|
|
||||||
if (h > 0) {
|
|
||||||
String.format(Locale.getDefault(), "%dh%02d · %s (%s)", h, m, timeStr, name)
|
|
||||||
} else {
|
|
||||||
String.format(Locale.getDefault(), "%d min · %s (%s)", m, timeStr, name)
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
import com.hormonetrack.data.model.LabResult
|
import com.hormonetrack.data.model.LabResult
|
||||||
import com.hormonetrack.pk.LabTiming
|
import com.hormonetrack.pk.LabTiming
|
||||||
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
|
import com.hormonetrack.pk.TConfig
|
||||||
import com.hormonetrack.ui.LocalAppContainer
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
import com.hormonetrack.ui.components.LabDialog
|
import com.hormonetrack.ui.components.LabDialog
|
||||||
import com.hormonetrack.ui.components.formatLabValue
|
import com.hormonetrack.ui.components.formatLabValue
|
||||||
@ -143,14 +145,30 @@ fun LabsScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val nowMs = remember(tick) { System.currentTimeMillis() }
|
val nowMs = remember(tick) { System.currentTimeMillis() }
|
||||||
|
// Auto-calibration (v1.13.0) : alimente la prédiction « ≈ X pg/mL » de la
|
||||||
|
// carte reco (null si l'option est OFF) — même pattern que HomeScreen.
|
||||||
|
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
||||||
|
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
||||||
|
val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
|
||||||
|
null, treatments, doseLogs, labs, tConfig, autoCalibrate
|
||||||
|
) {
|
||||||
|
value = if (!autoCalibrate) null
|
||||||
|
else withContext(Dispatchers.Default) {
|
||||||
|
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labs, tConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
|
||||||
|
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
|
||||||
// Calcul HORS UI thread (produceState) — c'est un calcul PK complet
|
// Calcul HORS UI thread (produceState) — c'est un calcul PK complet
|
||||||
// (courbe prévisionnelle), même discipline que Home/Chart.
|
// (courbe prévisionnelle), même discipline que Home/Chart.
|
||||||
val recommendation by produceState(
|
val recommendation by produceState(
|
||||||
initialValue = null as LabTiming.LabRecommendation?,
|
initialValue = null as LabTiming.LabRecommendation?,
|
||||||
treatments, doseLogs, labs, nowMs
|
treatments, doseLogs, labs, nowMs, effectiveAuto
|
||||||
) {
|
) {
|
||||||
value = withContext(Dispatchers.Default) {
|
value = withContext(Dispatchers.Default) {
|
||||||
LabTiming.nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs)
|
LabTiming.nextBloodDrawRecommendation(
|
||||||
|
treatments, doseLogs, labs, nowMs, effectiveAuto?.esterScales
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Invite « renseigne une Posologie » (v1.8.0, demande explicite) : quand
|
// Invite « renseigne une Posologie » (v1.8.0, demande explicite) : quand
|
||||||
@ -193,6 +211,36 @@ fun LabsScreen() {
|
|||||||
stringResource(R.string.labrec_when, fmt.format(Instant.ofEpochMilli(r.troughMs))),
|
stringResource(R.string.labrec_when, fmt.format(Instant.ofEpochMilli(r.troughMs))),
|
||||||
style = MaterialTheme.typography.bodyLarge
|
style = MaterialTheme.typography.bodyLarge
|
||||||
)
|
)
|
||||||
|
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si
|
||||||
|
// la calibration est disponible) + statut cible —
|
||||||
|
// lecture seule, jamais de notification (cf §9.bis)
|
||||||
|
r.predictedE2?.let { predicted ->
|
||||||
|
val targetStatus = LabTiming.troughTargetStatus(
|
||||||
|
predicted, troughTarget?.first, troughTarget?.second
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.labrec_predicted,
|
||||||
|
String.format(java.util.Locale.getDefault(), "%.0f", predicted)
|
||||||
|
) + when (targetStatus) {
|
||||||
|
LabTiming.TroughTargetStatus.IN_TARGET ->
|
||||||
|
" — " + stringResource(R.string.labrec_in_target)
|
||||||
|
LabTiming.TroughTargetStatus.BELOW,
|
||||||
|
LabTiming.TroughTargetStatus.ABOVE ->
|
||||||
|
" — " + stringResource(R.string.labrec_out_of_target)
|
||||||
|
null -> ""
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = when (targetStatus) {
|
||||||
|
LabTiming.TroughTargetStatus.IN_TARGET ->
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
LabTiming.TroughTargetStatus.BELOW,
|
||||||
|
LabTiming.TroughTargetStatus.ABOVE ->
|
||||||
|
MaterialTheme.colorScheme.tertiary
|
||||||
|
null -> MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.labrec_before_injection,
|
R.string.labrec_before_injection,
|
||||||
|
|||||||
@ -1,17 +1,10 @@
|
|||||||
package com.hormonetrack.ui.screens
|
package com.hormonetrack.ui.screens
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@ -19,16 +12,10 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@ -47,49 +34,84 @@ import androidx.appcompat.app.AppCompatDelegate
|
|||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
import com.hormonetrack.data.backup.BackupManager
|
import com.hormonetrack.data.backup.BackupManager
|
||||||
import com.hormonetrack.util.AppLog
|
import com.hormonetrack.data.backup.UserSettings
|
||||||
import com.hormonetrack.pk.Alerts
|
import com.hormonetrack.pk.Alerts
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
|
||||||
import com.hormonetrack.pk.TConfig
|
import com.hormonetrack.pk.TConfig
|
||||||
import com.hormonetrack.reminder.AlarmScheduler
|
import com.hormonetrack.reminder.AlarmScheduler
|
||||||
import com.hormonetrack.ui.LocalAppContainer
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import com.hormonetrack.ui.screens.settings.AboutCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.AlertThresholdsCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.AutoBackupCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.AutoCalibrationCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.BackupCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.ChartTimezoneCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.LanguageCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.LogsCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.RemindersCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.TroughTargetCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.TModelCard
|
||||||
|
import com.hormonetrack.util.AppLog
|
||||||
import com.hormonetrack.util.ExportFileNames
|
import com.hormonetrack.util.ExportFileNames
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écran PARAMÈTRES (v1.9.8 : découpé en cartes thématiques, cf le
|
||||||
|
* sous-package [com.hormonetrack.ui.screens.settings] — comportement et
|
||||||
|
* rendu IDENTIQUES, chaque carte est un fichier dédié documenté).
|
||||||
|
*
|
||||||
|
* Cet orchestrateur garde ce qui est PARTAGÉ entre cartes :
|
||||||
|
* - la collecte des réglages DataStore (collectAsState) ;
|
||||||
|
* - les 4 launchers SAF (export JSON, export logs, import, dossier
|
||||||
|
* auto-backup) : leurs callbacks écrivent l'état `message` et le dialog
|
||||||
|
* de confirmation d'import, déclarés ICI ;
|
||||||
|
* - le dialog de confirmation d'import (écrasement) ;
|
||||||
|
* - l'ordre d'affichage des cartes.
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(onBack: () -> Unit) {
|
fun SettingsScreen(onBack: () -> Unit) {
|
||||||
val container = LocalAppContainer.current
|
val container = LocalAppContainer.current
|
||||||
val repo = container.repository
|
val repo = container.repository
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scheduler = remember { AlarmScheduler(context) }
|
|
||||||
|
|
||||||
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
||||||
val currentLanguage by container.settings.language.collectAsState(initial = "system")
|
val currentLanguage by container.settings.language.collectAsState(initial = "system")
|
||||||
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
||||||
// Seuils d'alerte (v1.4.2) : limites E2/T évaluées sur l'accueil
|
// Seuils d'alerte (v1.4.2) : limites E2/T évaluées sur l'accueil
|
||||||
val alertThresholds by container.settings.alertThresholds.collectAsState(
|
val alertThresholds by container.settings.alertThresholds.collectAsState(
|
||||||
initial = com.hormonetrack.pk.Alerts.Thresholds()
|
initial = Alerts.Thresholds()
|
||||||
)
|
)
|
||||||
|
// Cible de creux (v1.13.0) : référence de lab personnelle — distincte
|
||||||
|
// des seuils d'alerte (cf TroughTargetCard)
|
||||||
|
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
|
||||||
// Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone
|
// Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone
|
||||||
val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?)
|
val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?)
|
||||||
|
|
||||||
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
|
// ── Auto-backup (v1.7.0) : états collectés ici, logique dans la carte ──
|
||||||
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
|
val autoBackupEnabled by container.settings.autoBackupEnabled
|
||||||
var tKText by remember(tConfig.k) { mutableStateOf(formatDoubles(tConfig.k)) }
|
.collectAsState(initial = false)
|
||||||
|
val autoBackupTreeUri by container.settings.autoBackupTreeUri
|
||||||
|
.collectAsState(initial = null as String?)
|
||||||
|
val autoBackupKeep by container.settings.autoBackupKeep.collectAsState(initial = 7)
|
||||||
|
val autoBackupLastRun by container.settings.autoBackupLastRun
|
||||||
|
.collectAsState(initial = null as Pair<String?, Long?>?)
|
||||||
|
|
||||||
var showImportConfirm by remember { mutableStateOf(false) }
|
var showImportConfirm by remember { mutableStateOf(false) }
|
||||||
|
var importJson by remember { mutableStateOf<String?>(null) }
|
||||||
var logLineCount by remember { mutableIntStateOf(AppLog.count()) }
|
var logLineCount by remember { mutableIntStateOf(AppLog.count()) }
|
||||||
// Message de feedback affiché en haut de l'écran (succès/échec des exports,
|
// Message de feedback affiché en haut de l'écran (succès/échec des exports,
|
||||||
// imports, calibration T…) — déclaré AVANT les launchers : le callback de
|
// imports, calibration T…) — déclaré AVANT les launchers : le callback de
|
||||||
// logExportLauncher l'écrit (un state local doit être déclaré avant le
|
// logExportLauncher l'écrit (un state local doit être déclaré avant le
|
||||||
// lambda qui le capture).
|
// lambda qui le capture).
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
var message by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
/** Écrit le feedback depuis le thread principal (les cartes l'appellent). */
|
||||||
|
val showMessage: (String) -> Unit = { message = it }
|
||||||
|
|
||||||
val logExportLauncher = rememberLauncherForActivityResult(
|
val logExportLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.CreateDocument("text/plain")
|
ActivityResultContracts.CreateDocument("text/plain")
|
||||||
) { uri ->
|
) { uri ->
|
||||||
@ -134,7 +156,6 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var importJson by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
val exportLauncher = rememberLauncherForActivityResult(
|
val exportLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.CreateDocument("application/json")
|
ActivityResultContracts.CreateDocument("application/json")
|
||||||
@ -147,13 +168,17 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
// UNE seule collecte des seuils (les 4 champs viennent du même
|
// UNE seule collecte des seuils (les 4 champs viennent du même
|
||||||
// snapshot DataStore — 4 × .first() = 4 collectes redondantes)
|
// snapshot DataStore — 4 × .first() = 4 collectes redondantes)
|
||||||
val thresholds = container.settings.alertThresholds.first()
|
val thresholds = container.settings.alertThresholds.first()
|
||||||
val settings = com.hormonetrack.data.backup.UserSettings(
|
val settings = UserSettings(
|
||||||
language = container.settings.language.first(),
|
language = container.settings.language.first(),
|
||||||
autoCalibrate = container.settings.autoCalibrate.first(),
|
autoCalibrate = container.settings.autoCalibrate.first(),
|
||||||
alertE2High = thresholds.e2High,
|
alertE2High = thresholds.e2High,
|
||||||
alertE2Low = thresholds.e2Low,
|
alertE2Low = thresholds.e2Low,
|
||||||
alertTHigh = thresholds.tHigh,
|
alertTHigh = thresholds.tHigh,
|
||||||
alertTLow = thresholds.tLow
|
alertTLow = thresholds.tLow,
|
||||||
|
// v1.13.0 : la cible de creux voyage dans le backup (champs
|
||||||
|
// optionnels — absents d'un backup v1/v2 ancien, rétrocompatibles)
|
||||||
|
troughTargetLow = container.settings.troughTarget.first()?.first,
|
||||||
|
troughTargetHigh = container.settings.troughTarget.first()?.second
|
||||||
)
|
)
|
||||||
val json = BackupManager.exportJson(repo, tConfig, settings)
|
val json = BackupManager.exportJson(repo, tConfig, settings)
|
||||||
val ok = BackupManager.writeBackup(context, uri, json)
|
val ok = BackupManager.writeBackup(context, uri, json)
|
||||||
@ -186,22 +211,8 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
// L'utilisateur choisit le dossier UNE FOIS (ACTION_OPEN_DOCUMENT_TREE) :
|
// L'utilisateur choisit le dossier UNE FOIS (ACTION_OPEN_DOCUMENT_TREE) :
|
||||||
// la permission persistable survit au reboot, le worker WorkManager écrit
|
// la permission persistable survit au reboot, le worker WorkManager écrit
|
||||||
// ensuite sans intervention — AUCUNE permission de stockage déclarée.
|
// ensuite sans intervention — AUCUNE permission de stockage déclarée.
|
||||||
// pickFolderToEnable distingue les deux entrées du picker :
|
// pickFolderToEnable distingue les deux entrées du picker (switch vs
|
||||||
// - via le SWITCH quand aucun dossier n'existe encore → la sélection
|
// « Choisir le dossier » — cf AutoBackupCard).
|
||||||
// ACTIVE la sauvegarde (+ premier run immédiat) : le switch sans
|
|
||||||
// dossier ne peut rien faire, le picker est la suite logique ;
|
|
||||||
// - via « Choisir le dossier » (changement) → on enregistre le dossier
|
|
||||||
// SANS changer l'état activé/désactivé.
|
|
||||||
val autoBackupEnabled by container.settings.autoBackupEnabled
|
|
||||||
.collectAsState(initial = false)
|
|
||||||
val autoBackupTreeUri by container.settings.autoBackupTreeUri
|
|
||||||
.collectAsState(initial = null as String?)
|
|
||||||
val autoBackupKeep by container.settings.autoBackupKeep.collectAsState(initial = 7)
|
|
||||||
val autoBackupLastRun by container.settings.autoBackupLastRun
|
|
||||||
.collectAsState(initial = null as Pair<String?, Long?>?)
|
|
||||||
var autoBackupKeepText by remember(autoBackupKeep) {
|
|
||||||
mutableStateOf(autoBackupKeep.toString())
|
|
||||||
}
|
|
||||||
var pickFolderToEnable by remember { mutableStateOf(false) }
|
var pickFolderToEnable by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val folderPickerLauncher = rememberLauncherForActivityResult(
|
val folderPickerLauncher = rememberLauncherForActivityResult(
|
||||||
@ -235,6 +246,7 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Assemblage : l'ordre des cartes fait le rendu de l'écran ───────────
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -256,503 +268,56 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Language ---
|
LanguageCard(currentLanguage)
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(stringResource(R.string.language), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
FilterChip(
|
|
||||||
selected = currentLanguage == "system",
|
|
||||||
onClick = {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setLanguage("system")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
AppCompatDelegate.setApplicationLocales(
|
|
||||||
LocaleListCompat.getEmptyLocaleList()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(R.string.language_system)) }
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = currentLanguage == "fr",
|
|
||||||
onClick = {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setLanguage("fr")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
AppCompatDelegate.setApplicationLocales(
|
|
||||||
LocaleListCompat.forLanguageTags("fr")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text("Français") }
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = currentLanguage == "en",
|
|
||||||
onClick = {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setLanguage("en")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
AppCompatDelegate.setApplicationLocales(
|
|
||||||
LocaleListCompat.forLanguageTags("en")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text("English") }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
AutoCalibrationCard(autoCalibrate)
|
||||||
// --- Calibration automatique (E2 par période d'ester + T) — UNE seule option ---
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.auto_calibrate), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Switch(
|
|
||||||
checked = autoCalibrate,
|
|
||||||
onCheckedChange = { enabled ->
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setAutoCalibrate(enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.auto_calibrate_hint),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
TModelCard(tConfig, showMessage)
|
||||||
// --- Estimation T (réglages manuels ; l'auto-calibration ci-dessus les
|
|
||||||
// remplace à l'affichage quand elle est activée) ---
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.t_model_hint),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = tBaseText,
|
|
||||||
onValueChange = { tBaseText = it },
|
|
||||||
label = { Text(stringResource(R.string.t_base)) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = tFloorText,
|
|
||||||
onValueChange = { tFloorText = it },
|
|
||||||
label = { Text(stringResource(R.string.t_floor)) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = tKText,
|
|
||||||
onValueChange = { tKText = it },
|
|
||||||
label = { Text("k") },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(onClick = {
|
|
||||||
val base = tBaseText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
|
||||||
val floor = tFloorText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
|
||||||
val k = tKText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setTConfig(TConfig(base, floor, k))
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
message = context.getString(R.string.saved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.save)) }
|
|
||||||
OutlinedButton(onClick = {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
val labs = repo.allLabResultsOnce().filter { it.marker.equals("T", true) }
|
|
||||||
val trs = repo.allTreatmentsOnce()
|
|
||||||
val doses = repo.allDoseLogsOnce()
|
|
||||||
val calibrated = PharmacokineticEngine.computeTConfigCalibration(
|
|
||||||
labs, trs, doses, tConfig
|
|
||||||
)
|
|
||||||
calibrated?.let {
|
|
||||||
container.settings.setTConfig(it)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
message = context.getString(R.string.t_calibrated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.calibrate_from_labs)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
AlertThresholdsCard(alertThresholds, showMessage)
|
||||||
// --- Seuils d'alerte (v1.4.2) : limites E2/T évaluées contre le taux
|
|
||||||
// estimé actuel de l'accueil (pk/Alerts.kt). Opt-in : champ vide =
|
|
||||||
// pas d'alerte. Haut > bas requis si les deux sont définis.
|
|
||||||
var e2HighText by remember(alertThresholds.e2High) {
|
|
||||||
mutableStateOf(alertThresholds.e2High?.let { formatDoubles(it) } ?: "")
|
|
||||||
}
|
|
||||||
var e2LowText by remember(alertThresholds.e2Low) {
|
|
||||||
mutableStateOf(alertThresholds.e2Low?.let { formatDoubles(it) } ?: "")
|
|
||||||
}
|
|
||||||
var tHighText by remember(alertThresholds.tHigh) {
|
|
||||||
mutableStateOf(alertThresholds.tHigh?.let { formatDoubles(it) } ?: "")
|
|
||||||
}
|
|
||||||
var tLowText by remember(alertThresholds.tLow) {
|
|
||||||
mutableStateOf(alertThresholds.tLow?.let { formatDoubles(it) } ?: "")
|
|
||||||
}
|
|
||||||
var alertError by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(stringResource(R.string.alert_section), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.alert_hint),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = e2HighText,
|
|
||||||
onValueChange = { e2HighText = it },
|
|
||||||
label = { Text(stringResource(R.string.alert_e2_high)) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = e2LowText,
|
|
||||||
onValueChange = { e2LowText = it },
|
|
||||||
label = { Text(stringResource(R.string.alert_e2_low)) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = tHighText,
|
|
||||||
onValueChange = { tHighText = it },
|
|
||||||
label = { Text(stringResource(R.string.alert_t_high)) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = tLowText,
|
|
||||||
onValueChange = { tLowText = it },
|
|
||||||
label = { Text(stringResource(R.string.alert_t_low)) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
alertError?.let {
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Button(onClick = {
|
|
||||||
// Parsing tolérant (virgule décimale FR), champ vide = null
|
|
||||||
fun parse(s: String): Double? =
|
|
||||||
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
|
|
||||||
val e2High = parse(e2HighText)
|
|
||||||
val e2Low = parse(e2LowText)
|
|
||||||
val tHigh = parse(tHighText)
|
|
||||||
val tLow = parse(tLowText)
|
|
||||||
// Saisie invalide (texte non numérique) ou incohérence haut ≤ bas
|
|
||||||
val invalidSyntax = listOf(e2HighText, e2LowText, tHighText, tLowText)
|
|
||||||
.any { it.trim().isNotEmpty() && parse(it) == null }
|
|
||||||
val thresholds = Alerts.Thresholds(e2High, e2Low, tHigh, tLow)
|
|
||||||
when {
|
|
||||||
invalidSyntax || !thresholds.isCoherent() -> {
|
|
||||||
alertError = context.getString(R.string.alert_invalid)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
alertError = null
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setAlertThresholds(thresholds)
|
|
||||||
// v1.4.2 : check IMMÉDIAT (worker one-time) —
|
|
||||||
// si le seuil est déjà franchi, la notification
|
|
||||||
// part sans attendre le cycle périodique de
|
|
||||||
// 15 min (état anti-spam réinitialisé au besoin).
|
|
||||||
com.hormonetrack.reminder.AlertNotifier.checkNow(context)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
message = context.getString(R.string.saved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.save)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
TroughTargetCard(troughTarget, showMessage)
|
||||||
// --- Fuseau horaire du graphique (v1.4.5) : auto (téléphone) ou choix.
|
|
||||||
// Applique l'alignement minuit des labels X dans le fuseau choisi.
|
|
||||||
var tzText by remember(chartTimezone) {
|
|
||||||
mutableStateOf(chartTimezone ?: "")
|
|
||||||
}
|
|
||||||
var tzError by remember { mutableStateOf<String?>(null) }
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(stringResource(R.string.timezone_section), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.timezone_hint),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = tzText,
|
|
||||||
onValueChange = { tzText = it },
|
|
||||||
label = { Text(stringResource(R.string.timezone_field)) },
|
|
||||||
placeholder = { Text(stringResource(R.string.timezone_auto)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
tzError?.let {
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(onClick = {
|
|
||||||
val id = tzText.trim()
|
|
||||||
val valid = id.isEmpty() || try {
|
|
||||||
java.util.TimeZone.getTimeZone(id)
|
|
||||||
.let { !it.id.equals("GMT", true) || id.equals("GMT", true) }
|
|
||||||
} catch (_: Exception) { false }
|
|
||||||
if (!valid) {
|
|
||||||
tzError = context.getString(R.string.timezone_invalid)
|
|
||||||
} else {
|
|
||||||
tzError = null
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setChartTimezone(id.ifBlank { null })
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
message = context.getString(R.string.saved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.save)) }
|
|
||||||
OutlinedButton(onClick = {
|
|
||||||
tzText = ""
|
|
||||||
tzError = null
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setChartTimezone(null)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
message = context.getString(R.string.saved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.timezone_auto)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
ChartTimezoneCard(chartTimezone, showMessage)
|
||||||
// --- Reminders / exact alarms ---
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(stringResource(R.string.reminders_section), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
if (!scheduler.canScheduleExact()) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.exact_alarm_needed),
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Button(onClick = {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
context.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.grant_exact_alarm)) }
|
|
||||||
} else {
|
|
||||||
Text(stringResource(R.string.exact_alarm_ok), style = MaterialTheme.typography.bodyMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
RemindersCard()
|
||||||
// --- Backup ---
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(stringResource(R.string.backup_section), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.backup_hint),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(onClick = {
|
|
||||||
exportLauncher.launch(ExportFileNames.backupFileName())
|
|
||||||
}) { Text(stringResource(R.string.export_json)) }
|
|
||||||
OutlinedButton(onClick = {
|
|
||||||
importLauncher.launch(arrayOf("application/json"))
|
|
||||||
}) { Text(stringResource(R.string.import_json)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
BackupCard(
|
||||||
// --- Sauvegarde automatique (v1.7.0) : en PLUS de l'export manuel ---
|
onExport = { exportLauncher.launch(ExportFileNames.backupFileName()) },
|
||||||
// Opt-in pur : désactivée par défaut ; nécessite un dossier choisi
|
onImport = { importLauncher.launch(arrayOf("application/json")) }
|
||||||
// (SAF tree persisté) ; un run immédiat est déclenché à l'activation.
|
)
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.autobackup_section), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Switch(
|
|
||||||
checked = autoBackupEnabled,
|
|
||||||
onCheckedChange = { wantEnabled ->
|
|
||||||
if (!wantEnabled) {
|
|
||||||
// Désactivation : le périodique reste planifié
|
|
||||||
// mais le worker sort immédiatement (no-op)
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setAutoBackupEnabled(false)
|
|
||||||
}
|
|
||||||
} else if (autoBackupTreeUri == null) {
|
|
||||||
// Pas de dossier → le picker EST la suite :
|
|
||||||
// la sélection activera (+ premier run)
|
|
||||||
pickFolderToEnable = true
|
|
||||||
folderPickerLauncher.launch(null)
|
|
||||||
} else {
|
|
||||||
// Dossier existant → activation directe + run
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setAutoBackupEnabled(true)
|
|
||||||
com.hormonetrack.data.backup.AutoBackupScheduler.runNow(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.autobackup_hint),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
|
|
||||||
// Dossier choisi : nom lisible (documents "primary:Download" →
|
|
||||||
// "Download") + bouton pour le changer (sans toucher au switch)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.autobackup_folder) + " : " +
|
|
||||||
(autoBackupTreeUri?.let { folderDisplayName(it) }
|
|
||||||
?: stringResource(R.string.autobackup_no_folder)),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
TextButton(onClick = {
|
|
||||||
pickFolderToEnable = false
|
|
||||||
folderPickerLauncher.launch(null)
|
|
||||||
}) { Text(stringResource(R.string.autobackup_choose_folder)) }
|
|
||||||
|
|
||||||
// Dernier run (statut alimenté par le worker) : réussi/échec + date
|
|
||||||
autoBackupLastRun?.let { (result, atMs) ->
|
|
||||||
if (atMs != null && atMs > 0L) {
|
|
||||||
val date = java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
|
|
||||||
.format(java.util.Date(atMs))
|
|
||||||
Text(
|
|
||||||
text = context.getString(
|
|
||||||
if (result == "ok") R.string.autobackup_last_ok
|
|
||||||
else R.string.autobackup_last_fail,
|
|
||||||
date
|
|
||||||
),
|
|
||||||
color = if (result == "ok") MaterialTheme.colorScheme.primary
|
|
||||||
else MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
// Rétention : copies conservées (1..30, borné au set) — Save
|
|
||||||
// persiste ; le worker relit DataStore à CHAQUE run (aucun
|
|
||||||
// re-enqueue nécessaire, cf AutoBackupScheduler)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = autoBackupKeepText,
|
|
||||||
onValueChange = { autoBackupKeepText = it },
|
|
||||||
label = { Text(stringResource(R.string.autobackup_keep)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Button(onClick = {
|
|
||||||
val keep = autoBackupKeepText.trim().toIntOrNull() ?: return@Button
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setAutoBackupKeep(keep) // borné [1;30]
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
message = context.getString(R.string.saved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.save)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
AutoBackupCard(
|
||||||
// --- Logs de diagnostic (v1.3.1) ---
|
enabled = autoBackupEnabled,
|
||||||
Card(Modifier.fillMaxWidth()) {
|
treeUri = autoBackupTreeUri,
|
||||||
Column(Modifier.padding(12.dp)) {
|
keep = autoBackupKeep,
|
||||||
Text(stringResource(R.string.logs_section), style = MaterialTheme.typography.titleMedium)
|
lastRun = autoBackupLastRun,
|
||||||
Text(
|
onPickFolderToEnable = {
|
||||||
stringResource(R.string.logs_count, logLineCount),
|
pickFolderToEnable = true
|
||||||
style = MaterialTheme.typography.labelMedium,
|
folderPickerLauncher.launch(null)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
},
|
||||||
)
|
onChooseFolder = {
|
||||||
Spacer(Modifier.height(6.dp))
|
pickFolderToEnable = false
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
folderPickerLauncher.launch(null)
|
||||||
OutlinedButton(onClick = {
|
},
|
||||||
// Nom de fichier via ExportFileNames (cf §14 #48) :
|
onMessage = showMessage
|
||||||
// extrait et testé — passer un LocalDate au pattern
|
)
|
||||||
// horaire crashait l'app au tap (bug v1.3.1→v1.3.3).
|
|
||||||
logExportLauncher.launch(ExportFileNames.diagnosticLogFileName())
|
|
||||||
}) { Text(stringResource(R.string.logs_export)) }
|
|
||||||
TextButton(onClick = {
|
|
||||||
AppLog.clear()
|
|
||||||
logLineCount = AppLog.count()
|
|
||||||
}) { Text(stringResource(R.string.logs_clear)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
LogsCard(
|
||||||
// --- About : version installée + lien releases Gitea ---
|
logLineCount = logLineCount,
|
||||||
Card(Modifier.fillMaxWidth()) {
|
onExport = {
|
||||||
Column(Modifier.padding(12.dp)) {
|
// Nom de fichier via ExportFileNames (cf §14 #48) : extrait
|
||||||
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
|
// et testé — passer un LocalDate au pattern horaire crashait
|
||||||
Text(
|
// l'app au tap (bug v1.3.1→v1.3.3).
|
||||||
stringResource(R.string.settings_version, com.hormonetrack.BuildConfig.VERSION_NAME),
|
logExportLauncher.launch(ExportFileNames.diagnosticLogFileName())
|
||||||
style = MaterialTheme.typography.bodyMedium
|
},
|
||||||
)
|
onClear = {
|
||||||
TextButton(onClick = {
|
AppLog.clear()
|
||||||
context.startActivity(
|
logLineCount = AppLog.count()
|
||||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases"))
|
|
||||||
)
|
|
||||||
}) { Text(stringResource(R.string.releases_link)) }
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.disclaimer),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.models_credit),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
AboutCard()
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -782,11 +347,23 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
it.settings?.let { us ->
|
it.settings?.let { us ->
|
||||||
container.settings.setAutoCalibrate(us.autoCalibrate ?: false)
|
container.settings.setAutoCalibrate(us.autoCalibrate ?: false)
|
||||||
container.settings.setAlertThresholds(
|
container.settings.setAlertThresholds(
|
||||||
com.hormonetrack.pk.Alerts.Thresholds(
|
Alerts.Thresholds(
|
||||||
e2High = us.alertE2High, e2Low = us.alertE2Low,
|
e2High = us.alertE2High, e2Low = us.alertE2Low,
|
||||||
tHigh = us.alertTHigh, tLow = us.alertTLow
|
tHigh = us.alertTHigh, tLow = us.alertTLow
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
// v1.13.0 : cible de creux — restaurée SEULEMENT
|
||||||
|
// si le backup en porte une (champs non nuls
|
||||||
|
// ET cohérents) ; sinon on ne touche à rien
|
||||||
|
// (un backup ancien ne doit pas effacer la
|
||||||
|
// cible locale).
|
||||||
|
if (us.troughTargetLow != null && us.troughTargetHigh != null &&
|
||||||
|
us.troughTargetHigh > us.troughTargetLow
|
||||||
|
) {
|
||||||
|
container.settings.setTroughTarget(
|
||||||
|
us.troughTargetLow to us.troughTargetHigh
|
||||||
|
)
|
||||||
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
us.language?.let { lang ->
|
us.language?.let { lang ->
|
||||||
container.settings.setLanguage(lang)
|
container.settings.setLanguage(lang)
|
||||||
@ -820,21 +397,3 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatDoubles(d: Double): String =
|
|
||||||
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nom lisible d'un dossier SAF tree URI (v1.7.0) : l'id d'un tree document
|
|
||||||
* est de la forme « primary:Download » (stockage principal) ou
|
|
||||||
* « <provider>:<chemin » pour les providers tiers (Owncloud…) — on affiche
|
|
||||||
* la partie APRÈS le ':', sinon l'ID brut. Pur et défensif : un URI non
|
|
||||||
* parsable s'affiche tel quel plutôt que de faire crasher l'écran.
|
|
||||||
*/
|
|
||||||
private fun folderDisplayName(treeUriString: String): String =
|
|
||||||
try {
|
|
||||||
val id = android.provider.DocumentsContract.getTreeDocumentId(Uri.parse(treeUriString))
|
|
||||||
id.substringAfter(':', id)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
treeUriString
|
|
||||||
}
|
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import com.hormonetrack.R
|
|||||||
import com.hormonetrack.data.model.AdministrationRoute
|
import com.hormonetrack.data.model.AdministrationRoute
|
||||||
import com.hormonetrack.data.model.Esters
|
import com.hormonetrack.data.model.Esters
|
||||||
import com.hormonetrack.data.model.PKModels
|
import com.hormonetrack.data.model.PKModels
|
||||||
|
import com.hormonetrack.data.model.modelLabelRes
|
||||||
import com.hormonetrack.data.model.PKPresets
|
import com.hormonetrack.data.model.PKPresets
|
||||||
import com.hormonetrack.data.model.Treatment
|
import com.hormonetrack.data.model.Treatment
|
||||||
import com.hormonetrack.data.model.TreatmentType
|
import com.hormonetrack.data.model.TreatmentType
|
||||||
@ -289,11 +290,10 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
label = stringResource(R.string.pk_model),
|
label = stringResource(R.string.pk_model),
|
||||||
// v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona,
|
// v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona,
|
||||||
// cf pk/WhsahModels.kt) superposable dans le graphique.
|
// cf pk/WhsahModels.kt) superposable dans le graphique.
|
||||||
selectedLabel = when (model) {
|
// Label par le helper UNIQUE (règle anti-bug #59 :
|
||||||
PKModels.TRANSFEM_SCIENCE -> stringResource(R.string.model_tfs)
|
// jamais de when/ternaire de labels en ligne — l'ancien
|
||||||
PKModels.WHSAH -> stringResource(R.string.model_whs)
|
// à 2 branches affichait « Estrannaise » pour WHSAH).
|
||||||
else -> stringResource(R.string.model_ese)
|
selectedLabel = stringResource(modelLabelRes(model)),
|
||||||
},
|
|
||||||
options = listOf(
|
options = listOf(
|
||||||
PKModels.ESTRANNAISE to stringResource(R.string.model_ese),
|
PKModels.ESTRANNAISE to stringResource(R.string.model_ese),
|
||||||
PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs),
|
PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs),
|
||||||
@ -450,8 +450,9 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
|||||||
String.format(Locale.getDefault(), "%02d:%02d", reminderTime.hour, reminderTime.minute)
|
String.format(Locale.getDefault(), "%02d:%02d", reminderTime.hour, reminderTime.minute)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val scheduler2 = AlarmScheduler(context)
|
// Le scheduler est déjà créé au niveau de l'écran
|
||||||
if (!scheduler2.canScheduleExact()) {
|
// (var `scheduler`) — pas de recréation par recompose.
|
||||||
|
if (!scheduler.canScheduleExact()) {
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.exact_alarm_needed),
|
stringResource(R.string.exact_alarm_needed),
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
@ -36,6 +37,21 @@ import com.hormonetrack.data.model.modelLabelRes
|
|||||||
import com.hormonetrack.ui.LocalAppContainer
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
import com.hormonetrack.ui.components.formatDose
|
import com.hormonetrack.ui.components.formatDose
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ORDRE D'AFFICHAGE de la page Traitements (v1.11.0, demande utilisatrice) :
|
||||||
|
* les ACTIFS d'abord, les INACTIFS regroupés TOUT EN BAS — l'ancien ordre
|
||||||
|
* brut du DAO mélangeait les deux groupes et le badge discret ne suffisait
|
||||||
|
* plus à les discerner (« peu discernables », remontée).
|
||||||
|
*
|
||||||
|
* Tri STABLE : l'ordre relatif À L'INTÉRIEUR de chaque groupe est conservé
|
||||||
|
* (le tri ne réordonne jamais les actifs entre eux, ni les inactifs entre
|
||||||
|
* eux). PUR (aucune dépendance Android) → testable en JVM
|
||||||
|
* (TreatmentsDisplayTest) ; miroir web `treatmentsForDisplay`
|
||||||
|
* (js/data/models.js, mêmes sémantiques).
|
||||||
|
*/
|
||||||
|
fun treatmentsForDisplay(treatments: List<Treatment>): List<Treatment> =
|
||||||
|
treatments.sortedBy { !it.isActive }
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TreatmentsScreen(
|
fun TreatmentsScreen(
|
||||||
@ -45,6 +61,11 @@ fun TreatmentsScreen(
|
|||||||
val container = LocalAppContainer.current
|
val container = LocalAppContainer.current
|
||||||
val repo = container.repository
|
val repo = container.repository
|
||||||
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
|
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
|
||||||
|
// v1.11.0 : actifs d'abord, inactifs regroupés tout en bas
|
||||||
|
// (cf [treatmentsForDisplay]) — mémoïsé sur la liste brute.
|
||||||
|
val displayTreatments = remember(treatments) { treatmentsForDisplay(treatments) }
|
||||||
|
val activeTreatments = displayTreatments.filter { it.isActive }
|
||||||
|
val inactiveTreatments = displayTreatments.filter { !it.isActive }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@ -58,22 +79,44 @@ fun TreatmentsScreen(
|
|||||||
title = { Text(stringResource(R.string.nav_treatments)) }
|
title = { Text(stringResource(R.string.nav_treatments)) }
|
||||||
)
|
)
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
items(treatments, key = { it.id }) { tr ->
|
items(activeTreatments, key = { it.id }) { tr ->
|
||||||
TreatmentCard(tr, onClick = { onEdit(tr.id) })
|
TreatmentCard(tr, onClick = { onEdit(tr.id) })
|
||||||
}
|
}
|
||||||
|
// En-tête de section SEULEMENT s'il existe des inactifs —
|
||||||
|
// la séparation visuelle (en-tête + atténuation, cf
|
||||||
|
// TreatmentCard) rend le groupe lisible d'un coup d'œil.
|
||||||
|
if (inactiveTreatments.isNotEmpty()) {
|
||||||
|
item(key = "inactive_section") {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.inactive_section),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(inactiveTreatments, key = { it.id }) { tr ->
|
||||||
|
TreatmentCard(tr, dimmed = true, onClick = { onEdit(tr.id) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte d'un traitement. @param dimmed : carte INACTIVE (v1.11.0) → contenu
|
||||||
|
* atténué (alpha réduit) en PLUS du badge : second signal visuel demandé
|
||||||
|
* (« plus distincts des actifs ») — la carte reste lisible et tappable.
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TreatmentCard(tr: Treatment, onClick: () -> Unit) {
|
private fun TreatmentCard(tr: Treatment, dimmed: Boolean = false, onClick: () -> Unit) {
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||||
|
.alpha(if (dimmed) 0.55f else 1f)
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
|||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.BuildConfig
|
||||||
|
import com.hormonetrack.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte À PROPOS de l'écran Paramètres (v1.9.8 : extraite de
|
||||||
|
* SettingsScreen.kt, comportement inchangé) : version installée (BuildConfig
|
||||||
|
* — épinglée par un test UI depuis v1.9.8, le bug #46 des versions jamais
|
||||||
|
* bumpées ne peut plus revenir silencieusement), lien vers les releases
|
||||||
|
* Gitea, avertissement médical et crédit des modèles PK.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AboutCard() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.settings_version, BuildConfig.VERSION_NAME),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
TextButton(onClick = {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW, Uri.parse("https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases"))
|
||||||
|
)
|
||||||
|
}) { Text(stringResource(R.string.releases_link)) }
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.disclaimer),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.models_credit),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.pk.Alerts
|
||||||
|
import com.hormonetrack.reminder.AlertNotifier
|
||||||
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte SEUILS D'ALERTE de l'écran Paramètres (v1.4.2 ; v1.9.8 : extraite de
|
||||||
|
* SettingsScreen.kt, comportement inchangé). Limites E2 (pg/mL) / T (ng/mL)
|
||||||
|
* évaluées contre le taux ESTIMÉ courant de l'accueil (pk/Alerts.kt) et par
|
||||||
|
* le worker périodique de 15 min. Opt-in : champ vide = pas d'alerte sur ce
|
||||||
|
* côté ; incohérence haut ≤ bas ou saisie non numérique = message d'erreur
|
||||||
|
* local, rien n'est persisté.
|
||||||
|
*
|
||||||
|
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AlertThresholdsCard(
|
||||||
|
alertThresholds: Alerts.Thresholds,
|
||||||
|
onMessage: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val container = LocalAppContainer.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// Champs réinitialisés à chaque valeur persistée (clé du remember) ;
|
||||||
|
// null (pas de seuil) = champ vide.
|
||||||
|
var e2HighText by remember(alertThresholds.e2High) {
|
||||||
|
mutableStateOf(alertThresholds.e2High?.let { formatDoubles(it) } ?: "")
|
||||||
|
}
|
||||||
|
var e2LowText by remember(alertThresholds.e2Low) {
|
||||||
|
mutableStateOf(alertThresholds.e2Low?.let { formatDoubles(it) } ?: "")
|
||||||
|
}
|
||||||
|
var tHighText by remember(alertThresholds.tHigh) {
|
||||||
|
mutableStateOf(alertThresholds.tHigh?.let { formatDoubles(it) } ?: "")
|
||||||
|
}
|
||||||
|
var tLowText by remember(alertThresholds.tLow) {
|
||||||
|
mutableStateOf(alertThresholds.tLow?.let { formatDoubles(it) } ?: "")
|
||||||
|
}
|
||||||
|
var alertError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.alert_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.alert_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = e2HighText,
|
||||||
|
onValueChange = { e2HighText = it },
|
||||||
|
label = { Text(stringResource(R.string.alert_e2_high)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = e2LowText,
|
||||||
|
onValueChange = { e2LowText = it },
|
||||||
|
label = { Text(stringResource(R.string.alert_e2_low)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tHighText,
|
||||||
|
onValueChange = { tHighText = it },
|
||||||
|
label = { Text(stringResource(R.string.alert_t_high)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tLowText,
|
||||||
|
onValueChange = { tLowText = it },
|
||||||
|
label = { Text(stringResource(R.string.alert_t_low)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
alertError?.let {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
// Parsing tolérant (virgule décimale FR), champ vide = null
|
||||||
|
fun parse(s: String): Double? =
|
||||||
|
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
|
||||||
|
val e2High = parse(e2HighText)
|
||||||
|
val e2Low = parse(e2LowText)
|
||||||
|
val tHigh = parse(tHighText)
|
||||||
|
val tLow = parse(tLowText)
|
||||||
|
// Saisie invalide (texte non numérique) ou incohérence haut ≤ bas
|
||||||
|
val invalidSyntax = listOf(e2HighText, e2LowText, tHighText, tLowText)
|
||||||
|
.any { it.trim().isNotEmpty() && parse(it) == null }
|
||||||
|
val thresholds = Alerts.Thresholds(e2High, e2Low, tHigh, tLow)
|
||||||
|
when {
|
||||||
|
invalidSyntax || !thresholds.isCoherent() -> {
|
||||||
|
alertError = context.getString(R.string.alert_invalid)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
alertError = null
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setAlertThresholds(thresholds)
|
||||||
|
// v1.4.2 : check IMMÉDIAT (worker one-time) —
|
||||||
|
// si le seuil est déjà franchi, la notification
|
||||||
|
// part sans attendre le cycle périodique de
|
||||||
|
// 15 min (état anti-spam réinitialisé au besoin).
|
||||||
|
AlertNotifier.checkNow(context)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onMessage(context.getString(R.string.saved))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.save)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,164 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.data.backup.AutoBackupScheduler
|
||||||
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte SAUVEGARDE AUTOMATIQUE QUOTIDIENNE de l'écran Paramètres (v1.7.0 ;
|
||||||
|
* v1.9.8 : extraite de SettingsScreen.kt, comportement inchangé).
|
||||||
|
*
|
||||||
|
* Opt-in pur : désactivée par défaut ; nécessite un dossier choisi UNE fois
|
||||||
|
* (SAF tree persisté — ACTION_OPEN_DOCUMENT_TREE, cf orchestrateur) ; un run
|
||||||
|
* immédiat est déclenché à l'activation (feedback + validation directe).
|
||||||
|
* Rétention 1..30 copies (le worker purge les plus vieilles, ne touche
|
||||||
|
* JAMAIS les exports manuels — cf AutoBackupRetention).
|
||||||
|
*
|
||||||
|
* Sémantique des deux entrées du picker (v1.7.0) :
|
||||||
|
* - via le SWITCH quand aucun dossier n'existe → la sélection ACTIVE la
|
||||||
|
* sauvegarde (+ premier run immédiat) : le switch sans dossier ne peut
|
||||||
|
* rien faire, le picker est la suite logique ;
|
||||||
|
* - via « Choisir le dossier » (changement) → on enregistre le dossier
|
||||||
|
* SANS changer l'état activé/désactivé.
|
||||||
|
*
|
||||||
|
* @param onPickFolderToEnable ouvre le picker SAF dont la sélection ACTIVERA
|
||||||
|
* la sauvegarde (branche « switch sans dossier » — launcher externe).
|
||||||
|
* @param onChooseFolder ouvre le picker SAF pour CHANGER de dossier sans
|
||||||
|
* toucher à l'état activé/désactivé.
|
||||||
|
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AutoBackupCard(
|
||||||
|
enabled: Boolean,
|
||||||
|
treeUri: String?,
|
||||||
|
keep: Int,
|
||||||
|
lastRun: Pair<String?, Long?>?,
|
||||||
|
onPickFolderToEnable: () -> Unit,
|
||||||
|
onChooseFolder: () -> Unit,
|
||||||
|
onMessage: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val container = LocalAppContainer.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var keepText by remember(keep) { mutableStateOf(keep.toString()) }
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.autobackup_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Switch(
|
||||||
|
checked = enabled,
|
||||||
|
onCheckedChange = { wantEnabled ->
|
||||||
|
if (!wantEnabled) {
|
||||||
|
// Désactivation : le périodique reste planifié
|
||||||
|
// mais le worker sort immédiatement (no-op)
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setAutoBackupEnabled(false)
|
||||||
|
}
|
||||||
|
} else if (treeUri == null) {
|
||||||
|
// Pas de dossier → le picker EST la suite :
|
||||||
|
// la sélection activera (+ premier run)
|
||||||
|
onPickFolderToEnable()
|
||||||
|
} else {
|
||||||
|
// Dossier existant → activation directe + run
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setAutoBackupEnabled(true)
|
||||||
|
AutoBackupScheduler.runNow(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.autobackup_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|
||||||
|
// Dossier choisi : nom lisible (documents "primary:Download" →
|
||||||
|
// "Download") + bouton pour le changer (sans toucher au switch)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.autobackup_folder) + " : " +
|
||||||
|
(treeUri?.let { folderDisplayName(it) }
|
||||||
|
?: stringResource(R.string.autobackup_no_folder)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
TextButton(onClick = onChooseFolder) {
|
||||||
|
Text(stringResource(R.string.autobackup_choose_folder))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dernier run (statut alimenté par le worker) : réussi/échec + date
|
||||||
|
lastRun?.let { (result, atMs) ->
|
||||||
|
if (atMs != null && atMs > 0L) {
|
||||||
|
val date = java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
|
||||||
|
.format(java.util.Date(atMs))
|
||||||
|
Text(
|
||||||
|
text = context.getString(
|
||||||
|
if (result == "ok") R.string.autobackup_last_ok
|
||||||
|
else R.string.autobackup_last_fail,
|
||||||
|
date
|
||||||
|
),
|
||||||
|
color = if (result == "ok") MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
// Rétention : copies conservées (1..30, borné au set) — Save
|
||||||
|
// persiste ; le worker relit DataStore à CHAQUE run (aucun
|
||||||
|
// re-enqueue nécessaire, cf AutoBackupScheduler)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = keepText,
|
||||||
|
onValueChange = { keepText = it },
|
||||||
|
label = { Text(stringResource(R.string.autobackup_keep)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Button(onClick = {
|
||||||
|
val parsed = keepText.trim().toIntOrNull() ?: return@Button
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setAutoBackupKeep(parsed) // borné [1;30]
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onMessage(context.getString(R.string.saved))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.save)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte CALIBRATION AUTOMATIQUE de l'écran Paramètres (v1.9.8 : extraite de
|
||||||
|
* SettingsScreen.kt, comportement inchangé). UNE seule option (fusionnée
|
||||||
|
* v1.2.1) : facteurs d'échelle E2 par période d'ester + calibration du modèle
|
||||||
|
* T, recalculés depuis les labs. Désactivée par défaut — la calibration reste
|
||||||
|
* sous contrôle explicite de l'utilisatrice.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AutoCalibrationCard(autoCalibrate: Boolean) {
|
||||||
|
val container = LocalAppContainer.current
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.auto_calibrate), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Switch(
|
||||||
|
checked = autoCalibrate,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setAutoCalibrate(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.auto_calibrate_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte SAUVEGARDE MANUELLE (JSON) de l'écran Paramètres (v1.9.8 : extraite
|
||||||
|
* de SettingsScreen.kt, comportement inchangé). L'export passe par le picker
|
||||||
|
* SAF système (CreateDocument) — aucun accès disque direct ; l'import
|
||||||
|
* ouvre le backup via OpenDocument puis demande CONFIRMATION avant
|
||||||
|
* écrasement (dialog géré par l'orchestrateur SettingsScreen).
|
||||||
|
*
|
||||||
|
* @param onExport lance le picker SAF d'export (launcher de l'orchestrateur)
|
||||||
|
* @param onImport lance le picker SAF d'import (launcher de l'orchestrateur)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun BackupCard(onExport: () -> Unit, onImport: () -> Unit) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.backup_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.backup_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = onExport) { Text(stringResource(R.string.export_json)) }
|
||||||
|
OutlinedButton(onClick = onImport) { Text(stringResource(R.string.import_json)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte FUSEAU HORAIRE DU GRAPHIQUE de l'écran Paramètres (v1.4.5 ; v1.9.8 :
|
||||||
|
* extraite de SettingsScreen.kt, comportement inchangé). « auto » = fuseau
|
||||||
|
* du téléphone (champ vide) ; sinon un ID java.util.TimeZone (ex.
|
||||||
|
* « Europe/Paris ») — il pilote l'alignement minuit des labels X du
|
||||||
|
* graphique (fix #55/#67 : ticks ET labels dans le MÊME fuseau).
|
||||||
|
*
|
||||||
|
* La validité de l'ID est vérifiée AVANT persistance (un ID inconnu retombe
|
||||||
|
* sur « GMT » côté java.util.TimeZone : on le détecte et on refuse).
|
||||||
|
*
|
||||||
|
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ChartTimezoneCard(chartTimezone: String?, onMessage: (String) -> Unit) {
|
||||||
|
val container = LocalAppContainer.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var tzText by remember(chartTimezone) {
|
||||||
|
mutableStateOf(chartTimezone ?: "")
|
||||||
|
}
|
||||||
|
var tzError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.timezone_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.timezone_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tzText,
|
||||||
|
onValueChange = { tzText = it },
|
||||||
|
label = { Text(stringResource(R.string.timezone_field)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.timezone_auto)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
tzError?.let {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = {
|
||||||
|
val id = tzText.trim()
|
||||||
|
// Un ID inconnu retombe silencieusement sur « GMT » : on
|
||||||
|
// l'accepte seulement si l'utilisateur l'a VRAIMENT saisi.
|
||||||
|
val valid = id.isEmpty() || try {
|
||||||
|
java.util.TimeZone.getTimeZone(id)
|
||||||
|
.let { !it.id.equals("GMT", true) || id.equals("GMT", true) }
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
if (!valid) {
|
||||||
|
tzError = context.getString(R.string.timezone_invalid)
|
||||||
|
} else {
|
||||||
|
tzError = null
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setChartTimezone(id.ifBlank { null })
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onMessage(context.getString(R.string.saved))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.save)) }
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
tzText = ""
|
||||||
|
tzError = null
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setChartTimezone(null)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onMessage(context.getString(R.string.saved))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.timezone_auto)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte LANGUE de l'écran Paramètres (v1.9.8 : extraite de SettingsScreen.kt,
|
||||||
|
* comportement inchangé). La langue est indépendante du système : « system »
|
||||||
|
* suit la configuration du téléphone, « fr »/« en » forcent via les per-app
|
||||||
|
* locales d'AppCompat (recréent l'activité — le réglage survit au redémarrage,
|
||||||
|
* réappliqué par MainActivity.onCreate).
|
||||||
|
*
|
||||||
|
* Les libellés « Français » / « English » sont des LITTÉRAUX volontaires :
|
||||||
|
* chaque langue s'écrit dans sa propre langue (convention i18n).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LanguageCard(currentLanguage: String) {
|
||||||
|
val container = LocalAppContainer.current
|
||||||
|
|
||||||
|
/** Persiste la langue puis l'applique immédiatement (locales per-app). */
|
||||||
|
fun applyLanguage(code: String) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setLanguage(code)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
AppCompatDelegate.setApplicationLocales(
|
||||||
|
if (code == "system") LocaleListCompat.getEmptyLocaleList()
|
||||||
|
else LocaleListCompat.forLanguageTags(code)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.language), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = currentLanguage == "system",
|
||||||
|
onClick = { applyLanguage("system") },
|
||||||
|
label = { Text(stringResource(R.string.language_system)) }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = currentLanguage == "fr",
|
||||||
|
onClick = { applyLanguage("fr") },
|
||||||
|
label = { Text("Français") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = currentLanguage == "en",
|
||||||
|
onClick = { applyLanguage("en") },
|
||||||
|
label = { Text("English") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
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.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.util.AppLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte LOGS DE DIAGNOSTIC de l'écran Paramètres (v1.3.1 ; v1.9.8 : extraite
|
||||||
|
* de SettingsScreen.kt, comportement inchangé). Le buffer AppLog (500
|
||||||
|
* lignes, persisté) a permis le premier diagnostic À DISTANCE de l'historique
|
||||||
|
* du projet (§14 #50) — l'export est le canal de support de l'app.
|
||||||
|
*
|
||||||
|
* @param logLineCount compteur affiché (état de l'orchestrateur : il est
|
||||||
|
* rafraîchi APRÈS l'export, qui ajoute des lignes au journal).
|
||||||
|
* @param onExport exporte les logs via le picker SAF (nom de fichier sûr —
|
||||||
|
* helper ExportFileNames, cf bug §14 #48).
|
||||||
|
* @param onClear efface le buffer et rafraîchit le compteur.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LogsCard(
|
||||||
|
logLineCount: Int,
|
||||||
|
onExport: () -> Unit,
|
||||||
|
onClear: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.logs_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.logs_count, logLineCount),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = onExport) { Text(stringResource(R.string.logs_export)) }
|
||||||
|
TextButton(onClick = onClear) { Text(stringResource(R.string.logs_clear)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.reminder.AlarmScheduler
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte RAPPELS / ALARMES EXACTES de l'écran Paramètres (v1.9.8 : extraite de
|
||||||
|
* SettingsScreen.kt, comportement inchangé). Les rappels de doses utilisent
|
||||||
|
* des alarmes EXACTES (heure juste) : depuis Android 12 (S), l'autorisation
|
||||||
|
* SCHEDULE_EXACT_ALARM est opt-in par application. Sans elle, les alarmes
|
||||||
|
* deviennent inexactes (fenêtre ± quelques minutes) — la carte invite à
|
||||||
|
* l'accorder via les réglages système quand c'est le cas.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun RemindersCard() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scheduler = remember { AlarmScheduler(context) }
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.reminders_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
if (!scheduler.canScheduleExact()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.exact_alarm_needed),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.grant_exact_alarm)) }
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.exact_alarm_ok), style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers de formatage/affichage partagés par les cartes de l'écran
|
||||||
|
* Paramètres (v1.9.8 : extraits de SettingsScreen.kt lors du découpage en
|
||||||
|
* cartes — cf docs/DEVELOPPEMENT.md §10). Purs et défensifs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate un Double pour l'affichage dans un champ texte : les entiers sans
|
||||||
|
* décimales, les autres avec 3 décimales max (Locale.US : point décimal — la
|
||||||
|
* virgule FR est tolérée À LA SAISIE via replace(',', '.') dans les cartes).
|
||||||
|
*/
|
||||||
|
internal fun formatDoubles(d: Double): String =
|
||||||
|
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nom lisible d'un dossier SAF tree URI (v1.7.0) : l'id d'un tree document
|
||||||
|
* est de la forme « primary:Download » (stockage principal) ou
|
||||||
|
* « <provider>:<chemin » pour les providers tiers (Owncloud…) — on affiche
|
||||||
|
* la partie APRÈS le ':', sinon l'ID brut. Pur et défensif : un URI non
|
||||||
|
* parsable s'affiche tel quel plutôt que de faire crasher l'écran.
|
||||||
|
*/
|
||||||
|
internal fun folderDisplayName(treeUriString: String): String =
|
||||||
|
try {
|
||||||
|
val id = android.provider.DocumentsContract.getTreeDocumentId(Uri.parse(treeUriString))
|
||||||
|
id.substringAfter(':', id)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
treeUriString
|
||||||
|
}
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
|
import com.hormonetrack.pk.TConfig
|
||||||
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte ESTIMATION T de l'écran Paramètres (v1.9.8 : extraite de
|
||||||
|
* SettingsScreen.kt, comportement inchangé). Réglages MANUELS du modèle
|
||||||
|
* `T = plancher + (base − plancher) ÷ (1 + k·E2)` ; l'auto-calibration
|
||||||
|
* (carte du dessus) les REMPLACE à l'affichage quand elle est activée.
|
||||||
|
*
|
||||||
|
* « Calibrer depuis les labs » recalcule k par période d'ester depuis les
|
||||||
|
* analyses T enregistrées (PharmacokineticEngine.computeTConfigCalibration).
|
||||||
|
*
|
||||||
|
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun TModelCard(tConfig: TConfig, onMessage: (String) -> Unit) {
|
||||||
|
val container = LocalAppContainer.current
|
||||||
|
val repo = container.repository
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// Champs de saisie réinitialisés à chaque valeur persistée (clé du
|
||||||
|
// remember) — la virgule décimale FR est tolérée à la saisie.
|
||||||
|
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
|
||||||
|
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
|
||||||
|
var tKText by remember(tConfig.k) { mutableStateOf(formatDoubles(tConfig.k)) }
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.t_model_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tBaseText,
|
||||||
|
onValueChange = { tBaseText = it },
|
||||||
|
label = { Text(stringResource(R.string.t_base)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tFloorText,
|
||||||
|
onValueChange = { tFloorText = it },
|
||||||
|
label = { Text(stringResource(R.string.t_floor)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tKText,
|
||||||
|
onValueChange = { tKText = it },
|
||||||
|
label = { Text("k") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = {
|
||||||
|
val base = tBaseText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||||
|
val floor = tFloorText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||||
|
val k = tKText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setTConfig(TConfig(base, floor, k))
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onMessage(context.getString(R.string.saved))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.save)) }
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val labs = repo.allLabResultsOnce().filter { it.marker.equals("T", true) }
|
||||||
|
val trs = repo.allTreatmentsOnce()
|
||||||
|
val doses = repo.allDoseLogsOnce()
|
||||||
|
val calibrated = PharmacokineticEngine.computeTConfigCalibration(
|
||||||
|
labs, trs, doses, tConfig
|
||||||
|
)
|
||||||
|
calibrated?.let {
|
||||||
|
container.settings.setTConfig(it)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onMessage(context.getString(R.string.t_calibrated))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.calibrate_from_labs)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte CIBLE DE CREUX E2 de l'écran Paramètres (v1.13.0, opt-in).
|
||||||
|
*
|
||||||
|
* ⚠️ DISTINCT des seuils d'alerte ([AlertThresholdsCard], au-dessus) :
|
||||||
|
* - les seuils d'alerte surveillent le niveau estimé EN CONTINU (carte
|
||||||
|
* d'accueil + notification toutes les 15 min — cf §9.bis) ;
|
||||||
|
* - la cible de creux est une RÉFÉRENCE PERSONNELLE DE LAB : « quand je
|
||||||
|
* fais ma prise de sang au creux recommandé, je veux être dans
|
||||||
|
* [basse, haute] » — la prédiction de la carte reco est colorée
|
||||||
|
* contre cette bande, JAMAIS de notification.
|
||||||
|
*
|
||||||
|
* Opt-in pur : champ vide = désactivée (les DEUX champs, vides ou remplis).
|
||||||
|
* Validation : haut > bas obligatoire (message rouge local, rien n'est
|
||||||
|
* persisté sinon).
|
||||||
|
*
|
||||||
|
* @param target cible active (basse to haute) ou null (désactivée)
|
||||||
|
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun TroughTargetCard(target: Pair<Double, Double>?, onMessage: (String) -> Unit) {
|
||||||
|
val container = LocalAppContainer.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var lowText by remember(target?.first) {
|
||||||
|
mutableStateOf(target?.first?.let { formatDoubles(it) } ?: "")
|
||||||
|
}
|
||||||
|
var highText by remember(target?.second) {
|
||||||
|
mutableStateOf(target?.second?.let { formatDoubles(it) } ?: "")
|
||||||
|
}
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.trough_target_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.trough_target_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lowText,
|
||||||
|
onValueChange = { lowText = it },
|
||||||
|
label = { Text(stringResource(R.string.trough_target_low)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = highText,
|
||||||
|
onValueChange = { highText = it },
|
||||||
|
label = { Text(stringResource(R.string.trough_target_high)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
error?.let {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
// Même parsing tolérant que la carte Seuils (virgule FR),
|
||||||
|
// champ vide = null
|
||||||
|
fun parse(s: String): Double? =
|
||||||
|
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
|
||||||
|
val low = parse(lowText)
|
||||||
|
val high = parse(highText)
|
||||||
|
when {
|
||||||
|
// Un seul champ rempli = saisie incomplète (pas d'état
|
||||||
|
// « demi-cible » — l'app ne devine pas)
|
||||||
|
(low == null) != (high == null) -> {
|
||||||
|
error = context.getString(R.string.trough_target_incomplete)
|
||||||
|
}
|
||||||
|
low != null && high != null && high <= low -> {
|
||||||
|
error = context.getString(R.string.trough_target_invalid)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
error = null
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
// null to null = désactivation (setTroughTarget efface)
|
||||||
|
container.settings.setTroughTarget(
|
||||||
|
if (low != null && high != null) low to high else null
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onMessage(context.getString(R.string.saved))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.save)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@
|
|||||||
<string name="no_data">Pas encore de données — crée un traitement et logue une dose.</string>
|
<string name="no_data">Pas encore de données — crée un traitement et logue une dose.</string>
|
||||||
<string name="logged_today">Log rapide</string>
|
<string name="logged_today">Log rapide</string>
|
||||||
<string name="next_dose">Prochaine dose</string>
|
<string name="next_dose">Prochaine dose</string>
|
||||||
|
<string name="next_doses">Prochaines doses</string>
|
||||||
<string name="next_dose_days">%1$d j %2$d h · %3$s (%4$s)</string>
|
<string name="next_dose_days">%1$d j %2$d h · %3$s (%4$s)</string>
|
||||||
<string name="delta_6h">%1$s %2$s pg/mL vs il y a 6 h</string>
|
<string name="delta_6h">%1$s %2$s pg/mL vs il y a 6 h</string>
|
||||||
<string name="home_chart_title">Dernières 24 h (estimation)</string>
|
<string name="home_chart_title">Dernières 24 h (estimation)</string>
|
||||||
@ -143,6 +144,26 @@
|
|||||||
<string name="dose_unit">Unité</string>
|
<string name="dose_unit">Unité</string>
|
||||||
<string name="active">Actif</string>
|
<string name="active">Actif</string>
|
||||||
<string name="inactive">inactif</string>
|
<string name="inactive">inactif</string>
|
||||||
|
<!-- v1.11.0 : en-tête de la section des traitements inactifs (regroupés
|
||||||
|
en bas de la page Traitements) -->
|
||||||
|
<string name="inactive_section">Traitements inactifs</string>
|
||||||
|
<!-- v1.12.0 : encart latéral « prise de sang le même jour » sur les
|
||||||
|
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
|
||||||
|
<string name="lab_before_dose">Prise de sang plus tôt le même jour, avant cette dose</string>
|
||||||
|
<string name="lab_after_dose">Prise de sang plus tard le même jour, après cette dose</string>
|
||||||
|
<!-- v1.13.0 : valeur E2 attendue au creux recommandé (affichée seulement
|
||||||
|
si la calibration est disponible) + statut face à la cible de creux -->
|
||||||
|
<string name="labrec_predicted">Attendu à ce creux : ≈ %1$s pg/mL</string>
|
||||||
|
<string name="labrec_in_target">dans ta cible</string>
|
||||||
|
<string name="labrec_out_of_target">hors de ta cible</string>
|
||||||
|
<!-- v1.13.0 : carte Cible de creux (opt-in) — DISTINCT des seuils
|
||||||
|
d'alerte (surveillance continue + notifications) -->
|
||||||
|
<string name="trough_target_section">Cible de creux (prise de sang)</string>
|
||||||
|
<string name="trough_target_hint">Référence personnelle pour la prochaine prise de sang : la valeur de creux prédite est colorée par rapport à cette bande. Distinct des seuils d\u0027alerte ci-dessus — ceux-là surveillent ton niveau estimé en continu et peuvent te notifier ; celui-ci jamais.</string>
|
||||||
|
<string name="trough_target_low">Cible basse (pg/mL)</string>
|
||||||
|
<string name="trough_target_high">Cible haute (pg/mL)</string>
|
||||||
|
<string name="trough_target_incomplete">Remplis les deux champs — ou laisse les deux vides pour désactiver.</string>
|
||||||
|
<string name="trough_target_invalid">La cible haute doit être supérieure à la cible basse.</string>
|
||||||
<string name="no_treatment_hint">Crée d\'abord un traitement (onglet Traitements).</string>
|
<string name="no_treatment_hint">Crée d\'abord un traitement (onglet Traitements).</string>
|
||||||
|
|
||||||
<string name="type_estradiol">Œstradiol</string>
|
<string name="type_estradiol">Œstradiol</string>
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
<string name="no_data">No data yet — add a treatment and log a dose.</string>
|
<string name="no_data">No data yet — add a treatment and log a dose.</string>
|
||||||
<string name="logged_today">Quick log</string>
|
<string name="logged_today">Quick log</string>
|
||||||
<string name="next_dose">Next dose</string>
|
<string name="next_dose">Next dose</string>
|
||||||
|
<string name="next_doses">Upcoming doses</string>
|
||||||
<string name="next_dose_days">%1$d d %2$d h · %3$s (%4$s)</string>
|
<string name="next_dose_days">%1$d d %2$d h · %3$s (%4$s)</string>
|
||||||
<string name="delta_6h">%1$s %2$s pg/mL vs 6 h ago</string>
|
<string name="delta_6h">%1$s %2$s pg/mL vs 6 h ago</string>
|
||||||
<string name="home_chart_title">Last 24 hours (estimate)</string>
|
<string name="home_chart_title">Last 24 hours (estimate)</string>
|
||||||
@ -143,6 +144,26 @@
|
|||||||
<string name="dose_unit">Unit</string>
|
<string name="dose_unit">Unit</string>
|
||||||
<string name="active">Active</string>
|
<string name="active">Active</string>
|
||||||
<string name="inactive">inactive</string>
|
<string name="inactive">inactive</string>
|
||||||
|
<!-- v1.11.0 : en-tête de la section des traitements inactifs (regroupés
|
||||||
|
en bas de la page Traitements) -->
|
||||||
|
<string name="inactive_section">Inactive treatments</string>
|
||||||
|
<!-- v1.12.0 : encart latéral « prise de sang le même jour » sur les
|
||||||
|
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
|
||||||
|
<string name="lab_before_dose">Blood test earlier the same day, before this dose</string>
|
||||||
|
<string name="lab_after_dose">Blood test later the same day, after this dose</string>
|
||||||
|
<!-- v1.13.0 : valeur E2 attendue au creux recommandé (affichée seulement
|
||||||
|
si la calibration est disponible) + statut face à la cible de creux -->
|
||||||
|
<string name="labrec_predicted">Expected at this trough: ≈ %1$s pg/mL</string>
|
||||||
|
<string name="labrec_in_target">within your target</string>
|
||||||
|
<string name="labrec_out_of_target">outside your target</string>
|
||||||
|
<!-- v1.13.0 : carte Cible de creux (opt-in) — DISTINCT des seuils
|
||||||
|
d'alerte (surveillance continue + notifications) -->
|
||||||
|
<string name="trough_target_section">Trough target (blood draw)</string>
|
||||||
|
<string name="trough_target_hint">Personal reference for the recommended blood draw: the predicted trough value is colored against this band. Distinct from the alert thresholds above — those watch your estimated level continuously and can notify you; this one never does.</string>
|
||||||
|
<string name="trough_target_low">Target low (pg/mL)</string>
|
||||||
|
<string name="trough_target_high">Target high (pg/mL)</string>
|
||||||
|
<string name="trough_target_incomplete">Fill both fields — or leave both empty to disable.</string>
|
||||||
|
<string name="trough_target_invalid">Target high must be greater than target low.</string>
|
||||||
<string name="no_treatment_hint">Create a treatment first (Treatments tab).</string>
|
<string name="no_treatment_hint">Create a treatment first (Treatments tab).</string>
|
||||||
|
|
||||||
<string name="type_estradiol">Estradiol</string>
|
<string name="type_estradiol">Estradiol</string>
|
||||||
|
|||||||
@ -124,6 +124,34 @@ class BackupGsonTest {
|
|||||||
assertEquals(0.3, s.alertTLow!!, 1e-9)
|
assertEquals(0.3, s.alertTLow!!, 1e-9)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `backup round-trip preserves the trough target (v1_13_0) and stays absent when unset`() {
|
||||||
|
// v1.13.0 : la cible de creux voyage dans le backup (champs optionnels)
|
||||||
|
val withTarget = BackupData(
|
||||||
|
tConfig = TConfig(),
|
||||||
|
settings = UserSettings(troughTargetLow = 150.0, troughTargetHigh = 200.0)
|
||||||
|
)
|
||||||
|
val parsed: BackupData = Gson().fromJson(
|
||||||
|
Gson().toJson(withTarget), BackupData::class.java
|
||||||
|
)
|
||||||
|
val s = parsed.settings
|
||||||
|
org.junit.Assert.assertNotNull(s)
|
||||||
|
assertEquals(150.0, s!!.troughTargetLow!!, 1e-9)
|
||||||
|
assertEquals(200.0, s.troughTargetHigh!!, 1e-9)
|
||||||
|
|
||||||
|
// Rétrocompatibilité : un backup SANS cible (v1.4.2→v1.12) parse les
|
||||||
|
// champs à null — l'import ne doit pas toucher à la cible locale
|
||||||
|
val without = BackupData(
|
||||||
|
tConfig = TConfig(),
|
||||||
|
settings = UserSettings(language = "en")
|
||||||
|
)
|
||||||
|
val parsed2: BackupData = Gson().fromJson(
|
||||||
|
Gson().toJson(without), BackupData::class.java
|
||||||
|
)
|
||||||
|
assertEquals(null, parsed2.settings!!.troughTargetLow)
|
||||||
|
assertEquals(null, parsed2.settings!!.troughTargetHigh)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `legacy v1 backup without settings parses with null settings (backward compat)`() {
|
fun `legacy v1 backup without settings parses with null settings (backward compat)`() {
|
||||||
// Les anciens exports (v1.4.1 et avant) n'ont PAS de champ settings :
|
// Les anciens exports (v1.4.1 et avant) n'ont PAS de champ settings :
|
||||||
|
|||||||
@ -56,7 +56,7 @@ class EstrannaiseCloudTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `cloud has 32 curves with real dispersion between them`() {
|
fun `cloud has 32 curves with real dispersion between them`() {
|
||||||
val cloud = EstrannaiseCloud.compute(
|
val cloud = EstrannaiseCloud.compute(
|
||||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW
|
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L
|
||||||
)
|
)
|
||||||
assertEquals("32 courbes par défaut", 32, cloud.size)
|
assertEquals("32 courbes par défaut", 32, cloud.size)
|
||||||
assertTrue(cloud.all { it.size >= 2 })
|
assertTrue(cloud.all { it.size >= 2 })
|
||||||
@ -74,7 +74,7 @@ class EstrannaiseCloudTest {
|
|||||||
val start = NOW - 10 * DAY_MS
|
val start = NOW - 10 * DAY_MS
|
||||||
val end = NOW
|
val end = NOW
|
||||||
val cloud = EstrannaiseCloud.compute(
|
val cloud = EstrannaiseCloud.compute(
|
||||||
listOf(ese()), doses(), start, end, 3_600_000L, NOW
|
listOf(ese()), doses(), start, end, 3_600_000L
|
||||||
)
|
)
|
||||||
assertTrue(cloud.all { c -> c.all { it.timestamp in start..end } })
|
assertTrue(cloud.all { c -> c.all { it.timestamp in start..end } })
|
||||||
}
|
}
|
||||||
@ -88,7 +88,7 @@ class EstrannaiseCloudTest {
|
|||||||
// (le pkModel stocké n'est pas un prérequis). Le nuage superpose les
|
// (le pkModel stocké n'est pas un prérequis). Le nuage superpose les
|
||||||
// doses EEn avec les params MCMC de EEN.
|
// doses EEn avec les params MCMC de EEN.
|
||||||
val tfsStored = ese().copy(pkModel = "TFS")
|
val tfsStored = ese().copy(pkModel = "TFS")
|
||||||
val cloud = EstrannaiseCloud.compute(listOf(tfsStored), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW)
|
val cloud = EstrannaiseCloud.compute(listOf(tfsStored), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L)
|
||||||
assertEquals(32, cloud.size)
|
assertEquals(32, cloud.size)
|
||||||
assertTrue(cloud.all { it.size >= 2 })
|
assertTrue(cloud.all { it.size >= 2 })
|
||||||
}
|
}
|
||||||
@ -103,7 +103,7 @@ class EstrannaiseCloudTest {
|
|||||||
route = AdministrationRoute.ORAL, doseAmount = 12.5, doseUnit = "mg",
|
route = AdministrationRoute.ORAL, doseAmount = 12.5, doseUnit = "mg",
|
||||||
esterType = "NONE", forecastIntervalDays = 2.0, createdAt = 0
|
esterType = "NONE", forecastIntervalDays = 2.0, createdAt = 0
|
||||||
)
|
)
|
||||||
assertTrue(EstrannaiseCloud.compute(listOf(oral), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW).isEmpty())
|
assertTrue(EstrannaiseCloud.compute(listOf(oral), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -112,7 +112,7 @@ class EstrannaiseCloudTest {
|
|||||||
// (comme dans la courbe ESE) — le nuage des autres esters reste
|
// (comme dans la courbe ESE) — le nuage des autres esters reste
|
||||||
val pep = ese().copy(esterType = "PEP")
|
val pep = ese().copy(esterType = "PEP")
|
||||||
val dosesPep = listOf(DoseLog(id = 9, treatmentId = 1, timestamp = NOW - 7 * DAY_MS, doseAmount = 25.0))
|
val dosesPep = listOf(DoseLog(id = 9, treatmentId = 1, timestamp = NOW - 7 * DAY_MS, doseAmount = 25.0))
|
||||||
val cloud = EstrannaiseCloud.compute(listOf(pep), dosesPep, NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW)
|
val cloud = EstrannaiseCloud.compute(listOf(pep), dosesPep, NOW - 30 * DAY_MS, NOW, 3_600_000L)
|
||||||
// PEP n'est pas couvert par le fit ESE → pas d'échantillon → nuage vide
|
// PEP n'est pas couvert par le fit ESE → pas d'échantillon → nuage vide
|
||||||
assertTrue(cloud.isEmpty())
|
assertTrue(cloud.isEmpty())
|
||||||
}
|
}
|
||||||
@ -120,14 +120,14 @@ class EstrannaiseCloudTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `no doses on the carrier produces no cloud`() {
|
fun `no doses on the carrier produces no cloud`() {
|
||||||
assertTrue(EstrannaiseCloud.compute(
|
assertTrue(EstrannaiseCloud.compute(
|
||||||
listOf(ese()), emptyList(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW
|
listOf(ese()), emptyList(), NOW - 30 * DAY_MS, NOW, 3_600_000L
|
||||||
).isEmpty())
|
).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `nbCurves below 2 produces no cloud`() {
|
fun `nbCurves below 2 produces no cloud`() {
|
||||||
assertTrue(EstrannaiseCloud.compute(
|
assertTrue(EstrannaiseCloud.compute(
|
||||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW, nbCurves = 1
|
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, nbCurves = 1
|
||||||
).isEmpty())
|
).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,11 +141,11 @@ class EstrannaiseCloudTest {
|
|||||||
// niveau entre nuage calibré et nuage brut ≈ ×2 également).
|
// niveau entre nuage calibré et nuage brut ≈ ×2 également).
|
||||||
val scaled = mapOf("EEN" to 2.0)
|
val scaled = mapOf("EEN" to 2.0)
|
||||||
val calibrated = EstrannaiseCloud.compute(
|
val calibrated = EstrannaiseCloud.compute(
|
||||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW,
|
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L,
|
||||||
scalePerEster = scaled
|
scalePerEster = scaled
|
||||||
)
|
)
|
||||||
val raw = EstrannaiseCloud.compute(
|
val raw = EstrannaiseCloud.compute(
|
||||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, NOW
|
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L
|
||||||
)
|
)
|
||||||
assertEquals(calibrated.size, raw.size)
|
assertEquals(calibrated.size, raw.size)
|
||||||
assertTrue(calibrated.isNotEmpty())
|
assertTrue(calibrated.isNotEmpty())
|
||||||
|
|||||||
@ -0,0 +1,77 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import com.hormonetrack.data.model.AdministrationRoute
|
||||||
|
import com.hormonetrack.data.model.DoseLog
|
||||||
|
import com.hormonetrack.data.model.Treatment
|
||||||
|
import com.hormonetrack.data.model.TreatmentType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **FIX #70 (v1.12.0)** — un traitement INACTIF ne projette PLUS de créneaux
|
||||||
|
* (demande utilisatrice : « un traitement mis en inactif ne devrait plus
|
||||||
|
* être pris en compte côté graphique de simulation pour les futures
|
||||||
|
* prédictions d'injections » — le bug : un injectable archivé AVEC Posologie
|
||||||
|
* et de l'historique continuait de générer ses créneaux dans la simulation).
|
||||||
|
*
|
||||||
|
* Sémantique §6.bis INCHANGÉE : l'historique d'un inactif reste simulé
|
||||||
|
* ([PharmacokineticEngine.computeCurve] ne passe pas par
|
||||||
|
* [PharmacokineticEngine.generateForecastDoses]) — seul le FUTUR s'arrête,
|
||||||
|
* comme les rappels depuis v1.4.0 (nextReminderFireFor garde déjà !isActive).
|
||||||
|
*/
|
||||||
|
class ForecastDosesGuardTest {
|
||||||
|
|
||||||
|
private val DAY_MS = 86_400_000L
|
||||||
|
private val NOW = 1_790_000_000_000L
|
||||||
|
|
||||||
|
private fun tr(id: Long, name: String, isActive: Boolean) = Treatment(
|
||||||
|
id = id, name = name, type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_IM,
|
||||||
|
doseAmount = 5.0, doseUnit = "mg",
|
||||||
|
isActive = isActive,
|
||||||
|
forecastIntervalDays = 7.0,
|
||||||
|
createdAt = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun doses(treatmentId: Long) = listOf(
|
||||||
|
DoseLog(treatmentId = treatmentId, timestamp = NOW - 8 * DAY_MS, doseAmount = 5.0),
|
||||||
|
DoseLog(treatmentId = treatmentId, timestamp = NOW - DAY_MS, doseAmount = 5.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inactive treatment with posology and history generates NO forecast slots`() {
|
||||||
|
val archived = tr(1, "EEn-old", isActive = false)
|
||||||
|
val slots = PharmacokineticEngine.generateForecastDoses(
|
||||||
|
archived, doses(1), NOW + 30 * DAY_MS, NOW
|
||||||
|
)
|
||||||
|
assertEquals("un inactif ne projette plus rien", 0, slots.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `active twin treatment still forecasts (behavior unchanged)`() {
|
||||||
|
val active = tr(2, "EEn", isActive = true)
|
||||||
|
val slots = PharmacokineticEngine.generateForecastDoses(
|
||||||
|
active, doses(2), NOW + 30 * DAY_MS, NOW
|
||||||
|
)
|
||||||
|
assertTrue("un actif à Posologie projette toujours", slots.isNotEmpty())
|
||||||
|
// 1er créneau = dernière dose + 7 j, créneaux passés sautés (#35)
|
||||||
|
assertEquals(NOW - DAY_MS + 7 * DAY_MS, slots.first().timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `archiving an active treatment stops its future slots (semantics)`() {
|
||||||
|
// Le MÊME traitement, avant/après archivage : les créneaux existants
|
||||||
|
// (futurs) disparaissent de la projection dès le passage à inactif.
|
||||||
|
val treatment = tr(3, "EEn", isActive = true)
|
||||||
|
val history = doses(3)
|
||||||
|
val before = PharmacokineticEngine.generateForecastDoses(
|
||||||
|
treatment, history, NOW + 30 * DAY_MS, NOW
|
||||||
|
)
|
||||||
|
val after = PharmacokineticEngine.generateForecastDoses(
|
||||||
|
treatment.copy(isActive = false), history, NOW + 30 * DAY_MS, NOW
|
||||||
|
)
|
||||||
|
assertTrue(before.isNotEmpty())
|
||||||
|
assertEquals(0, after.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,7 +25,8 @@ import java.io.File
|
|||||||
* honnête — l'UI affiche alors l'invite « renseigne une Posologie ») ;
|
* honnête — l'UI affiche alors l'invite « renseigne une Posologie ») ;
|
||||||
* - une prise de sang RÉCENTE fait recommander le creux SUIVANT (jamais un
|
* - une prise de sang RÉCENTE fait recommander le creux SUIVANT (jamais un
|
||||||
* creux déjà mesuré) ;
|
* creux déjà mesuré) ;
|
||||||
* - modèle Estrannaise : t½ LUE dans la table (aucun crash, valeur finie).
|
* - modèle Estrannaise : t½ ANALYTIQUE depuis v1.9.0 (fin de la lecture
|
||||||
|
* de table — cf le dernier test du fichier ; aucun crash, valeur finie).
|
||||||
*/
|
*/
|
||||||
class LabTimingTest {
|
class LabTimingTest {
|
||||||
|
|
||||||
@ -174,16 +175,55 @@ class LabTimingTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `recent interval change resets stabilization`() {
|
fun `recent interval change resets stabilization`() {
|
||||||
// 9 semaines à 7 j, puis une dose 2 j après la première des deux plus
|
// 9 semaines à 7 j, puis une dose 2 j après la précédente : l'écart
|
||||||
// récentes : l'écart terminal (NOW−9 j → NOW−7 j = 2 j) devient
|
// de 2 j (48 h) est HORS de la fenêtre Posologie ± 24 h (v1.9.7,
|
||||||
// l'écart EXIGÉ du régime → la dose à NOW−9 j fonde le nouveau régime
|
// [6 j ; 8 j] pour 7 j) → le régime s'arrête à la dose NOW−7 j (un
|
||||||
// (la dose NOW−14 j avait un écart de 5 j ≠ 2 j → hors régime)
|
// vrai changement de créneau reste un changement — la tolérance ne
|
||||||
|
// rattrape pas un écart de 2 j).
|
||||||
val doses = weeklyDoses(9) + DoseLog(
|
val doses = weeklyDoses(9) + DoseLog(
|
||||||
id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0
|
id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0
|
||||||
)
|
)
|
||||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||||
// régimeStart = NOW−9 j → stabilisation ≈ NOW+13,7 j → 1ᵉʳ créneau : +14 j
|
// régimeStart = NOW−7 j → stabilisation ≈ NOW+15,7 j → 1ᵉʳ créneau : +21 j
|
||||||
assertEquals(NOW + 14 * DAY_MS, rec.injectionMs)
|
assertEquals(NOW + 21 * DAY_MS, rec.injectionMs)
|
||||||
|
assertEquals(false, rec.wasAlreadyStabilized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `logging time jitter does NOT reset stabilization (v1_9_7)`() {
|
||||||
|
// REMONTÉE v1.9.7 : « la suggestion change tout le temps à chaque
|
||||||
|
// injection si l'injection n'est pas faite pile à la même heure » —
|
||||||
|
// l'ancienne comparaison EXACTE cassait le régime pour un log à
|
||||||
|
// 12:30 au lieu de 12:00. Désormais : chaque écart reste dans
|
||||||
|
// Posologie ± 24 h → le régime remonte toute la séquence (63 j) →
|
||||||
|
// déjà stabilisée → 1ᵉʳ creux du créneau, wasAlreadyStabilized=true.
|
||||||
|
val doses = listOf(
|
||||||
|
DoseLog(id = 91, treatmentId = 1, timestamp = NOW - 63 * DAY_MS + 2 * HOUR_MS, doseAmount = 5.0),
|
||||||
|
DoseLog(id = 92, treatmentId = 1, timestamp = NOW - 56 * DAY_MS - 3 * HOUR_MS, doseAmount = 5.0),
|
||||||
|
DoseLog(id = 93, treatmentId = 1, timestamp = NOW - 49 * DAY_MS + 5 * HOUR_MS, doseAmount = 5.0),
|
||||||
|
DoseLog(id = 94, treatmentId = 1, timestamp = NOW - 42 * DAY_MS - 1 * HOUR_MS, doseAmount = 5.0),
|
||||||
|
DoseLog(id = 95, treatmentId = 1, timestamp = NOW - 35 * DAY_MS + 23 * HOUR_MS, doseAmount = 5.0),
|
||||||
|
DoseLog(id = 96, treatmentId = 1, timestamp = NOW - 28 * DAY_MS - 23 * HOUR_MS, doseAmount = 5.0),
|
||||||
|
DoseLog(id = 97, treatmentId = 1, timestamp = NOW - 21 * DAY_MS + 1 * HOUR_MS, doseAmount = 5.0),
|
||||||
|
DoseLog(id = 98, treatmentId = 1, timestamp = NOW - 14 * DAY_MS - 30 * 60_000L, doseAmount = 5.0),
|
||||||
|
DoseLog(id = 99, treatmentId = 1, timestamp = NOW - 7 * DAY_MS + 45 * 60_000L, doseAmount = 5.0),
|
||||||
|
)
|
||||||
|
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||||
|
// Le créneau se cale sur la dernière dose RÉELLE (+45 min) — voulu :
|
||||||
|
// le creux suit l'heure réelle, la tolérance protège la STABILISATION.
|
||||||
|
assertEquals(NOW + 7 * DAY_MS + 45 * 60_000L, rec.injectionMs)
|
||||||
|
assertTrue("les écarts ± 23 h sont le MÊME régime", rec.wasAlreadyStabilized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `gap beyond 24h tolerance resets stabilization (v1_9_7)`() {
|
||||||
|
// 8 écarts réguliers, puis un retard de 25 h sur le 7 j (8 j + 1 h) :
|
||||||
|
// hors fenêtre Posologie ± 24 h → le régime ne remonte PAS avant la
|
||||||
|
// dose en retard → PAS stabilisée (5 × t½ ≈ 22,7 j > 1,4 j).
|
||||||
|
val doses = weeklyDoses(9).mapIndexed { idx, d ->
|
||||||
|
if (idx == 7) d.copy(timestamp = NOW - 14 * DAY_MS + 25 * HOUR_MS) else d
|
||||||
|
}
|
||||||
|
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||||
assertEquals(false, rec.wasAlreadyStabilized)
|
assertEquals(false, rec.wasAlreadyStabilized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
166
app/src/test/java/com/hormonetrack/pk/LabTimingV113Test.kt
Normal file
166
app/src/test/java/com/hormonetrack/pk/LabTimingV113Test.kt
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import com.hormonetrack.data.model.AdministrationRoute
|
||||||
|
import com.hormonetrack.data.model.DoseLog
|
||||||
|
import com.hormonetrack.data.model.Treatment
|
||||||
|
import com.hormonetrack.data.model.TreatmentType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests des ajouts v1.13.0 à la recommandation de prise de sang (§7.11) :
|
||||||
|
* - **régime POOLÉ** ([LabTiming.pooledRegimeDoses]) : les doses de tous
|
||||||
|
* les traitements partageant (ester effectif, mg) forment UNE séquence —
|
||||||
|
* un re-parenting d'historique (« … - old ») ne redémarre plus la
|
||||||
|
* stabilisation ; ester ≠ → pas de mutualisation ; dose ≠ → exclue ;
|
||||||
|
* - **prédiction E2 au creux** ([LabRecommendation.predictedE2]) :
|
||||||
|
* brute × facteur du ester actif, `null` sans calibration ;
|
||||||
|
* - **statut cible** ([LabTiming.troughTargetStatus]) : in/below/above,
|
||||||
|
* `null` si cible incomplète.
|
||||||
|
*
|
||||||
|
* Miroirs web : `tests/doses-extras.test.js` (lab-timing) + js/pk.
|
||||||
|
*/
|
||||||
|
class LabTimingV113Test {
|
||||||
|
|
||||||
|
private val DAY_MS = 86_400_000L
|
||||||
|
private val NOW = 1_790_000_000_000L
|
||||||
|
|
||||||
|
private fun een(id: Long, name: String, isActive: Boolean, mg: Double = 5.0) = Treatment(
|
||||||
|
id = id, name = name, type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_IM,
|
||||||
|
doseAmount = mg, doseUnit = "mg", esterType = "EEN", pkModel = "WHS",
|
||||||
|
isActive = isActive, forecastIntervalDays = 7.0, createdAt = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun dose(treatmentId: Long, atMs: Long, mg: Double = 5.0) =
|
||||||
|
DoseLog(treatmentId = treatmentId, timestamp = atMs, doseAmount = mg)
|
||||||
|
|
||||||
|
// ── pooledRegimeDoses ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pooling crosses the treatment boundary for same ester and same mg`() {
|
||||||
|
// Scénario réel de la remontée v1.12.0 : l'historique est coupé en
|
||||||
|
// deux traitements (actif + « - old » inactif créé a posteriori).
|
||||||
|
// Le pool doit reconstituer LA séquence pharmacologique continue.
|
||||||
|
val active = een(1, "EEn 7d", isActive = true)
|
||||||
|
val old = een(10, "EEn 6d - old", isActive = false)
|
||||||
|
val treatments = listOf(active, old)
|
||||||
|
val all = listOf(
|
||||||
|
dose(1, NOW - 50 * DAY_MS),
|
||||||
|
dose(1, NOW - 43 * DAY_MS),
|
||||||
|
dose(10, NOW - 36 * DAY_MS), // re-parenté vers « old »
|
||||||
|
dose(10, NOW - 29 * DAY_MS), // idem
|
||||||
|
dose(1, NOW - 22 * DAY_MS), // retour côté actif
|
||||||
|
dose(1, NOW - 15 * DAY_MS),
|
||||||
|
dose(1, NOW - 8 * DAY_MS)
|
||||||
|
)
|
||||||
|
val pooled = LabTiming.pooledRegimeDoses(active, all, treatments)
|
||||||
|
assertEquals(
|
||||||
|
"le pool traverse la frontière t1/t10",
|
||||||
|
7,
|
||||||
|
pooled.size
|
||||||
|
)
|
||||||
|
// Trous ≤ 8 j : la marche arrière remonte au PREMIER dose du pool
|
||||||
|
assertEquals(NOW - 50 * DAY_MS, LabTiming.regimeStartMs(active, pooled))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `different ester is never pooled (EV inactive stays out of the EEN regime)`() {
|
||||||
|
val activeEen = een(1, "EEn", isActive = true)
|
||||||
|
val inactiveEv = Treatment(
|
||||||
|
id = 2, name = "EV-old", type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_IM,
|
||||||
|
doseAmount = 5.0, doseUnit = "mg", esterType = "EV", pkModel = "TFS",
|
||||||
|
isActive = false, forecastIntervalDays = 4.0, createdAt = 0
|
||||||
|
)
|
||||||
|
val all = listOf(
|
||||||
|
dose(2, NOW - 20 * DAY_MS), // EV inactif
|
||||||
|
dose(1, NOW - 8 * DAY_MS), // EEn actif
|
||||||
|
dose(1, NOW - DAY_MS)
|
||||||
|
)
|
||||||
|
val pooled = LabTiming.pooledRegimeDoses(activeEen, all, listOf(activeEen, inactiveEv))
|
||||||
|
assertEquals(2, pooled.size)
|
||||||
|
assertEquals(
|
||||||
|
"l'EV inactif ne participe pas au régime EEn",
|
||||||
|
NOW - 8 * DAY_MS,
|
||||||
|
LabTiming.regimeStartMs(activeEen, pooled)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `different dose amount is excluded and the resulting gap breaks the regime`() {
|
||||||
|
val active = een(1, "EEn 5mg", isActive = true)
|
||||||
|
val all = listOf(
|
||||||
|
dose(1, NOW - 28 * DAY_MS, mg = 5.0),
|
||||||
|
dose(1, NOW - 21 * DAY_MS, mg = 5.0),
|
||||||
|
dose(1, NOW - 14 * DAY_MS, mg = 5.0),
|
||||||
|
dose(1, NOW - 7 * DAY_MS, mg = 8.0), // dose ≠ → hors pool
|
||||||
|
dose(1, NOW, mg = 5.0)
|
||||||
|
)
|
||||||
|
val pooled = LabTiming.pooledRegimeDoses(active, all, listOf(active))
|
||||||
|
assertEquals("le 8 mg est exclu du pool", 4, pooled.size)
|
||||||
|
// L'exclusion du 8 mg crée un trou de 14 j juste avant la dernière
|
||||||
|
// dose : la marche arrière casse AU trou → régime = la dose qui suit
|
||||||
|
// (si le 8 mg avait été poolé, la séquence aurait été continue
|
||||||
|
// 7 j partout et le régime aurait remonté à −28 j).
|
||||||
|
assertEquals(NOW, LabTiming.regimeStartMs(active, pooled))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── predictedE2 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `predictedE2 is null without calibration and scaled with it`() {
|
||||||
|
val treatment = een(1, "EEn", isActive = true)
|
||||||
|
val doses = listOf(
|
||||||
|
dose(1, NOW - 63 * DAY_MS),
|
||||||
|
dose(1, NOW - 56 * DAY_MS),
|
||||||
|
dose(1, NOW - 49 * DAY_MS),
|
||||||
|
dose(1, NOW - 42 * DAY_MS),
|
||||||
|
dose(1, NOW - 35 * DAY_MS),
|
||||||
|
dose(1, NOW - 28 * DAY_MS),
|
||||||
|
dose(1, NOW - 21 * DAY_MS),
|
||||||
|
dose(1, NOW - 14 * DAY_MS),
|
||||||
|
dose(1, NOW - 7 * DAY_MS)
|
||||||
|
)
|
||||||
|
val raw = LabTiming.nextBloodDrawRecommendation(
|
||||||
|
listOf(treatment), doses, emptyList(), NOW
|
||||||
|
)
|
||||||
|
assertNotNull(raw)
|
||||||
|
assertNull("sans calibration → pas de valeur affichée", raw!!.predictedE2)
|
||||||
|
|
||||||
|
val scaled = LabTiming.nextBloodDrawRecommendation(
|
||||||
|
listOf(treatment), doses, emptyList(), NOW,
|
||||||
|
scalePerEster = mapOf("EEN" to 1.25)
|
||||||
|
)
|
||||||
|
assertNotNull(scaled!!.predictedE2)
|
||||||
|
// Le creux recommandé est identique (le facteur est multiplicatif —
|
||||||
|
// il ne déplace pas le minimum) et la valeur est le brut × facteur
|
||||||
|
assertEquals(raw.troughMs, scaled.troughMs)
|
||||||
|
// re-calcul du brut à la même heure pour vérifier la multiplication
|
||||||
|
// (le scale exact est vérifié par cohérence relative)
|
||||||
|
assertTrue(
|
||||||
|
"la valeur prédite est le brut × facteur",
|
||||||
|
scaled.predictedE2!! > 0.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── troughTargetStatus ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `trough target status matrix`() {
|
||||||
|
val s = LabTiming
|
||||||
|
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(175.0, 150.0, 200.0))
|
||||||
|
assertEquals(LabTiming.TroughTargetStatus.BELOW, s.troughTargetStatus(140.0, 150.0, 200.0))
|
||||||
|
assertEquals(LabTiming.TroughTargetStatus.ABOVE, s.troughTargetStatus(220.0, 150.0, 200.0))
|
||||||
|
// bornes incluses : 150 et 200 sont DANS la cible
|
||||||
|
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(150.0, 150.0, 200.0))
|
||||||
|
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(200.0, 150.0, 200.0))
|
||||||
|
// pas de prédiction / cible incomplète → null (rien n'est affiché)
|
||||||
|
assertNull(s.troughTargetStatus(null, 150.0, 200.0))
|
||||||
|
assertNull(s.troughTargetStatus(175.0, null, 200.0))
|
||||||
|
assertNull(s.troughTargetStatus(175.0, 150.0, null))
|
||||||
|
}
|
||||||
|
}
|
||||||
169
app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt
Normal file
169
app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.hormonetrack.data.backup.BackupData
|
||||||
|
import com.hormonetrack.data.model.TreatmentType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **6ᵉ régression épinglée sur données réelles** (export v1.9.8, HORS dépôt
|
||||||
|
* `local-test-data/backup-v1.9.8.json`, gitigné — données de santé).
|
||||||
|
*
|
||||||
|
* Contexte (remontée 2026-09-27) : « la recommandation suggère une date
|
||||||
|
* antérieure à la stabilisation des taux ». L'invariant pharmacocinétique de
|
||||||
|
* la carte (§7.11) : le creux recommandé (et son créneau d'injection) ne
|
||||||
|
* doivent JAMAIS précéder la date de stabilisation du régime courant
|
||||||
|
* (5 × t½ terminale) — c'est toute la valeur de la recommandation.
|
||||||
|
*
|
||||||
|
* Profil réel : EEn 5 mg / 7 j (WHS) ACTIF porteur, + CPA oral 25 mg actif
|
||||||
|
* (changement de dose en cours de période), + Ritaline/Fluoxetine/Doliprane
|
||||||
|
* (OTHER), + 2 injectables INACTIFS (EV, EEn-old) restés dans les données
|
||||||
|
* (§6.bis) ; logs d'injection à heures variables (l'écart réel 6,2 → 7,4 j).
|
||||||
|
*
|
||||||
|
* ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) :
|
||||||
|
* aucune valeur de santé en dur dans ce fichier (garde de confidentialité
|
||||||
|
* §8.bis — le scanner refuserait, et ce serait une fuite).
|
||||||
|
*/
|
||||||
|
class RegressionUserCase6Test {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DATA_FILE: File = listOf(
|
||||||
|
File("../local-test-data/backup-v1.9.8.json"),
|
||||||
|
File("local-test-data/backup-v1.9.8.json"),
|
||||||
|
File("app/local-test-data/backup-v1.9.8.json")
|
||||||
|
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.9.8.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DAY_MS = 86_400_000L
|
||||||
|
private val HOUR_MS = 3_600_000L
|
||||||
|
|
||||||
|
private lateinit var data: BackupData
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
assumeTrue(
|
||||||
|
"local-test-data/backup-v1.9.8.json absent — tests ignorés (données locales)",
|
||||||
|
DATA_FILE.exists()
|
||||||
|
)
|
||||||
|
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le porteur de la recommandation : E2 injectable actif à Posologie. */
|
||||||
|
private fun carrier() = data.treatments.first {
|
||||||
|
it.type == TreatmentType.ESTRADIOL && it.isActive && it.usesProfileModel &&
|
||||||
|
(it.forecastIntervalDays ?: 0.0) > 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structure attendue de l'export (les nombres viennent du FICHIER). */
|
||||||
|
@Test
|
||||||
|
fun `backup parses with the expected shape`() {
|
||||||
|
val carrier = carrier()
|
||||||
|
assertTrue("Posologie du porteur > 0", (carrier.forecastIntervalDays ?: 0.0) > 0.0)
|
||||||
|
val myDoses = data.doseLogs.filter { it.treatmentId == carrier.id }
|
||||||
|
assertTrue(
|
||||||
|
"le porteur a un historique suffisant pour un régime (≥ 5 doses)",
|
||||||
|
myDoses.size >= 5
|
||||||
|
)
|
||||||
|
assertTrue("labs présents", data.labResults.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **LE fix de cette régression** : le creux recommandé n'est JAMAIS
|
||||||
|
* antérieur à la date de stabilisation annoncée. Balayage de `now` sur
|
||||||
|
* ±60 jours autour de l'export (pas 6 h) — le bug a été vu à une date
|
||||||
|
* précise du quotidien ; l'invariant doit tenir À TOUT INSTANT, pas
|
||||||
|
* seulement à l'instant de l'export.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `recommended trough is never before the stabilization date (any now)`() {
|
||||||
|
val start = data.exportedAt - 60L * DAY_MS
|
||||||
|
val end = data.exportedAt + 60L * DAY_MS
|
||||||
|
var checked = 0
|
||||||
|
var violations = 0
|
||||||
|
var now = start
|
||||||
|
while (now <= end) {
|
||||||
|
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||||
|
data.treatments, data.doseLogs, data.labResults, now
|
||||||
|
)
|
||||||
|
if (rec != null) {
|
||||||
|
checked++
|
||||||
|
if (rec.troughMs < rec.stabilizedAtMs || rec.injectionMs < rec.stabilizedAtMs) {
|
||||||
|
violations++
|
||||||
|
println(
|
||||||
|
"VIOLATION now=$now : trough=${rec.troughMs} " +
|
||||||
|
"stab=${rec.stabilizedAtMs} regimeStart=${rec.regimeStartMs}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now += 6L * HOUR_MS
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
"au moins une recommandation calculée sur la fenêtre balayée (checked=$checked)",
|
||||||
|
checked > 0
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"creux/créneau toujours ≥ stabilisation ($violations violations)",
|
||||||
|
0, violations
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La recommandation à l'instant de l'export : le régime détecté doit
|
||||||
|
* couvrir la séquence régulière récente (logs à heures variables MAIS
|
||||||
|
* écarts dans la fenêtre Posologie ± 24 h — règle v1.9.7), et le creux
|
||||||
|
* recommandé doit être le PREMIER créneau post-stabilisation.
|
||||||
|
*
|
||||||
|
* v1.13.0 : le régime est désormais POOLÉ entre traitements
|
||||||
|
* ([LabTiming.pooledRegimeDoses]) — sur cet export (structure d'avant
|
||||||
|
* re-parenting, un seul traitement EEn), le pool est identique à
|
||||||
|
* l'historique du porteur : les assertions restent valables telles
|
||||||
|
* quelles.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `regime detection covers the recent regular sequence`() {
|
||||||
|
val carrier = carrier()
|
||||||
|
val pooled = LabTiming.pooledRegimeDoses(carrier, data.doseLogs, data.treatments)
|
||||||
|
val regimeStart = LabTiming.regimeStartMs(carrier, pooled)
|
||||||
|
// Les 4 dernières doses sont régulières (même dose, écart dans la
|
||||||
|
// fenêtre) : le régime commence AU PLUS TARD à l'avant-dernière…
|
||||||
|
// assertion data-driven : le régime couvre au moins les 4 doses
|
||||||
|
// les plus récentes (séquence régulière du fichier).
|
||||||
|
val sorted = pooled.map { it.timestamp }.sorted()
|
||||||
|
assertTrue(
|
||||||
|
"le régime détecté couvre la séquence régulière récente " +
|
||||||
|
"(regimeStart=$regimeStart, 4ᵉ dernière=${sorted[sorted.size - 4]})",
|
||||||
|
regimeStart <= sorted[sorted.size - 4]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cohérences d'affichage à l'instant de l'export (cf régression n°5). */
|
||||||
|
@Test
|
||||||
|
fun `recommendation at export time is consistent`() {
|
||||||
|
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||||
|
data.treatments, data.doseLogs, data.labResults, data.exportedAt
|
||||||
|
)
|
||||||
|
assertNotNull("porteur E2 actif à Posologie → reco calculable", rec)
|
||||||
|
rec!!.let {
|
||||||
|
assertTrue("creux futur", it.troughMs > data.exportedAt)
|
||||||
|
assertTrue(
|
||||||
|
"creux dans la fenêtre inter-créneau (trough=${it.troughMs}, inj=${it.injectionMs})",
|
||||||
|
it.troughMs < it.injectionMs &&
|
||||||
|
it.troughMs > it.injectionMs - 8L * DAY_MS
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"creux postérieur à la dernière analyse",
|
||||||
|
it.troughMs > data.labResults.maxOf { l -> l.timestamp }
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"INVARIANT central : creux ≥ stabilisation",
|
||||||
|
it.troughMs >= it.stabilizedAtMs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
app/src/test/java/com/hormonetrack/pk/RegressionUserCase7Test.kt
Normal file
187
app/src/test/java/com/hormonetrack/pk/RegressionUserCase7Test.kt
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.hormonetrack.data.backup.BackupData
|
||||||
|
import com.hormonetrack.data.model.Treatment
|
||||||
|
import com.hormonetrack.data.model.TreatmentType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **7ᵉ régression épinglée sur données réelles** (export v1.12.0, HORS dépôt
|
||||||
|
* `local-test-data/backup-v1.12.0.json`, gitigné — données de santé).
|
||||||
|
*
|
||||||
|
* Contexte (remontée 2026-09-29) : l'historique EEn a été RE-PARENTÉ —
|
||||||
|
* création d'un traitement « Injection EEn 5mg 6d - old » INACTIF (Posologie
|
||||||
|
* 6 j) regroupant les doses du 18 août au 6 sept, le traitement actif
|
||||||
|
* gardant les ères 7 j. Avant le fix, ce découpage créait un trou
|
||||||
|
* synthétique de 32 jours dans l'historique du porteur → le régime
|
||||||
|
* redémarrait au 13 sept et la stabilisation repartait de 5 × t½, alors
|
||||||
|
* que pharmacologiquement RIEN n'avait changé (EEn 5 mg, intervalles
|
||||||
|
* 5,5–7,9 j, tous dans la fenêtre Posologie ± 24 h).
|
||||||
|
*
|
||||||
|
* **Le fix v1.13.0 ([LabTiming.pooledRegimeDoses])** : le régime est poolé
|
||||||
|
* entre traitements partageant (ester effectif, mg) — le régime détecté
|
||||||
|
* doit TRAVERSER la frontière t1/t10 et rester arrêté par le 8 mg du
|
||||||
|
* 5 août (dose ≠ → hors pool, le trou qui suit est conservateur).
|
||||||
|
*
|
||||||
|
* Profil réel : EEn 5 mg (WHS) actif + EEn 6d-old INACTIF + EEn TFS 10 mg
|
||||||
|
* (une dose de janvier — ester ✓ mais mg ✗ → hors pool) + CPA 25 mg oral +
|
||||||
|
* Ritaline/Fluoxetine/Paroxetine/Sertraline/Doliprane ; labs jusqu'au
|
||||||
|
* 4 oct (E2 176 au creux — dans la cible personnelle 150-200).
|
||||||
|
*
|
||||||
|
* ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) :
|
||||||
|
* aucune valeur de santé en dur dans ce fichier (garde de confidentialité
|
||||||
|
* §8.bis — le scanner refuserait, et ce serait une fuite).
|
||||||
|
*/
|
||||||
|
class RegressionUserCase7Test {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DATA_FILE: File = listOf(
|
||||||
|
File("../local-test-data/backup-v1.12.0.json"),
|
||||||
|
File("local-test-data/backup-v1.12.0.json"),
|
||||||
|
File("app/local-test-data/backup-v1.12.0.json")
|
||||||
|
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.12.0.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DAY_MS = 86_400_000L
|
||||||
|
|
||||||
|
private lateinit var data: BackupData
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
assumeTrue(
|
||||||
|
"local-test-data/backup-v1.12.0.json absent — tests ignorés (données locales)",
|
||||||
|
DATA_FILE.exists()
|
||||||
|
)
|
||||||
|
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Porteur = injectable E2 ACTIF à Posologie (l'EEn 7 j). */
|
||||||
|
private fun carrier() = data.treatments.first {
|
||||||
|
it.type == TreatmentType.ESTRADIOL && it.isActive && it.usesProfileModel &&
|
||||||
|
(it.forecastIntervalDays ?: 0.0) > 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le traitement « … - old » INACTIF du même ester (le re-parenté). */
|
||||||
|
private fun reparentedInactive(): Treatment? =
|
||||||
|
data.treatments.firstOrNull {
|
||||||
|
it.type == TreatmentType.ESTRADIOL && !it.isActive && it.usesProfileModel
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structure attendue de l'export (les nombres viennent du FICHIER). */
|
||||||
|
@Test
|
||||||
|
fun `backup parses with the expected shape`() {
|
||||||
|
assertEquals(10, data.treatments.size)
|
||||||
|
assertNotNull("le re-parenté inactif existe (données du bug)", reparentedInactive())
|
||||||
|
val carrier = carrier()
|
||||||
|
assertTrue("Posologie du porteur > 0", (carrier.forecastIntervalDays ?: 0.0) > 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **LE fix de cette régression** : le régime du porteur TRAVERSE la
|
||||||
|
* frontière de re-parenting. Sans pool, `regimeStart` tombait après la
|
||||||
|
* dernière dose du traitement inactif (trou synthétique) ; avec le pool,
|
||||||
|
* il remonte AU-DELÀ — et reste ARRÊTÉ par la dose de mg différent
|
||||||
|
* (conservateur).
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `pooled regime crosses the re-parenting boundary but stops at the dose change`() {
|
||||||
|
val carrier = carrier()
|
||||||
|
val pooled = LabTiming.pooledRegimeDoses(carrier, data.doseLogs, data.treatments)
|
||||||
|
val regimeStart = LabTiming.regimeStartMs(carrier, pooled)
|
||||||
|
|
||||||
|
// 1) Le régime couvre les doses du traitement re-parenté inactif
|
||||||
|
val reparented = reparentedInactive()!!
|
||||||
|
val reparentedLastTs = data.doseLogs
|
||||||
|
.filter { it.treatmentId == reparented.id }
|
||||||
|
.maxOf { it.timestamp }
|
||||||
|
assertTrue(
|
||||||
|
"le régime traverse la frontière de re-parenting " +
|
||||||
|
"(regimeStart=$regimeStart ≤ dernière dose du re-parenté=$reparentedLastTs)",
|
||||||
|
regimeStart <= reparentedLastTs
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2) La dose de MG DIFFÉRENT du porteur (8 mg) arrête le régime :
|
||||||
|
// elle est exclue du pool → le régime démarre APRÈS elle
|
||||||
|
val oddDoseTs = data.doseLogs
|
||||||
|
.filter { it.treatmentId == carrier.id && Math.abs(it.doseAmount - carrier.doseAmount) > 1e-6 }
|
||||||
|
.maxOf { it.timestamp }
|
||||||
|
assertTrue(
|
||||||
|
"le régime reste arrêté par la dose de mg différent " +
|
||||||
|
"(regimeStart=$regimeStart > 8mg=$oddDoseTs)",
|
||||||
|
regimeStart > oddDoseTs
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3) La stabilisation arrive RAPIDEMENT après l'export (le régime
|
||||||
|
// poolé démarre dans le dernier mois et demi) — l'artefact
|
||||||
|
// supprimé par le fix est le TROU SYNTHÉTIQUE de re-parenting,
|
||||||
|
// pas les vrais redémarrages de régime (l'écart 5,8 j du
|
||||||
|
// 25→31 août reste hors fenêtre ± 24 h et casse légitimement).
|
||||||
|
assertTrue(
|
||||||
|
"stabilisation proche de l'export (pas de régime artificiellement repoussé)",
|
||||||
|
regimeStart >= data.exportedAt - 60L * DAY_MS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La recommandation complète sur ce profil : calculable, creux futur
|
||||||
|
* post-stabilisation, et — v1.13.0 — valeur E2 PRÉDITE si l'auto-cal
|
||||||
|
* fournit ses facteurs (l'export porte autoCalibrate = true).
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `recommendation at export time is consistent and predicts the trough value`() {
|
||||||
|
val auto = PharmacokineticEngine.autoCalibrated(
|
||||||
|
data.treatments, data.doseLogs, data.labResults, data.tConfig
|
||||||
|
)
|
||||||
|
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||||
|
data.treatments, data.doseLogs, data.labResults, data.exportedAt,
|
||||||
|
auto.esterScales
|
||||||
|
)
|
||||||
|
assertNotNull("porteur E2 actif à Posologie → reco calculable", rec)
|
||||||
|
rec!!.let {
|
||||||
|
assertTrue("creux futur", it.troughMs > data.exportedAt)
|
||||||
|
assertTrue(
|
||||||
|
"INVARIANT creux ≥ stabilisation (fix #68)",
|
||||||
|
it.troughMs >= it.stabilizedAtMs
|
||||||
|
)
|
||||||
|
// La prédiction : présente (auto-cal fournie) et physiologique
|
||||||
|
assertNotNull("auto-cal → valeur prédite", it.predictedE2)
|
||||||
|
assertTrue(
|
||||||
|
"E2 prédite physiologique (${it.predictedE2})",
|
||||||
|
it.predictedE2!! in 20.0..1000.0
|
||||||
|
)
|
||||||
|
// Sans calibration → PAS de valeur (honnêteté)
|
||||||
|
val raw = LabTiming.nextBloodDrawRecommendation(
|
||||||
|
data.treatments, data.doseLogs, data.labResults, data.exportedAt
|
||||||
|
)
|
||||||
|
assertEquals(null, raw!!.predictedE2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le statut cible appliqué à la prédiction : la cible vient des champs
|
||||||
|
* du fichier quand présents (ici absents de l'export v2 → null) — la
|
||||||
|
* matrice complète est épinglée dans LabTimingV113Test.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `target status stays null when the export carries no trough target`() {
|
||||||
|
val auto = PharmacokineticEngine.autoCalibrated(
|
||||||
|
data.treatments, data.doseLogs, data.labResults, data.tConfig
|
||||||
|
)
|
||||||
|
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||||
|
data.treatments, data.doseLogs, data.labResults, data.exportedAt,
|
||||||
|
auto.esterScales
|
||||||
|
)!!
|
||||||
|
// L'export v2 ne porte pas de cible de creux → statut null
|
||||||
|
assertEquals(
|
||||||
|
null,
|
||||||
|
LabTiming.troughTargetStatus(rec.predictedE2, null, null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
package com.hormonetrack.pk
|
||||||
|
|
||||||
|
import com.hormonetrack.data.model.DoseLog
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du garde « dose déjà loggée aujourd'hui » (v1.10.0, demande
|
||||||
|
* utilisatrice) — [PharmacokineticEngine.hasDoseLoggedOnDay] : la
|
||||||
|
* notification de rappel ne doit pas sonner pour une prise DÉJÀ saisie dans
|
||||||
|
* la journée (le receiver saute la notif et re-programme le créneau suivant).
|
||||||
|
*
|
||||||
|
* Pur et DÉTERMINISTE : zone fixe (Europe/Paris), instants construits —
|
||||||
|
* aucun System.currentTimeMillis. Le garde compare des JOURS CALENDAIRES
|
||||||
|
* locaux : la frontière minuit est épinglée (23:50 la veille ≠ jour suivant).
|
||||||
|
*/
|
||||||
|
class ReminderLoggedDayTest {
|
||||||
|
|
||||||
|
private val ZONE = ZoneId.of("Europe/Paris")
|
||||||
|
|
||||||
|
/** 2026-09-27 12:00 Europe/Paris (instant de référence du jour J). */
|
||||||
|
private val noon = ZonedDateTime.of(2026, 9, 27, 12, 0, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||||
|
|
||||||
|
private val T1 = 1L
|
||||||
|
private val T2 = 2L
|
||||||
|
private val HOUR_MS = 3_600_000L
|
||||||
|
|
||||||
|
private fun dose(treatmentId: Long, atMs: Long) =
|
||||||
|
DoseLog(treatmentId = treatmentId, timestamp = atMs, doseAmount = 5.0)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dose logged the same day skips the reminder`() {
|
||||||
|
// Prise à 09:00, rappel à 12:00 — LE cas remonté (dose saisie avant
|
||||||
|
// l'heure de rappel) : le créneau du jour est déjà honoré.
|
||||||
|
val doses = listOf(dose(T1, noon - 3 * HOUR_MS))
|
||||||
|
assertTrue(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dose logged yesterday does not skip today's reminder`() {
|
||||||
|
val doses = listOf(dose(T1, noon - 24 * HOUR_MS))
|
||||||
|
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `midnight boundary is a calendar-day comparison in the given zone`() {
|
||||||
|
// 23:50 le 26 → rappel 00:10 le 27 : jour CALendaire différent → pas de skip
|
||||||
|
val lateEvening = ZonedDateTime.of(2026, 9, 26, 23, 50, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||||
|
val earlyMorning = ZonedDateTime.of(2026, 9, 27, 0, 10, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||||
|
assertFalse(
|
||||||
|
"23:50 la veille ne couvre pas le rappel de 00:10",
|
||||||
|
PharmacokineticEngine.hasDoseLoggedOnDay(T1, listOf(dose(T1, lateEvening)), earlyMorning, ZONE)
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"00:10 et 12:00 du MÊME jour = même jour calendaire",
|
||||||
|
PharmacokineticEngine.hasDoseLoggedOnDay(T1, listOf(dose(T1, earlyMorning)), noon, ZONE)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a dose of another treatment never skips`() {
|
||||||
|
val doses = listOf(dose(T2, noon - 3 * HOUR_MS))
|
||||||
|
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no dose at all never skips`() {
|
||||||
|
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, emptyList(), noon, ZONE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `several treatments mixed - only the carrier's own day counts`() {
|
||||||
|
val doses = listOf(
|
||||||
|
dose(T2, noon - 1 * HOUR_MS), // autre traitement aujourd'hui
|
||||||
|
dose(T1, noon - 30 * HOUR_MS), // porteur, mais la veille
|
||||||
|
)
|
||||||
|
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -281,6 +281,32 @@ class ChartZoomTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `xLabelTicks daily steps stay at local midnight across DST transition`() {
|
||||||
|
// v1.9.5 (audit) : l'avancement du pas se fait par Calendar (et non
|
||||||
|
// un +24h fixe en ms) — sans ça, le tick qui FRANCHIT la transition
|
||||||
|
// tombait à 23h/01h locale au lieu de minuit. Europe/Paris 2026 :
|
||||||
|
// passage à l'heure d'été le 29 mars (02:00 → 03:00).
|
||||||
|
val zone = java.util.TimeZone.getTimeZone("Europe/Paris")
|
||||||
|
val cal = java.util.Calendar.getInstance(zone).apply {
|
||||||
|
clear()
|
||||||
|
set(2026, 2, 27, 12, 0, 0) // 27/03/2026 12:00 — la fenêtre chevauche la transition
|
||||||
|
}
|
||||||
|
val t0 = cal.timeInMillis
|
||||||
|
val ticks = com.hormonetrack.ui.components.xLabelTicks(
|
||||||
|
t0, t0 + 6L * 86_400_000L, 24L, zone
|
||||||
|
)
|
||||||
|
assertTrue(ticks.isNotEmpty())
|
||||||
|
var crossed = false
|
||||||
|
for (t in ticks) {
|
||||||
|
val c = java.util.Calendar.getInstance(zone).apply { timeInMillis = t }
|
||||||
|
assertEquals("tick à ${c.time} doit être minuit Paris (DST franchi)",
|
||||||
|
0, c.get(java.util.Calendar.HOUR_OF_DAY))
|
||||||
|
if (c.get(java.util.Calendar.DAY_OF_MONTH) >= 30) crossed = true
|
||||||
|
}
|
||||||
|
assertTrue("la fenêtre doit franchir la transition DST pour distinguer les deux implémentations", crossed)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `xLabelTicks hourly steps land at whole local hours`() {
|
fun `xLabelTicks hourly steps land at whole local hours`() {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
|||||||
106
app/src/test/java/com/hormonetrack/ui/DosesExtrasTest.kt
Normal file
106
app/src/test/java/com/hormonetrack/ui/DosesExtrasTest.kt
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
package com.hormonetrack.ui
|
||||||
|
|
||||||
|
import com.hormonetrack.data.model.AdministrationRoute
|
||||||
|
import com.hormonetrack.data.model.LabResult
|
||||||
|
import com.hormonetrack.ui.screens.labMarkersForDose
|
||||||
|
import com.hormonetrack.ui.screens.routeIcon
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests des ajouts v1.12.0 à la page Doses :
|
||||||
|
* - [routeIcon] : une icône PAR forme de prise (oral / IM / SC / gel /
|
||||||
|
* patch / autre) — IM et SC partagent le glyphe seringue (choix assumé,
|
||||||
|
* la teinte UI les distingue) : le test épingle la table de mapping ;
|
||||||
|
* - [labMarkersForDose] : marqueurs « prise de sang le même jour » —
|
||||||
|
* avant/après la dose, jour calendaire LOCAL, frontière minuit, lab
|
||||||
|
* d'un autre jour ignoré.
|
||||||
|
*
|
||||||
|
* Déterministes (zone fixe, instants construits) ; miroir web
|
||||||
|
* `tests/doses-extras.test.js`.
|
||||||
|
*/
|
||||||
|
class DosesExtrasTest {
|
||||||
|
|
||||||
|
private val ZONE = ZoneId.of("Europe/Paris")
|
||||||
|
|
||||||
|
/** 2026-09-27 12:00 Europe/Paris. */
|
||||||
|
private val noon = ZonedDateTime.of(2026, 9, 27, 12, 0, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||||
|
|
||||||
|
private fun lab(atMs: Long) = LabResult(
|
||||||
|
marker = "E2", value = 100.0, unit = "pg/mL", timestamp = atMs
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── routeIcon ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `each route has its own glyph (except IM-SC sharing the syringe by design)`() {
|
||||||
|
val oral = routeIcon(AdministrationRoute.ORAL)
|
||||||
|
val im = routeIcon(AdministrationRoute.INJECTION_IM)
|
||||||
|
val sc = routeIcon(AdministrationRoute.INJECTION_SUBCUT)
|
||||||
|
val gel = routeIcon(AdministrationRoute.TRANSDERMAL_GEL)
|
||||||
|
val patch = routeIcon(AdministrationRoute.TRANSDERMAL_PATCH)
|
||||||
|
val other = routeIcon(AdministrationRoute.OTHER)
|
||||||
|
|
||||||
|
assertNotEquals(oral, im)
|
||||||
|
assertNotEquals(oral, gel)
|
||||||
|
assertNotEquals(oral, patch)
|
||||||
|
assertNotEquals(im, gel)
|
||||||
|
assertNotEquals(gel, patch)
|
||||||
|
assertNotEquals(patch, other)
|
||||||
|
assertEquals("IM/SC : même seringue (teinte UI distincte)", im, sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `null route falls back to the OTHER glyph`() {
|
||||||
|
assertEquals(routeIcon(AdministrationRoute.OTHER), routeIcon(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── labMarkersForDose ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same-day lab before and after the dose produce both markers`() {
|
||||||
|
val markers = labMarkersForDose(
|
||||||
|
listOf(lab(noon - 3_600_000L), lab(noon + 3_600_000L)), noon, ZONE
|
||||||
|
)
|
||||||
|
assertTrue(markers.first)
|
||||||
|
assertTrue(markers.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lab of another day never marks the dose`() {
|
||||||
|
val yesterday = noon - 24 * 3_600_000L
|
||||||
|
val markers = labMarkersForDose(listOf(lab(yesterday)), noon, ZONE)
|
||||||
|
assertFalse(markers.first)
|
||||||
|
assertFalse(markers.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `midnight boundary is a calendar-day comparison`() {
|
||||||
|
// Lab à 23:50 la veille, dose à 00:10 : jours calendaires DIFFÉRENTS
|
||||||
|
// → pas de marqueur (le lien clinique ne traverse pas minuit).
|
||||||
|
val lateEvening = ZonedDateTime.of(2026, 9, 26, 23, 50, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||||
|
val earlyMorning = ZonedDateTime.of(2026, 9, 27, 0, 10, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||||
|
val markers = labMarkersForDose(listOf(lab(lateEvening)), earlyMorning, ZONE)
|
||||||
|
assertFalse(markers.first)
|
||||||
|
assertFalse(markers.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lab exactly at dose time counts as AFTER (documented convention)`() {
|
||||||
|
val markers = labMarkersForDose(listOf(lab(noon)), noon, ZONE)
|
||||||
|
assertFalse(markers.first)
|
||||||
|
assertTrue(markers.second)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no labs - no markers`() {
|
||||||
|
val markers = labMarkersForDose(emptyList(), noon, ZONE)
|
||||||
|
assertFalse(markers.first)
|
||||||
|
assertFalse(markers.second)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/src/test/java/com/hormonetrack/ui/NextDoseLineTest.kt
Normal file
45
app/src/test/java/com/hormonetrack/ui/NextDoseLineTest.kt
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package com.hormonetrack.ui
|
||||||
|
|
||||||
|
import com.hormonetrack.ui.screens.nextDoseLine
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du helper PUR `nextDoseLine` (v1.9.6 — carte « Prochaines doses » de
|
||||||
|
* l'Accueil, une ligne par traitement triée par prochaine prise). Zone et
|
||||||
|
* locale FIXES pour un format déterministe.
|
||||||
|
*/
|
||||||
|
class NextDoseLineTest {
|
||||||
|
|
||||||
|
private val zone = ZoneId.of("Europe/Paris")
|
||||||
|
private val locale = Locale.US
|
||||||
|
// Template du cas ≥ 24 h (string EN `next_dose_days`)
|
||||||
|
private val daysTemplate = "%1\$d d %2\$d h · %3\$s (%4\$s)"
|
||||||
|
|
||||||
|
private fun at(hoursFromNow: Long): Long =
|
||||||
|
ZonedDateTime.of(2026, 9, 21, 12, 0, 0, 0, zone).toInstant().toEpochMilli() + hoursFromNow * 3_600_000L
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `au-dela de 24 h - jours + jour de semaine`() {
|
||||||
|
// now = lundi 21/09 12:00 → fire = samedi 26/09 10:00 (118 h)
|
||||||
|
val line = nextDoseLine(at(0), at(118), "Injection EEn 5mg 7d", daysTemplate, zone, locale)
|
||||||
|
assertEquals("4 d 22 h · Sat 26 10:00 (Injection EEn 5mg 7d)", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `entre 1 h et 24 h - heures et minutes`() {
|
||||||
|
// now = 12:00 → fire = 14:05 (2 h 05)
|
||||||
|
val line = nextDoseLine(at(0), at(2) + 5 * 60_000L, "CPA", daysTemplate, zone, locale)
|
||||||
|
assertEquals("2h05 · 14:05 (CPA)", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moins d'une heure - minutes`() {
|
||||||
|
// now = 12:00 → fire = 12:07
|
||||||
|
val line = nextDoseLine(at(0), at(0) + 7 * 60_000L, "CPA", daysTemplate, zone, locale)
|
||||||
|
assertEquals("7 min · 12:07 (CPA)", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
package com.hormonetrack.ui
|
||||||
|
|
||||||
|
import com.hormonetrack.data.model.AdministrationRoute
|
||||||
|
import com.hormonetrack.data.model.Treatment
|
||||||
|
import com.hormonetrack.data.model.TreatmentType
|
||||||
|
import com.hormonetrack.ui.screens.treatmentsForDisplay
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de l'ORDRE D'AFFICHAGE de la page Traitements (v1.11.0, demande
|
||||||
|
* utilisatrice : « les traitements inactifs faudrait les placer à la fin
|
||||||
|
* (tout en bas) de la liste ») — [treatmentsForDisplay] :
|
||||||
|
* - les ACTIFS d'abord, les INACTIFS regroupés après ;
|
||||||
|
* - tri STABLE : l'ordre relatif DANS chaque groupe est conservé (le tri
|
||||||
|
* ne réordonne jamais les actifs entre eux, ni les inactifs entre eux) ;
|
||||||
|
* - cas limites : liste vide, tout actif, tout inactif (identité).
|
||||||
|
*
|
||||||
|
* PUR (aucune dépendance Android) ; miroir web `treatmentsForDisplay`
|
||||||
|
* (js/data/models.js, tests/treatments-display.test.js).
|
||||||
|
*/
|
||||||
|
class TreatmentsDisplayTest {
|
||||||
|
|
||||||
|
private fun tr(id: Long, name: String, isActive: Boolean) = Treatment(
|
||||||
|
id = id, name = name, type = TreatmentType.ESTRADIOL,
|
||||||
|
route = AdministrationRoute.INJECTION_IM,
|
||||||
|
doseAmount = 5.0, doseUnit = "mg",
|
||||||
|
isActive = isActive, createdAt = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inactive treatments are grouped at the end`() {
|
||||||
|
val mixed = listOf(
|
||||||
|
tr(1, "EEn", true),
|
||||||
|
tr(2, "EV-old", false),
|
||||||
|
tr(3, "CPA", true),
|
||||||
|
tr(4, "EEn-old", false),
|
||||||
|
tr(5, "Ritaline", true)
|
||||||
|
)
|
||||||
|
val display = treatmentsForDisplay(mixed)
|
||||||
|
assertEquals(
|
||||||
|
"actifs d'abord (ordre conservé), inactifs après (ordre conservé)",
|
||||||
|
listOf(1L, 3L, 5L, 2L, 4L),
|
||||||
|
display.map { it.id }
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"aucun inactif avant un actif",
|
||||||
|
0,
|
||||||
|
display.indexOfFirst { !it.isActive }.let { firstInactive ->
|
||||||
|
if (firstInactive < 0) 0
|
||||||
|
else display.subList(firstInactive, display.size).count { it.isActive }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ordering inside each group is preserved (stable sort)`() {
|
||||||
|
val mixed = listOf(
|
||||||
|
tr(10, "A-inactif", false),
|
||||||
|
tr(11, "B-actif", true),
|
||||||
|
tr(12, "C-inactif", false),
|
||||||
|
tr(13, "D-actif", true)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(11L, 13L, 10L, 12L),
|
||||||
|
treatmentsForDisplay(mixed).map { it.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all active - order unchanged`() {
|
||||||
|
val all = listOf(tr(3, "C", true), tr(1, "A", true), tr(2, "B", true))
|
||||||
|
assertEquals(listOf(3L, 1L, 2L), treatmentsForDisplay(all).map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all inactive - order unchanged`() {
|
||||||
|
val all = listOf(tr(2, "B", false), tr(1, "A", false))
|
||||||
|
assertEquals(listOf(2L, 1L), treatmentsForDisplay(all).map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty list stays empty`() {
|
||||||
|
assertEquals(0, treatmentsForDisplay(emptyList()).size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,306 @@
|
|||||||
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
||||||
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
|
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
|
||||||
|
|
||||||
|
## [1.13.0] — 2026-09-30 (versionCode 50)
|
||||||
|
|
||||||
|
### Ajouté — la reco de prise de sang devient prédictive (3 niveaux clairs)
|
||||||
|
|
||||||
|
- **Valeur E2 attendue au creux recommandé** : la carte (Accueil + Analyses)
|
||||||
|
affiche désormais « ≈ X pg/mL » quand l'auto-calibration fournit ses
|
||||||
|
facteurs — sinon rien (une valeur brute non calibrée serait trompeuse).
|
||||||
|
Tu sais AVANT de piquer si le creux tombera dans ta cible.
|
||||||
|
- **Régime POOLÉ entre traitements identiques** : `pooledRegimeDoses` — les
|
||||||
|
doses de tous les traitements partageant (ester effectif, mg) forment UNE
|
||||||
|
séquence. Un re-parenting d'historique (créer un « … - old » et y
|
||||||
|
déplacer des doses) ne fait plus redémarrer la stabilisation. Garde-fous
|
||||||
|
épinglés : ester ≠ → jamais poolé (transition EV→EEn) ; dose ≠ → exclue
|
||||||
|
(le 8 mg casse le régime, trou conservateur).
|
||||||
|
- **Cible de creux E2** (opt-in, Paramètres) : référence personnelle « je
|
||||||
|
veux être dans [basse, haute] au creux recommandé » — la prédiction est
|
||||||
|
colorée dans/hors cible sur la carte. ⚠️ **DISTINCT des seuils d'alerte**
|
||||||
|
(§9.bis) : les seuils surveillent le niveau en continu et notifient ; la
|
||||||
|
cible est une référence de lab, elle ne notifie JAMAIS. Voyage dans le
|
||||||
|
backup (champs optionnels rétrocompatibles — un backup ancien n'efface
|
||||||
|
pas la cible locale).
|
||||||
|
- **Régression n°7** épinglée sur données réelles (`backup-v1.12.0.json`,
|
||||||
|
gitigné — l'export avec le re-parenting « 6d-old ») : le régime poolé
|
||||||
|
traverse la frontière de re-parenting et reste arrêté par le 8 mg.
|
||||||
|
|
||||||
|
Vérifié : **284 tests JVM** (250 sans données locales) + **14 tests UI** +
|
||||||
|
lint verts ; **web 199 tests + E2E verts** (`check.sh`).
|
||||||
|
|
||||||
|
## [1.12.0] — 2026-09-29 (versionCode 49)
|
||||||
|
|
||||||
|
### Corrigé — un traitement inactif à Posologie projetait encore ses injections (#70)
|
||||||
|
|
||||||
|
- **Remontée** : « une injection qui n'est plus active est toujours prise en
|
||||||
|
compte dans le graphique de simulations si elle contient une posologie et
|
||||||
|
qu'elle a déjà été utilisée — un traitement mis en inactif ne devrait
|
||||||
|
plus être pris en compte pour les futures prédictions ».
|
||||||
|
- **Cause** : `generateForecastDoses` (source unique des créneaux
|
||||||
|
prévisionnels) ne vérifiait pas l'archivage ; de plus l'horizon du chip
|
||||||
|
Prévision du graphique incluait les Posologies INACTIVES.
|
||||||
|
- **Fix** : garde `!isActive` dans le moteur (l'historique reste simulé —
|
||||||
|
§6.bis inchangé — seul le FUTUR s'arrête, comme les rappels depuis
|
||||||
|
v1.4.0) + horizon/chip Prévision calculés sur les ACTIFS uniquement.
|
||||||
|
**Miroir web appliqué** (`js/pk/pk-engine.js` + `js/ui/chart.js`).
|
||||||
|
- Tests : 3 tests JVM (`ForecastDosesGuardTest`) + 3 miroirs web ;
|
||||||
|
émulateur avec données modifiées (2 inactifs à Posologie 7 j) :
|
||||||
|
Prévision on/off sans crash.
|
||||||
|
|
||||||
|
### Ajouté — icônes de forme de prise dans la page Doses
|
||||||
|
|
||||||
|
- Chaque ligne de dose porte désormais l'icône de sa forme : 💊-icône pilule
|
||||||
|
(oral), seringue (injection IM et SC — teinte distincte pour la SC),
|
||||||
|
goutte (gel), sparadrap (patch), compte-gouttes (autre). Miroir web :
|
||||||
|
émojis (`routeGlyph`, même mapping).
|
||||||
|
|
||||||
|
### Ajouté — marqueurs « prise de sang le même jour » sur les lignes de doses
|
||||||
|
|
||||||
|
- Une analyse de sang effectuée LE MÊME JOUR qu'une dose affiche un petit
|
||||||
|
encart latéral 🧪↑ (avant la dose) et/ou 🧪↓ (après) — pour visualiser
|
||||||
|
les prises de sang dans le tableau de dosages (demande utilisatrice).
|
||||||
|
Fenêtre = jour calendaire LOCAL (au-delà le lien clinique devient ténu).
|
||||||
|
Helper pur `labMarkersForDose` (Kotlin + miroir web) — 7 tests JVM
|
||||||
|
(`DosesExtrasTest` : mapping icônes + marqueurs, frontière minuit,
|
||||||
|
convention « à l'heure de la dose = après ») + 10 miroirs web.
|
||||||
|
- Vérifié émulateur avec données réelles : glyphes en tête de liste,
|
||||||
|
marqueur « Blood test later the same day » sur la dose du 10 sept
|
||||||
|
(analyse 35 min après).
|
||||||
|
|
||||||
|
Vérifié : **274 tests JVM** (240 sans données locales) + **14 tests UI** +
|
||||||
|
lint verts ; **web 194 tests + E2E verts** (`check.sh`).
|
||||||
|
|
||||||
|
## [1.11.0] — 2026-09-29 (versionCode 48)
|
||||||
|
|
||||||
|
### Ajouté — les traitements inactifs regroupés en bas de la page Traitements
|
||||||
|
|
||||||
|
- **Demande** : « les traitements inactifs sont peu discernables par rapport
|
||||||
|
aux traitements actifs — les placer à la fin (tout en bas) de la liste
|
||||||
|
pour éviter les confusions ».
|
||||||
|
- **Tri** : les ACTIFS d'abord, les INACTIFS regroupés **tout en bas**, sous
|
||||||
|
un en-tête de section « Traitements inactifs » (n'apparaît que s'il
|
||||||
|
existe des inactifs). Tri STABLE : l'ordre relatif à l'intérieur de
|
||||||
|
chaque groupe est conservé — le tri ne réordonne jamais les actifs entre
|
||||||
|
eux, ni les inactifs entre eux.
|
||||||
|
- **Distinction visuelle renforcée** : les cartes inactives sont ATTÉNUÉES
|
||||||
|
(alpha 0,55) en plus du badge « inactif » — second signal demandé
|
||||||
|
(« peut-être les rendre plus distincts ») ; elles restent lisibles et
|
||||||
|
tappables (l'édition d'un traitement archivé doit rester possible).
|
||||||
|
- Helper pur `treatmentsForDisplay` (Kotlin + **miroir web** en
|
||||||
|
`js/data/models.js`) — 5 tests JVM (`TreatmentsDisplayTest`) + 4 tests
|
||||||
|
web (`tests/treatments-display.test.js`).
|
||||||
|
- Vérifié émulateur avec données réelles seedées : les 5 actifs en haut,
|
||||||
|
en-tête puis les 2 inactifs (EV-old, EEn-old) tout en bas.
|
||||||
|
|
||||||
|
Vérifié : **264 tests JVM** (230 sans données locales) + **14 tests UI** +
|
||||||
|
lint verts ; **web 184 tests + E2E verts** (`check.sh`).
|
||||||
|
|
||||||
|
## [1.10.1] — 2026-09-28 (versionCode 47)
|
||||||
|
|
||||||
|
### Amélioré — durcissement du système de rappels (revue de code post-v1.10.0)
|
||||||
|
|
||||||
|
- **`launchMode="singleTop"` sur MainActivity** : un tap sur la notification
|
||||||
|
quand l'app est DÉJÀ ouverte délivre désormais `onNewIntent` (dialog de
|
||||||
|
saisie ouvert, extras traités) **sans détruire/recréer l'activité** —
|
||||||
|
l'ancien combo `standard` + `CLEAR_TOP` recréait tout et rendait le chemin
|
||||||
|
`onNewIntent` de la v1.10.0 inopérant (code mort).
|
||||||
|
- **Instant PLANIFIÉ dans l'alarme (`EXTRA_PLANNED_AT`)** : le garde
|
||||||
|
« créneau déjà honoré » (v1.10.0) évaluait le jour au moment du FEU — une
|
||||||
|
alarme inexacte (`setWindow`) ou retardée (Doze) glissant après minuit
|
||||||
|
évaluait le mauvais jour (23:58 → 00:02). L'instant planifié voyage
|
||||||
|
désormais dans l'intent de l'alarme, le snooze le FORWARDE tel quel, et
|
||||||
|
le garde évalue toujours le jour du CRÉNEAU. Vérifié sur émulateur par
|
||||||
|
broadcast avec `planned_at` daté d'hier → notification correctement
|
||||||
|
sautée.
|
||||||
|
- **`BootReceiver` consolidé** (dette §20.bis #2) : `goAsync` + coroutine IO
|
||||||
|
(pattern ReminderReceiver) — remplace le `Thread` + double `runBlocking`
|
||||||
|
de la v1.0 ; une seule lecture séquentielle des DAO, `finish()` garanti.
|
||||||
|
- Audit doc : comptes de tests périmés corrigés (§14 note #64, §16.ter).
|
||||||
|
|
||||||
|
Vérifié : 259 tests JVM + 14 tests UI + lint verts ; scénarios émulateur
|
||||||
|
(singleTop + planned_at). Web non concerné (aucune logique miroir touchée).
|
||||||
|
|
||||||
|
## [1.10.0] — 2026-09-27 (versionCode 46)
|
||||||
|
|
||||||
|
### Corrigé — la recommandation de prise de sang précédait la stabilisation (#68)
|
||||||
|
|
||||||
|
- **Remontée** : « la recommandation suggère une date antérieure à la
|
||||||
|
stabilisation des taux » (données réelles de la remontée → 6ᵉ régression
|
||||||
|
épinglée `local-test-data/backup-v1.9.8.json`, gitigné).
|
||||||
|
- **Cause (double)** : le « creux » était le **minimum de la fenêtre entière**
|
||||||
|
(créneau N−1 → créneau N). Pour un ester à montée lente (EEn : pic ~J+5 ≈
|
||||||
|
intervalle 7 j), ce minimum tombe **juste après l'injection PRÉCÉDENTE**
|
||||||
|
(creux d'absorption, ~3 h) — pas juste avant la suivante : la date proposée
|
||||||
|
était gouvernée par l'injection N−1, elle-même antérieure à la
|
||||||
|
stabilisation, et le texte « juste avant ton injection du … » était faux de
|
||||||
|
plusieurs jours. Balayage sur ±60 jours de `now` (test) : creux 5 oct 02:32
|
||||||
|
vs stabilisation 7 oct 04:03.
|
||||||
|
- **Fix** : le creux = **niveau PRÉ-INJECTION du créneau** (dernier point de
|
||||||
|
courbe avant l'injection) — restaure le principe v1.8.0 documenté (« juste
|
||||||
|
avant l'injection suivante, le moment le plus comparable »), rend la carte
|
||||||
|
cohérente (creux 46 min avant le créneau étiqueté) et le garde de
|
||||||
|
stabilisation s'exprime sur l'instant réellement proposé. Pour les esters à
|
||||||
|
t½ courte (EV), le point pré-injection EST le minimum de fenêtre :
|
||||||
|
comportement inchangé. **Miroir web appliqué** (`js/pk/lab-timing.js`).
|
||||||
|
- Tests : régression n°6 data-driven (4 tests dont un balayage de `now` sur
|
||||||
|
±60 j — l'invariant « creux ≥ stabilisation » doit tenir À TOUT INSTANT) ;
|
||||||
|
les 17 LabTimingTest passent inchangés (leur sémantique était celle du fix).
|
||||||
|
|
||||||
|
### Corrigé — le dialog de dose se rouvrait à chaque retour sur l'accueil (#69)
|
||||||
|
|
||||||
|
- **Remontée** : tap sur la notification de rappel → le dialog de saisie
|
||||||
|
s'ouvre ; le fermer puis changer d'onglet et revenir sur l'accueil le
|
||||||
|
ROUVRAIT à chaque fois (reproduit et revérifié sur émulateur).
|
||||||
|
- **Cause** : la demande vivait au niveau ACTIVITÉ (paramètre d'intent) mais
|
||||||
|
la consommation vivait dans un `LaunchedEffect` de Home — quitter l'onglet
|
||||||
|
détruit les `remember` locaux, le paramètre survivait → à chaque
|
||||||
|
ré-entrée en composition, le dialog se rouvrait.
|
||||||
|
- **Fix** : la demande est un état `MutableState<Long?>` détenu par
|
||||||
|
MainActivity (survit aux onglets), consommé UNE fois par HomeScreen ;
|
||||||
|
les extras sont retirés de l'intent après lecture (un recreate ne doit
|
||||||
|
jamais rouvrir une notification traitée) ; et un tap notification quand
|
||||||
|
l'app est déjà ouverte ouvre désormais aussi le dialog (avant :
|
||||||
|
silencieusement perdu).
|
||||||
|
- Tests : nouveau test UI instrumenté `LogDoseRequestTest` (ouverture 1× +
|
||||||
|
plus jamais après aller-retour d'onglets) + vérification émulateur.
|
||||||
|
|
||||||
|
### Ajouté — la notification de rappel saute un créneau déjà honoré
|
||||||
|
|
||||||
|
- **Demande** : si la dose prévue du jour est saisie AVANT l'heure de
|
||||||
|
rappel, la notification ne doit pas sonner pour une prise déjà faite ; le
|
||||||
|
rappel suivant repart au créneau suivant (idem pour le quotidien des
|
||||||
|
oraux sans Posologie).
|
||||||
|
- **Implémentation** : garde au moment du déclenchement —
|
||||||
|
`PharmacokineticEngine.hasDoseLoggedOnDay` (pur, jour calendaire LOCAL,
|
||||||
|
testable) consultée par `ReminderReceiver` : dose loguée aujourd'hui →
|
||||||
|
notification sautée (AppLog) + re-programmation du créneau suivant dans
|
||||||
|
tous les cas. Robuste quel que soit le moment de la saisie.
|
||||||
|
- Tests : 6 tests JVM (`ReminderLoggedDayTest` : même jour, veille,
|
||||||
|
frontière minuit, autre traitement, liste vide, mélange) + vérification
|
||||||
|
A/B sur émulateur (EEn dose-loguée-ce-jour → 0 notif ; Fluoxetine sans
|
||||||
|
dose → 1 notif).
|
||||||
|
|
||||||
|
Vérifié : **259 tests unitaires** (dont régression n°6 sur données réelles)
|
||||||
|
+ **14 tests UI** + lint vert ; web miroir : **180 tests + E2E verts**
|
||||||
|
(`check.sh`). Version web alignée au moment de la release (checklist 4.bis).
|
||||||
|
|
||||||
|
## [1.9.8] — 2026-09-22 (versionCode 45)
|
||||||
|
|
||||||
|
### Ajouté — tests UI Compose, signature release dédiée, README anglais
|
||||||
|
|
||||||
|
- **13 tests UI instrumentés** (navigation 5 onglets, dialog « Nouveautés »
|
||||||
|
— épinglage du fix v1.3.2 « ne réapparaît pas après dismiss » —, sections
|
||||||
|
Paramètres, version installée = BuildConfig : le bug #46 « versions jamais
|
||||||
|
bumpées » ne peut plus revenir silencieusement). Déterministes : permission
|
||||||
|
de notifications pré-grantée, zéro dépendance aux données, indépendants de
|
||||||
|
la langue FR/EN. Exécution : `./gradlew connectedDebugAndroidTest`.
|
||||||
|
- **Signature release dédiée, prête à l'emploi** : `scripts/make-release-keystore.sh`
|
||||||
|
crée le keystore + `keystore.properties` (gitignorés) ; `assembleRelease`
|
||||||
|
l'utilise dès qu'il existe (sinon clé debug inchangée). Migration
|
||||||
|
téléphone documentée (export JSON → désinstallation → réinstallation →
|
||||||
|
réimport). Aucun changement pour l'utilisatrice tant que la bascule n'est
|
||||||
|
pas faite.
|
||||||
|
- **README anglais** ([README.en.md](README.en.md)) — lien FR/EN en tête de
|
||||||
|
chaque fichier ; la doc de développement reste en français.
|
||||||
|
|
||||||
|
### Modifié — écran Paramètres découpé (maintenance)
|
||||||
|
|
||||||
|
- `SettingsScreen.kt` (840 lignes) devient un orchestrateur de 377 lignes +
|
||||||
|
11 cartes thématiques dans `ui/screens/settings/` (langue, calibration,
|
||||||
|
modèle T, seuils, fuseau, rappels, sauvegarde, auto-backup, logs, à propos).
|
||||||
|
**Comportement et rendu strictement identiques** (déplacement pur) ;
|
||||||
|
validé par les tests UI + smoke test RELEASE sur émulateur (toutes les
|
||||||
|
sections, version 1.9.8, 0 crash — anti-bug #64).
|
||||||
|
- `ChartScreen` reste volontairement entier : sa garde de source (#54)
|
||||||
|
exige le bloc de gestes dans ce fichier.
|
||||||
|
|
||||||
|
Vérifié : 249 tests unitaires + 13 tests UI verts, lint vert, APK debug +
|
||||||
|
release (2,7 Mo) buildés, smoke test release complet (accueil, 5 onglets,
|
||||||
|
Paramètres, éditeur de traitement, dialog de dose — 0 crash).
|
||||||
|
|
||||||
|
## [1.9.7] — 2026-09-20 (versionCode 44)
|
||||||
|
|
||||||
|
### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection
|
||||||
|
|
||||||
|
- **Remontée** : « la suggestion de prochain test sanguin change tout le
|
||||||
|
temps à chaque injection si l'injection n'est pas faite pile à la même
|
||||||
|
heure ou si elle est faite 30 minutes plus tard que la précédente ».
|
||||||
|
- **Cause** : la règle de stabilisation v1.8.1 comparait l'écart inter-doses
|
||||||
|
**EXACTEMENT** à l'écart précédent (au ms près) — un log à 12:30 au lieu
|
||||||
|
de 12:00 cassait le régime, repoussant la stabilisation de 5 × t½ à
|
||||||
|
CHAQUE injection : le creux recommandé fuyait vers l'avant sans jamais
|
||||||
|
se fixer.
|
||||||
|
- **Fix (tolérance)** : chaque écart doit rester dans **l'intervalle de
|
||||||
|
Posologie ± 24 h** (`GAP_TOLERANCE_MS` — « je m'injecte le même jour, à
|
||||||
|
l'heure près »). La fenêtre se réfère à l'INTERVALLE THÉORIQUE, pas à
|
||||||
|
l'écart précédent : des logs à 6,8 j puis 7,2 j ne se déstabilisent plus
|
||||||
|
en cascade. Un vrai changement de créneau (2 j au lieu de 7 j) reste
|
||||||
|
hors fenêtre et réinitialise, comme avant. Le créneau affiché se cale
|
||||||
|
toujours sur la dernière dose réelle (voulu).
|
||||||
|
- Tests : le test v1.8.1 « interval change » ré-épinglé (NOW+21 j) + 2
|
||||||
|
nouveaux (flou d'heure ± 23 h → PAS de reset ; écart 25 h → reset).
|
||||||
|
249 tests verts + lint. Web miroir v1.9.7 (12 tests lab-timing).
|
||||||
|
|
||||||
|
## [1.9.6] — 2026-09-20 (versionCode 43)
|
||||||
|
|
||||||
|
### Modifié — la carte de l'Accueil liste TOUTES les prochaines prises
|
||||||
|
|
||||||
|
- **Remontée** : « si une injection et une prise d'un autre médicament ont
|
||||||
|
lieu en même temps… le rappel ne montre que la prise qui vient, non
|
||||||
|
toutes les prises du jour » — avec une prise quotidienne (CPA), le
|
||||||
|
rappel de l'injection hebdomadaire (EEn) « n'arrive qu'après celui de la
|
||||||
|
CPA, du coup je ne le vois quasiment pas ».
|
||||||
|
- **Fix** : la carte « Prochaine dose » devient « **Prochaines doses** »
|
||||||
|
(titre pluriel dès 2 lignes) — **une ligne par traitement, TRIÉES par
|
||||||
|
prochaine prise** ; la 1ʳᵉ ligne (la plus proche) reste mise en avant.
|
||||||
|
Format de ligne inchangé (`Xj Yh · ven. 25 10:00 (nom)`, `XhMM · HH:mm`,
|
||||||
|
`N min`). Le CPA se lit désormais AU-DESSUS de l'EEn du samedi, jamais à
|
||||||
|
sa place.
|
||||||
|
- Les NOTIFICATIONS de rappel sont inchangées (une alarme par traitement
|
||||||
|
— le problème était purement l'affichage de l'Accueil).
|
||||||
|
- Moteur : helper PUR `nextDoseLine` (HomeScreen.kt, locale/zone
|
||||||
|
paramétrables, template jours localisé) — 3 tests (NextDoseLineTest).
|
||||||
|
247 tests verts + lint.
|
||||||
|
|
||||||
|
## [1.9.5] — 2026-09-20 (versionCode 42)
|
||||||
|
|
||||||
|
### Corrigé — revue de code post-v1.9.4 (audit lecture-seule des deux dépôts)
|
||||||
|
|
||||||
|
- **#67 — les ticks X ignoraient le fuseau choisi** : `drawXLabels` formatait
|
||||||
|
les labels DANS le fuseau choisi mais appelait `xLabelTicks` SANS le
|
||||||
|
passer — les ticks étaient alignés sur le fuseau de l'appareil (heures
|
||||||
|
décalées dès que les deux divergent, contredisant le fix #55). Fix :
|
||||||
|
zone passée + **pas avancé par Calendar** (un `+24 h` fixe dérivait d'1 h
|
||||||
|
aux transitions DST) ; test DST épinglé (Europe/Paris, 29/03/2026).
|
||||||
|
- **`EstrannaiseCloud.compute` : paramètre `nowMs` mort supprimé** (jamais
|
||||||
|
lu — le KDoc prétendait une sémantique que le code n'avait pas) ;
|
||||||
|
appelants + tests mis à jour.
|
||||||
|
|
||||||
|
### Amélioré — perf (suite de l'optimisation v1.8.2)
|
||||||
|
|
||||||
|
- **Contexte moteur pré-calculé** (`E2Context` + `prepareE2Context`) : le
|
||||||
|
tracé labs appelle `e2At` par point de grille (720 pts) — chaque appel
|
||||||
|
reconstruisait les maps internes (groupBy/cutoffs/Bateman), diluant
|
||||||
|
l'optimisation v1.8.2. Préparé UNE fois par courbe labs ET par
|
||||||
|
calibration par ester ; **résultat bit-identique** (244 tests verts).
|
||||||
|
- **Canvas** : Paints texte MÉMOÏSÉS par (couleur, taille, align, gras)
|
||||||
|
(une alloc par extremum/label × frame → une par clé) ; `detectExtrema`
|
||||||
|
mémoïsé par série (scan O(n) par frame → par changement de séries) ;
|
||||||
|
formatters X/marqueurs mémoïsés (SimpleDateFormat par frame → par
|
||||||
|
fuseau).
|
||||||
|
|
||||||
|
### Documenté — commentaires et docs rattrapés après les refontes
|
||||||
|
|
||||||
|
- Commentaires à jour avec le code : `PKPresets.kt` (l'ODS n'existe plus
|
||||||
|
au runtime), `Treatment.kt` (ESE analytique 6 esters),
|
||||||
|
`LabTimingTest.kt` (t½ analytique), doc §7.2/§7.3 (asset historique,
|
||||||
|
cutoff = 10 × t½ depuis v1.9.0).
|
||||||
|
- Règle anti-#59 ré-appliquée : labels de modèle via `modelLabelRes` (le
|
||||||
|
`when` inline de l'éditeur violait la règle) ; literals « ESE »/« TFS »/
|
||||||
|
« WHS » remplacés par `PKModels.*` (ChartScreen/CurveChart) ;
|
||||||
|
`scheduler2` supprimé (le scheduler de l'écran existe déjà).
|
||||||
|
|
||||||
## [1.9.4] — 2026-09-19 (versionCode 41)
|
## [1.9.4] — 2026-09-19 (versionCode 41)
|
||||||
|
|
||||||
### Ajouté — les presets du modèle ESE couvrent ses 6 esters injectables
|
### Ajouté — les presets du modèle ESE couvrent ses 6 esters injectables
|
||||||
|
|||||||
@ -11,12 +11,12 @@
|
|||||||
|
|
||||||
1. [Contexte & objectifs](#1-contexte--objectifs)
|
1. [Contexte & objectifs](#1-contexte--objectifs)
|
||||||
2. [Historique du projet](#2-historique-du-projet)
|
2. [Historique du projet](#2-historique-du-projet)
|
||||||
3. [Stack & versions (épinglées — à jour v1.8.1)](#3-stack--versions-épinglées--à-jour-v181)
|
3. [Stack & versions (épinglées — à jour v1.10.0)](#3-stack--versions-épinglées--à-jour-v1100)
|
||||||
4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine)
|
4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine)
|
||||||
5. [Architecture générale](#5-architecture-générale)
|
5. [Architecture générale](#5-architecture-générale)
|
||||||
6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre)
|
6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre)
|
||||||
7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique) + [7.8 Modèle TFS V3C](#78-modèle-tfs-v3c-v140--pktransfemsciencemodelskt) + [7.9 Modèle WHSAH](#79-modèle-whsah-v146--pkwhsahmodelskt) + [7.10 « Tracé labs »](#710--tracé-labs--v150--pklabtrajectorymodelkt) + [7.10.bis Prolongation](#710bis-prolongation-au-delà-du-dernier-lab-v160--pklabtrajectorymodelkt) + [7.11 Prise de sang recommandée](#711-recommandation-de-prochaine-prise-de-sang-v180--pklabtimingkt) + [7.12 Estrannaise analytique](#712-modèle-estrannaise-analytique--nuage-mcmc-v190--pkestrannaisemodelskt-pkestrannaisecloudk t)
|
7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique) + [7.8 Modèle TFS V3C](#78-modèle-tfs-v3c-v140--pktransfemsciencemodelskt) + [7.9 Modèle WHSAH](#79-modèle-whsah-v146--pkwhsahmodelskt) + [7.10 « Tracé labs »](#710--tracé-labs--v150--pklabtrajectorymodelkt) + [7.10.bis Prolongation](#710bis-prolongation-au-delà-du-dernier-lab-v160--pklabtrajectorymodelkt) + [7.11 Prise de sang recommandée](#711-recommandation-de-prochaine-prise-de-sang-v180--pklabtimingkt) + [7.12 Estrannaise analytique](#712-modèle-estrannaise-analytique--nuage-mcmc-v190--pkestrannaisemodelskt-pkestrannaisecloudk t)
|
||||||
8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test réelles : HORS dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data)
|
8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test réelles : HORS dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data) + [8.ter Tests UI Compose](#8ter-tests-ui-compose-appsrcandroidtest-v198)
|
||||||
9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil--reminderalertnotifierkt--worker-v142)
|
9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil--reminderalertnotifierkt--worker-v142)
|
||||||
10. [UI & navigation](#10-ui--navigation)
|
10. [UI & navigation](#10-ui--navigation)
|
||||||
11. [Graphiques (CurveChart)](#11-graphiques-curvechart)
|
11. [Graphiques (CurveChart)](#11-graphiques-curvechart)
|
||||||
@ -24,7 +24,7 @@
|
|||||||
13. [Sauvegarde JSON](#13-sauvegarde-json) + [13.bis Auto-backup quotidien](#13bis-sauvegarde-automatique-quotidienne-v170--databackupautobackupworkerkt)
|
13. [Sauvegarde JSON](#13-sauvegarde-json) + [13.bis Auto-backup quotidien](#13bis-sauvegarde-automatique-quotidienne-v170--databackupautobackupworkerkt)
|
||||||
14. [Bugs corrigés (historique complet — à ne pas réintroduire)](#14-bugs-corrigés)
|
14. [Bugs corrigés (historique complet — à ne pas réintroduire)](#14-bugs-corrigés)
|
||||||
15. [Comment régénérer l'asset pk_profiles.json](#15-comment-régénérer-lasset-pk_profilesjson)
|
15. [Comment régénérer l'asset pk_profiles.json](#15-comment-régénérer-lasset-pk_profilesjson)
|
||||||
16. [Workflow build / test / install / git](#16-workflow-build--test--install--git) + [16.bis Releases Gitea](#16bis-releases-gitea-avec-apk-téléchargeable) + [16.ter Recette : test sur émulateur](#16ter-recette--test-manuel-sur-émulateur-reproductible--v134)
|
16. [Workflow build / test / install / git](#16-workflow-build--test--install--git) + [16.bis Releases Gitea](#16bis-releases-gitea-avec-apk-téléchargeable) + [16.quater Signature release : keystore dédié](#16quater-signature-release-keystore-dédié-v198) + [16.ter Recette : test sur émulateur](#16ter-recette--test-manuel-sur-émulateur-reproductible--v134)
|
||||||
17. [Montre : Gadgetbridge & options](#17-montre--gadgetbridge--options)
|
17. [Montre : Gadgetbridge & options](#17-montre--gadgetbridge--options)
|
||||||
18. [Espace disque & coûts](#18-espace-disque--coûts)
|
18. [Espace disque & coûts](#18-espace-disque--coûts)
|
||||||
19. [Limites connues](#19-limites-connues)
|
19. [Limites connues](#19-limites-connues)
|
||||||
@ -63,6 +63,15 @@ Fonctionnalités v1 :
|
|||||||
|
|
||||||
| Date | Événement |
|
| Date | Événement |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| 30 sept. 2026 (session v1.13.0) | **La reco de prise de sang devient prédictive — 3 niveaux clarifiés** (demande « améliorer les recommandations sur la base de ma data » + rappel explicite : ne pas confondre avec les alarmes existantes). **Système à 3 niveaux documentés (§7.11)** : seuils d'alerte (continu + notifications, §9.bis) ≠ cible de creux (référence de lab, opt-in, JAMAIS de notification) ≠ reco (quand tester + combien attendre). (a) **A — `predictedE2`** : valeur brute au creux × facteur du ester actif (`scalePerEster` optionnel fourni par Home/Labs quand l'auto-cal est ON — sinon `null`, la carte n'affiche RIEN : une valeur brute serait trompeuse). (b) **B — régime POOLÉ** (`pooledRegimeDoses`) : les doses de tous les traitements partageant (ester effectif, mg) forment une séquence — le re-parenting « 6d-old » (v1.12.0) ne fait plus redémarrer la stabilisation ; garde-fous : ester ≠ jamais poolé, dose ≠ exclue (le 8 mg casse via le trou). Sur la data réelle : régime = **12 août** (« stable depuis ») au lieu de « pas stabilisée avant ~20 oct ». (c) **C — cible de creux** : DataStore (2 champs opt-in) + backup rétrocompatible (un backup ancien n'efface PAS la cible locale) + carte settings (hint croisé « distinct des seuils d'alerte ») + coloration carte reco (primaire = dans la cible, tertiary = hors). **Régression n°7** data-driven sur le nouvel export (`backup-v1.12.0.json`) : le régime traverse la frontière de re-parenting (≤ dernière dose du « 6d-old ») et reste arrêté par le 8 mg ; prédiction physiologique ; sans calibration → pas de valeur. ⚠️ Leçons : (1) le `selected` des FilterChip n'apparaît pas dans uiautomator (vérifier par la légende) ; (2) `makeDose(ts, amount, overrides)` web ; (3) l'entrée de cible via IME adb est trop fragile — validation passée par le flux d'IMPORT backup (SAF + recherche du picker), qui valide en même temps la restauration des champs. Émulateur (données réelles seedées) : « Expected at this trough: ≈ 208 pg/mL — outside your target » (208 > 200), régime poolé = stable depuis le 12 août, 0 crash. **284 tests JVM** (250 sans données locales) + 14 UI + lint ; **web 199 tests + E2E** (check.sh) — leçon E2E : bump de WEB_VERSION SANS section CHANGELOG = dialog vide (v1.7.1 re-mordue). **Publication** : commit ae14cee + tag v1.13.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 84, farewell id 1588598) ; **web v1.13.0 sync complet** (commit 902d521 + tag + **releases publiées sur les 2 instances — étape 4 incluse**, zip 196 Ko vérifié par téléchargement). |
|
||||||
|
| 29 sept. 2026 (session v1.12.0) | **Fix #70 + 2 améliorations Doses** (demandes). (a) **#70 — inactif à Posologie projetait encore** : `generateForecastDoses` (source unique des créneaux) ne vérifiait pas l'archivage, et l'horizon du chip Prévision incluait les Posologies INACTIVES. FIX : garde `!isActive` dans le moteur + horizon/chip sur les ACTIFS — §6.bis inchangé (historique toujours simulé, seul le FUTUR s'arrête, comme les rappels v1.4.0). **Miroir web** (pk-engine.js + chart.js). 3 tests JVM + 3 web. Émulateur : seed modifié (2 inactifs à Posologie 7 j), Prévision on/off 0 crash — leçon uiautomator : l'attribut `selected` des FilterChip ne reflète PAS la sélection Compose (vérifier par la LÉGENDE attendue, comme dit la recette §16.ter). (b) **Icônes de forme de prise** dans Doses (demande) : `routeIcon` pur — pilule/seringue (IM+SC, teinte tertiary pour SC)/goutte/sparadrap/compte-gouttes ; ⚠️ `MedicationLiquid` N'EXISTE PAS dans icons-extended 1.7.8 (résolu hors BOM) → `Colorize` pour OTHER (vérifié au jar). (c) **Marqueurs « prise de sang le même jour »** : `labMarkersForDose` pur (jour calendaire LOCAL, lab à l'heure exacte = APRÈS — convention documentée) → encart latéral 🧪↑/🧪↓ sur la ligne ; miroir web émojis + title. 7 tests JVM `DosesExtrasTest` + 10 web `doses-extras.test.js` (⚠️ helper web `makeDose(ts, amount, overrides)` — le 1ᵉʳ arg est le TIMESTAMP). Validé émulateur : content-desc des icônes (IM injection/Oral) + marqueur « Blood test later the same day » sur la dose du 10 sept. 274 tests JVM + 14 UI + lint ; web 194 tests + E2E (check.sh). **Publication** : commit b3372a8 + tag v1.12.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 82, farewell id 1588590) ; web v1.12.0 sync (commit 4bdcd52 + tag, farewell = miroir serveur déjà répliqué). **⚠️ OUBLI de l'utilisatrice : les RELEASES web n'avaient pas été publiées depuis v1.9.7** (étape 4 du processus web sautée — tags seuls) → backfill le jour même : v1.10.0, v1.11.0, v1.12.0 sur cloudyfy ET farewell + **v1.9.5 backfillée sur farewell** (jamais publiée) ; zips vérifiés par téléchargement ; leçon documentée §10 web (« commit + tag » ≠ « release »). |
|
||||||
|
| 29 sept. 2026 (session v1.11.0) | **Page Traitements : inactifs regroupés en bas + atténués** (demande : « peu discernables — les placer à la fin, et peut-être les rendre plus distincts »). Helper PUR `treatmentsForDisplay` (tri STABLE : actifs d'abord, inactifs après, ordre relatif conservé DANS chaque groupe) + LazyColumn en 3 sections (actifs / en-tête `inactive_section` SEULEMENT si des inactifs existent / inactifs `dimmed = true`) + cartes inactives **alpha 0,55** en plus du badge (lisibles et tappables — l'édition d'un archivé doit rester possible). **Miroir web** : `treatmentsForDisplay` dans `js/data/models.js` (node-safe, pas de DOM) + rendu sections + `opacity:0.55` + clés i18n `inactive_section` FR/EN. Tests : **5 JVM** (`TreatmentsDisplayTest` : regroupement, stabilité, tout-actif, tout-inactif, vide) + **4 web** (`tests/treatments-display.test.js`) — pas de test UI instrumenté pour l'en-tête (dépendrait de données inactives, contre la convention data-indépendance §8.ter) : couverture JVM + validation émulateur (5 actifs en haut, en-tête + EV-old/EEn-old tout en bas, dumps tr1/tr2). 264 tests JVM + lint ; web 184 tests + E2E verts (check.sh). **Publication** : commit a0f3ac5 + tag v1.11.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 80, farewell id 1588588) ; web v1.11.0 sync (commit 0dd5fa7 + tag, farewell = miroir serveur déjà répliqué). |
|
||||||
|
| 28 sept. 2026 (session v1.10.1) | **Durcissement rappels — revue de code post-v1.10.0** (audit demandé : « optimisations ou problèmes, web et Android »). (a) **`launchMode="singleTop"`** : le combo `standard`+`CLEAR_TOP` recréait l'activité au tap notification app-ouverte et rendait le `onNewIntent` de la v1.10.0 inopérant (code mort) — désormais délivré SANS recreation (état UI préservé). (b) **`EXTRA_PLANNED_AT`** : le garde « créneau déjà honoré » évaluait le jour au moment du FEU — une alarme inexacte (`setWindow`/Doze) glissant 23:58→00:02 évaluait le mauvais jour ; l'instant planifié voyage dans l'intent (scheduleFor), le snooze le FORWARDE, le garde évalue le jour du CRÉNEAU ; vérifié émulateur (broadcast `planned_at` hier → notif sautée). (c) **`BootReceiver` consolidé** (dette §20.bis #2 FAIT) : `goAsync`+coroutine, `runBlocking` supprimés. (d) Style (import `mutableStateOf`) + audit doc (comptes 223 résiduels §14/#64 et §16.ter → 259 ; §9 et §20.bis à jour). Web NON concerné (miroir lab-timing laissé tel quel — boucle correcte et testée ; `importJson` par lots reste en dette). 259 tests JVM + 14 UI + lint verts. **Publication** : commit d531eca + tag v1.10.1 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 78, farewell id 1588586) ; validation §16.ter sur l'APK RELEASE AVANT tag (accueil + données, singleTop, planned_at A/B, 0 crash). |
|
||||||
|
| 27-28 sept. 2026 (session v1.10.0) | **2 fixes + 1 amélioration rappels** (remontées + nouvelles données réelles → 6ᵉ régression épinglée `backup-v1.9.8.json`). (a) **#68 — reco de prise de sang antérieure à la stabilisation** : le « creux » = minimum de la FENÊTRE ENTIÈRE tombait juste après l'injection PRÉCÉDENTE pour les esters à montée lente (EEn pic ~J+5 ≈ intervalle 7 j → creux d'absorption ~3 h post-injection) — date gouvernée par l'injection N−1 (antérieure à la stab) et « juste avant ton injection du … » faux de plusieurs jours (réel : creux 5 oct 02:32 < stab 7 oct 04:03). FIX : creux = niveau PRÉ-INJECTION du créneau (restaure le principe v1.8.0 documenté ; EV inchangé — le point pré-injection EST son min de fenêtre) ; **miroir web appliqué** (lab-timing.js). Tests : RegressionUserCase6Test data-driven dont balayage de `now` ±60 j (invariant « creux ≥ stab » à TOUT INSTANT) ; les 17 LabTimingTest passent inchangés. (b) **#69 — dialog fantôme** : tap notification → dialog ; le fermer + changer d'onglet + revenir sur l'accueil le ROUVRAIT à chaque fois (paramètre d'activité vs consommation dans les remember de Home, détruits à chaque sortie d'onglet — reproduit sur émulateur AVANT fix). FIX : `MutableState<Long?>` détenu par MainActivity, consommé UNE fois (extras retirés de l'intent après lecture ; bonus : tap notification app-ouverte ouvre aussi le dialog). Test UI `LogDoseRequestTest` + vérification émulateur. (c) **Rappel sauté si dose déjà loggée** (demande) : garde au déclenchement — `hasDoseLoggedOnDay` (pur, jour calendaire LOCAL) dans ReminderReceiver → notif sautée + re-programmation créneau suivant dans tous les cas ; 6 tests JVM + vérification A/B émulateur (EEn dose-du-jour → 0 notif, Fluoxetine sans dose → 1 notif). **259 tests JVM + 14 UI + lint** ; **web 180 tests + E2E verts** (check.sh). **Publication** : commit a4f859f + tag v1.10.0 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 77, farewell id 1588583). **Web miroir v1.10.0 sync** (fix #68 lab-timing, WEB_VERSION alignée, commit b865b16 + tag poussés ; #69/garde rappel = Android-only, notés dans le changelog web). |
|
||||||
|
| 22 sept. 2026 (session v1.9.8) | **Socle qualité : tests UI Compose + signature release dédiée + découpage Settings** (revue de code du 21 sept. : « pas de CI, pas de tests UI, APK signé clé debug, écrans qui grandissent »). (a) **13 tests UI instrumentés** (`app/src/androidTest/uitest/`, §8.ter) : navigation 5 onglets (preuves = éléments inconditionnels : chips 24 h, FAB), dialog Nouveautés (épinglage du fix v1.3.2 : ne réapparaît pas après dismiss+restart), sections Paramètres + version installée = BuildConfig (le bug #46 ne peut plus revenir) ; déterminisme = pm grant shell pré-launch + markChangelogSeen + assertions via targetContext (indépendant FR/EN) + zéro dépendance aux données Room ; deps androidTest (BOM + ui-test-junit4 + ext:junit-ktx + junit explicite). (b) **Signature release conditionnelle** (§16.quater) : `scripts/make-release-keystore.sh` (shellcheck+shfmt) génère keystore + keystore.properties (gitignorés), `assembleRelease` l'utilise s'il existe, sinon fallback clé debug inchangé ; migration backup→désinstall→réimport documentée. (c) **SettingsScreen découpé** (840 → 377 + 11 fichiers 33–164 lignes dans `ui/screens/settings/`, §10) — déplacement pur, commentaires historiques conservés, ChartScreen volontairement entier (garde de source #54). **Vérifications** : 249 tests JVM + 13 UI verts, lint vert, assembleDebug/Release OK, **smoke test RELEASE émulateur** (accueil, 5 onglets, Paramètres complet scroll compris — version 1.9.8 visible, éditeur traitement, dialog dose + garde « crée un traitement », 0 crash logcat — anti-#64). **README EN** (README.en.md, liens FR/EN croisés ; DEVELOPPEMENT.md reste FR par choix assumé). **Publication** : commit 09cddfc + tag v1.9.8 poussés sur les 2 Gitea, APK release (2,7 Mo) + debug (21 Mo) publiés et vérifiés par téléchargement (cloudyfy id 75, farewell id 1588581). Web miroir NON concerné (v1.9.8 = socle Android uniquement : tests instrumentés, signature APK, découpage Compose, README du dépôt Android — rien à porter côté web, qui reste à v1.9.7). |
|
||||||
|
| 20 sept. 2026 (session v1.9.7) | **Fix « la suggestion de prise de sang change tout le temps »** (remontée + données réelles : logs d'injection à heures variables) : la règle de stabilisation v1.8.1 comparait l'écart inter-doses EXACTEMENT à l'écart précédent — un log 30 min plus tard cassait le régime et repoussait la stabilisation de 5 × t½ à CHAQUE injection (creux fuyant). FIX : l'écart doit rester dans **l'intervalle de Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'interval THÉORIQUE — des logs 6,8 j puis 7,2 j ne se déstabilisent plus en cascade ; un vrai passage à 2 j reste hors fenêtre). Le créneau affiché suit toujours la dernière dose réelle (voulu). Tests : « interval change » ré-épinglé + 2 tests de tolérance. 249 verts + lint. Web miroir v1.9.7 (12 verts lab-timing). §7.11 mis à jour. |
|
||||||
|
| 20 sept. 2026 (session v1.9.6) | **Carte Accueil « Prochaines doses » — toutes les prises, triées** (remontée : le rappel quotidien CPA masquait le rappel hebdomadaire EEn — « je ne le vois quasiment pas ») : la carte liste UNE ligne par traitement TRIÉES par prochaine prise (1ʳᵉ en avant, suivantes en style secondaire), titre pluriel dès 2 lignes (`next_doses` FR/EN) ; helper PUR `nextDoseLine` (locale/zone paramétrables, template jours localisé `next_dose_days`) — 3 tests `NextDoseLineTest`. Notifications inchangées (une alarme par traitement). Web miroir v1.9.6 : helper pur `nextDoseLineText` (format.js) + même carte + 3 tests miroirs (misc) — 247 verts Android / 178 web. |
|
||||||
|
| 20 sept. 2026 (session v1.9.5) | **Revue de code post-v1.9.4 — audit lecture-seule des deux dépôts puis corrections** (demande : « les logiques, choix techniques, commentaires, optimisations ») : (a) **#67** : ticks X alignés sur le fuseau APPAREIL pendant que les labels étaient formatés dans le fuseau CHOISI (zone manquante à `xLabelTicks`, contredisant #55) + pas fixe `+24 h` dérivant d'1 h aux transitions DST → zone passée + avancement Calendar, test DST (Europe/Paris 29/03) ; (b) `EstrannaiseCloud.compute` : `nowMs` mort supprimé (KDoc mensonger) ; (c) **perf** : `E2Context`/`prepareE2Context` — le tracé labs (720 pts) reconstruisait les maps moteur par point, diluant v1.8.2 (résultat bit-identique) ; Paints texte + `detectExtrema` + formatters mémoïsés dans le Canvas ; (d) **règles ré-appliquées** : `modelLabelRes` (anti-#59), `PKModels.*` partout (fini les literals), `scheduler2` supprimé ; (e) **doc rattrapée** : 3 commentaires périmés (ODS/lookup/t½-table), §7.2/§7.3 (asset historique, cutoff 10×t½). **Web miroir v1.9.5** : garde `hasModel` ESE dans `cutoffHours` (un backup importé ESE+ester non couvert levait TypeError → écran vide), portage des optimisations v1.8.2 (contexte préparé, cutoffs par traitement, Bateman paresseux), min/max en une passe (RangeError possible sur ~230 k pts avec le Nuage), init MCMC non bloquante (data-ready ⇒ asset chargé), `pk_profiles.json` exclu du zip (export-ignore, −548 Ko livrés), E2E version paramétrée (`data-version` — anti-bug v1.9.3), nettoyages (import mort lab-timing, JSDoc « troncature », dialogs ESE=3, COLORS accueil, JSDoc orpheline). 244 verts Android + lint · 175 verts web + E2E ✓. |
|
||||||
| 19 sept. 2026 (session v1.9.4) | **Presets du modèle ESE étendus à ses 6 esters injectables** (remontée : « depuis l'ajout du modèle source ESE, les presets n'ont pas été mis à jour ») : 3 presets ajoutés (EC huile 5 mg IM, EB 1 mg IM, EUCS suspension cristalline 10 mg SC — 25 entrées), champs Bateman informatifs = t½ des fits ; **éditeur** : la liste des esters suit `Esters.choicesForModel` (parité DoseDialog/web) et rebascule sur EV si l'ester sort de la couverture au changement de modèle ; **web** : 2 trous du portage v1.9.0 corrigés (`Esters.EUCS` manquant dans pk-engine.js — preset référençant `undefined` — et `choicesForModel` resté sur la couverture ODS à 3 esters) + `tests/presets.test.js` (6 tests, miroir `PKPresetsTest`). 243 verts / 213 sans données locales + lint + check web (175). Doc : doublon §7.12 supprimé. Web v1.9.4 sync. |
|
| 19 sept. 2026 (session v1.9.4) | **Presets du modèle ESE étendus à ses 6 esters injectables** (remontée : « depuis l'ajout du modèle source ESE, les presets n'ont pas été mis à jour ») : 3 presets ajoutés (EC huile 5 mg IM, EB 1 mg IM, EUCS suspension cristalline 10 mg SC — 25 entrées), champs Bateman informatifs = t½ des fits ; **éditeur** : la liste des esters suit `Esters.choicesForModel` (parité DoseDialog/web) et rebascule sur EV si l'ester sort de la couverture au changement de modèle ; **web** : 2 trous du portage v1.9.0 corrigés (`Esters.EUCS` manquant dans pk-engine.js — preset référençant `undefined` — et `choicesForModel` resté sur la couverture ODS à 3 esters) + `tests/presets.test.js` (6 tests, miroir `PKPresetsTest`). 243 verts / 213 sans données locales + lint + check web (175). Doc : doublon §7.12 supprimé. Web v1.9.4 sync. |
|
||||||
| 19 sept. 2026 (session v1.9.2) | **Fix nuage vide sans traitement stocké ESE** (remontée : « il ne s'active que autour du tracé émulé, pas autour du modèle Estrannaise ») : le filtre du nuage testait le pkModel STOCKÉ — or la courbe ESE redessine TOUTES les doses E2 (modelOverride ESE) quel que soit le modèle stocké. FIX : le nuage couvre les doses à ester effectif couvert par ESE (peu importe le stocké) ; l'exclusivité reste portée par le chip ; l'oral Bateman reste hors nuage. 2 tests réécrits (237 verts / 207 sans). Web miroir v1.9.2. |
|
| 19 sept. 2026 (session v1.9.2) | **Fix nuage vide sans traitement stocké ESE** (remontée : « il ne s'active que autour du tracé émulé, pas autour du modèle Estrannaise ») : le filtre du nuage testait le pkModel STOCKÉ — or la courbe ESE redessine TOUTES les doses E2 (modelOverride ESE) quel que soit le modèle stocké. FIX : le nuage couvre les doses à ester effectif couvert par ESE (peu importe le stocké) ; l'exclusivité reste portée par le chip ; l'oral Bateman reste hors nuage. 2 tests réécrits (237 verts / 207 sans). Web miroir v1.9.2. |
|
||||||
| 19 sept. 2026 (session v1.9.1) | **Fix nuage calibré + suggestion sur l'Accueil + explication +4 pg/mL** : (a) remontée « le nuage ne s'active que autour du tracé, pas autour du modèle Estrannaise » : la courbe ESE est CALIBRÉE (scalePerEster) mais le nuage était BRUT → échelles différentes. FIX : EstrannaiseCloud.compute reçoit le MÊME scalePerEster que la courbe ESE (Android + web miroir) ; test nuage calibré ×2 (Android). (b) Le +4 pg/mL constaté depuis v1.8.2 : l'ARRONDI de la médiane (fix v1.8.2, plus juste que la troncature) a déplacé une échelle auto-calibrée d'un cran — le niveau actuel est le CORRIGÉ, documenté CHANGELOG 1.9.1. (c) Émulateur : nuage visible autour de la courbe ESE calibrée (1363 px rose pâle, seed réel + traitement EV ESE de test), 0 crash. Web v1.9.1 sync (harmonisation de l'arrondi — les niveaux web/android convergent). 235 verts / 205 sans données locales. |
|
| 19 sept. 2026 (session v1.9.1) | **Fix nuage calibré + suggestion sur l'Accueil + explication +4 pg/mL** : (a) remontée « le nuage ne s'active que autour du tracé, pas autour du modèle Estrannaise » : la courbe ESE est CALIBRÉE (scalePerEster) mais le nuage était BRUT → échelles différentes. FIX : EstrannaiseCloud.compute reçoit le MÊME scalePerEster que la courbe ESE (Android + web miroir) ; test nuage calibré ×2 (Android). (b) Le +4 pg/mL constaté depuis v1.8.2 : l'ARRONDI de la médiane (fix v1.8.2, plus juste que la troncature) a déplacé une échelle auto-calibrée d'un cran — le niveau actuel est le CORRIGÉ, documenté CHANGELOG 1.9.1. (c) Émulateur : nuage visible autour de la courbe ESE calibrée (1363 px rose pâle, seed réel + traitement EV ESE de test), 0 crash. Web v1.9.1 sync (harmonisation de l'arrondi — les niveaux web/android convergent). 235 verts / 205 sans données locales. |
|
||||||
@ -114,7 +123,7 @@ Leçon importante de la session build : **les erreurs de compilation et les bugs
|
|||||||
(bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant
|
(bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant
|
||||||
et en testant** — aucun build n'avait été lancé avant la session 3.
|
et en testant** — aucun build n'avait été lancé avant la session 3.
|
||||||
|
|
||||||
## 3. Stack & versions (épinglées — à jour v1.8.1)
|
## 3. Stack & versions (épinglées — à jour v1.10.0)
|
||||||
|
|
||||||
| Composant | Version | Où |
|
| Composant | Version | Où |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@ -128,7 +137,8 @@ et en testant** — aucun build n'avait été lancé avant la session 3.
|
|||||||
| AppCompat | 1.8.0 (langue par app) | idem |
|
| AppCompat | 1.8.0 (langue par app) | idem |
|
||||||
| DataStore Preferences | 1.2.1 | idem |
|
| DataStore Preferences | 1.2.1 | idem |
|
||||||
| Gson | 2.14.0 | idem |
|
| Gson | 2.14.0 | idem |
|
||||||
| JUnit | 4.13.2 (testImplementation) | idem |
|
| JUnit | 4.13.2 (testImplementation + androidTestImplementation explicite) | idem |
|
||||||
|
| Tests UI Compose | **ui-test-junit4 via le BOM** + `androidx.test.ext:junit-ktx:1.3.0` + `androidx.test.uiautomator:uiautomator:2.3.0` (androidTest, v1.9.8/v1.10.0 — §8.ter) | idem |
|
||||||
| WorkManager | 2.11.2 (**UTILISÉ depuis v1.4.2** : worker périodique des seuils d'alerte, cf §9.bis) | idem |
|
| WorkManager | 2.11.2 (**UTILISÉ depuis v1.4.2** : worker périodique des seuils d'alerte, cf §9.bis) | idem |
|
||||||
| compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | app |
|
| compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | app |
|
||||||
| buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | app |
|
| buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | app |
|
||||||
@ -198,10 +208,14 @@ MainActivity (AppCompatActivity)
|
|||||||
```
|
```
|
||||||
|
|
||||||
Points clés :
|
Points clés :
|
||||||
- `HormoneTrackApp.onCreate()` : init `PKProfileStore` (asset), canal de notification
|
- `HormoneTrackApp.onCreate()` : init `EstrannaiseModels` (asset MCMC —
|
||||||
|
`PKProfileStore` HORS runtime depuis v1.9.0), canal de notification
|
||||||
- `MainActivity` : applique la langue sauvegardée (`AppCompatDelegate.setApplicationLocales`),
|
- `MainActivity` : applique la langue sauvegardée (`AppCompatDelegate.setApplicationLocales`),
|
||||||
demande POST_NOTIFICATIONS (API 33+), lit les extras d'intent `open_log_dose` +
|
demande POST_NOTIFICATIONS (API 33+) ; demande « logger une dose »
|
||||||
`treatment_id` (venus de la notification) → Home pré-ouvre le dialog de log
|
(`open_log_dose` + `treatment_id` venus de la notification) = `MutableState<Long?>`
|
||||||
|
détenu par l'activité, **consommé UNE fois** par Home via
|
||||||
|
[HormoneTrackRoot] (fix #69 — extras retirés de l'intent après lecture,
|
||||||
|
tap notification app-ouverte géré par `onNewIntent`)
|
||||||
- **Tout calcul PK est hors UI thread** (`produceState` + `Dispatchers.Default`)
|
- **Tout calcul PK est hors UI thread** (`produceState` + `Dispatchers.Default`)
|
||||||
|
|
||||||
## 6. Modèle de données (Room)
|
## 6. Modèle de données (Room)
|
||||||
@ -252,6 +266,7 @@ simulation de tout l'historique EV disparaissait) :
|
|||||||
| ✅ Retiré des chips « Log rapide » (accueil) | `HomeScreen` → `activeTreatments` |
|
| ✅ Retiré des chips « Log rapide » (accueil) | `HomeScreen` → `activeTreatments` |
|
||||||
| ✅ Retiré du dropdown des **nouvelles** doses | `DosesScreen` : création → actifs only ; **édition → tous** (une dose existante reste rattachée à son traitement inactif) |
|
| ✅ Retiré du dropdown des **nouvelles** doses | `DosesScreen` : création → actifs only ; **édition → tous** (une dose existante reste rattachée à son traitement inactif) |
|
||||||
| ✅ Rappels annulés (au save, au boot, au calcul de « prochaine dose ») | `rescheduleAll` + `nextReminderFireMs` (filtre `isActive`) ; `BootReceiver` lit `getAllOnce()` pour annuler les alarmes des inactifs |
|
| ✅ Rappels annulés (au save, au boot, au calcul de « prochaine dose ») | `rescheduleAll` + `nextReminderFireMs` (filtre `isActive`) ; `BootReceiver` lit `getAllOnce()` pour annuler les alarmes des inactifs |
|
||||||
|
| ✅ **Plus AUCUN créneau prévisionnel (v1.12.0, fix #70)** | `generateForecastDoses` garde `!isActive` — ni graphique Prévision, ni extension de fenêtre, ni horizon LabTiming ; le chip Prévision du graphique ne considère que les Posologies ACTIVES |
|
||||||
| ❌ JAMAIS retiré de la simulation | Home/Chart passent **`allTreatments`** au moteur — les écrans ne filtrent jamais |
|
| ❌ JAMAIS retiré de la simulation | Home/Chart passent **`allTreatments`** au moteur — les écrans ne filtrent jamais |
|
||||||
| ❌ JAMAIS retiré de la calibration (auto) | `autoCalibrated(allTreatments, …)` |
|
| ❌ JAMAIS retiré de la calibration (auto) | `autoCalibrated(allTreatments, …)` |
|
||||||
| ❌ JAMAIS supprimé de l'historique des doses | `DosesScreen` liste tout, suppression manuelle uniquement |
|
| ❌ JAMAIS supprimé de l'historique des doses | `DosesScreen` liste tout, suppression manuelle uniquement |
|
||||||
@ -286,11 +301,16 @@ Pics de référence (pg/mL par mg) :
|
|||||||
| `EU_tfs` | Transfem Science | undecylate | 10,11 | ~198 h |
|
| `EU_tfs` | Transfem Science | undecylate | 10,11 | ~198 h |
|
||||||
| `EEn_tfs` | Transfem Science | enanthate | 31,97 | ~156 h |
|
| `EEn_tfs` | Transfem Science | enanthate | 31,97 | ~156 h |
|
||||||
|
|
||||||
### 7.2 `PKProfileStore` (asset loader + échantillonnage)
|
### 7.2 `PKProfileStore` (⚠️ HORS RUNTIME depuis v1.9.0 — voir §7.12)
|
||||||
|
|
||||||
- Asset `app/src/main/assets/pk_profiles.json` : `{ "params": {D/k1/k2/k3…},
|
- **v1.9.0 (§7.12)** : le moteur Estrannaise est devenu ANALYTIQUE (forme
|
||||||
|
close de estrannaise.js) et `PKProfileStore` a quitté le runtime (asset
|
||||||
|
déplacé en `src/test/assets/`, −550 Ko d'APK). Ce qui suit décrit
|
||||||
|
l'architecture HISTORIQUE, encore active pour le TEST de fidélité RMS.
|
||||||
|
- Asset `src/test/assets/pk_profiles.json` : `{ "params": {D/k1/k2/k3…},
|
||||||
"profiles": { "EV_ese": [8001 floats], … } }` (550 KB, parse ~ms via `JsonParser`)
|
"profiles": { "EV_ese": [8001 floats], … } }` (550 KB, parse ~ms via `JsonParser`)
|
||||||
- **`initWithJson(json)`** = point d'entrée testable (JVM) ; `init(context)` lit l'asset
|
- **`initWithJson(json)`** = point d'entrée testable (JVM) ; `init(context)`
|
||||||
|
(ancienne lecture d'asset) n'existe plus au runtime
|
||||||
- `sample(ester, model, dtHours)` :
|
- `sample(ester, model, dtHours)` :
|
||||||
- modèle **strict** : seul "TFS"→`tfs` et "ESE"→`ese` ; tout autre → 0 (piège corrigé,
|
- modèle **strict** : seul "TFS"→`tfs` et "ESE"→`ese` ; tout autre → 0 (piège corrigé,
|
||||||
cf §14)
|
cf §14)
|
||||||
@ -305,8 +325,9 @@ Pics de référence (pg/mL par mg) :
|
|||||||
|
|
||||||
Contribution d'une dose = `sample(...) × dose_mg` ; niveau total = somme des contributions
|
Contribution d'une dose = `sample(...) × dose_mg` ; niveau total = somme des contributions
|
||||||
de toutes les doses E2, chacune multipliée par le `scaleFactor` de son traitement.
|
de toutes les doses E2, chacune multipliée par le `scaleFactor` de son traitement.
|
||||||
Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils,
|
Coupure par dose `cutoffHours` (§7.12 pour l'ESE actuel) : **10 × t½ terminale**
|
||||||
`30 × t½` pour Bateman.
|
du modèle de profil (v1.9.0 — analytique), `30 × t½` pour Bateman ;
|
||||||
|
la longueur de table 8001 h ne s'applique qu'à l'historique pré-1.9.0.
|
||||||
|
|
||||||
### 7.3b Override de modèle + prévision + auto-calibration (v1.2)
|
### 7.3b Override de modèle + prévision + auto-calibration (v1.2)
|
||||||
|
|
||||||
@ -613,39 +634,79 @@ prévisionnel STABILISÉ** — deux idées pharmacocinétiques :
|
|||||||
2. un creux n'interprétable que si le régime est **stabilisé** : 5 × t½
|
2. un creux n'interprétable que si le régime est **stabilisé** : 5 × t½
|
||||||
terminale après le dernier changement (≈ 97 % de l'équilibre).
|
terminale après le dernier changement (≈ 97 % de l'équilibre).
|
||||||
|
|
||||||
|
**v1.13.0 — le suivi s'appuie sur un système à TROIS niveaux distincts** :
|
||||||
|
|
||||||
|
| Niveau | Rôle | Notifications |
|
||||||
|
|---|---|---|
|
||||||
|
| Seuils d'alerte (§9.bis, `pk/Alerts.kt`) | surveiller le niveau estimé **en continu** | ✅ carte accueil + worker 15 min |
|
||||||
|
| **Cible de creux** (v1.13.0, opt-in) | référence **de lab** : « au creux recommandé, je veux être dans [basse, haute] » | ❌ jamais — lecture seule |
|
||||||
|
| Reco de prise de sang (cette section) | **quand** tester + **combien attendre** (`predictedE2`, v1.13.0) | — |
|
||||||
|
|
||||||
|
Les bandes sont volontairement indépendantes (ex. réelles : alertes
|
||||||
|
210–350, cible de creux 150–200 — la cible basse est SOUS l'alerte basse,
|
||||||
|
les fusionner serait faux).
|
||||||
|
|
||||||
- **Traitement porteur** : ESTRADIOL + injectable (`usesProfileModel`) +
|
- **Traitement porteur** : ESTRADIOL + injectable (`usesProfileModel`) +
|
||||||
**actif** + Posologie > 0. Sinon `null` → carte cachée ; et si un
|
**actif** + Posologie > 0. Sinon `null` → carte cachée ; et si un
|
||||||
injectable E2 actif est SANS Posologie, l'UI affiche l'**invite**
|
injectable E2 actif est SANS Posologie, l'UI affiche l'**invite**
|
||||||
([LabTiming.shouldSuggestPosology], demande v1.8.0 : rendre découvrable le
|
([LabTiming.shouldSuggestPosology], demande v1.8.0 : rendre découvrable le
|
||||||
lien Posologie → recommandation). Les deux encarts sont exclusifs.
|
lien Posologie → recommandation). Les deux encarts sont exclusifs.
|
||||||
- **t½ terminale** : TFS/WHSAH analytiques (`terminalHalfLifeDays`) ;
|
- **t½ terminale** : TFS/WHSAH **analytiques** (`terminalHalfLifeDays`) ;
|
||||||
**Estrannaise** : lue dans la table par
|
**Estrannaise analytique** depuis v1.9.0
|
||||||
[PKProfileStore.terminalHalfLifeDays] (refactor v1.8.0 : la pente
|
([EstrannaiseModels.terminalHalfLifeDays] — forme close estrannaise.js ;
|
||||||
log-linéaire utilisée par l'extrapolation de `sample()` — dernier point
|
l'ancienne lecture de table `PKProfileStore` est HORS runtime, cf §7.12).
|
||||||
≥ 1 % du pic, 48 h précédentes — est partagée).
|
|
||||||
- **Début du régime courant** (v1.8.1, **correction du proxy v1.8.0** —
|
- **Début du régime courant** (v1.8.1, **correction du proxy v1.8.0** —
|
||||||
critique : « changé d'ester, de dosage ET de posologie, et l'app disait
|
critique : « changé d'ester, de dosage ET de posologie, et l'app disait
|
||||||
stabilisée depuis février ») : le régime courant = la **séquence terminale
|
stabilisée depuis février ») : le régime courant = la **séquence terminale
|
||||||
de doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques
|
de doses** où (a) l'**ester effectif** et la **dose (mg)** sont identiques
|
||||||
à la dose la plus récente, ET (b) **l'écart entre doses consécutives est
|
à la dose la plus récente, ET (b) **l'écart entre doses consécutives
|
||||||
constant** (= l'écart des deux doses les plus récentes, comparé
|
reste dans l'intervalle de Posologie ± 24 h** (v1.9.7 — la comparaison
|
||||||
exactement — 7 j ± 1 h casse le régime). Stabilisation = régimeStart +
|
exacte v1.8.1 cassait le régime à chaque variation d'heure de log et
|
||||||
|
faisait fuir le creux recommandé ; « je m'injecte le même jour, à l'heure
|
||||||
|
près » — un vrai changement de créneau, ex. 2 j au lieu de 7 j, reste
|
||||||
|
hors fenêtre). **v1.13.0 : séquence POOLÉE entre traitements**
|
||||||
|
([LabTiming.pooledRegimeDoses]) — les doses de tous les traitements
|
||||||
|
partageant (ester effectif, mg) forment UNE séquence : le re-parenting
|
||||||
|
d'historique (« … - old » créé a posteriori, remontée v1.12.0) ne fait
|
||||||
|
plus redémarrer la stabilisation. Garde-fous : ester ≠ → jamais poolé
|
||||||
|
(transition EV→EEn, régression n°3) ; dose ≠ → exclue (le 8 mg casse le
|
||||||
|
régime via le trou qui suit — conservateur). Sur la data réelle de la
|
||||||
|
remontée : régime = 12 août (« stable depuis ») au lieu de « pas
|
||||||
|
stabilisée avant ~20 oct ». Stabilisation = régimeStart +
|
||||||
5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la
|
5 × t½. Conservative assumée : des intervalles chaotiques maintiennent la
|
||||||
carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est
|
carte « non stabilisée » (pharmacocinétiquement vrai — le trough n'est
|
||||||
comparable que sur un intervalle régulier).
|
comparable que sur un intervalle régulier).
|
||||||
- **Creux** : minimum local de la courbe E2 prévisionnelle (doses réelles +
|
- **Creux** (**fix #68 v1.10.0**) : niveau PRÉ-INJECTION du créneau —
|
||||||
[PharmacokineticEngine.generateForecastDoses], pas 1 h) entre deux
|
dernier point de la courbe E2 prévisionnelle (doses réelles +
|
||||||
injections ; FORME brute (sans calibration — le facteur ne déplace pas le
|
[PharmacokineticEngine.generateForecastDoses], pas 1 h) juste avant
|
||||||
minimum) ; horizons : au moins 3 créneaux, étendu pour couvrir la
|
l'injection (principe 1). ⚠️ L'ancien « minimum de la fenêtre entière »
|
||||||
stabilisation, borné à 12.
|
tombait juste APRÈS l'injection précédente pour les esters à montée
|
||||||
|
lente (EEn : pic ~J+5 ≈ intervalle 7 j → creux d'absorption) : date
|
||||||
|
antérieure à la stabilisation + texte « juste avant ton injection » faux
|
||||||
|
de plusieurs jours. Pour EV (t½ courte) le point pré-injection EST le
|
||||||
|
minimum de fenêtre → comportement inchangé. FORME brute (sans
|
||||||
|
calibration — le facteur ne déplace pas le minimum) ; horizons : au
|
||||||
|
moins 3 créneaux, étendu pour couvrir la stabilisation, borné à 12.
|
||||||
- **Filtres** : creux ≥ now + 6 h ; creux > dernière prise de sang (jamais
|
- **Filtres** : creux ≥ now + 6 h ; creux > dernière prise de sang (jamais
|
||||||
recommander un creux déjà mesuré) ; créneau ≥ stabilisation (sinon creux
|
recommander un creux déjà mesuré) ; **creux ≥ stabilisation** (sinon
|
||||||
du créneau suivant — décision v1.8.0 « sauter »).
|
creux du créneau suivant — décision v1.8.0 « sauter » ; depuis #68 le
|
||||||
|
garde s'exprime sur le creux lui-même, instant réellement proposé).
|
||||||
|
- **Valeur prédite + cible (v1.13.0)** : `LabRecommendation.predictedE2`
|
||||||
|
= valeur brute du modèle au creux × facteur du ester actif
|
||||||
|
(`scalePerEster` optionnel, fourni par Home/Labs quand l'auto-cal est ON ;
|
||||||
|
`null` → la carte n'affiche RIEN — une valeur brute non calibrée serait
|
||||||
|
trompeuse) ; statut face à la cible de creux personnelle via
|
||||||
|
[LabTiming.troughTargetStatus] (IN_TARGET/BELOW/ABOVE, bornes incluses,
|
||||||
|
`null` si cible absente) → coloration primaire (dans la cible) /
|
||||||
|
tertiary (hors) + suffixe « dans/hors de ta cible ».
|
||||||
- **UI** : LabsScreen — calcul `produceState` (Dispatchers.Default) + tick
|
- **UI** : LabsScreen — calcul `produceState` (Dispatchers.Default) + tick
|
||||||
minute ; carte avec creux exact (« At the estimated trough »), créneau
|
minute ; carte avec creux exact (« At the estimated trough »), créneau
|
||||||
associé (+ mention « ou simplement la veille »), statut de stabilisation,
|
associé (+ mention « ou simplement la veille »), statut de stabilisation,
|
||||||
disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée.
|
disclaimer. Stabilisation hors horizon (ester ultra-long) → carte cachée.
|
||||||
- 11 tests `LabTimingTest` (8 + 3 v1.8.1 régime récent) + 1 test régression n°5.
|
- Tests `LabTimingTest` (17 — v1.9.7 : 2 tests de tolérance ± 24 h ;
|
||||||
|
sémantique pré-injection inchangée par le fix #68) + régressions n°5 et
|
||||||
|
**n°6** (data-driven `backup-v1.9.8.json` : invariant « creux ≥
|
||||||
|
stabilisation » balayé sur `now` ± 60 j, pas 6 h).
|
||||||
|
|
||||||
### 7.12 Modèle Estrannaise ANALYTIQUE + nuage MCMC (v1.9.0) — `pk/EstrannaiseModels.kt`, `pk/EstrannaiseCloud.kt`
|
### 7.12 Modèle Estrannaise ANALYTIQUE + nuage MCMC (v1.9.0) — `pk/EstrannaiseModels.kt`, `pk/EstrannaiseCloud.kt`
|
||||||
|
|
||||||
@ -724,9 +785,9 @@ devient à son tour**.
|
|||||||
|
|
||||||
## 8. Tests unitaires
|
## 8. Tests unitaires
|
||||||
|
|
||||||
**234 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **193 sans
|
**284 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — **250 sans
|
||||||
les données de test locales** (cf §8.bis : les 5 classes de régression,
|
les données de test locales** (cf §8.bis : les 6 classes de régression sont
|
||||||
6/6/6/5/7 tests, sont skippées via `Assume`). Dépendance : JUnit 4.13.2.
|
skippées via `Assume`). Dépendance : JUnit 4.13.2.
|
||||||
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
|
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
|
||||||
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
|
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
|
||||||
|
|
||||||
@ -933,14 +994,20 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
|
|||||||
|
|
||||||
- emplacement : `local-test-data/backup-v1.0.0.json`, `backup-v1.2.0.json`,
|
- emplacement : `local-test-data/backup-v1.0.0.json`, `backup-v1.2.0.json`,
|
||||||
`backup-v1.2.3.json` (ancien export, plus consommé par un test),
|
`backup-v1.2.3.json` (ancien export, plus consommé par un test),
|
||||||
`backup-v1.3.1.json`, `backup-v1.4.2.json` et `backup-v1.7.0.json`
|
`backup-v1.3.1.json`, `backup-v1.4.2.json`, `backup-v1.7.0.json`, `backup-v1.9.8.json` et
|
||||||
|
`backup-v1.12.0.json`
|
||||||
(copiés tels quels depuis l'export JSON de l'app) ;
|
(copiés tels quels depuis l'export JSON de l'app) ;
|
||||||
`RegressionUserCaseTest` lit v1.0.0,
|
`RegressionUserCaseTest` lit v1.0.0,
|
||||||
`RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit
|
`RegressionUserCase2Test` lit v1.2.0, `RegressionUserCase3Test` lit
|
||||||
**v1.3.1**, `RegressionUserCase4Test` lit **v1.4.2** (premier export avec
|
**v1.3.1**, `RegressionUserCase4Test` lit **v1.4.2** (premier export avec
|
||||||
les settings v2), `RegressionUserCase5Test` lit **v1.7.0** (profil à 3
|
les settings v2), `RegressionUserCase5Test` lit **v1.7.0** (profil à 3
|
||||||
traitements dont CPA oral ; paire E2+T avec DEUX notes distinctes —
|
traitements dont CPA oral ; paire E2+T avec DEUX notes distinctes —
|
||||||
épine le fix « une note sur deux » v1.7.1) ;
|
épine le fix « une note sur deux » v1.7.1), `RegressionUserCase6Test` lit
|
||||||
|
**v1.9.8** (profil à 7 traitements, logs à heures variables ; épine le
|
||||||
|
fix #68 « creux avant stabilisation » — invariant balayé sur `now` ±60 j),
|
||||||
|
`RegressionUserCase7Test` lit **v1.12.0** (export avec re-parenting
|
||||||
|
« 6d-old » — épine le régime POOLÉ v1.13.0 : le régime doit traverser la
|
||||||
|
frontière de re-parenting et rester arrêté par la dose de mg différent) ;
|
||||||
- `.gitignore` contient `local-test-data/` → jamais commités.
|
- `.gitignore` contient `local-test-data/` → jamais commités.
|
||||||
**GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** :
|
**GARDES DE CONFIDENTIALITÉ (rappel 7 sept. 2026, à chaque release)** :
|
||||||
1. `git check-ignore -v local-test-data/…` → la règle matche ;
|
1. `git check-ignore -v local-test-data/…` → la règle matche ;
|
||||||
@ -954,9 +1021,9 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
|
|||||||
dans le code (le scanner refuserait, et ce serait une fuite).
|
dans le code (le scanner refuserait, et ce serait une fuite).
|
||||||
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le
|
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le
|
||||||
fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
|
fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
|
||||||
exécute 193 tests au lieu de 223 (v1.8.1 ; chiffres historiques : 44/87 à v1.3.x,
|
exécute 250 tests au lieu de 284 (v1.13.0 ; chiffres historiques : 44/87 à v1.3.x,
|
||||||
150/172 à v1.4.x, 160/183 à v1.5.0, 168/191 à v1.6.0, 177/200 à v1.7.0,
|
150/172 à v1.4.x, 160/183 à v1.5.0, 168/191 à v1.6.0, 177/200 à v1.7.0,
|
||||||
181/211 à v1.7.1, 190/220 à v1.8.0) ;
|
181/211 à v1.7.1, 190/220 à v1.8.0, 219/249 à v1.9.x, 225/259 à v1.10.x, 230/264 à v1.11.x, 240/274 à v1.12.x) ;
|
||||||
- le workdir des tests Gradle est le dossier du module (`app/`) → les tests
|
- le workdir des tests Gradle est le dossier du module (`app/`) → les tests
|
||||||
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
|
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
|
||||||
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous
|
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous
|
||||||
@ -983,8 +1050,51 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
|
|||||||
|
|
||||||
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
|
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
|
||||||
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
|
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
|
||||||
**Toute modification du moteur passe par ces tests.** Suite envisageable : Robolectric
|
**Toute modification du moteur passe par ces tests.** Suite envisagée : Robolectric
|
||||||
(UI/logic Android), tests Compose, lint.
|
(UI/logic Android — les tests Compose existent depuis v1.9.8, cf §8.ter).
|
||||||
|
|
||||||
|
### 8.ter Tests UI Compose (`app/src/androidTest/`, v1.9.8)
|
||||||
|
|
||||||
|
**14 tests instrumentés** (`uitest/`) exécutés sur émulateur/appareil :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew connectedDebugAndroidTest # émulateur requis (ex. AVD hrt36)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`NavigationTest` (7)** : la barre d'onglets affiche les 5 entrées, chaque
|
||||||
|
onglet mène bien à son écran (preuves = éléments INCONDITIONNELS : chips de
|
||||||
|
plage « 24 h » du graphique, FAB « + » de Doses/Analyses/Traitements —
|
||||||
|
indépendants des données Room), et les Paramètres s'ouvrent depuis l'icône
|
||||||
|
de l'accueil ;
|
||||||
|
- **`ChangelogDialogTest` (3)** : dialog « Nouveautés » affiché quand la
|
||||||
|
version installée est plus récente que la dernière vue, absent sinon, et
|
||||||
|
**ne réapparaît pas après dismiss + redémarrage** (épinglage du fix v1.3.2
|
||||||
|
en test UI) ;
|
||||||
|
- **`SettingsScreenTest` (3)** : sections de l'écran Paramètres toutes
|
||||||
|
présentes + chips de langue littéraux (« Français »/« English ») +
|
||||||
|
**version installée = BuildConfig.VERSION_NAME** (le bug #46 — versions
|
||||||
|
jamais bumpées dans les tags — ne peut plus revenir silencieusement) ;
|
||||||
|
- **`LogDoseRequestTest` (1, v1.10.0)** : le dialog de dose s'ouvre UNE fois
|
||||||
|
au lancement avec les extras de notification, puis ne se rouvre JAMAIS
|
||||||
|
après fermeture + aller-retour d'onglets (épinglage du fix #69). Lancement
|
||||||
|
manuel avec intent (règle vide + ActivityScenario) ; BACK = fermeture
|
||||||
|
indépendante des libellés (UiDevice, dep `uiautomator:2.3.0`).
|
||||||
|
|
||||||
|
**Déterminisme** (les tests tournent contre l'app RÉELLE, données incluses) :
|
||||||
|
- permission `POST_NOTIFICATIONS` pré-grantée via `pm grant` shell AVANT le
|
||||||
|
lancement (sinon le dialog SYSTÈME masque l'app → timeout) ;
|
||||||
|
- dialog « Nouveautés » neutralisé en marquant la version vue
|
||||||
|
(`UiTestEnv.markChangelogSeen`) SAUF pour les tests qui le ciblent — ceux-ci
|
||||||
|
lancent l'activité MANUELLEMENT (`ActivityScenario` + `createEmptyComposeRule`)
|
||||||
|
pour régler le DataStore AVANT la lecture au démarrage ;
|
||||||
|
- assertions de texte via `targetContext.getString(...)` : résolues dans la
|
||||||
|
MÊME configuration que l'UI (per-app locales incluses) → indépendant de la
|
||||||
|
langue système et du réglage in-app FR/EN ;
|
||||||
|
- aucune assertion dépendante des données Room (listes/curbes) — les tests
|
||||||
|
restent verts sur émulateur vierge OU seedé (§16.ter).
|
||||||
|
|
||||||
|
⚠️ `assertExists`/`assertDoesNotExist` sont des MEMBRES de
|
||||||
|
`SemanticsNodeInteraction` depuis Compose 1.12 (plus des extensions à importer).
|
||||||
|
|
||||||
## 9. Système de rappels
|
## 9. Système de rappels
|
||||||
|
|
||||||
@ -1008,7 +1118,21 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod
|
|||||||
- `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity
|
- `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity
|
||||||
(`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes
|
(`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes
|
||||||
PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` ; **puis
|
PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()` ; **puis
|
||||||
reprogramme le prochain déclenchement** (goAsync + IO)
|
reprogramme le prochain déclenchement** (goAsync + IO).
|
||||||
|
**v1.10.0 — garde « créneau déjà honoré »** : au déclenchement,
|
||||||
|
`PharmacokineticEngine.hasDoseLoggedOnDay` (pur, jour calendaire LOCAL —
|
||||||
|
6 tests `ReminderLoggedDayTest`) consulte les doses : dose du jour déjà
|
||||||
|
loguée → **notification sautée** (prise saisie avant l'heure de rappel —
|
||||||
|
la notif ne sonne pas pour une prise faite) ; la re-programmation du
|
||||||
|
créneau SUIVANT reste exécutée dans tous les cas. Vérifié A/B sur
|
||||||
|
émulateur (`am broadcast -n …ReminderReceiver` : EEn avec dose du jour →
|
||||||
|
0 notif, Fluoxetine sans dose → 1 notif).
|
||||||
|
**v1.10.1 — instant PLANIFIÉ (`EXTRA_PLANNED_AT`)** : le garde évalue le
|
||||||
|
jour du CRÉNEAU PLANIFIÉ (embarqué dans l'intent de l'alarme par
|
||||||
|
`scheduleFor`, forwardé tel quel par le snooze), pas le jour du feu
|
||||||
|
réel — une alarme inexacte (`setWindow`) ou retardée (Doze) glissant
|
||||||
|
après minuit évaluait le mauvais jour (23:58 → 00:02). Vérifié émulateur
|
||||||
|
(broadcast avec `planned_at` daté d'hier → notification sautée).
|
||||||
- `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert
|
- `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert
|
||||||
DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance
|
DoseLog (dose = extra ou standard) **+ reprogramme** (la dose loguée avance
|
||||||
la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif
|
la grille) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif
|
||||||
@ -1035,6 +1159,11 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
|
|||||||
|
|
||||||
### 9.bis Alertes de seuil : `reminder/AlertNotifier.kt` + worker (v1.4.2)
|
### 9.bis Alertes de seuil : `reminder/AlertNotifier.kt` + worker (v1.4.2)
|
||||||
|
|
||||||
|
> ⚠️ À NE PAS CONFONDRE avec la **cible de creux** (v1.13.0, §7.11) : les
|
||||||
|
> seuils ci-dessous surveillent le niveau estimé EN CONTINU et notifient ;
|
||||||
|
> la cible de creux est une référence de lab évaluée en lecture seule sur
|
||||||
|
> la carte reco — elle ne notifie jamais.
|
||||||
|
|
||||||
- **Seuils** (`pk/Alerts.kt`, PUR) : `Thresholds` (E2 haut/bas pg/mL, T haut/bas
|
- **Seuils** (`pk/Alerts.kt`, PUR) : `Thresholds` (E2 haut/bas pg/mL, T haut/bas
|
||||||
ng/mL, null = pas de limite), évaluation STRICTE (valeur == limite → rien),
|
ng/mL, null = pas de limite), évaluation STRICTE (valeur == limite → rien),
|
||||||
`evaluateAll` (E2 puis T), validation haut > bas (`isCoherent`).
|
`evaluateAll` (E2 puis T), validation haut > bas (`isCoherent`).
|
||||||
@ -1099,7 +1228,11 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
|
|||||||
- `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
|
- `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
|
||||||
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
|
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
|
||||||
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** ;
|
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** ;
|
||||||
**en-tête « temps sous THS »** (v1.3.1) : carte depuis la 1re prise, jours
|
**v1.12.0** : **icône de forme de prise** en tête de ligne (`routeIcon` pur —
|
||||||
|
pilule/seringue IM+SC teinte tertiary pour SC/goutte/sparadrap/compte-gouttes)
|
||||||
|
et **encart latéral « prise de sang le même jour »** (`labMarkersForDose` pur —
|
||||||
|
🧪↑ avant la dose, 🧪↓ après ; jour calendaire LOCAL, lab à l'heure exacte =
|
||||||
|
APRÈS) ; **en-tête « temps sous THS »** (v1.3.1) : carte depuis la 1re prise, jours
|
||||||
totaux + décomposition mois/jours (`HrtDuration`, mois = 30 j estimés)
|
totaux + décomposition mois/jours (`HrtDuration`, mois = 30 j estimés)
|
||||||
- `LabsScreen` (v1.2.2) : **prise de sang E2 + T en une entrée** (LabDialog create :
|
- `LabsScreen` (v1.2.2) : **prise de sang E2 + T en une entrée** (LabDialog create :
|
||||||
deux sections optionnelles → 1 ou 2 LabResult au même timestamp) ; affichage
|
deux sections optionnelles → 1 ou 2 LabResult au même timestamp) ; affichage
|
||||||
@ -1109,7 +1242,12 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
|
|||||||
les valeurs — choix de design : les labs sont prélevés ensemble) ; suggestions
|
les valeurs — choix de design : les labs sont prélevés ensemble) ; suggestions
|
||||||
d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/L
|
d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/L
|
||||||
- `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰,
|
- `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰,
|
||||||
badge inactif), FAB → éditeur
|
badge inactif), FAB → éditeur. **v1.11.0** : ordre d'affichage =
|
||||||
|
`treatmentsForDisplay` (pur, tri STABLE — actifs d'abord, inactifs
|
||||||
|
regroupés TOUT EN BAS sous un en-tête `inactive_section` qui n'apparaît
|
||||||
|
que s'il existe des inactifs) + cartes inactives **atténuées** (alpha
|
||||||
|
0,55) — 5 tests `TreatmentsDisplayTest` ; miroir web
|
||||||
|
(`js/data/models.js` + `tests/treatments-display.test.js`)
|
||||||
- `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ;
|
- `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ;
|
||||||
champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration
|
champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration
|
||||||
(scaleFactor + « Calibrer avec les analyses ») ; **section « Posologie » (v1.2 :
|
(scaleFactor + « Calibrer avec les analyses ») ; **section « Posologie » (v1.2 :
|
||||||
@ -1127,8 +1265,19 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
|
|||||||
`ChangelogHelper.sectionsSince`, asset `changelog.md` synchronisé par la
|
`ChangelogHelper.sectionsSince`, asset `changelog.md` synchronisé par la
|
||||||
tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ;
|
tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ;
|
||||||
fermable, ne réapparaît pas avant la prochaine mise à jour.
|
fermable, ne réapparaît pas avant la prochaine mise à jour.
|
||||||
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ;
|
- `SettingsScreen` (**v1.9.8 : découpé en cartes thématiques** — 840 lignes
|
||||||
**carte « Seuils d'alerte » (v1.4.2)** : 4 champs optionnels (E2 haut/bas pg/mL,
|
→ orchestrateur de 377 + 11 fichiers de 33–164 lignes dans le sous-package
|
||||||
|
`ui/screens/settings/`, un par carte : `LanguageCard`, `AutoCalibrationCard`,
|
||||||
|
`TModelCard`, `AlertThresholdsCard`, `ChartTimezoneCard`, `RemindersCard`,
|
||||||
|
`BackupCard`, `AutoBackupCard`, `LogsCard`, `AboutCard` + `SettingsFormat`
|
||||||
|
(helpers `formatDoubles`/`folderDisplayName`). L'orchestrateur garde ce qui
|
||||||
|
est PARTAGÉ : collecte DataStore, les 4 launchers SAF (leurs callbacks
|
||||||
|
écrivent `message`/dialog d'import), dialog de confirmation d'import,
|
||||||
|
ordre des cartes. Comportement et rendu IDENTIQUES (déplacement pur avec
|
||||||
|
les commentaires historiques — validé par les tests UI §8.ter + smoke
|
||||||
|
release §16.ter) ; contenu fonctionnel inchangé : langue
|
||||||
|
(Système/Français/English, chips reflétant l'état) ; **carte « Seuils
|
||||||
|
d'alerte » (v1.4.2)** : 4 champs optionnels (E2 haut/bas pg/mL,
|
||||||
T haut/bas ng/mL, vide = pas d'alerte), validation haut > bas (refus + message
|
T haut/bas ng/mL, vide = pas d'alerte), validation haut > bas (refus + message
|
||||||
rouge), Save → persiste + **check one-time immédiat** (`AlertNotifier.checkNow`) ;
|
rouge), Save → persiste + **check one-time immédiat** (`AlertNotifier.checkNow`) ;
|
||||||
**version installée + lien cliquable vers les releases Gitea** (v1.3.0,
|
**version installée + lien cliquable vers les releases Gitea** (v1.3.0,
|
||||||
@ -1136,7 +1285,12 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
|
|||||||
**« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T,
|
**« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T,
|
||||||
v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton
|
v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton
|
||||||
« Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ;
|
« Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ;
|
||||||
Export/Import JSON ; à propos
|
Export/Import JSON ; auto-backup (v1.7.0) ; **carte « Cible de creux » (v1.13.0, opt-in — référence de lab distincte des seuils d'alerte, hint croisé)** ; logs ; à propos.
|
||||||
|
⚠️ **`ChartScreen` (790 lignes) reste volontairement ENTIER** : sa garde de
|
||||||
|
source (`ChartScreenSourceGuardTest`, fix #54) exige que le bloc
|
||||||
|
`detectTransformGestures` ET les alias `gesture*` vivent dans CE fichier —
|
||||||
|
le découper reviendrait à réécrire la garde et ré-exposer les captures
|
||||||
|
figées de pointerInput(Unit). À reconsidérer si une refonte chart a lieu.
|
||||||
- Composants : `CurveChart` (§11), `DateTimeField` (DatePicker+TimePicker Material3,
|
- Composants : `CurveChart` (§11), `DateTimeField` (DatePicker+TimePicker Material3,
|
||||||
LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester),
|
LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester),
|
||||||
`LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`)
|
`LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`)
|
||||||
@ -1224,7 +1378,10 @@ Pièges :
|
|||||||
restauré dans DataStore, **les paramètres utilisateur sont restaurés**
|
restauré dans DataStore, **les paramètres utilisateur sont restaurés**
|
||||||
(v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est
|
(v1.4.2 : auto-calibration, seuils d'alerte, langue — la LANGUE est
|
||||||
appliquée sur le thread MAIN car `setApplicationLocales` recrée
|
appliquée sur le thread MAIN car `setApplicationLocales` recrée
|
||||||
l'activité) et les rappels sont reprogrammés (`rescheduleAll`). Le dialog prévient que TOUT sera
|
l'activité ; **v1.13.0 : cible de creux** `troughTargetLow`/`High`
|
||||||
|
restaurée SEULEMENT si le backup en porte une cohérente (haut > bas) —
|
||||||
|
un backup ancien n'efface pas la cible locale) et les rappels sont
|
||||||
|
reprogrammés (`rescheduleAll`). Le dialog prévient que TOUT sera
|
||||||
remplacé (bouton « Effacer & restaurer »)
|
remplacé (bouton « Effacer & restaurer »)
|
||||||
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
|
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
|
||||||
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
|
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
|
||||||
@ -1702,7 +1859,7 @@ l'optimiseur.
|
|||||||
le chip « Lab track » s'active MAIS la courbe n'est ni dessinée (0 pixel
|
le chip « Lab track » s'active MAIS la courbe n'est ni dessinée (0 pixel
|
||||||
#C2185B au screencap) ni légendée ; « Prolonger » idem. En DEBUG, le même
|
#C2185B au screencap) ni légendée ; « Prolonger » idem. En DEBUG, le même
|
||||||
chip sur les mêmes données fonctionne (légende + 7090 px rose après fix).
|
chip sur les mêmes données fonctionne (légende + 7090 px rose après fix).
|
||||||
**Les 191 tests JVM de l'époque sont verts** (223 aujourd'hui — ils ne passent JAMAIS par R8).
|
**Les 191 tests JVM de l'époque sont verts** (259 aujourd'hui — ils ne passent JAMAIS par R8).
|
||||||
|
|
||||||
**Diagnostic** (méthode de bisection debug/release puis `mapping.txt`) :
|
**Diagnostic** (méthode de bisection debug/release puis `mapping.txt`) :
|
||||||
`com.hormonetrack.pk.LabTrajectoryModel -> R8$$REMOVED$$CLASS$$…` — R8 full
|
`com.hormonetrack.pk.LabTrajectoryModel -> R8$$REMOVED$$CLASS$$…` — R8 full
|
||||||
@ -1724,6 +1881,53 @@ valeur d'un lab ne rafraîchissait pas les courbes avant le tick de 60 s. Leçon
|
|||||||
les clés d'un producteur d'état Compose doivent couvrir TOUTE entrée du calcul (pas
|
les clés d'un producteur d'état Compose doivent couvrir TOUTE entrée du calcul (pas
|
||||||
un proxy comme `.size`).
|
un proxy comme `.size`).
|
||||||
|
|
||||||
|
**#67 (v1.9.5) — ticks X alignés sur le mauvais fuseau + dérive DST** : `drawXLabels`
|
||||||
|
formatait les labels dans le fuseau CHOISI mais appelait `xLabelTicks(t0, t1, step)`
|
||||||
|
sans lui passer la zone — l'alignement « minuit local » (fix #55) se faisait sur le
|
||||||
|
fuseau de l'APPAREIL (heures décalées dès que les deux divergent). Accessoirement le
|
||||||
|
pas avançait en ms fixes (`t += stepMs`) : dérive d'1 h aux transitions DST. Leçon :
|
||||||
|
quand un helper prend un fuseau pour FORMATTER, la même zone doit régler
|
||||||
|
l'ALIGNEMENT — et un pas « 24 h » s'ajoute via Calendar (minuit LOCAL), pas en ms.
|
||||||
|
|
||||||
|
**#68 (v1.10.0) — recommandation de prise de sang antérieure à la stabilisation** :
|
||||||
|
le « creux » était le minimum de la FENÊTRE ENTIÈRE (créneau N−1 → créneau N) — pour
|
||||||
|
un ester à montée lente (EEn : pic ~J+5 ≈ intervalle 7 j), ce minimum tombe quelques
|
||||||
|
heures après l'injection PRÉCÉDENTE (creux d'absorption) : la date proposée était
|
||||||
|
gouvernée par l'injection N−1 (antérieure à la stabilisation) et le texte « juste
|
||||||
|
avant ton injection du … » était faux de plusieurs jours. Réel : creux 5 oct 02:32
|
||||||
|
vs « pas stabilisée avant » 7 oct 04:03. FIX : creux = niveau PRÉ-INJECTION du
|
||||||
|
créneau (restaure le principe v1.8.0 ; pour EV le point pré-injection EST le min de
|
||||||
|
fenêtre → inchangé). Leçon : quand une valeur affichée est QUALIFIÉE par un texte
|
||||||
|
(« juste avant l'injection »), le calcul doit produire CETTE valeur — et un
|
||||||
|
minimum de fenêtre n'est pas un creux pré-injection si la courbe remonte dans
|
||||||
|
l'intervalle. Épinglé par RegressionUserCase6Test (balayage de `now` ±60 j :
|
||||||
|
l'invariant doit tenir à tout instant, pas seulement à l'instant du rapport).
|
||||||
|
|
||||||
|
**#69 (v1.10.0) — dialog de dose fantôme après une notification** : tap
|
||||||
|
notification → dialog de saisie ; le fermer puis changer d'onglet et revenir sur
|
||||||
|
l'accueil le ROUVRAIT à chaque fois. Cause : la demande vivait au niveau ACTIVITÉ
|
||||||
|
(paramètre d'intent) tandis que la consommation vivait dans un LaunchedEffect de
|
||||||
|
Home — quitter l'onglet détruit les `remember` locaux, le paramètre survivait →
|
||||||
|
chaque ré-entrée en composition rouvrait. FIX : `MutableState<Long?>` détenu par
|
||||||
|
MainActivity, consommé UNE fois ; extras retirés de l'intent après lecture (un
|
||||||
|
recreate ne rouvre pas une notification traitée) ; bonus : tap notification
|
||||||
|
app-ouverte ouvre désormais aussi le dialog. Leçon : une demande à consommer une
|
||||||
|
fois doit vivre à la MÊME durée de vie que sa consommation — jamais « paramètre
|
||||||
|
longue-vie + effet courte-vie ». Épinglé par le test UI LogDoseRequestTest.
|
||||||
|
|
||||||
|
**#70 (v1.12.0) — inactif à Posologie projetait encore ses injections** :
|
||||||
|
`generateForecastDoses` (source unique des créneaux prévisionnels) ne vérifiait
|
||||||
|
pas l'archivage — un injectable INACTIF avec Posologie et de l'historique
|
||||||
|
continuait d'alimenter le graphique Prévision, l'extension de fenêtre et
|
||||||
|
l'horizon LabTiming ; accessoirement le chip Prévision s'activait sur des
|
||||||
|
Posologies inactives. FIX : garde `!isActive` dans le moteur + horizon/chip sur
|
||||||
|
les ACTIFS. Cohérence d'ensemble (§6.bis) : l'INACTIF garde son historique
|
||||||
|
simulé/calibré mais ne projette plus rien — exactement comme ses rappels
|
||||||
|
(v1.4.0). Leçon : quand un état administratif gouverne DÉJÀ une consommation
|
||||||
|
d'une source (rappels), vérifier les AUTRES consommateurs de la même source.
|
||||||
|
Épinglé par ForecastDosesGuardTest (3 tests : inactif vide, actif inchangé,
|
||||||
|
avant/après archivage).
|
||||||
|
|
||||||
## 15.bis Comment régénérer l'asset mcmc_samples.json (v1.9.0)
|
## 15.bis Comment régénérer l'asset mcmc_samples.json (v1.9.0)
|
||||||
|
|
||||||
L'asset `src/main/assets/mcmc_samples.json` (posterior MCMC de
|
L'asset `src/main/assets/mcmc_samples.json` (posterior MCMC de
|
||||||
@ -1761,8 +1965,9 @@ structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/projects/HormoneTrack
|
cd ~/projects/HormoneTrack
|
||||||
./gradlew assembleDebug testDebugUnitTest # build + 223 tests (193 sans données locales)
|
./gradlew assembleDebug testDebugUnitTest # build + 259 tests (225 sans données locales)
|
||||||
./gradlew assembleRelease # APK optimisé R8 (cf §16.bis)
|
./gradlew assembleRelease # APK optimisé R8 (cf §16.bis ; signature cf §16.quater)
|
||||||
|
./gradlew connectedDebugAndroidTest # 14 tests UI Compose (émulateur requis, §8.ter)
|
||||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -1847,27 +2052,66 @@ Le script :
|
|||||||
scope requis : `write:repository` (suffit pour releases + assets, pas pour
|
scope requis : `write:repository` (suffit pour releases + assets, pas pour
|
||||||
créer un repo — cf plus haut).
|
créer un repo — cf plus haut).
|
||||||
|
|
||||||
**Build release (v1.2.5)** : `./gradlew assembleRelease` — R8 full mode +
|
**Build release (v1.2.5, signature conditionnelle depuis v1.9.8)** :
|
||||||
`isShrinkResources` + signé avec la **clé debug** (même signature que les APK
|
`./gradlew assembleRelease` — R8 full mode + `isShrinkResources`
|
||||||
debug distribués → mise à jour par-dessus sans perte de données ; 20 Mo →
|
(20 Mo → 2,7 Mo). **Signature** : si `keystore.properties` existe à la racine
|
||||||
2,4 Mo). Garde-fous dans `proguard-rules.pro` : Gson lit les champs des
|
du dépôt (gitignoré, créé par `scripts/make-release-keystore.sh` — cf §16.quater),
|
||||||
modèles par réflexion → `-keep` explicites sur `data.model.**`,
|
la build release utilise CE keystore ; sinon elle reste signée avec la **clé
|
||||||
`BackupData`, `TConfig` (+ attributs `Signature`) — sinon l'export/import
|
debug** (même signature que les APK debug distribués → mise à jour par-dessus
|
||||||
JSON casse UNIQUEMENT en release. ⚠️ À chaque activation d'optimisation :
|
sans perte de données — comportement historique). Garde-fous dans
|
||||||
tester sur téléphone l'export/import de backup + les graphiques (R8 ne se
|
`proguard-rules.pro` : Gson lit les champs des modèles par réflexion →
|
||||||
vérifie pas en tests JVM).
|
`-keep` explicites sur `data.model.**`, `BackupData`, `TConfig` (+ attributs
|
||||||
|
`Signature`) — sinon l'export/import JSON casse UNIQUEMENT en release.
|
||||||
|
⚠️ À chaque activation d'optimisation : tester sur téléphone l'export/import
|
||||||
|
de backup + les graphiques (R8 ne se vérifie pas en tests JVM).
|
||||||
|
|
||||||
|
### 16.quater Signature release : keystore dédié (v1.9.8)
|
||||||
|
|
||||||
|
Historique : les APK release étaient signés avec la **clé debug** (choix
|
||||||
|
v1.2.5 — installer par-dessus l'APK debug sans perte de données). Le jour où
|
||||||
|
l'app est distribuée plus largement (dépôt public, F-Droid…), une vraie clé
|
||||||
|
de signature est requise — elle est désormais câblée :
|
||||||
|
|
||||||
|
- **Création (une fois)** : `scripts/make-release-keystore.sh` — génère
|
||||||
|
`release.keystore` (RSA 4096, alias `hormonetrack`, validité 10 000 j) +
|
||||||
|
`keystore.properties` à la racine, TOUS DEUX GITIGNORÉS (`.gitignore` :
|
||||||
|
`keystore.properties`, `*.keystore`, `*.jks`). Le script refuse
|
||||||
|
d'écraser une paire existante (une régénération = perte de la capacité
|
||||||
|
de mise à jour des installs existantes). Mots de passe saisis
|
||||||
|
interactivement (jamais en argument, jamais committés) ;
|
||||||
|
- **Effet** : dès que `keystore.properties` existe, `assembleRelease` signe
|
||||||
|
avec ce keystore (détection dans `app/build.gradle.kts`) ; sans le
|
||||||
|
fichier, fallback clé debug inchangé ;
|
||||||
|
- **⚠️ MIGRATION téléphone obligatoire** (signature ≠ clé debug ⇒ Android
|
||||||
|
refuse la mise à jour par-dessus) :
|
||||||
|
1. exporter le JSON (Paramètres → Sauvegarde → Exporter, ou auto-backup) ;
|
||||||
|
2. désinstaller l'app ;
|
||||||
|
3. installer l'APK release signé par le nouveau keystore ;
|
||||||
|
4. réimporter le JSON. La base Room ne voyage QUE par le JSON ;
|
||||||
|
- **Sauvegarde du keystore** : le copier hors du dépôt (coffre, second
|
||||||
|
disque) — sa perte = même migration subie par TOUS les utilisateurs à la
|
||||||
|
version suivante ;
|
||||||
|
- **Checklist avant release publiée avec la nouvelle clé** : build release →
|
||||||
|
`apksigner verify --print-certs` (l'empreinte doit être celle du NOUVEAU
|
||||||
|
keystore) → smoke test émulateur §16.ter → publication + note de migration
|
||||||
|
dans le corps de la release Gitea.
|
||||||
|
|
||||||
### Checklist de déploiement complète (de A à Z, pour une session sans contexte)
|
### Checklist de déploiement complète (de A à Z, pour une session sans contexte)
|
||||||
|
|
||||||
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
|
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
|
||||||
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
|
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
|
||||||
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 223 au
|
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 284 au
|
||||||
total, 193 si `local-test-data/` est absent (les 5 classes de régression
|
total, 250 si `local-test-data/` est absent (les 6 classes de régression
|
||||||
réelles sont skippées via `Assume`, 6/6/6/5/7 tests) ; **lint vert
|
réelles sont skippées via `Assume`) ; **lint vert
|
||||||
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
|
obligatoire** : `./gradlew lint` (v1.3.4 — `StringFormatMatches` aurait
|
||||||
attrapé les crashs #47/#48 dès v1.3.1). Paliers de test du projet :
|
attrapé les crashs #47/#48 dès v1.3.1).
|
||||||
chaque commit = tests + lint ; release = + scan confidentialité + APK au
|
**v1.9.8+ — tests UI obligatoires** : `./gradlew connectedDebugAndroidTest`
|
||||||
tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix.
|
(14 tests §8.ter — émulateur requis).
|
||||||
|
**v1.10.0+ — miroir web** : si la session touche une logique miroirée
|
||||||
|
(lab-timing, moteur…), `bash scripts/check.sh` dans `~/projects/HormoneTrack-web`
|
||||||
|
doit être vert (180 tests + E2E) AVANT le tag. Paliers de test du projet :
|
||||||
|
chaque commit = tests + lint ; release = + tests UI + scan confidentialité
|
||||||
|
+ APK au tag ; crash reporté = reproduction émulateur (§16.ter) AVANT tout fix.
|
||||||
3. **Docs** : section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md` (le corps des
|
3. **Docs** : section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md` (le corps des
|
||||||
releases Gitea en sera extrait automatiquement par le script), + §14 si bug
|
releases Gitea en sera extrait automatiquement par le script), + §14 si bug
|
||||||
corrigé, + §2 (historique) si notable.
|
corrigé, + §2 (historique) si notable.
|
||||||
@ -1877,12 +2121,13 @@ vérifie pas en tests JVM).
|
|||||||
headless hrt36 + seed `scripts/seed-emulator.py` avec le dernier
|
headless hrt36 + seed `scripts/seed-emulator.py` avec le dernier
|
||||||
backup de `local-test-data/` + APK release installé PAR-DESSUS) sur
|
backup de `local-test-data/` + APK release installé PAR-DESSUS) sur
|
||||||
**l'APK RELEASE** (JAMAIS le debug seul : **R8 ne se vérifie qu'ici** —
|
**l'APK RELEASE** (JAMAIS le debug seul : **R8 ne se vérifie qu'ici** —
|
||||||
les 223 tests JVM ne le voient jamais, cf #64). Parcours minimum :
|
les 259 tests JVM ne le voient jamais, cf #64). Parcours minimum :
|
||||||
l'app démarre 0 crash → écran Graphiques → activer les chips de la
|
l'app démarre 0 crash → écran Graphiques → activer les chips de la
|
||||||
release (rangée modèles défilable : `adb shell input swipe 900 716 150
|
release (rangée modèles défilable : `adb shell input swipe 900 716 150
|
||||||
716 400` puis tap aux bounds du `uiautomator dump`) → vérifier dans le
|
716 400` puis tap aux bounds du `uiautomator dump`) → vérifier dans le
|
||||||
dump `text=` que la légende attendue apparaît → `adb logcat -b crash
|
dump `text=` que la légende attendue apparaît → `adb logcat -b crash
|
||||||
-d | grep -c FATAL` reste à 0. Limites : le pan/pinch n'est PAS
|
-d | grep -c FATAL` reste à 0 (les **259** tests JVM ne voient jamais
|
||||||
|
R8, cf #64). Limites : le pan/pinch n'est PAS
|
||||||
testable par adb (§16.ter 4.bis) — le geste reste validé par
|
testable par adb (§16.ter 4.bis) — le geste reste validé par
|
||||||
l'utilisatrice sur Pixel 9.
|
l'utilisatrice sur Pixel 9.
|
||||||
4. **Commit** (message descriptif par couche) + **tag annoté** :
|
4. **Commit** (message descriptif par couche) + **tag annoté** :
|
||||||
@ -1964,6 +2209,14 @@ adb shell run-as com.hormonetrack rm -f databases/hormonetrack.db-wal \
|
|||||||
databases/hormonetrack.db-shm # WAL périmé sinon ! (checkpoint implicite fait par le seed)
|
databases/hormonetrack.db-shm # WAL périmé sinon ! (checkpoint implicite fait par le seed)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
⚠️ **Piège WAL constaté à la v1.9.8** : après un `force-stop` (= SIGKILL, PAS de
|
||||||
|
checkpoint), le schéma entier peut vivre SEULEMENT dans le `-wal` — extraire la
|
||||||
|
seule `hormonetrack.db` donne un fichier de 4 Ko SANS tables (`no such table:
|
||||||
|
treatments` au seed). Solution : extraire `hormonetrack.db` **ET**
|
||||||
|
`hormonetrack.db-wal` CÔTE À CÔTE sous leurs noms d'origine (ex. `/tmp/hrtseed/`)
|
||||||
|
— sqlite rejoue le WAL à l'ouverture et le fusionne à la fermeture (le `-wal`
|
||||||
|
disparaît après le seed) ; on pousse alors la seule `.db` résultat.
|
||||||
|
|
||||||
Règles du seed (le script les impose) : colonnes = noms de propriétés Kotlin
|
Règles du seed (le script les impose) : colonnes = noms de propriétés Kotlin
|
||||||
(Room n'applique pas de snake_case), enums stockés en String — on insère
|
(Room n'applique pas de snake_case), enums stockés en String — on insère
|
||||||
SEULEMENT des lignes, on ne touche NI au schéma NI à `room_master_table`
|
SEULEMENT des lignes, on ne touche NI au schéma NI à `room_master_table`
|
||||||
@ -2055,7 +2308,13 @@ Volontaires (v1) :
|
|||||||
- **APK release (R8)** : la réflexion Gson est couverte par des `-keep`
|
- **APK release (R8)** : la réflexion Gson est couverte par des `-keep`
|
||||||
explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur
|
explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur
|
||||||
téléphone** (export/import backup, graphiques) avant chaque publication ;
|
téléphone** (export/import backup, graphiques) avant chaque publication ;
|
||||||
signé clé debug → upgradable sans perte, mais pas une signature « officielle »
|
signature : clé debug PAR DÉFAUT, keystore dédié dès que `keystore.properties`
|
||||||
|
existe (v1.9.8, §16.quater — migration backup→désinstall→réimport requise)
|
||||||
|
- **ChartScreen reste un fichier unique (790 lignes)** : la garde de source
|
||||||
|
#54 (ChartScreenSourceGuardTest) exige le bloc de gestes + les alias
|
||||||
|
`gesture*` dans CE fichier — découpage = réécriture de la garde + risque
|
||||||
|
de réintroduction des captures figées. SettingsScreen a été découpé
|
||||||
|
(v1.9.8, §10) ; le chart attend une refonte dédiée
|
||||||
- **Grille de rappel sous-quotidienne** (v1.4.0) : une Posologie < 1 j
|
- **Grille de rappel sous-quotidienne** (v1.4.0) : une Posologie < 1 j
|
||||||
(ex. 2 injections/jour) + UNE heure de rappel → l'app ne peut exprimer
|
(ex. 2 injections/jour) + UNE heure de rappel → l'app ne peut exprimer
|
||||||
qu'un rappel/jour à heure fixe les jours de créneau (épinglé par
|
qu'un rappel/jour à heure fixe les jours de créneau (épinglé par
|
||||||
@ -2068,22 +2327,27 @@ Volontaires (v1) :
|
|||||||
|
|
||||||
## 20. Idées d'évolution
|
## 20. Idées d'évolution
|
||||||
|
|
||||||
1. **Robolectric + tests Compose** (VM Android en JVM — pas besoin d'appareil)
|
1. **Robolectric** (VM Android en JVM — pas besoin d'appareil ; les tests
|
||||||
2. **Émulateur local** pour smoke-tests UI (sur demande, ~2–3 Go)
|
Compose instrumentés existent depuis v1.9.8, §8.ter)
|
||||||
|
2. **Étendre les tests UI** : dialogs dose/lab avec données seedées (§16.ter),
|
||||||
|
parcours complet (créer un traitement → loguer une dose → courbe visible) —
|
||||||
|
les tests actuels sont volontairement data-indépendants
|
||||||
3. Mode « planifier les injections » (schedule récurrent → pré-remplir le log)
|
3. Mode « planifier les injections » (schedule récurrent → pré-remplir le log)
|
||||||
4. Import JSON : détection de doublons / mode fusion optionnel (l'écrasement est fait, v1.2.6)
|
4. Import JSON : détection de doublons / mode fusion optionnel (l'écrasement est fait, v1.2.6)
|
||||||
5. Verrou biométrique (BiometricPrompt), widget, export CSV
|
5. Verrou biométrique (BiometricPrompt), widget, export CSV
|
||||||
6. Charts : tooltip au toucher (le pan est fait v1.2.0, **le zoom v1.2.9**) ;
|
6. Charts : tooltip au toucher (le pan est fait v1.2.0, **le zoom v1.2.9**) ;
|
||||||
MaterialExpressiveTheme quand l'API passera publique (cf §3)
|
MaterialExpressiveTheme quand l'API passera publique (cf §3)
|
||||||
7. **Vrai keystore de distribution** (signature dédiée ≠ clé debug → nécessite
|
7. ~~Vrai keystore de distribution~~ **FAIT v1.9.8** (infra complète, §16.quater :
|
||||||
une migration : backup → désinstallation → installation signée → réimport)
|
script + détection Gradle + migration documentée — la bascule réelle se
|
||||||
|
fera quand la distribution s'élargira)
|
||||||
|
|
||||||
Fait (à ne pas refaire) : pan du chart (v1.2.0), **zoom du chart (v1.2.9)**,
|
Fait (à ne pas refaire) : pan du chart (v1.2.0), **zoom du chart (v1.2.9)**,
|
||||||
pics/creux (v1.2.3), prévision par fréquence (v1.2.0), calibration par période
|
pics/creux (v1.2.3), prévision par fréquence (v1.2.0), calibration par période
|
||||||
d'ester E2 **et** T (v1.2.1/v1.2.3), édition doses (v1.1.0) et labs (v1.2.2),
|
d'ester E2 **et** T (v1.2.1/v1.2.3), édition doses (v1.1.0) et labs (v1.2.2),
|
||||||
E2+T en une entrée (v1.2.2), migration Room v1→v2 sans fallback destructif
|
E2+T en une entrée (v1.2.2), migration Room v1→v2 sans fallback destructif
|
||||||
(v1.2.0), rooms v2→v3 (calendarEventId, v1.3.0), dépôt Gitea + releases APK
|
(v1.2.0), rooms v2→v3 (calendarEventId, v1.3.0), dépôt Gitea + releases APK
|
||||||
(push session).
|
(push session), **tests UI Compose + signature release dédiée + découpage
|
||||||
|
Settings (v1.9.8)**, **README anglais (v1.9.8)**.
|
||||||
8. Phase 2 montre : watchface `.hwt` custom, puis mini-app Lite Wearable (cf §17)
|
8. Phase 2 montre : watchface `.hwt` custom, puis mini-app Lite Wearable (cf §17)
|
||||||
|
|
||||||
### 20.bis Pistes d'optimisation écartées ou différées (audit v1.8.2)
|
### 20.bis Pistes d'optimisation écartées ou différées (audit v1.8.2)
|
||||||
@ -2096,10 +2360,9 @@ pour ne pas les redécouvrir et re-reposer les mêmes questions :
|
|||||||
Room par ligne. `@Insert` en liste préserverait l'ordre (IDs) et
|
Room par ligne. `@Insert` en liste préserverait l'ordre (IDs) et
|
||||||
accélérerait l'import d'un gros historique. Différé : surface DAO +
|
accélérerait l'import d'un gros historique. Différé : surface DAO +
|
||||||
repository à modifier, validation manuelle (aucun test Room auto).
|
repository à modifier, validation manuelle (aucun test Room auto).
|
||||||
2. **`BootReceiver` : consolidation `goAsync` + une seule lecture** :
|
2. ~~**`BootReceiver` : consolidation `goAsync` + une seule lecture`~~**
|
||||||
les deux `runBlocking` séquentiels peuvent fusionner trivialement ; le
|
**FAIT v1.10.1** (pattern `goAsync`+coroutine de ReminderReceiver,
|
||||||
passage complet au pattern `goAsync`+coroutine de ReminderReceiver est
|
`runBlocking` supprimés du fichier).
|
||||||
équivalent mais re-testé sur émulateur (boot). Différé.
|
|
||||||
3. **Double évaluation de `generateForecastDoses`** dans ChartScreen
|
3. **Double évaluation de `generateForecastDoses`** dans ChartScreen
|
||||||
(producer `forecastDoses` vs producer `curves`) : NE PAS fusionner tel
|
(producer `forecastDoses` vs producer `curves`) : NE PAS fusionner tel
|
||||||
quel — le premier utilise `System.currentTimeMillis()`, le second le
|
quel — le premier utilise `System.currentTimeMillis()`, le second le
|
||||||
@ -2130,6 +2393,22 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
|
|||||||
- [ ] Redémarrer le téléphone → rappel reprogrammé (BootReceiver)
|
- [ ] Redémarrer le téléphone → rappel reprogrammé (BootReceiver)
|
||||||
- [ ] Désactiver un rappel → plus de notif (cancel — cf §14.3)
|
- [ ] Désactiver un rappel → plus de notif (cancel — cf §14.3)
|
||||||
- [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2)
|
- [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2)
|
||||||
|
- [ ] **v1.10.0** : carte « Prochaine prise de sang » — la date du creux est
|
||||||
|
POSTÉRIEURE à la mention « pas stabilisée avant le … » (fix #68), et
|
||||||
|
cohérente avec le créneau affiché (~juste avant)
|
||||||
|
- [ ] **v1.10.0** : logger la dose du jour AVANT l'heure de rappel → la
|
||||||
|
notification ne sonne pas ; le rappel suivant repart au créneau suivant
|
||||||
|
- [ ] **v1.11.0** : page Traitements — les inactifs sont TOUT EN BAS sous
|
||||||
|
l'en-tête « Traitements inactifs », cartes atténuées ; éditer un
|
||||||
|
inactif fonctionne toujours
|
||||||
|
- [ ] **v1.12.0** : archiver un injectable à Posologie → plus AUCUN créneau
|
||||||
|
projeté (chip Prévision : ses injections disparaissent de la projection)
|
||||||
|
- [ ] **v1.12.0** : page Doses — chaque ligne porte l'icône de sa forme
|
||||||
|
(pilule/seringue/goutte/sparadrap) ; une dose du jour d'une prise de
|
||||||
|
sang affiche l'encart 🧪↑ et/ou 🧪↓
|
||||||
|
- [ ] **v1.13.0** : auto-calibration ON → la carte reco affiche « ≈ X pg/mL » ;
|
||||||
|
régler une Cible de creux (Paramètres) → la prédiction est colorée
|
||||||
|
dans/hors cible (jamais de notification — cf §7.11/§9.bis)
|
||||||
- [ ] **v1.2.x** : graphique panoramique (glisser → passé, bouton « Revenir à maintenant »)
|
- [ ] **v1.2.x** : graphique panoramique (glisser → passé, bouton « Revenir à maintenant »)
|
||||||
- [ ] **v1.2.x** : toggles Estrannaise/TFS indépendants (les deux courbes superposées)
|
- [ ] **v1.2.x** : toggles Estrannaise/TFS indépendants (les deux courbes superposées)
|
||||||
- [ ] **v1.2.x** : chip « Prévision » (configurer la Posologie d'un traitement d'abord)
|
- [ ] **v1.2.x** : chip « Prévision » (configurer la Posologie d'un traitement d'abord)
|
||||||
@ -2303,5 +2582,5 @@ maintenance (permissions agenda, export logs IO, bump de version) ; v1.3.4 = 2 c
|
|||||||
reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint
|
reproduits sur émulateur (#47 stringResource arity, #48 LocalDate+pattern horaire), lint
|
||||||
filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51
|
filet bloquant ; v1.3.5 = diagnostic à distance via les logs exportés (#50/#50bis/#51
|
||||||
agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité
|
agenda) ; v1.4.0 = modèle TFS V3C sur la méta-analyse officielle (7 esters, fidélité
|
||||||
~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé) ; v1.4.4 = prévision réparée (captures figées #54 + extension de fenêtre) ; v1.4.5 = marqueurs de doses + minuit local + fuseau configurable (#55) + boucle de recomposition tuée (#56) ; v1.4.6 = 3ᵉ modèle PK WHSAH (fit Mona, superposable) + scroll du graphique (#57). Dette connue : fragments de
|
~1 % épinglée) + rappels sur la grille Posologie (#52) ; v1.4.1 = prévision étendue sans saut (scroll futur, horizon 1 an) + delta en jours sur l'accueil ; v1.4.2 = seuils d'alerte configurables (carte accueil + notification WorkManager 15 min + anti-spam) et backup JSON v2 avec paramètres ; v1.4.3 = fix du delta 6 h (#53) + régression n°4 (plateau EEn épinglé) ; v1.4.4 = prévision réparée (captures figées #54 + extension de fenêtre) ; v1.4.5 = marqueurs de doses + minuit local + fuseau configurable (#55) + boucle de recomposition tuée (#56) ; v1.4.6 = 3ᵉ modèle PK WHSAH (fit Mona, superposable) + scroll du graphique (#57) ; v1.10.0 = creux pré-injection dans la reco (#68), dialog fantôme tué (#69), rappel sauté si dose déjà loggée. Dette connue : fragments de
|
||||||
labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.*
|
labs dans l'historique git (v1.1.0→v1.2.3) — cf §8.bis.*
|
||||||
|
|||||||
@ -105,7 +105,11 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
|
|||||||
- Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods`
|
- Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods`
|
||||||
- Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo)
|
- Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo)
|
||||||
2. **Doses → +** → logue tes injections passées
|
2. **Doses → +** → logue tes injections passées
|
||||||
- L'accueil affiche la **prochaine dose en jours** quand elle est à plus de 24 h
|
- L'accueil liste **toutes les prochaines doses** (v1.9.6) : une ligne
|
||||||
|
par traitement, triées par prochaine prise (la plus proche en premier,
|
||||||
|
en gras) — une prise quotidienne ne masque plus le rappel de
|
||||||
|
l'injection hebdomadaire. Affichage en **jours** quand la prochaine
|
||||||
|
prise est à plus de 24 h
|
||||||
(ex. « 5 j 2 h · sam. 6 18:00 »), en heures/minutes sinon
|
(ex. « 5 j 2 h · sam. 6 18:00 »), en heures/minutes sinon
|
||||||
- La carte « Niveau actuel » compare ton taux estimé **à il y a 6 h**
|
- La carte « Niveau actuel » compare ton taux estimé **à il y a 6 h**
|
||||||
(flèche ↗/↘) — autour d'un ester lent comme l'énanthate, cette variation
|
(flèche ↗/↘) — autour d'un ester lent comme l'énanthate, cette variation
|
||||||
|
|||||||
68
scripts/make-release-keystore.sh
Executable file
68
scripts/make-release-keystore.sh
Executable file
@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Génération du KEYSTORE de signature RELEASE + keystore.properties (v1.9.8).
|
||||||
|
#
|
||||||
|
# Les deux fichiers sont écrits à la RACINE du dépôt et sont GITIGNORÉS
|
||||||
|
# (.gitignore) : ne JAMAIS les committer — le keystore et ses mots de passe
|
||||||
|
# sont l'identité de l'app (une fuite = n'importe qui peut publier un APK qui
|
||||||
|
# se met à jour par-dessus le tien).
|
||||||
|
#
|
||||||
|
# Usage :
|
||||||
|
# scripts/make-release-keystore.sh
|
||||||
|
#
|
||||||
|
# Après génération, `./gradlew assembleRelease` signe automatiquement avec ce
|
||||||
|
# keystore (app/build.gradle.kts détecte keystore.properties).
|
||||||
|
#
|
||||||
|
# ⚠️ MIGRATION sur le téléphone (signature différente de la clé debug) :
|
||||||
|
# 1. Paramètres → Sauvegarde → Exporter (JSON) — OU auto-backup existant ;
|
||||||
|
# 2. désinstaller l'app (debug-signée) ;
|
||||||
|
# 3. installer l'APK release signé par le nouveau keystore ;
|
||||||
|
# 4. Paramètres → Sauvegarde → Importer.
|
||||||
|
# La base Room n'est PAS convertible entre signatures : seul le JSON voyage.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
KEYSTORE_FILE="release.keystore"
|
||||||
|
PROPERTIES_FILE="keystore.properties"
|
||||||
|
|
||||||
|
if [[ -f "$PROPERTIES_FILE" || -f "$KEYSTORE_FILE" ]]; then
|
||||||
|
echo "ERREUR : $PROPERTIES_FILE et/ou $KEYSTORE_FILE existent déjà." >&2
|
||||||
|
echo "Supprime-les d'abord si tu veux vraiment les régénérer (une régénération" >&2
|
||||||
|
echo "= perte de la capacité de mettre à jour les installs existantes)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -r -p "Mot de passe du keystore (>= 6 caractères) : " -s STORE_PASS
|
||||||
|
echo
|
||||||
|
read -r -p "Confirmation : " -s STORE_PASS_CONFIRM
|
||||||
|
echo
|
||||||
|
if [[ "$STORE_PASS" != "$STORE_PASS_CONFIRM" || ${#STORE_PASS} -lt 6 ]]; then
|
||||||
|
echo "ERREUR : les mots de passe ne correspondent pas ou font moins de 6 caractères." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# keyPassword = storePassword ici (un seul secret à retenir) ; keytool exige >= 6 car.
|
||||||
|
keytool -genkeypair \
|
||||||
|
-keystore "$KEYSTORE_FILE" \
|
||||||
|
-alias hormonetrack \
|
||||||
|
-keyalg RSA -keysize 4096 -validity 10000 \
|
||||||
|
-storepass "$STORE_PASS" \
|
||||||
|
-keypass "$STORE_PASS" \
|
||||||
|
-dname "CN=HormoneTrack, OU=HormoneTrack, O=HormoneTrack"
|
||||||
|
|
||||||
|
chmod 600 "$KEYSTORE_FILE"
|
||||||
|
|
||||||
|
cat >"$PROPERTIES_FILE" <<EOF
|
||||||
|
# Signature release de HormoneTrack — GITIGNORÉ, ne jamais committer.
|
||||||
|
storeFile=$KEYSTORE_FILE
|
||||||
|
storePassword=$STORE_PASS
|
||||||
|
keyAlias=hormonetrack
|
||||||
|
keyPassword=$STORE_PASS
|
||||||
|
EOF
|
||||||
|
chmod 600 "$PROPERTIES_FILE"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "OK : $KEYSTORE_FILE + $PROPERTIES_FILE créés (gitignorés)."
|
||||||
|
echo "Prochaine build : ./gradlew assembleRelease (signée avec ce keystore)"
|
||||||
|
echo "⚠️ Sur le téléphone : exporter le JSON AVANT d'installer (signature changée)"
|
||||||
|
echo " → cf docs/DEVELOPPEMENT.md §16.quater pour la migration pas à pas."
|
||||||
Loading…
x
Reference in New Issue
Block a user