v1.9.8 : socle qualité — tests UI Compose (13), signature release dédiée, découpage Settings + README anglais

- 13 tests UI instrumentés (uitest/) : navigation 5 onglets, dialog Nouveautés
  (épinglage du fix v1.3.2), sections Paramètres, version installée = BuildConfig
  (anti-bug #46). Déterministes : pm grant shell, markChangelogSeen, assertions
  via targetContext (FR/EN), zéro dépendance aux données Room.
- Signature release conditionnelle : scripts/make-release-keystore.sh (shellcheck+shfmt)
  + keystore.properties gitignoré ; assembleRelease l'utilise s'il existe, sinon
  clé debug inchangée. Migration documentée §16.quater.
- SettingsScreen découpé : 840 lignes -> orchestrateur 377 + 11 cartes dans
  ui/screens/settings/ (déplacement pur, commentaires conservés). ChartScreen
  volontairement entier (garde de source #54).
- README.en.md complet + liens FR/EN croisés.

Vérifié : 249 tests JVM + 13 UI verts, lint vert, smoke RELEASE émulateur
(données réelles seedées : accueil, prochaines doses, suggestion creux,
graphiques ESE+TFS superposés, 0 crash logcat — anti-#64).
This commit is contained in:
Siphonight 2026-09-22 22:39:42 +02:00
parent d6d95080b4
commit 09cddfc8ee
23 changed files with 1975 additions and 595 deletions

7
.gitignore vendored
View File

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

295
README.en.md Normal file
View File

@ -0,0 +1,295 @@
# 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.9.8 — Android build ✅, **lint clean** ✅, **249 unit tests** ✅
(219 without the local test data; regressions pinned on real data
**not committed to the repo**), **13 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**
- **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
- **Inactive treatments**: an archived treatment disappears from input and
reminders, but **its whole history stays simulated and calibrated** — useful
for a valerate → enanthate transition
- **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
- **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
- **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 # 249 tests (219 without local test data)
./gradlew connectedDebugAndroidTest # 13 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.9.8`) 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)).

View File

@ -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.7 — build Android ✅, **lint vert** ✅, **249 tests unitaires** ✅ (219 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.9.8 — build Android ✅, **lint vert** ✅, **249 tests unitaires** ✅ (219 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), **13 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
@ -124,6 +127,7 @@ 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 # 249 tests (219 sans les données locales) ./gradlew testDebugUnitTest # 249 tests (219 sans les données locales)
./gradlew connectedDebugAndroidTest # 13 tests UI (émulateur/appareil requis)
./gradlew lint # lint vert obligatoire avant release ./gradlew lint # lint vert obligatoire avant release
``` ```
@ -135,9 +139,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.9.8`) 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 +157,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 +227,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 +236,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 +259,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))

View File

@ -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 = 44 versionCode = 45
versionName = "1.9.7" versionName = "1.9.8"
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,14 @@ 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")
} }

View File

@ -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()
}
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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,80 @@ 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.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()
) )
// 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 +152,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,7 +164,7 @@ 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,
@ -186,22 +203,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 +238,7 @@ fun SettingsScreen(onBack: () -> Unit) {
} }
} }
// ── Assemblage : l'ordre des cartes fait le rendu de l'écran ───────────
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -256,503 +260,54 @@ 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))
ChartTimezoneCard(chartTimezone, 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))
RemindersCard()
// --- 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))
BackupCard(
// --- Backup --- onExport = { exportLauncher.launch(ExportFileNames.backupFileName()) },
Card(Modifier.fillMaxWidth()) { onImport = { importLauncher.launch(arrayOf("application/json")) }
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))
AutoBackupCard(
// --- Sauvegarde automatique (v1.7.0) : en PLUS de l'export manuel --- enabled = autoBackupEnabled,
// Opt-in pur : désactivée par défaut ; nécessite un dossier choisi treeUri = autoBackupTreeUri,
// (SAF tree persisté) ; un run immédiat est déclenché à l'activation. keep = autoBackupKeep,
Card(Modifier.fillMaxWidth()) { lastRun = autoBackupLastRun,
Column(Modifier.padding(12.dp)) { onPickFolderToEnable = {
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 pickFolderToEnable = true
folderPickerLauncher.launch(null) folderPickerLauncher.launch(null)
} else { },
// Dossier existant → activation directe + run onChooseFolder = {
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 pickFolderToEnable = false
folderPickerLauncher.launch(null) folderPickerLauncher.launch(null)
}) { Text(stringResource(R.string.autobackup_choose_folder)) } },
onMessage = showMessage
// 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))
LogsCard(
// --- Logs de diagnostic (v1.3.1) --- logLineCount = logLineCount,
Card(Modifier.fillMaxWidth()) { onExport = {
Column(Modifier.padding(12.dp)) { // Nom de fichier via ExportFileNames (cf §14 #48) : extrait
Text(stringResource(R.string.logs_section), 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.logs_count, logLineCount),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {
// Nom de fichier via ExportFileNames (cf §14 #48) :
// 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()) logExportLauncher.launch(ExportFileNames.diagnosticLogFileName())
}) { Text(stringResource(R.string.logs_export)) } },
TextButton(onClick = { onClear = {
AppLog.clear() AppLog.clear()
logLineCount = AppLog.count() logLineCount = AppLog.count()
}) { Text(stringResource(R.string.logs_clear)) }
}
}
} }
)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
AboutCard()
// --- About : version installée + lien releases Gitea ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.settings_version, com.hormonetrack.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
)
}
}
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
} }
@ -782,7 +337,7 @@ 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
) )
@ -820,21 +375,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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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") }
)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,40 @@
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.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) ## [1.9.7] — 2026-09-20 (versionCode 44)
### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection ### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection

View File

@ -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.9.8)](#3-stack--versions-épinglées--à-jour-v198)
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,7 @@ Fonctionnalités v1 :
| Date | Événement | | Date | Événement |
|---|---| |---|---|
| 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é). |
| 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.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.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 ✓. | | 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 ✓. |
@ -117,7 +118,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.9.8)
| Composant | Version | Où | | Composant | Version | Où |
|---|---|---| |---|---|---|
@ -131,7 +132,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` (androidTest, v1.9.8 — §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 |
@ -996,8 +998,46 @@ 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)
**13 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).
**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
@ -1140,8 +1180,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,
@ -1149,7 +1200,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) ; 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`)
@ -1782,8 +1838,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 + 249 tests (219 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 # 13 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
``` ```
@ -1868,15 +1925,49 @@ 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)
@ -2076,7 +2167,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
@ -2089,22 +2186,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)

View 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."