From 09cddfc8ee0234fbb0ab8a29baca6212331b0eed Mon Sep 17 00:00:00 2001 From: Siphonight Date: Tue, 22 Sep 2026 22:39:42 +0200 Subject: [PATCH] =?UTF-8?q?v1.9.8=20:=20socle=20qualit=C3=A9=20=E2=80=94?= =?UTF-8?q?=20tests=20UI=20Compose=20(13),=20signature=20release=20d=C3=A9?= =?UTF-8?q?di=C3=A9e,=20d=C3=A9coupage=20Settings=20+=20README=20anglais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .gitignore | 7 + README.en.md | 295 ++++++++ README.md | 41 +- app/build.gradle.kts | 57 +- .../uitest/ChangelogDialogTest.kt | 108 +++ .../com/hormonetrack/uitest/NavigationTest.kt | 104 +++ .../hormonetrack/uitest/SettingsScreenTest.kt | 84 +++ .../java/com/hormonetrack/uitest/UiTestEnv.kt | 65 ++ .../hormonetrack/ui/screens/SettingsScreen.kt | 631 +++--------------- .../ui/screens/settings/AboutCard.kt | 55 ++ .../screens/settings/AlertThresholdsCard.kt | 144 ++++ .../ui/screens/settings/AutoBackupCard.kt | 164 +++++ .../screens/settings/AutoCalibrationCard.kt | 58 ++ .../ui/screens/settings/BackupCard.kt | 48 ++ .../ui/screens/settings/ChartTimezoneCard.kt | 110 +++ .../ui/screens/settings/LanguageCard.kt | 77 +++ .../ui/screens/settings/LogsCard.kt | 55 ++ .../ui/screens/settings/RemindersCard.kt | 58 ++ .../ui/screens/settings/SettingsFormat.kt | 33 + .../ui/screens/settings/TModelCard.kt | 118 ++++ docs/CHANGELOG.md | 34 + docs/DEVELOPPEMENT.md | 156 ++++- scripts/make-release-keystore.sh | 68 ++ 23 files changed, 1975 insertions(+), 595 deletions(-) create mode 100644 README.en.md create mode 100644 app/src/androidTest/java/com/hormonetrack/uitest/ChangelogDialogTest.kt create mode 100644 app/src/androidTest/java/com/hormonetrack/uitest/NavigationTest.kt create mode 100644 app/src/androidTest/java/com/hormonetrack/uitest/SettingsScreenTest.kt create mode 100644 app/src/androidTest/java/com/hormonetrack/uitest/UiTestEnv.kt create mode 100644 app/src/main/java/com/hormonetrack/ui/screens/settings/AboutCard.kt create mode 100644 app/src/main/java/com/hormonetrack/ui/screens/settings/AlertThresholdsCard.kt create mode 100644 app/src/main/java/com/hormonetrack/ui/screens/settings/AutoBackupCard.kt create mode 100644 app/src/main/java/com/hormonetrack/ui/screens/settings/AutoCalibrationCard.kt create mode 100644 app/src/main/java/com/hormonetrack/ui/screens/settings/BackupCard.kt create mode 100644 app/src/main/java/com/hormonetrack/ui/screens/settings/ChartTimezoneCard.kt create mode 100644 app/src/main/java/com/hormonetrack/ui/screens/settings/LanguageCard.kt create mode 100644 app/src/main/java/com/hormonetrack/ui/screens/settings/LogsCard.kt create mode 100644 app/src/main/java/com/hormonetrack/ui/screens/settings/RemindersCard.kt create mode 100644 app/src/main/java/com/hormonetrack/ui/screens/settings/SettingsFormat.kt create mode 100644 app/src/main/java/com/hormonetrack/ui/screens/settings/TModelCard.kt create mode 100755 scripts/make-release-keystore.sh diff --git a/.gitignore b/.gitignore index a3feb60..5010cb4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,13 @@ build/ # Local configuration (machine-specific, never commit) 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 .idea/ *.iml diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..aa377b0 --- /dev/null +++ b/README.en.md @@ -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 && 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)). diff --git a/README.md b/README.md index 6e440e9..443c384 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 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. > La contribution humaine a été **essentielle** : feedback continu, retours > 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 > 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) - **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) - **Montre / Gadgetbridge** : [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md) +- **README anglais** : [README.en.md](README.en.md) ## Fonctionnalités @@ -124,6 +127,7 @@ git clone && cd HormoneTrack echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME ./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk ./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 ``` @@ -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) — `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)) : -`-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 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 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 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/ -├── README.md ← ce fichier +├── README.md ← ce fichier (README.en.md = version anglaise) ├── docs/ │ ├── GUIDE_INSTALLATION.md guide utilisateur (téléphone + montre) │ ├── DEVELOPPEMENT.md doc de dev complète (architecture, maths, bugs, tests) @@ -226,9 +236,11 @@ HormoneTrack/ ├── scripts/ │ ├── 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) -│ └── 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 │ 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) ├── settings.gradle.kts ├── gradle.properties @@ -247,22 +259,25 @@ HormoneTrack/ │ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes) │ │ ├── settings/ (DataStore : TConfig, langue) │ │ ├── ui/ (Compose : screens, components, theme) + │ │ │ └── screens/settings/ ← cartes des Paramètres (découpage v1.9.8) │ │ ├── HormoneTrackApp.kt │ │ └── MainActivity.kt │ └── res/ (strings FR/EN, thème, icônes) - └── test/java/com/hormonetrack/ ← tests unitaires JVM - ├── pk/ (moteur, profils ESE/TFS/WHSAH, calibration - │ par modèle, rappels, régressions data-driven) - ├── data/backup/ (round-trip Gson + paramètres) - ├── ui/ (fenêtre graphique, garde de source) - ├── util/ (noms de fichiers d'export, durées) - ├── reminder/ + settings/ (RRULE agenda, changelog) + ├── test/java/com/hormonetrack/ ← tests unitaires JVM + │ ├── pk/ (moteur, profils ESE/TFS/WHSAH, calibration + │ │ par modèle, rappels, régressions data-driven) + │ ├── data/backup/ (round-trip Gson + paramètres) + │ ├── ui/ (fenêtre graphique, garde de source) + │ ├── util/ (noms de fichiers d'export, durées) + │ └── reminder/ + settings/ (RRULE agenda, changelog) + └── androidTest/java/com/hormonetrack/ ← tests UI Compose (v1.9.8) + └── uitest/ (navigation, dialog changelog, Paramètres) ``` ## Feuille de route - [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 - [ ] Phase 2 montre : watchface personnalisée et/ou mini-app Lite Wearable (voir [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md)) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e8dd080..446b467 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,9 +1,25 @@ +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.plugin.compose") 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 { namespace = "com.hormonetrack" compileSdk = 37 @@ -17,8 +33,8 @@ android { // tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0" // (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans // les APK publiés (Paramètres + titre du dialog « Nouveautés »). - versionCode = 44 - versionName = "1.9.7" + versionCode = 45 + versionName = "1.9.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -35,12 +51,27 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - // ⚠️ Signé avec la CLÉ DEBUG volontairement : l'APK release reste - // installable PAR-DESSUS l'APK debug déjà installé (même signature), - // sans désinstallation ni perte de données, tout en bénéficiant de - // R8. Si un jour on distribue largement : générer un vrai keystore - // (signature différente → migration backup→désinstall→réimport). - signingConfig = signingConfigs.getByName("debug") + // Signature : VRAI keystore si keystore.properties existe (racine du + // dépôt, gitignoré — créé par scripts/make-release-keystore.sh) ; + // sinon CLÉ DEBUG volontairement : l'APK release reste installable + // PAR-DESSUS l'APK debug déjà installé (même signature), sans + // désinstallation ni perte de données, tout en bénéficiant de R8. + // ⚠️ 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 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") } diff --git a/app/src/androidTest/java/com/hormonetrack/uitest/ChangelogDialogTest.kt b/app/src/androidTest/java/com/hormonetrack/uitest/ChangelogDialogTest.kt new file mode 100644 index 0000000..1d1d4db --- /dev/null +++ b/app/src/androidTest/java/com/hormonetrack/uitest/ChangelogDialogTest.kt @@ -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? = 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() + } + } +} diff --git a/app/src/androidTest/java/com/hormonetrack/uitest/NavigationTest.kt b/app/src/androidTest/java/com/hormonetrack/uitest/NavigationTest.kt new file mode 100644 index 0000000..74789f6 --- /dev/null +++ b/app/src/androidTest/java/com/hormonetrack/uitest/NavigationTest.kt @@ -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() + + 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() + } +} diff --git a/app/src/androidTest/java/com/hormonetrack/uitest/SettingsScreenTest.kt b/app/src/androidTest/java/com/hormonetrack/uitest/SettingsScreenTest.kt new file mode 100644 index 0000000..33d6569 --- /dev/null +++ b/app/src/androidTest/java/com/hormonetrack/uitest/SettingsScreenTest.kt @@ -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() + + 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() + } +} diff --git a/app/src/androidTest/java/com/hormonetrack/uitest/UiTestEnv.kt b/app/src/androidTest/java/com/hormonetrack/uitest/UiTestEnv.kt new file mode 100644 index 0000000..057b355 --- /dev/null +++ b/app/src/androidTest/java/com/hormonetrack/uitest/UiTestEnv.kt @@ -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() + } +} diff --git a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt index 3db93b9..ac64f16 100644 --- a/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt @@ -1,17 +1,10 @@ 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.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.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding 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.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.TextButton import androidx.compose.material3.TopAppBar @@ -47,49 +34,80 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import com.hormonetrack.R 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.PharmacokineticEngine import com.hormonetrack.pk.TConfig import com.hormonetrack.reminder.AlarmScheduler 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch 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) @Composable fun SettingsScreen(onBack: () -> Unit) { val container = LocalAppContainer.current val repo = container.repository val context = LocalContext.current - val scheduler = remember { AlarmScheduler(context) } val tConfig by container.settings.tConfig.collectAsState(initial = TConfig()) val currentLanguage by container.settings.language.collectAsState(initial = "system") val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false) // Seuils d'alerte (v1.4.2) : limites E2/T évaluées sur l'accueil 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 val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?) - 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)) } + // ── Auto-backup (v1.7.0) : états collectés ici, logique dans la carte ── + 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?) var showImportConfirm by remember { mutableStateOf(false) } + var importJson by remember { mutableStateOf(null) } var logLineCount by remember { mutableIntStateOf(AppLog.count()) } // 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 // logExportLauncher l'écrit (un state local doit être déclaré avant le // lambda qui le capture). var message by remember { mutableStateOf(null) } + + /** Écrit le feedback depuis le thread principal (les cartes l'appellent). */ + val showMessage: (String) -> Unit = { message = it } + val logExportLauncher = rememberLauncherForActivityResult( ActivityResultContracts.CreateDocument("text/plain") ) { uri -> @@ -134,7 +152,6 @@ fun SettingsScreen(onBack: () -> Unit) { } } } - var importJson by remember { mutableStateOf(null) } val exportLauncher = rememberLauncherForActivityResult( ActivityResultContracts.CreateDocument("application/json") @@ -147,7 +164,7 @@ fun SettingsScreen(onBack: () -> Unit) { // UNE seule collecte des seuils (les 4 champs viennent du même // snapshot DataStore — 4 × .first() = 4 collectes redondantes) val thresholds = container.settings.alertThresholds.first() - val settings = com.hormonetrack.data.backup.UserSettings( + val settings = UserSettings( language = container.settings.language.first(), autoCalibrate = container.settings.autoCalibrate.first(), alertE2High = thresholds.e2High, @@ -186,22 +203,8 @@ fun SettingsScreen(onBack: () -> Unit) { // L'utilisateur choisit le dossier UNE FOIS (ACTION_OPEN_DOCUMENT_TREE) : // la permission persistable survit au reboot, le worker WorkManager écrit // ensuite sans intervention — AUCUNE permission de stockage déclarée. - // pickFolderToEnable distingue les deux entrées du picker : - // - via le SWITCH quand aucun dossier n'existe encore → 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é. - 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?) - var autoBackupKeepText by remember(autoBackupKeep) { - mutableStateOf(autoBackupKeep.toString()) - } + // pickFolderToEnable distingue les deux entrées du picker (switch vs + // « Choisir le dossier » — cf AutoBackupCard). var pickFolderToEnable by remember { mutableStateOf(false) } val folderPickerLauncher = rememberLauncherForActivityResult( @@ -235,6 +238,7 @@ fun SettingsScreen(onBack: () -> Unit) { } } + // ── Assemblage : l'ordre des cartes fait le rendu de l'écran ─────────── Column( Modifier .fillMaxSize() @@ -256,503 +260,54 @@ fun SettingsScreen(onBack: () -> Unit) { Spacer(Modifier.height(8.dp)) } - // --- Language --- - 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") } - ) - } - } - } + LanguageCard(currentLanguage) Spacer(Modifier.height(12.dp)) - - // --- 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 - ) - } - } + AutoCalibrationCard(autoCalibrate) Spacer(Modifier.height(12.dp)) - - // --- 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)) } - } - } - } + TModelCard(tConfig, showMessage) Spacer(Modifier.height(12.dp)) - - // --- 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(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)) } - } - } + AlertThresholdsCard(alertThresholds, showMessage) Spacer(Modifier.height(12.dp)) - - // --- 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(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)) } - } - } - } + ChartTimezoneCard(chartTimezone, showMessage) Spacer(Modifier.height(12.dp)) - - // --- 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) - } - } - } + RemindersCard() Spacer(Modifier.height(12.dp)) - - // --- Backup --- - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp)) { - Text(stringResource(R.string.backup_section), style = MaterialTheme.typography.titleMedium) - Text( - stringResource(R.string.backup_hint), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(6.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { - exportLauncher.launch(ExportFileNames.backupFileName()) - }) { Text(stringResource(R.string.export_json)) } - OutlinedButton(onClick = { - importLauncher.launch(arrayOf("application/json")) - }) { Text(stringResource(R.string.import_json)) } - } - } - } + BackupCard( + onExport = { exportLauncher.launch(ExportFileNames.backupFileName()) }, + onImport = { importLauncher.launch(arrayOf("application/json")) } + ) Spacer(Modifier.height(12.dp)) - - // --- Sauvegarde automatique (v1.7.0) : en PLUS de l'export manuel --- - // Opt-in pur : désactivée par défaut ; nécessite un dossier choisi - // (SAF tree persisté) ; un run immédiat est déclenché à l'activation. - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp)) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.autobackup_section), style = MaterialTheme.typography.titleMedium) - Switch( - checked = autoBackupEnabled, - onCheckedChange = { wantEnabled -> - if (!wantEnabled) { - // Désactivation : le périodique reste planifié - // mais le worker sort immédiatement (no-op) - CoroutineScope(Dispatchers.IO).launch { - container.settings.setAutoBackupEnabled(false) - } - } else if (autoBackupTreeUri == null) { - // Pas de dossier → le picker EST la suite : - // la sélection activera (+ premier run) - pickFolderToEnable = true - folderPickerLauncher.launch(null) - } else { - // Dossier existant → activation directe + run - CoroutineScope(Dispatchers.IO).launch { - container.settings.setAutoBackupEnabled(true) - com.hormonetrack.data.backup.AutoBackupScheduler.runNow(context) - } - } - } - ) - } - Text( - stringResource(R.string.autobackup_hint), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(6.dp)) - - // Dossier choisi : nom lisible (documents "primary:Download" → - // "Download") + bouton pour le changer (sans toucher au switch) - Text( - stringResource(R.string.autobackup_folder) + " : " + - (autoBackupTreeUri?.let { folderDisplayName(it) } - ?: stringResource(R.string.autobackup_no_folder)), - style = MaterialTheme.typography.bodyMedium - ) - TextButton(onClick = { - pickFolderToEnable = false - folderPickerLauncher.launch(null) - }) { Text(stringResource(R.string.autobackup_choose_folder)) } - - // Dernier run (statut alimenté par le worker) : réussi/échec + date - autoBackupLastRun?.let { (result, atMs) -> - if (atMs != null && atMs > 0L) { - val date = java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault()) - .format(java.util.Date(atMs)) - Text( - text = context.getString( - if (result == "ok") R.string.autobackup_last_ok - else R.string.autobackup_last_fail, - date - ), - color = if (result == "ok") MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - } - - Spacer(Modifier.height(6.dp)) - // Rétention : copies conservées (1..30, borné au set) — Save - // persiste ; le worker relit DataStore à CHAQUE run (aucun - // re-enqueue nécessaire, cf AutoBackupScheduler) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = autoBackupKeepText, - onValueChange = { autoBackupKeepText = it }, - label = { Text(stringResource(R.string.autobackup_keep)) }, - singleLine = true, - modifier = Modifier.weight(1f) - ) - Button(onClick = { - val keep = autoBackupKeepText.trim().toIntOrNull() ?: return@Button - CoroutineScope(Dispatchers.IO).launch { - container.settings.setAutoBackupKeep(keep) // borné [1;30] - withContext(Dispatchers.Main) { - message = context.getString(R.string.saved) - } - } - }) { Text(stringResource(R.string.save)) } - } - } - } + AutoBackupCard( + enabled = autoBackupEnabled, + treeUri = autoBackupTreeUri, + keep = autoBackupKeep, + lastRun = autoBackupLastRun, + onPickFolderToEnable = { + pickFolderToEnable = true + folderPickerLauncher.launch(null) + }, + onChooseFolder = { + pickFolderToEnable = false + folderPickerLauncher.launch(null) + }, + onMessage = showMessage + ) Spacer(Modifier.height(12.dp)) - - // --- Logs de diagnostic (v1.3.1) --- - 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 = { - // 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()) - }) { Text(stringResource(R.string.logs_export)) } - TextButton(onClick = { - AppLog.clear() - logLineCount = AppLog.count() - }) { Text(stringResource(R.string.logs_clear)) } - } + LogsCard( + logLineCount = logLineCount, + onExport = { + // 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()) + }, + onClear = { + AppLog.clear() + logLineCount = AppLog.count() } - } + ) Spacer(Modifier.height(12.dp)) - - // --- 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 - ) - } - } + AboutCard() Spacer(Modifier.height(32.dp)) } @@ -782,7 +337,7 @@ fun SettingsScreen(onBack: () -> Unit) { it.settings?.let { us -> container.settings.setAutoCalibrate(us.autoCalibrate ?: false) container.settings.setAlertThresholds( - com.hormonetrack.pk.Alerts.Thresholds( + Alerts.Thresholds( e2High = us.alertE2High, e2Low = us.alertE2Low, 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 - * « : 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(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)) } + } + } +} diff --git a/app/src/main/java/com/hormonetrack/ui/screens/settings/AutoBackupCard.kt b/app/src/main/java/com/hormonetrack/ui/screens/settings/AutoBackupCard.kt new file mode 100644 index 0000000..7374100 --- /dev/null +++ b/app/src/main/java/com/hormonetrack/ui/screens/settings/AutoBackupCard.kt @@ -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?, + 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)) } + } + } + } +} diff --git a/app/src/main/java/com/hormonetrack/ui/screens/settings/AutoCalibrationCard.kt b/app/src/main/java/com/hormonetrack/ui/screens/settings/AutoCalibrationCard.kt new file mode 100644 index 0000000..cf6d04c --- /dev/null +++ b/app/src/main/java/com/hormonetrack/ui/screens/settings/AutoCalibrationCard.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/com/hormonetrack/ui/screens/settings/BackupCard.kt b/app/src/main/java/com/hormonetrack/ui/screens/settings/BackupCard.kt new file mode 100644 index 0000000..7867d56 --- /dev/null +++ b/app/src/main/java/com/hormonetrack/ui/screens/settings/BackupCard.kt @@ -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)) } + } + } + } +} diff --git a/app/src/main/java/com/hormonetrack/ui/screens/settings/ChartTimezoneCard.kt b/app/src/main/java/com/hormonetrack/ui/screens/settings/ChartTimezoneCard.kt new file mode 100644 index 0000000..a55a17b --- /dev/null +++ b/app/src/main/java/com/hormonetrack/ui/screens/settings/ChartTimezoneCard.kt @@ -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(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)) } + } + } + } +} diff --git a/app/src/main/java/com/hormonetrack/ui/screens/settings/LanguageCard.kt b/app/src/main/java/com/hormonetrack/ui/screens/settings/LanguageCard.kt new file mode 100644 index 0000000..d51bccf --- /dev/null +++ b/app/src/main/java/com/hormonetrack/ui/screens/settings/LanguageCard.kt @@ -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") } + ) + } + } + } +} diff --git a/app/src/main/java/com/hormonetrack/ui/screens/settings/LogsCard.kt b/app/src/main/java/com/hormonetrack/ui/screens/settings/LogsCard.kt new file mode 100644 index 0000000..4b2728c --- /dev/null +++ b/app/src/main/java/com/hormonetrack/ui/screens/settings/LogsCard.kt @@ -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)) } + } + } + } +} diff --git a/app/src/main/java/com/hormonetrack/ui/screens/settings/RemindersCard.kt b/app/src/main/java/com/hormonetrack/ui/screens/settings/RemindersCard.kt new file mode 100644 index 0000000..86f709b --- /dev/null +++ b/app/src/main/java/com/hormonetrack/ui/screens/settings/RemindersCard.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/hormonetrack/ui/screens/settings/SettingsFormat.kt b/app/src/main/java/com/hormonetrack/ui/screens/settings/SettingsFormat.kt new file mode 100644 index 0000000..7fb5678 --- /dev/null +++ b/app/src/main/java/com/hormonetrack/ui/screens/settings/SettingsFormat.kt @@ -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 + * « : 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)) } + } + } + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 59e9330..86325f7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,6 +3,40 @@ 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`, …). +## [1.9.8] — 2026-09-22 (versionCode 45) + +### Ajouté — tests UI Compose, signature release dédiée, README anglais + +- **13 tests UI instrumentés** (navigation 5 onglets, dialog « Nouveautés » + — épinglage du fix v1.3.2 « ne réapparaît pas après dismiss » —, sections + Paramètres, version installée = BuildConfig : le bug #46 « versions jamais + bumpées » ne peut plus revenir silencieusement). Déterministes : permission + de notifications pré-grantée, zéro dépendance aux données, indépendants de + la langue FR/EN. Exécution : `./gradlew connectedDebugAndroidTest`. +- **Signature release dédiée, prête à l'emploi** : `scripts/make-release-keystore.sh` + crée le keystore + `keystore.properties` (gitignorés) ; `assembleRelease` + l'utilise dès qu'il existe (sinon clé debug inchangée). Migration + téléphone documentée (export JSON → désinstallation → réinstallation → + réimport). Aucun changement pour l'utilisatrice tant que la bascule n'est + pas faite. +- **README anglais** ([README.en.md](README.en.md)) — lien FR/EN en tête de + chaque fichier ; la doc de développement reste en français. + +### Modifié — écran Paramètres découpé (maintenance) + +- `SettingsScreen.kt` (840 lignes) devient un orchestrateur de 377 lignes + + 11 cartes thématiques dans `ui/screens/settings/` (langue, calibration, + modèle T, seuils, fuseau, rappels, sauvegarde, auto-backup, logs, à propos). + **Comportement et rendu strictement identiques** (déplacement pur) ; + validé par les tests UI + smoke test RELEASE sur émulateur (toutes les + sections, version 1.9.8, 0 crash — anti-bug #64). +- `ChartScreen` reste volontairement entier : sa garde de source (#54) + exige le bloc de gestes dans ce fichier. + +Vérifié : 249 tests unitaires + 13 tests UI verts, lint vert, APK debug + +release (2,7 Mo) buildés, smoke test release complet (accueil, 5 onglets, +Paramètres, éditeur de traitement, dialog de dose — 0 crash). + ## [1.9.7] — 2026-09-20 (versionCode 44) ### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index ba27bb3..55390cf 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -11,12 +11,12 @@ 1. [Contexte & objectifs](#1-contexte--objectifs) 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) 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) 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) 10. [UI & navigation](#10-ui--navigation) 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) 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) -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) 18. [Espace disque & coûts](#18-espace-disque--coûts) 19. [Limites connues](#19-limites-connues) @@ -63,6 +63,7 @@ Fonctionnalités v1 : | 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.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 ✓. | @@ -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 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ù | |---|---|---| @@ -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 | | DataStore Preferences | 1.2.1 | 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 | | compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | 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 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 -(UI/logic Android), tests Compose, lint. +**Toute modification du moteur passe par ces tests.** Suite envisagée : Robolectric +(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 @@ -1140,8 +1180,19 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17). `ChangelogHelper.sectionsSince`, asset `changelog.md` synchronisé par la tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ; fermable, ne réapparaît pas avant la prochaine mise à jour. -- `SettingsScreen` : 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, +- `SettingsScreen` (**v1.9.8 : découpé en cartes thématiques** — 840 lignes + → 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 rouge), Save → persiste + **check one-time immédiat** (`AlertNotifier.checkNow`) ; **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, 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 ; - 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, LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester), `LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`) @@ -1782,8 +1838,9 @@ structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour ```bash cd ~/projects/HormoneTrack -./gradlew assembleDebug testDebugUnitTest # build + 223 tests (193 sans données locales) -./gradlew assembleRelease # APK optimisé R8 (cf §16.bis) +./gradlew assembleDebug testDebugUnitTest # build + 249 tests (219 sans données locales) +./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 ``` @@ -1868,15 +1925,49 @@ Le script : scope requis : `write:repository` (suffit pour releases + assets, pas pour créer un repo — cf plus haut). -**Build release (v1.2.5)** : `./gradlew assembleRelease` — R8 full mode + -`isShrinkResources` + signé avec la **clé debug** (même signature que les APK -debug distribués → mise à jour par-dessus sans perte de données ; 20 Mo → -2,4 Mo). Garde-fous dans `proguard-rules.pro` : Gson lit les champs des -modèles par réflexion → `-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). +**Build release (v1.2.5, signature conditionnelle depuis v1.9.8)** : +`./gradlew assembleRelease` — R8 full mode + `isShrinkResources` +(20 Mo → 2,7 Mo). **Signature** : si `keystore.properties` existe à la racine +du dépôt (gitignoré, créé par `scripts/make-release-keystore.sh` — cf §16.quater), +la build release utilise CE keystore ; sinon elle reste signée avec la **clé +debug** (même signature que les APK debug distribués → mise à jour par-dessus +sans perte de données — comportement historique). Garde-fous dans +`proguard-rules.pro` : Gson lit les champs des modèles par réflexion → +`-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) @@ -2076,7 +2167,13 @@ Volontaires (v1) : - **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 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 (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 @@ -2089,22 +2186,27 @@ Volontaires (v1) : ## 20. Idées d'évolution -1. **Robolectric + tests Compose** (VM Android en JVM — pas besoin d'appareil) -2. **Émulateur local** pour smoke-tests UI (sur demande, ~2–3 Go) +1. **Robolectric** (VM Android en JVM — pas besoin d'appareil ; les tests + 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) 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 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) -7. **Vrai keystore de distribution** (signature dédiée ≠ clé debug → nécessite - une migration : backup → désinstallation → installation signée → réimport) +7. ~~Vrai keystore de distribution~~ **FAIT v1.9.8** (infra complète, §16.quater : + 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)**, 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), 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 -(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) ### 20.bis Pistes d'optimisation écartées ou différées (audit v1.8.2) diff --git a/scripts/make-release-keystore.sh b/scripts/make-release-keystore.sh new file mode 100755 index 0000000..8d467ed --- /dev/null +++ b/scripts/make-release-keystore.sh @@ -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" <