v1.9.8 : socle qualité — tests UI Compose (13), signature release dédiée, découpage Settings + README anglais
- 13 tests UI instrumentés (uitest/) : navigation 5 onglets, dialog Nouveautés (épinglage du fix v1.3.2), sections Paramètres, version installée = BuildConfig (anti-bug #46). Déterministes : pm grant shell, markChangelogSeen, assertions via targetContext (FR/EN), zéro dépendance aux données Room. - Signature release conditionnelle : scripts/make-release-keystore.sh (shellcheck+shfmt) + keystore.properties gitignoré ; assembleRelease l'utilise s'il existe, sinon clé debug inchangée. Migration documentée §16.quater. - SettingsScreen découpé : 840 lignes -> orchestrateur 377 + 11 cartes dans ui/screens/settings/ (déplacement pur, commentaires conservés). ChartScreen volontairement entier (garde de source #54). - README.en.md complet + liens FR/EN croisés. Vérifié : 249 tests JVM + 13 UI verts, lint vert, smoke RELEASE émulateur (données réelles seedées : accueil, prochaines doses, suggestion creux, graphiques ESE+TFS superposés, 0 crash logcat — anti-#64).
This commit is contained in:
parent
d6d95080b4
commit
d0741a0fa8
7
.gitignore
vendored
7
.gitignore
vendored
@ -5,6 +5,13 @@ build/
|
|||||||
# Local configuration (machine-specific, never commit)
|
# Local configuration (machine-specific, never commit)
|
||||||
local.properties
|
local.properties
|
||||||
|
|
||||||
|
# Release signing (v1.9.8) — created by scripts/make-release-keystore.sh.
|
||||||
|
# NEVER commit: the keystore + its passwords are the identity of the app
|
||||||
|
# (leaking them = anyone can ship an APK that upgrades over yours).
|
||||||
|
keystore.properties
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
|
||||||
# IntelliJ / Android Studio
|
# IntelliJ / Android Studio
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
|
|||||||
295
README.en.md
Normal file
295
README.en.md
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
# HormoneTrack
|
||||||
|
|
||||||
|
> **Language**: [Français](README.md) · **English** (this file)
|
||||||
|
|
||||||
|
> **🤖 AI-assisted development**: this project was designed and coded with an
|
||||||
|
> AI assistant. The human contribution was **essential**: continuous feedback,
|
||||||
|
> user reports (testing on a real phone, bug reports with real data exports),
|
||||||
|
> improvement suggestions and validation of every release. The session-by-session
|
||||||
|
> detail is documented in
|
||||||
|
> [docs/DEVELOPPEMENT.md §2](docs/DEVELOPPEMENT.md) (French).
|
||||||
|
|
||||||
|
Hormone therapy (HRT) tracker on Android, with **hour-by-hour** estimated
|
||||||
|
curves of estradiol (E2) and testosterone (T), calibration against blood
|
||||||
|
tests, reminders mirrored on a smartwatch (Huawei Watch GT 3 via Gadgetbridge
|
||||||
|
or Huawei Health) and JSON backup. **100% local, no account, no server.**
|
||||||
|
|
||||||
|
> **⚠️ Medical disclaimer**: the curves are **pharmacokinetic estimates**
|
||||||
|
> for informational purposes — they are not measurements. Always rely on your
|
||||||
|
> blood tests and on your endocrinologist's guidance.
|
||||||
|
|
||||||
|
- **Status**: v1.9.8 — Android build ✅, **lint clean** ✅, **249 unit tests** ✅
|
||||||
|
(219 without the local test data; regressions pinned on real data
|
||||||
|
**not committed to the repo**), **13 Compose UI tests** ✅ (emulator),
|
||||||
|
smartwatch integration = notifications ✅, **private Gitea repo + releases
|
||||||
|
with APK** ✅
|
||||||
|
- **Changelog**: [docs/CHANGELOG.md](docs/CHANGELOG.md) (French)
|
||||||
|
- **User guide**: [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) (French)
|
||||||
|
- **Development docs** (architecture, math, decisions, bugs): [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) (French)
|
||||||
|
- **Watch / Gadgetbridge**: [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md) (French)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Hour-by-hour estimated curves**: E2 (pg/mL) and T (ng/mL), 24 h / 7 d / 30 d views,
|
||||||
|
**dose markers** (forecast and actual), **configurable chart timezone** (v1.4.5),
|
||||||
|
**pan** (drag to scroll back in time), **zoom** (pinch or − / + buttons,
|
||||||
|
6 h → 300 d, adaptive sampling), displayable **peaks & troughs** with their
|
||||||
|
**estimated values** (▲▼ triangles at local extrema, toggleable)
|
||||||
|
- **Two PK models, superimposable**: **Estrannaise (EstraNase)** (hourly tables
|
||||||
|
from the `.ods` spreadsheet) and **Transfem Science** — since v1.4.0 the TFS
|
||||||
|
model is the **official 3-compartment meta-analysis** (exact closed form,
|
||||||
|
parameters from the [TFS simulator](https://transfemscience.org/misc/injectable-e2-simulator/),
|
||||||
|
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
||||||
|
covering **7 esters** (EV, EU, EEn, EB, EC oil, EC suspension, PEP) — WHSAH
|
||||||
|
covers 6 (no PEP). Displayed side by side with independent toggles (3 colors) —
|
||||||
|
**pre-checked according to your treatments' models** (v1.4.7) and **each model
|
||||||
|
calibrated separately** by your labs (v1.4.8 — scale factors adapt to each profile)
|
||||||
|
- **"Lab track" curve (v1.5.0, extended in v1.6.0)**: the curve anchored on your
|
||||||
|
blood tests (`Lab track` chip, off by default) — the PK model's shape between
|
||||||
|
labs, with the amplitude rescaled to pass EXACTLY through every E2 lab value;
|
||||||
|
log-linear transition from one lab to the next, keeps out-of-action-window labs
|
||||||
|
(#61); **`Extend` chip (v1.6.0)**: the curve extends beyond the last lab
|
||||||
|
(model × last lab ratio, horizon = model extinction, estimated part drawn
|
||||||
|
faded + dedicated legend) — the curve is a COMPARISON reference, never an
|
||||||
|
estimate of action (it never feeds the home screen or the alerts)
|
||||||
|
- **Analytical Estrannaise model (v1.9.0)**: the ESE model now uses the 3C
|
||||||
|
closed form published by estrannaise.js (fidelity to the old tables pinned at
|
||||||
|
RMS 0) — **MCMC uncertainty cloud** (`Cloud` chip, ESE-exclusive: 32 posterior
|
||||||
|
curves showing the imprecision range, like the estrannaise website); **6
|
||||||
|
injectable esters** (incl. EUCS, exclusive); the historical ODS is no longer
|
||||||
|
used at runtime (fidelity = tests only); analytical terminal t½
|
||||||
|
(blood draw recommendation)
|
||||||
|
- **Configurable Bateman model** (time to peak, half-life, bioavailability) for
|
||||||
|
gel, patch and oral routes
|
||||||
|
- **Forecast simulation**: set a **Dosage interval** (days) on a treatment →
|
||||||
|
upcoming doses are projected on the chart (never saved); **enabling forecast
|
||||||
|
does not move the chart start** but EXTENDS the window to the next dose
|
||||||
|
(v1.4.4), and the projection is browsed by dragging left, up to **1 year**
|
||||||
|
- **Dose log** with exact date/time, dose in mg, **per-injection ester**
|
||||||
|
(EV↔EU↔EEn switch like in the spreadsheet), **editable** (tap a row in Doses),
|
||||||
|
**interval in days between doses** displayed
|
||||||
|
- **Inactive treatments**: an archived treatment disappears from input and
|
||||||
|
reminders, but **its whole history stays simulated and calibrated** — useful
|
||||||
|
for a valerate → enanthate transition
|
||||||
|
- **Blood tests**: **E2 + T in a single entry** (each optional), displayed
|
||||||
|
**side by side** when they share the same date/time, editable (tap → pick the
|
||||||
|
entry); T units: ng/mL, ng/dL, ng/L, nmol/L
|
||||||
|
- **Suggested next blood draw (v1.8.0, extended v1.8.1 to the Labs and Home pages)**:
|
||||||
|
the Labs page recommends the **estimated trough just before the next injection**
|
||||||
|
(the most comparable moment), **at the first trough where your regimen is
|
||||||
|
stabilized** (~5 half-lives after your last change — dose, ester or interval) —
|
||||||
|
derived from the curves and the Dosage interval; requires an active injectable
|
||||||
|
E2 with a Dosage (a prompt suggests setting one otherwise); estimates, never
|
||||||
|
medical advice
|
||||||
|
- **Calibration**: per-treatment scale factor = median(lab ÷ model prediction),
|
||||||
|
computed automatically (the `.ods` "Scale factor", automated) — or **permanent
|
||||||
|
automatic calibration** (option, off by default) which calibrates **each ester
|
||||||
|
with the labs of its own period** (valerate labs → valerate doses, enanthate
|
||||||
|
labs → enanthate doses) and recalibrates the T model
|
||||||
|
- **Empirical T estimation** `T = floor + (base − floor) ÷ (1 + k·E2)`, with
|
||||||
|
**k calibrated per ester period** (T suppression differs valerate vs enanthate),
|
||||||
|
against the already-calibrated E2 — accepted T units: ng/mL, ng/dL, ng/L, nmol/L
|
||||||
|
- **Daily reminders** with **"Taken" / "Snooze 1 h"** actions right in the
|
||||||
|
notification; notifications are mirrored to the GT 3 watch (Gadgetbridge or
|
||||||
|
Huawei Health); **reminders follow the Dosage interval** (v1.4.0): a treatment
|
||||||
|
injected every 7 days only rings on injection day, not every day
|
||||||
|
- **Configurable alert thresholds** (v1.4.2): E2 (pg/mL) and T (ng/mL) high/low
|
||||||
|
limits in Settings → warning card on the home screen + **notification every
|
||||||
|
15 min even with the app closed** (WorkManager, anti-spam, dedicated channel) —
|
||||||
|
evaluated on the **estimated** level, opt-in
|
||||||
|
- **Full JSON backup**: treatments + doses + labs + T settings
|
||||||
|
**+ parameters (language, auto-calibration, alert thresholds)** (v1.4.2)
|
||||||
|
- **Daily automatic backup (v1.7.0, opt-in)**: IN ADDITION to the manual export —
|
||||||
|
every day, a full JSON backup (same format, directly importable) is written to
|
||||||
|
**the folder you choose** (e.g. a synchronized Owncloud folder). No storage
|
||||||
|
permission (SAF, folder picked once, persistent revocable permission),
|
||||||
|
configurable retention (1–30 copies, default 7 — timestamped file per run,
|
||||||
|
never overwritten; manual exports and foreign files **never touched**), last
|
||||||
|
run status in Settings, first backup immediately on activation
|
||||||
|
- **What's new on every update**: automatic changelog dialog (dismissed =
|
||||||
|
won't show again before the next version)
|
||||||
|
- **Time on HRT** displayed at the top of the Doses page (since the first dose);
|
||||||
|
**next dose in days** on the home screen when it is more than 24 h away (v1.4.1)
|
||||||
|
- **Exportable diagnostic logs** (Settings) — useful for support
|
||||||
|
- **Calendar events**: dose reminders can create a recurring (posology) event in
|
||||||
|
a "HormoneTrack" calendar on your phone
|
||||||
|
- **Full JSON backup/restore** (treatments + doses + labs + T settings + parameters, v1.4.2)
|
||||||
|
- **FR + EN** (per-app language, independent of the system)
|
||||||
|
- Jetpack Compose UI (BOM 2026.08, Material You); 100% local, no account
|
||||||
|
|
||||||
|
## Quick start (building from source)
|
||||||
|
|
||||||
|
Prerequisites: JDK 17+ (Java 21 OK), Android SDK (platform 37 will be
|
||||||
|
auto-downloaded by AGP if the licenses are signed). The wrapper downloads
|
||||||
|
Gradle 9.7.1.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo> && cd HormoneTrack
|
||||||
|
echo "sdk.dir=/path/to/android-sdk" > local.properties # or ANDROID_HOME
|
||||||
|
./gradlew assembleDebug # APK: app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
./gradlew testDebugUnitTest # 249 tests (219 without local test data)
|
||||||
|
./gradlew connectedDebugAndroidTest # 13 UI tests (emulator/device required)
|
||||||
|
./gradlew lint # clean lint required before a release
|
||||||
|
```
|
||||||
|
|
||||||
|
Installing on a phone: developer mode + USB debugging, then Android Studio
|
||||||
|
(**Run ▶️**) or `adb install -r app/build/outputs/apk/debug/app-debug.apk`.
|
||||||
|
No Play Store: the app is sideloaded. Step-by-step details (French):
|
||||||
|
[docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md).
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
Repositories: **gitea.cloudyfy.fr** and **gitea.farewell.dev** (mirror) —
|
||||||
|
`Siphonight/HormoneTrack` on both (private), with **tagged releases**
|
||||||
|
(`v1.1.0` → `v1.9.8`) and **two APKs per release** (since v1.2.5)
|
||||||
|
(downloadable without building, cf
|
||||||
|
[docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)):
|
||||||
|
`-release.apk` (**recommended**, R8-optimized, 2.7 MB) and `-debug.apk` (20 MB):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag # list releases
|
||||||
|
git log --oneline # layer-by-layer history (toolchain / engine / UI / docs)
|
||||||
|
git push -u origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web port**: since v1.4.10, the browser version lives in its own repository
|
||||||
|
`Siphonight/HormoneTrack-web` (same instances) — **aligned versions**
|
||||||
|
(web v1.4.10 = port of Android v1.4.10), synced releases (checklist §16 step
|
||||||
|
4.bis), separate changelogs.
|
||||||
|
|
||||||
|
Every release commit passes `./gradlew testDebugUnitTest` (green required) and
|
||||||
|
is an annotated tag. See [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
|
||||||
|
|
||||||
|
**Release signing**: the release APK is signed with the debug key as long as
|
||||||
|
no `keystore.properties` exists at the repo root; once created via
|
||||||
|
`scripts/make-release-keystore.sh` (both files gitignored), `assembleRelease`
|
||||||
|
signs with that keystore — phone migration documented in
|
||||||
|
[docs/DEVELOPPEMENT.md §16.quater](docs/DEVELOPPEMENT.md).
|
||||||
|
|
||||||
|
## The models in short
|
||||||
|
|
||||||
|
Each injection contributes `dose_mg × profile(dt)` where `profile` is the
|
||||||
|
normalized response (pg/mL per mg); contributions superimpose. **Three
|
||||||
|
superimposable models**:
|
||||||
|
|
||||||
|
- **Estrannaise** = hourly tables from the `.ods` (8001 h), EV/EU/EEn
|
||||||
|
- **Transfem Science** = 3-compartment meta-analysis (V3C, closed form,
|
||||||
|
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
||||||
|
7 esters (EV, EU, EEn, EB, EC, EC suspension, PEP)
|
||||||
|
- **WHSAH** (v1.4.6) = "license-free" fit of the [WHSAH Collective via
|
||||||
|
Mona](https://github.com/mona-hrt/mona) — same mathematical family but
|
||||||
|
independent parameters with explicit bioavailability F < 1: faster rise
|
||||||
|
at D+1 (EEn ~70 pg/mL at D+1 for 5 mg vs ~22 for TFS) and longer decay
|
||||||
|
(EEn t½ 7.3 d vs 4.5 d). 6 esters (no PEP)
|
||||||
|
|
||||||
|
Reference peaks (pg/mL per mg):
|
||||||
|
|
||||||
|
| Profile | Model | Peak (pg/mL/mg) | Tmax | Terminal t½ |
|
||||||
|
|----------|------------------|-----------------|--------|-------------|
|
||||||
|
| EV | Estrannaise | 61.1 | ~45 h | — |
|
||||||
|
| EU | Estrannaise | 3.4 | ~55 h (long plateau) | — |
|
||||||
|
| EEn | Estrannaise | 31.4 | ~152 h | — |
|
||||||
|
| EV | Transfem Science | 59.0 | ~51 h | 3.0 d |
|
||||||
|
| EU | Transfem Science | 10.1 | ~198 h | — |
|
||||||
|
| EEn | Transfem Science | 32.0 | ~156 h | 4.5 d |
|
||||||
|
| EB | Transfem Science | 194.2 | ~16 h | 1.2 d |
|
||||||
|
| EC (oil) | Transfem Science | 31.1 | ~103 h | 6.7 d |
|
||||||
|
| EC (susp.) | Transfem Science | 48.2 | ~29 h | 5.1 d |
|
||||||
|
| PEP | Transfem Science | 1.03 (~6.5× dose) | ~18 d | 28.4 d |
|
||||||
|
| EV | WHSAH | 73.5 | ~41 h | 3.1 d |
|
||||||
|
| EEn | WHSAH | 37.6 | ~120 h | 7.3 d |
|
||||||
|
| EB | WHSAH | 260.1 | ~12 h | 1.3 d |
|
||||||
|
| EC (oil) | WHSAH | 25.0 | ~81 h | 7.9 d |
|
||||||
|
| EC (susp.) | WHSAH | 53.5 | ~16 h | 7.1 d |
|
||||||
|
| EU | WHSAH | 4.9 | ~67 h | 31.7 d |
|
||||||
|
|
||||||
|
**Calibration (v1.4.8)**: the scale factor applies per ESTER and per
|
||||||
|
injection PERIOD (median of lab ÷ prediction ratios, like the original
|
||||||
|
spreadsheet's "Scale factor" column) — and per MODEL: every displayed curve
|
||||||
|
(Estrannaise / Transfem Science / WHSAH) is calibrated with ITS OWN model's
|
||||||
|
prediction → they all stick to your labs, whatever their shape.
|
||||||
|
Auto-calibration is optional (off by default).
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
- **The app is 100% local**: Room database on the phone, no server, no
|
||||||
|
telemetry; Gadgetbridge-compatible (FOSS) with no Huawei Health dependency
|
||||||
|
- **The repository contains no health data**: code and docs are generic; the
|
||||||
|
regression tests that use real exports load their data from
|
||||||
|
`local-test-data/` (**gitignored**, outside the repo — and the history was
|
||||||
|
cleaned before the first push, cf
|
||||||
|
[docs/DEVELOPPEMENT.md §8.bis](docs/DEVELOPPEMENT.md))
|
||||||
|
- Backups = JSON files you store wherever you want (Owncloud, etc.)
|
||||||
|
- **The daily auto-backup (v1.7.0) only writes to the folder YOU picked
|
||||||
|
yourself** (system SAF picker, revocable permission) — nothing ever leaves
|
||||||
|
the phone, no server
|
||||||
|
- `allowBackup=false` (sensitive data); biometric lock planned for Phase 2
|
||||||
|
- The repository is **private**: release APKs download while logged in;
|
||||||
|
making the repo public would make the APKs downloadable without an account
|
||||||
|
(no data risk, see above)
|
||||||
|
|
||||||
|
## Repository structure
|
||||||
|
|
||||||
|
```
|
||||||
|
HormoneTrack/
|
||||||
|
├── README.md ← this file's French twin
|
||||||
|
├── docs/
|
||||||
|
│ ├── GUIDE_INSTALLATION.md user guide (phone + watch, French)
|
||||||
|
│ ├── DEVELOPPEMENT.md full dev docs (architecture, math, bugs, tests, French)
|
||||||
|
│ ├── CHANGELOG.md detailed version history (French)
|
||||||
|
│ └── MONTRE-GADGETBRIDGE.md Huawei GT 3 watch: options + limits (French)
|
||||||
|
├── scripts/
|
||||||
|
│ ├── gitea-release.py publishes a release body (CHANGELOG + APK)
|
||||||
|
│ ├── publish-release.py publishes a tag's 2 APKs (verified by download)
|
||||||
|
│ ├── seed-emulator.py injects a JSON backup into an emulator DB
|
||||||
|
│ └── make-release-keystore.sh generates the release keystore + properties (v1.9.8)
|
||||||
|
├── local-test-data/ ← gitignored: real backups for regression
|
||||||
|
│ tests (NEVER in the repo, cf §8.bis)
|
||||||
|
├── keystore.properties ← gitignored: release signing (optional, §16.quater)
|
||||||
|
├── build.gradle.kts root Gradle config (AGP/Kotlin/KSP pinned)
|
||||||
|
├── settings.gradle.kts
|
||||||
|
├── gradle.properties
|
||||||
|
├── gradle/wrapper/ Gradle 9.7.1 wrapper (jar + properties)
|
||||||
|
├── gradlew / gradlew.bat
|
||||||
|
└── app/
|
||||||
|
├── build.gradle.kts dependencies (Compose, Room, DataStore, Gson…)
|
||||||
|
├── proguard-rules.pro
|
||||||
|
└── src/
|
||||||
|
├── main/
|
||||||
|
│ ├── AndroidManifest.xml
|
||||||
|
│ ├── assets/pk_profiles.json ← hourly tables (Estrannaise/TFS)
|
||||||
|
│ ├── java/com/hormonetrack/
|
||||||
|
│ │ ├── data/ (Room: models, DAOs, repository, backup)
|
||||||
|
│ │ ├── pk/ (PK engine + profiles: Estrannaise/TFS/WHSAH, alerts, export)
|
||||||
|
│ │ ├── reminder/ (exact alarms, notifs + actions, boot, alert worker)
|
||||||
|
│ │ ├── settings/ (DataStore: TConfig, language)
|
||||||
|
│ │ ├── ui/ (Compose: screens, components, theme)
|
||||||
|
│ │ │ └── screens/settings/ ← Settings cards (v1.9.8 split)
|
||||||
|
│ │ ├── HormoneTrackApp.kt
|
||||||
|
│ │ └── MainActivity.kt
|
||||||
|
│ └── res/ (FR/EN strings, theme, icons)
|
||||||
|
├── test/java/com/hormonetrack/ ← JVM unit tests
|
||||||
|
│ ├── pk/ (engine, ESE/TFS/WHSAH profiles, per-model
|
||||||
|
│ │ calibration, reminders, data-driven regressions)
|
||||||
|
│ ├── data/backup/ (Gson round-trip + parameters)
|
||||||
|
│ ├── ui/ (chart window, source guard)
|
||||||
|
│ ├── util/ (export file names, durations)
|
||||||
|
│ └── reminder/ + settings/ (calendar RRULE, changelog)
|
||||||
|
└── androidTest/java/com/hormonetrack/ ← Compose UI tests (v1.9.8)
|
||||||
|
└── uitest/ (navigation, changelog dialog, settings)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [x] v1: E2/T curves, dose log, labs + calibration, reminders, JSON backup, FR/EN
|
||||||
|
- [x] Compose UI tests (13) + dedicated release signing infrastructure (v1.9.8)
|
||||||
|
- [ ] Biometric lock, widget, CSV export
|
||||||
|
- [ ] Watch Phase 2: custom watchface and/or Lite Wearable mini-app (see
|
||||||
|
[docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md))
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
**GPL-3.0** — see [LICENSE](LICENSE). Consistent with the Gadgetbridge
|
||||||
|
ecosystem. The PK models belong to their respective authors
|
||||||
|
([Estrannaise](https://estrannaise.github.io/),
|
||||||
|
[Transfem Science](https://transfemscience.org)).
|
||||||
41
README.md
41
README.md
@ -1,5 +1,7 @@
|
|||||||
# HormoneTrack
|
# HormoneTrack
|
||||||
|
|
||||||
|
> **Langue** : Français (ce fichier) · [English](README.en.md)
|
||||||
|
|
||||||
> **🤖 Développé avec l'IA** : ce projet a été conçu et codé avec un assistant IA.
|
> **🤖 Développé avec l'IA** : ce projet a été conçu et codé avec un assistant IA.
|
||||||
> La contribution humaine a été **essentielle** : feedback continu, retours
|
> La contribution humaine a été **essentielle** : feedback continu, retours
|
||||||
> utilisateur (tests sur vrai téléphone, rapports de bugs accompagnés d'exports
|
> utilisateur (tests sur vrai téléphone, rapports de bugs accompagnés d'exports
|
||||||
@ -16,11 +18,12 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
|||||||
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
|
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
|
||||||
> sang et aux consignes de ton endocrinologue.
|
> sang et aux consignes de ton endocrinologue.
|
||||||
|
|
||||||
- **Statut** : v1.9.7 — build Android ✅, **lint vert** ✅, **249 tests unitaires** ✅ (219 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
|
- **Statut** : v1.9.8 — build Android ✅, **lint vert** ✅, **249 tests unitaires** ✅ (219 sans les données de test locales ; régressions épinglées sur données réelles **non versionnées**), **13 tests UI Compose** ✅ (émulateur), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
|
||||||
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
||||||
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
|
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
|
||||||
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
|
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
|
||||||
- **Montre / Gadgetbridge** : [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md)
|
- **Montre / Gadgetbridge** : [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md)
|
||||||
|
- **README anglais** : [README.en.md](README.en.md)
|
||||||
|
|
||||||
## Fonctionnalités
|
## Fonctionnalités
|
||||||
|
|
||||||
@ -124,6 +127,7 @@ git clone <repo> && cd HormoneTrack
|
|||||||
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
||||||
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
||||||
./gradlew testDebugUnitTest # 249 tests (219 sans les données locales)
|
./gradlew testDebugUnitTest # 249 tests (219 sans les données locales)
|
||||||
|
./gradlew connectedDebugAndroidTest # 13 tests UI (émulateur/appareil requis)
|
||||||
./gradlew lint # lint vert obligatoire avant release
|
./gradlew lint # lint vert obligatoire avant release
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -135,9 +139,9 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
|
|||||||
|
|
||||||
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
|
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
|
||||||
`Siphonight/HormoneTrack` sur les deux (privé), avec
|
`Siphonight/HormoneTrack` sur les deux (privé), avec
|
||||||
**releases taguées** (`v1.1.0` → `v1.8.1`) et **deux APK par release** (depuis v1.2.5)
|
**releases taguées** (`v1.1.0` → `v1.9.8`) et **deux APK par release** (depuis v1.2.5)
|
||||||
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
|
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
|
||||||
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :
|
`-release.apk` (**recommandé**, optimisé R8, 2,7 Mo) et `-debug.apk` (20 Mo) :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag # lister les releases
|
git tag # lister les releases
|
||||||
@ -153,6 +157,12 @@ dépôt `Siphonight/HormoneTrack-web` (mêmes instances) — versions **alignée
|
|||||||
Chaque commit de release passe `./gradlew testDebugUnitTest` (vert obligatoire) et est
|
Chaque commit de release passe `./gradlew testDebugUnitTest` (vert obligatoire) et est
|
||||||
taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
|
taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
|
||||||
|
|
||||||
|
**Signature release** : l'APK release est signé avec la clé debug tant qu'aucun
|
||||||
|
`keystore.properties` n'existe à la racine ; dès sa création via
|
||||||
|
`scripts/make-release-keystore.sh` (fichiers gitignorés), `assembleRelease`
|
||||||
|
signe avec ce keystore — migration téléphone documentée dans
|
||||||
|
[docs/DEVELOPPEMENT.md §16.quater](docs/DEVELOPPEMENT.md).
|
||||||
|
|
||||||
## Les modèles en bref
|
## Les modèles en bref
|
||||||
|
|
||||||
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée
|
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée
|
||||||
@ -217,7 +227,7 @@ forme. L'auto-calibration est optionnelle (désactivée par défaut).
|
|||||||
|
|
||||||
```
|
```
|
||||||
HormoneTrack/
|
HormoneTrack/
|
||||||
├── README.md ← ce fichier
|
├── README.md ← ce fichier (README.en.md = version anglaise)
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── GUIDE_INSTALLATION.md guide utilisateur (téléphone + montre)
|
│ ├── GUIDE_INSTALLATION.md guide utilisateur (téléphone + montre)
|
||||||
│ ├── DEVELOPPEMENT.md doc de dev complète (architecture, maths, bugs, tests)
|
│ ├── DEVELOPPEMENT.md doc de dev complète (architecture, maths, bugs, tests)
|
||||||
@ -226,9 +236,11 @@ HormoneTrack/
|
|||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── gitea-release.py publie le corps d'une release (CHANGELOG + APK)
|
│ ├── gitea-release.py publie le corps d'une release (CHANGELOG + APK)
|
||||||
│ ├── publish-release.py publie les 2 APK d'un tag (vérification par téléchargement)
|
│ ├── publish-release.py publie les 2 APK d'un tag (vérification par téléchargement)
|
||||||
│ └── seed-emulator.py injecte un backup JSON dans la DB d'un émulateur (cf §16.ter)
|
│ ├── seed-emulator.py injecte un backup JSON dans la DB d'un émulateur (cf §16.ter)
|
||||||
|
│ └── make-release-keystore.sh génère le keystore de signature release (v1.9.8, §16.quater)
|
||||||
├── local-test-data/ ← gitignoré : backups réels pour les tests
|
├── local-test-data/ ← gitignoré : backups réels pour les tests
|
||||||
│ de régression (JAMAIS dans le dépôt, cf §8.bis)
|
│ de régression (JAMAIS dans le dépôt, cf §8.bis)
|
||||||
|
├── keystore.properties ← gitignoré : signature release (optionnel, §16.quater)
|
||||||
├── build.gradle.kts config Gradle racine (AGP/Kotlin/KSP épinglés)
|
├── build.gradle.kts config Gradle racine (AGP/Kotlin/KSP épinglés)
|
||||||
├── settings.gradle.kts
|
├── settings.gradle.kts
|
||||||
├── gradle.properties
|
├── gradle.properties
|
||||||
@ -247,22 +259,25 @@ HormoneTrack/
|
|||||||
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes)
|
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes)
|
||||||
│ │ ├── settings/ (DataStore : TConfig, langue)
|
│ │ ├── settings/ (DataStore : TConfig, langue)
|
||||||
│ │ ├── ui/ (Compose : screens, components, theme)
|
│ │ ├── ui/ (Compose : screens, components, theme)
|
||||||
|
│ │ │ └── screens/settings/ ← cartes des Paramètres (découpage v1.9.8)
|
||||||
│ │ ├── HormoneTrackApp.kt
|
│ │ ├── HormoneTrackApp.kt
|
||||||
│ │ └── MainActivity.kt
|
│ │ └── MainActivity.kt
|
||||||
│ └── res/ (strings FR/EN, thème, icônes)
|
│ └── res/ (strings FR/EN, thème, icônes)
|
||||||
└── test/java/com/hormonetrack/ ← tests unitaires JVM
|
├── test/java/com/hormonetrack/ ← tests unitaires JVM
|
||||||
├── pk/ (moteur, profils ESE/TFS/WHSAH, calibration
|
│ ├── pk/ (moteur, profils ESE/TFS/WHSAH, calibration
|
||||||
│ par modèle, rappels, régressions data-driven)
|
│ │ par modèle, rappels, régressions data-driven)
|
||||||
├── data/backup/ (round-trip Gson + paramètres)
|
│ ├── data/backup/ (round-trip Gson + paramètres)
|
||||||
├── ui/ (fenêtre graphique, garde de source)
|
│ ├── ui/ (fenêtre graphique, garde de source)
|
||||||
├── util/ (noms de fichiers d'export, durées)
|
│ ├── util/ (noms de fichiers d'export, durées)
|
||||||
├── reminder/ + settings/ (RRULE agenda, changelog)
|
│ └── reminder/ + settings/ (RRULE agenda, changelog)
|
||||||
|
└── androidTest/java/com/hormonetrack/ ← tests UI Compose (v1.9.8)
|
||||||
|
└── uitest/ (navigation, dialog changelog, Paramètres)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Feuille de route
|
## Feuille de route
|
||||||
|
|
||||||
- [x] v1 : courbes E2/T, log doses, labs + calibration, rappels, backup JSON, FR/EN
|
- [x] v1 : courbes E2/T, log doses, labs + calibration, rappels, backup JSON, FR/EN
|
||||||
- [ ] Tests UI Compose + compilation release signée
|
- [x] Tests UI Compose (13) + infrastructure de compilation release signée (v1.9.8)
|
||||||
- [ ] Verrou biométrique, widget, export CSV
|
- [ ] Verrou biométrique, widget, export CSV
|
||||||
- [ ] Phase 2 montre : watchface personnalisée et/ou mini-app Lite Wearable (voir [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md))
|
- [ ] Phase 2 montre : watchface personnalisée et/ou mini-app Lite Wearable (voir [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md))
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,25 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
id("com.google.devtools.ksp")
|
id("com.google.devtools.ksp")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Signature RELEASE (v1.9.8) ──────────────────────────────────────────────
|
||||||
|
// keystore.properties (RACINE du dépôt, GITIGNORÉ) décrit le vrai keystore :
|
||||||
|
// storeFile=release.keystore (chemin relatif à la racine du dépôt)
|
||||||
|
// storePassword=…
|
||||||
|
// keyAlias=…
|
||||||
|
// keyPassword=…
|
||||||
|
// Tant que ce fichier est ABSENT, la build release reste signée avec la clé
|
||||||
|
// DEBUG (comportement historique : installation par-dessus l'APK debug sans
|
||||||
|
// perte de données, cf §16.quater). Dès qu'il existe (créé par
|
||||||
|
// scripts/make-release-keystore.sh), la build release l'utilise — ⚠️ signature
|
||||||
|
// différente ⇒ migration obligatoire : export JSON → désinstallation →
|
||||||
|
// réinstallation → réimport (documenté §16.quater).
|
||||||
|
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.hormonetrack"
|
namespace = "com.hormonetrack"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
@ -17,8 +33,8 @@ android {
|
|||||||
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
||||||
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
||||||
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
||||||
versionCode = 44
|
versionCode = 45
|
||||||
versionName = "1.9.7"
|
versionName = "1.9.8"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@ -35,12 +51,27 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
// ⚠️ Signé avec la CLÉ DEBUG volontairement : l'APK release reste
|
// Signature : VRAI keystore si keystore.properties existe (racine du
|
||||||
// installable PAR-DESSUS l'APK debug déjà installé (même signature),
|
// dépôt, gitignoré — créé par scripts/make-release-keystore.sh) ;
|
||||||
// sans désinstallation ni perte de données, tout en bénéficiant de
|
// sinon CLÉ DEBUG volontairement : l'APK release reste installable
|
||||||
// R8. Si un jour on distribue largement : générer un vrai keystore
|
// PAR-DESSUS l'APK debug déjà installé (même signature), sans
|
||||||
// (signature différente → migration backup→désinstall→réimport).
|
// désinstallation ni perte de données, tout en bénéficiant de R8.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// ⚠️ Passer au vrai keystore CHANGE la signature : migration
|
||||||
|
// backup→désinstall→réimport requise sur le téléphone (§16.quater).
|
||||||
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
|
val props = Properties().apply {
|
||||||
|
keystorePropertiesFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
signingConfigs.create("release") {
|
||||||
|
storeFile = rootProject.file(props.getProperty("storeFile"))
|
||||||
|
storePassword = props.getProperty("storePassword")
|
||||||
|
keyAlias = props.getProperty("keyAlias")
|
||||||
|
keyPassword = props.getProperty("keyPassword")
|
||||||
|
}
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,4 +160,14 @@ dependencies {
|
|||||||
|
|
||||||
// Tests unitaires JVM
|
// Tests unitaires JVM
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
|
||||||
|
// ── Tests UI Compose (v1.9.8, androidTest — cf docs/DEVELOPPEMENT.md §8.ter) ──
|
||||||
|
// Exécution : ./gradlew connectedDebugAndroidTest (émulateur/appareil requis).
|
||||||
|
// Le BOM est réappliqué sur la configuration androidTest pour épinner les
|
||||||
|
// mêmes versions que le runtime Compose de l'app.
|
||||||
|
androidTestImplementation(composeBom)
|
||||||
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
|
||||||
|
// JUnit explicite (org.junit.Rule/@Test) — ne pas compter sur le transitif
|
||||||
|
androidTestImplementation("junit:junit:4.13.2")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,108 @@
|
|||||||
|
package com.hormonetrack.uitest
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.hormonetrack.BuildConfig
|
||||||
|
import com.hormonetrack.MainActivity
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.BeforeClass
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test UI du dialog « Nouveautés » (v1.9.8) — épinglé sur les comportements
|
||||||
|
* historiques des fixes v1.3.2/v1.3.3 (docs/DEVELOPPEMENT.md §14) :
|
||||||
|
* - affiché quand la version installée est plus récente que la dernière vue ;
|
||||||
|
* - PAS affiché quand la version courante a déjà été vue ;
|
||||||
|
* - une fois FERMÉ, il ne RÉAPPARAÎT PAS au redémarrage (la version vue est
|
||||||
|
* mémorisée AVANT l'affichage — sinon le même changelog ressortait à
|
||||||
|
* chaque réouverture de l'app, bug v1.3.2).
|
||||||
|
*
|
||||||
|
* ⚠️ Technique : ces tests lancent l'activité MANUELLEMENT (ActivityScenario
|
||||||
|
* + règle Compose vide) et non via createAndroidComposeRule, car l'état
|
||||||
|
* DataStore (changelogSeenVersion) doit être réglé AVANT que
|
||||||
|
* HormoneTrackRoot ne lise la version vue dans son LaunchedEffect de
|
||||||
|
* démarrage. La règle automatique lancerait l'activité trop tôt (avant le
|
||||||
|
* @Before du test) et le dialog du test précédent fausserait l'état.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ChangelogDialogTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createEmptyComposeRule()
|
||||||
|
|
||||||
|
private var scenario: ActivityScenario<MainActivity>? = null
|
||||||
|
|
||||||
|
private fun res(id: Int, vararg args: Any): String =
|
||||||
|
UiTestEnv.targetContext.getString(id, *args)
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
scenario?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dialogShownWhenInstalledVersionIsNewerThanLastSeen() {
|
||||||
|
runBlocking {
|
||||||
|
UiTestEnv.app.container.settings.setChangelogSeenVersion("1.0.0")
|
||||||
|
}
|
||||||
|
scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dialogAbsentWhenVersionAlreadySeen() {
|
||||||
|
runBlocking {
|
||||||
|
UiTestEnv.app.container.settings.setChangelogSeenVersion(BuildConfig.VERSION_NAME)
|
||||||
|
}
|
||||||
|
scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||||
|
).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dialogDoesNotReappearAfterDismissAndRestart() {
|
||||||
|
runBlocking {
|
||||||
|
UiTestEnv.app.container.settings.setChangelogSeenVersion("1.0.0")
|
||||||
|
}
|
||||||
|
scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
// Fermeture par le bouton « OK » (identique FR/EN)
|
||||||
|
composeRule.onNodeWithText(res(R.string.ok)).performClick()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||||
|
).assertDoesNotExist()
|
||||||
|
// Redémarrage de l'activité : le dialog ne doit PAS revenir — la
|
||||||
|
// version vue a été persistée AVANT l'affichage (fix v1.3.2).
|
||||||
|
scenario!!.recreate()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||||
|
).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@BeforeClass
|
||||||
|
@JvmStatic
|
||||||
|
fun setUpApp() {
|
||||||
|
// Permission de notifications uniquement : le changelog est réglé
|
||||||
|
// PAR TEST (avant chaque lancement manuel d'activité).
|
||||||
|
UiTestEnv.grantPostNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
package com.hormonetrack.uitest
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.hormonetrack.MainActivity
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import org.junit.BeforeClass
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test UI de NAVIGATION (v1.9.8) : la barre d'onglets mène bien à chaque
|
||||||
|
* écran, et les Paramètres s'ouvrent depuis l'icône de l'accueil.
|
||||||
|
*
|
||||||
|
* Les preuves de navigation utilisent des éléments **inconditionnels**
|
||||||
|
* (indépendants des données Room, contrairement au contenu des listes) :
|
||||||
|
* - Graphiques → le chip de plage « 24 h » (toujours affiché) ;
|
||||||
|
* - Doses / Analyses / Traitements → leur FloatingActionButton « + »
|
||||||
|
* (contentDescription add_dose / add_lab / add_treatment) ;
|
||||||
|
* - Paramètres → le titre de l'écran + les sections toujours présentes.
|
||||||
|
*
|
||||||
|
* Textes résolus via targetContext (cf UiTestEnv) : indépendant de la langue
|
||||||
|
* système et du réglage in-app FR/EN.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class NavigationTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bottomBarShowsAllFiveTabs() {
|
||||||
|
listOf(
|
||||||
|
R.string.nav_home, R.string.nav_chart, R.string.nav_doses,
|
||||||
|
R.string.nav_labs, R.string.nav_treatments
|
||||||
|
).forEach { labelRes ->
|
||||||
|
composeRule.onNodeWithText(res(labelRes)).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun homeTabShowsAppTitle() {
|
||||||
|
// Retour explicite à l'accueil d'abord : l'état sauvegardé de la
|
||||||
|
// navigation peut restaurer l'onglet du test précédent.
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithText(res(R.string.app_name)).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chartTabShowsRangeChips() {
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_chart)).performClick()
|
||||||
|
composeRule.onNodeWithText(res(R.string.chart_24h)).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText(res(R.string.chart_7j)).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText(res(R.string.chart_30j)).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dosesTabShowsLogDoseFab() {
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_doses)).performClick()
|
||||||
|
composeRule.onNodeWithContentDescription(res(R.string.add_dose))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun labsTabShowsAddLabFab() {
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_labs)).performClick()
|
||||||
|
composeRule.onNodeWithContentDescription(res(R.string.add_lab))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun treatmentsTabShowsAddTreatmentFab() {
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_treatments)).performClick()
|
||||||
|
composeRule.onNodeWithContentDescription(res(R.string.add_treatment))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsOpensFromHomeGearIcon() {
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithContentDescription(res(R.string.settings)).performClick()
|
||||||
|
// Titre de l'écran + bouton retour (TopAppBar des Paramètres)
|
||||||
|
composeRule.onNodeWithText(res(R.string.settings)).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithContentDescription(res(R.string.back))
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@BeforeClass
|
||||||
|
@JvmStatic
|
||||||
|
fun setUpApp() = UiTestEnv.prepare()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
package com.hormonetrack.uitest
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.hormonetrack.MainActivity
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import org.junit.BeforeClass
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test UI de l'écran Paramètres (v1.9.8) : chaque SECTION est présente après
|
||||||
|
* l'ouverture depuis l'accueil. Les sections profondes (sauvegarde, logs,
|
||||||
|
* à propos) vivent SOUS le pli de l'écran scrollable → `assertExists`
|
||||||
|
* (présence dans la hiérarchie) et non `assertIsDisplayed` (visibilité à
|
||||||
|
* l'écran) : ce test vérifie la STRUCTURE, pas la mise en page.
|
||||||
|
*
|
||||||
|
* Les chips de langue « Français » / « English » sont des LITTÉRAUX dans le
|
||||||
|
* code (chaque langue s'écrit dans sa propre langue — convention UX i18n) :
|
||||||
|
* l'assertion ne dépend d'aucune ressource.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SettingsScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
|
||||||
|
|
||||||
|
private fun openSettings() {
|
||||||
|
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||||
|
composeRule.onNodeWithContentDescription(res(R.string.settings)).performClick()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun languageSectionShowsLocalizedChips() {
|
||||||
|
openSettings()
|
||||||
|
composeRule.onNodeWithText(res(R.string.language)).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Français").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("English").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allSettingsSectionsArePresent() {
|
||||||
|
openSettings()
|
||||||
|
listOf(
|
||||||
|
R.string.auto_calibrate, // Calibration automatique
|
||||||
|
R.string.t_model_title, // Estimation T
|
||||||
|
R.string.alert_section, // Seuils d'alerte
|
||||||
|
R.string.timezone_section, // Fuseau du graphique
|
||||||
|
R.string.reminders_section, // Rappels / alarmes exactes
|
||||||
|
R.string.backup_section, // Sauvegarde JSON manuelle
|
||||||
|
R.string.autobackup_section, // Auto-backup quotidien
|
||||||
|
R.string.logs_section, // Logs de diagnostic
|
||||||
|
R.string.about_title // À propos / version
|
||||||
|
).forEach { sectionRes ->
|
||||||
|
composeRule.onNodeWithText(res(sectionRes)).assertExists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aboutSectionShowsInstalledVersion() {
|
||||||
|
openSettings()
|
||||||
|
// « Version : X.Y.Z » — la valeur vient de BuildConfig.VERSION_NAME
|
||||||
|
// (épinglé : les tags v1.3.0→v1.3.2 embarquaient un versionName
|
||||||
|
// jamais bumpé, bug §14 #46 — ce test l'attraperait désormais).
|
||||||
|
val expected = UiTestEnv.targetContext.getString(
|
||||||
|
R.string.settings_version, com.hormonetrack.BuildConfig.VERSION_NAME
|
||||||
|
)
|
||||||
|
composeRule.onNodeWithText(expected).assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@BeforeClass
|
||||||
|
@JvmStatic
|
||||||
|
fun setUpApp() = UiTestEnv.prepare()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
package com.hormonetrack.uitest
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.hormonetrack.BuildConfig
|
||||||
|
import com.hormonetrack.HormoneTrackApp
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environnement partagé des tests UI Compose (v1.9.8, cf docs/DEVELOPPEMENT.md
|
||||||
|
* §8.ter). Les tests instrumentés tournent CONTRE l'app réelle installée :
|
||||||
|
* ils doivent donc être **déterministes quel que soit l'état de l'appareil**
|
||||||
|
* (données Room seedées ou non, locale système, version précédemment vue).
|
||||||
|
*
|
||||||
|
* Deux prérequis réglés ici AVANT le lancement de l'activité :
|
||||||
|
* - **permission POST_NOTIFICATIONS** : MainActivity la demande au premier
|
||||||
|
* lancement (API 33+). Sans pré-grant, le dialog SYSTÈME s'afficherait par
|
||||||
|
* dessus l'app et les assertions Compose ne voient que la fenêtre de
|
||||||
|
* l'app → timeout. Le grant shell (`pm grant`) est persistant.
|
||||||
|
* - **dialog « Nouveautés » neutralisé** : HormoneTrackRoot affiche le
|
||||||
|
* changelog au lancement quand la version installée est plus récente que
|
||||||
|
* la dernière vue — un dialog MODAL qui masquerait toute la navigation.
|
||||||
|
* En marquant la version courante comme vue, il ne s'affiche plus. Les
|
||||||
|
* tests dédiés au dialog (ChangelogDialogTest) relancent l'activité
|
||||||
|
* manuellement APRÈS avoir réglé leur propre état.
|
||||||
|
*
|
||||||
|
* Les assertions de texte passent par `targetContext.getString(...)` : la
|
||||||
|
* ressource est résolue dans la MÊME configuration que celle de l'UI (les
|
||||||
|
* per-app locales d'AppCompat s'appliquent à toute l'application), ce qui
|
||||||
|
* rend les tests indépendants de la langue système OU du réglage in-app.
|
||||||
|
*/
|
||||||
|
object UiTestEnv {
|
||||||
|
|
||||||
|
val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
|
||||||
|
/** L'Application réelle du process instrumenté (même instance que l'UI). */
|
||||||
|
val app: HormoneTrackApp =
|
||||||
|
targetContext.applicationContext as HormoneTrackApp
|
||||||
|
|
||||||
|
/** Exécute une commande shell et ATTEND sa fin (lecture jusqu'à EOF). */
|
||||||
|
fun runShellCommand(command: String) {
|
||||||
|
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||||
|
automation.executeShellCommand(command).use { pfd ->
|
||||||
|
java.io.FileInputStream(pfd.fileDescriptor).use { it.readBytes() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pré-grante la permission de notifications (idempotent, API 33+). */
|
||||||
|
fun grantPostNotifications() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
runShellCommand("pm grant com.hormonetrack android.permission.POST_NOTIFICATIONS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marque le changelog de la version courante comme déjà vu. */
|
||||||
|
fun markChangelogSeen() = runBlocking {
|
||||||
|
app.container.settings.setChangelogSeenVersion(BuildConfig.VERSION_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** À appeler dans le @BeforeClass de chaque classe de test UI. */
|
||||||
|
fun prepare() {
|
||||||
|
grantPostNotifications()
|
||||||
|
markChangelogSeen()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,17 +1,10 @@
|
|||||||
package com.hormonetrack.ui.screens
|
package com.hormonetrack.ui.screens
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@ -19,16 +12,10 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@ -47,49 +34,80 @@ import androidx.appcompat.app.AppCompatDelegate
|
|||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import com.hormonetrack.R
|
import com.hormonetrack.R
|
||||||
import com.hormonetrack.data.backup.BackupManager
|
import com.hormonetrack.data.backup.BackupManager
|
||||||
import com.hormonetrack.util.AppLog
|
import com.hormonetrack.data.backup.UserSettings
|
||||||
import com.hormonetrack.pk.Alerts
|
import com.hormonetrack.pk.Alerts
|
||||||
import com.hormonetrack.pk.PharmacokineticEngine
|
|
||||||
import com.hormonetrack.pk.TConfig
|
import com.hormonetrack.pk.TConfig
|
||||||
import com.hormonetrack.reminder.AlarmScheduler
|
import com.hormonetrack.reminder.AlarmScheduler
|
||||||
import com.hormonetrack.ui.LocalAppContainer
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import com.hormonetrack.ui.screens.settings.AboutCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.AlertThresholdsCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.AutoBackupCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.AutoCalibrationCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.BackupCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.ChartTimezoneCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.LanguageCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.LogsCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.RemindersCard
|
||||||
|
import com.hormonetrack.ui.screens.settings.TModelCard
|
||||||
|
import com.hormonetrack.util.AppLog
|
||||||
import com.hormonetrack.util.ExportFileNames
|
import com.hormonetrack.util.ExportFileNames
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écran PARAMÈTRES (v1.9.8 : découpé en cartes thématiques, cf le
|
||||||
|
* sous-package [com.hormonetrack.ui.screens.settings] — comportement et
|
||||||
|
* rendu IDENTIQUES, chaque carte est un fichier dédié documenté).
|
||||||
|
*
|
||||||
|
* Cet orchestrateur garde ce qui est PARTAGÉ entre cartes :
|
||||||
|
* - la collecte des réglages DataStore (collectAsState) ;
|
||||||
|
* - les 4 launchers SAF (export JSON, export logs, import, dossier
|
||||||
|
* auto-backup) : leurs callbacks écrivent l'état `message` et le dialog
|
||||||
|
* de confirmation d'import, déclarés ICI ;
|
||||||
|
* - le dialog de confirmation d'import (écrasement) ;
|
||||||
|
* - l'ordre d'affichage des cartes.
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(onBack: () -> Unit) {
|
fun SettingsScreen(onBack: () -> Unit) {
|
||||||
val container = LocalAppContainer.current
|
val container = LocalAppContainer.current
|
||||||
val repo = container.repository
|
val repo = container.repository
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scheduler = remember { AlarmScheduler(context) }
|
|
||||||
|
|
||||||
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
||||||
val currentLanguage by container.settings.language.collectAsState(initial = "system")
|
val currentLanguage by container.settings.language.collectAsState(initial = "system")
|
||||||
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
||||||
// Seuils d'alerte (v1.4.2) : limites E2/T évaluées sur l'accueil
|
// Seuils d'alerte (v1.4.2) : limites E2/T évaluées sur l'accueil
|
||||||
val alertThresholds by container.settings.alertThresholds.collectAsState(
|
val alertThresholds by container.settings.alertThresholds.collectAsState(
|
||||||
initial = com.hormonetrack.pk.Alerts.Thresholds()
|
initial = Alerts.Thresholds()
|
||||||
)
|
)
|
||||||
// Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone
|
// Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone
|
||||||
val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?)
|
val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?)
|
||||||
|
|
||||||
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
|
// ── Auto-backup (v1.7.0) : états collectés ici, logique dans la carte ──
|
||||||
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
|
val autoBackupEnabled by container.settings.autoBackupEnabled
|
||||||
var tKText by remember(tConfig.k) { mutableStateOf(formatDoubles(tConfig.k)) }
|
.collectAsState(initial = false)
|
||||||
|
val autoBackupTreeUri by container.settings.autoBackupTreeUri
|
||||||
|
.collectAsState(initial = null as String?)
|
||||||
|
val autoBackupKeep by container.settings.autoBackupKeep.collectAsState(initial = 7)
|
||||||
|
val autoBackupLastRun by container.settings.autoBackupLastRun
|
||||||
|
.collectAsState(initial = null as Pair<String?, Long?>?)
|
||||||
|
|
||||||
var showImportConfirm by remember { mutableStateOf(false) }
|
var showImportConfirm by remember { mutableStateOf(false) }
|
||||||
|
var importJson by remember { mutableStateOf<String?>(null) }
|
||||||
var logLineCount by remember { mutableIntStateOf(AppLog.count()) }
|
var logLineCount by remember { mutableIntStateOf(AppLog.count()) }
|
||||||
// Message de feedback affiché en haut de l'écran (succès/échec des exports,
|
// Message de feedback affiché en haut de l'écran (succès/échec des exports,
|
||||||
// imports, calibration T…) — déclaré AVANT les launchers : le callback de
|
// imports, calibration T…) — déclaré AVANT les launchers : le callback de
|
||||||
// logExportLauncher l'écrit (un state local doit être déclaré avant le
|
// logExportLauncher l'écrit (un state local doit être déclaré avant le
|
||||||
// lambda qui le capture).
|
// lambda qui le capture).
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
var message by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
/** Écrit le feedback depuis le thread principal (les cartes l'appellent). */
|
||||||
|
val showMessage: (String) -> Unit = { message = it }
|
||||||
|
|
||||||
val logExportLauncher = rememberLauncherForActivityResult(
|
val logExportLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.CreateDocument("text/plain")
|
ActivityResultContracts.CreateDocument("text/plain")
|
||||||
) { uri ->
|
) { uri ->
|
||||||
@ -134,7 +152,6 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var importJson by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
val exportLauncher = rememberLauncherForActivityResult(
|
val exportLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.CreateDocument("application/json")
|
ActivityResultContracts.CreateDocument("application/json")
|
||||||
@ -147,7 +164,7 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
// UNE seule collecte des seuils (les 4 champs viennent du même
|
// UNE seule collecte des seuils (les 4 champs viennent du même
|
||||||
// snapshot DataStore — 4 × .first() = 4 collectes redondantes)
|
// snapshot DataStore — 4 × .first() = 4 collectes redondantes)
|
||||||
val thresholds = container.settings.alertThresholds.first()
|
val thresholds = container.settings.alertThresholds.first()
|
||||||
val settings = com.hormonetrack.data.backup.UserSettings(
|
val settings = UserSettings(
|
||||||
language = container.settings.language.first(),
|
language = container.settings.language.first(),
|
||||||
autoCalibrate = container.settings.autoCalibrate.first(),
|
autoCalibrate = container.settings.autoCalibrate.first(),
|
||||||
alertE2High = thresholds.e2High,
|
alertE2High = thresholds.e2High,
|
||||||
@ -186,22 +203,8 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
// L'utilisateur choisit le dossier UNE FOIS (ACTION_OPEN_DOCUMENT_TREE) :
|
// L'utilisateur choisit le dossier UNE FOIS (ACTION_OPEN_DOCUMENT_TREE) :
|
||||||
// la permission persistable survit au reboot, le worker WorkManager écrit
|
// la permission persistable survit au reboot, le worker WorkManager écrit
|
||||||
// ensuite sans intervention — AUCUNE permission de stockage déclarée.
|
// ensuite sans intervention — AUCUNE permission de stockage déclarée.
|
||||||
// pickFolderToEnable distingue les deux entrées du picker :
|
// pickFolderToEnable distingue les deux entrées du picker (switch vs
|
||||||
// - via le SWITCH quand aucun dossier n'existe encore → la sélection
|
// « Choisir le dossier » — cf AutoBackupCard).
|
||||||
// ACTIVE la sauvegarde (+ premier run immédiat) : le switch sans
|
|
||||||
// dossier ne peut rien faire, le picker est la suite logique ;
|
|
||||||
// - via « Choisir le dossier » (changement) → on enregistre le dossier
|
|
||||||
// SANS changer l'état activé/désactivé.
|
|
||||||
val autoBackupEnabled by container.settings.autoBackupEnabled
|
|
||||||
.collectAsState(initial = false)
|
|
||||||
val autoBackupTreeUri by container.settings.autoBackupTreeUri
|
|
||||||
.collectAsState(initial = null as String?)
|
|
||||||
val autoBackupKeep by container.settings.autoBackupKeep.collectAsState(initial = 7)
|
|
||||||
val autoBackupLastRun by container.settings.autoBackupLastRun
|
|
||||||
.collectAsState(initial = null as Pair<String?, Long?>?)
|
|
||||||
var autoBackupKeepText by remember(autoBackupKeep) {
|
|
||||||
mutableStateOf(autoBackupKeep.toString())
|
|
||||||
}
|
|
||||||
var pickFolderToEnable by remember { mutableStateOf(false) }
|
var pickFolderToEnable by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val folderPickerLauncher = rememberLauncherForActivityResult(
|
val folderPickerLauncher = rememberLauncherForActivityResult(
|
||||||
@ -235,6 +238,7 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Assemblage : l'ordre des cartes fait le rendu de l'écran ───────────
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@ -256,503 +260,54 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Language ---
|
LanguageCard(currentLanguage)
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(stringResource(R.string.language), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
FilterChip(
|
|
||||||
selected = currentLanguage == "system",
|
|
||||||
onClick = {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setLanguage("system")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
AppCompatDelegate.setApplicationLocales(
|
|
||||||
LocaleListCompat.getEmptyLocaleList()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(R.string.language_system)) }
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = currentLanguage == "fr",
|
|
||||||
onClick = {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setLanguage("fr")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
AppCompatDelegate.setApplicationLocales(
|
|
||||||
LocaleListCompat.forLanguageTags("fr")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text("Français") }
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = currentLanguage == "en",
|
|
||||||
onClick = {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setLanguage("en")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
AppCompatDelegate.setApplicationLocales(
|
|
||||||
LocaleListCompat.forLanguageTags("en")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text("English") }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
AutoCalibrationCard(autoCalibrate)
|
||||||
// --- Calibration automatique (E2 par période d'ester + T) — UNE seule option ---
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.auto_calibrate), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Switch(
|
|
||||||
checked = autoCalibrate,
|
|
||||||
onCheckedChange = { enabled ->
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setAutoCalibrate(enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.auto_calibrate_hint),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
TModelCard(tConfig, showMessage)
|
||||||
// --- Estimation T (réglages manuels ; l'auto-calibration ci-dessus les
|
|
||||||
// remplace à l'affichage quand elle est activée) ---
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.t_model_hint),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = tBaseText,
|
|
||||||
onValueChange = { tBaseText = it },
|
|
||||||
label = { Text(stringResource(R.string.t_base)) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = tFloorText,
|
|
||||||
onValueChange = { tFloorText = it },
|
|
||||||
label = { Text(stringResource(R.string.t_floor)) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = tKText,
|
|
||||||
onValueChange = { tKText = it },
|
|
||||||
label = { Text("k") },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(onClick = {
|
|
||||||
val base = tBaseText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
|
||||||
val floor = tFloorText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
|
||||||
val k = tKText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setTConfig(TConfig(base, floor, k))
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
message = context.getString(R.string.saved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.save)) }
|
|
||||||
OutlinedButton(onClick = {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
val labs = repo.allLabResultsOnce().filter { it.marker.equals("T", true) }
|
|
||||||
val trs = repo.allTreatmentsOnce()
|
|
||||||
val doses = repo.allDoseLogsOnce()
|
|
||||||
val calibrated = PharmacokineticEngine.computeTConfigCalibration(
|
|
||||||
labs, trs, doses, tConfig
|
|
||||||
)
|
|
||||||
calibrated?.let {
|
|
||||||
container.settings.setTConfig(it)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
message = context.getString(R.string.t_calibrated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.calibrate_from_labs)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
AlertThresholdsCard(alertThresholds, showMessage)
|
||||||
// --- Seuils d'alerte (v1.4.2) : limites E2/T évaluées contre le taux
|
|
||||||
// estimé actuel de l'accueil (pk/Alerts.kt). Opt-in : champ vide =
|
|
||||||
// pas d'alerte. Haut > bas requis si les deux sont définis.
|
|
||||||
var e2HighText by remember(alertThresholds.e2High) {
|
|
||||||
mutableStateOf(alertThresholds.e2High?.let { formatDoubles(it) } ?: "")
|
|
||||||
}
|
|
||||||
var e2LowText by remember(alertThresholds.e2Low) {
|
|
||||||
mutableStateOf(alertThresholds.e2Low?.let { formatDoubles(it) } ?: "")
|
|
||||||
}
|
|
||||||
var tHighText by remember(alertThresholds.tHigh) {
|
|
||||||
mutableStateOf(alertThresholds.tHigh?.let { formatDoubles(it) } ?: "")
|
|
||||||
}
|
|
||||||
var tLowText by remember(alertThresholds.tLow) {
|
|
||||||
mutableStateOf(alertThresholds.tLow?.let { formatDoubles(it) } ?: "")
|
|
||||||
}
|
|
||||||
var alertError by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(stringResource(R.string.alert_section), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.alert_hint),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = e2HighText,
|
|
||||||
onValueChange = { e2HighText = it },
|
|
||||||
label = { Text(stringResource(R.string.alert_e2_high)) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = e2LowText,
|
|
||||||
onValueChange = { e2LowText = it },
|
|
||||||
label = { Text(stringResource(R.string.alert_e2_low)) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = tHighText,
|
|
||||||
onValueChange = { tHighText = it },
|
|
||||||
label = { Text(stringResource(R.string.alert_t_high)) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = tLowText,
|
|
||||||
onValueChange = { tLowText = it },
|
|
||||||
label = { Text(stringResource(R.string.alert_t_low)) },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
alertError?.let {
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Button(onClick = {
|
|
||||||
// Parsing tolérant (virgule décimale FR), champ vide = null
|
|
||||||
fun parse(s: String): Double? =
|
|
||||||
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
|
|
||||||
val e2High = parse(e2HighText)
|
|
||||||
val e2Low = parse(e2LowText)
|
|
||||||
val tHigh = parse(tHighText)
|
|
||||||
val tLow = parse(tLowText)
|
|
||||||
// Saisie invalide (texte non numérique) ou incohérence haut ≤ bas
|
|
||||||
val invalidSyntax = listOf(e2HighText, e2LowText, tHighText, tLowText)
|
|
||||||
.any { it.trim().isNotEmpty() && parse(it) == null }
|
|
||||||
val thresholds = Alerts.Thresholds(e2High, e2Low, tHigh, tLow)
|
|
||||||
when {
|
|
||||||
invalidSyntax || !thresholds.isCoherent() -> {
|
|
||||||
alertError = context.getString(R.string.alert_invalid)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
alertError = null
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setAlertThresholds(thresholds)
|
|
||||||
// v1.4.2 : check IMMÉDIAT (worker one-time) —
|
|
||||||
// si le seuil est déjà franchi, la notification
|
|
||||||
// part sans attendre le cycle périodique de
|
|
||||||
// 15 min (état anti-spam réinitialisé au besoin).
|
|
||||||
com.hormonetrack.reminder.AlertNotifier.checkNow(context)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
message = context.getString(R.string.saved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.save)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
ChartTimezoneCard(chartTimezone, showMessage)
|
||||||
// --- Fuseau horaire du graphique (v1.4.5) : auto (téléphone) ou choix.
|
|
||||||
// Applique l'alignement minuit des labels X dans le fuseau choisi.
|
|
||||||
var tzText by remember(chartTimezone) {
|
|
||||||
mutableStateOf(chartTimezone ?: "")
|
|
||||||
}
|
|
||||||
var tzError by remember { mutableStateOf<String?>(null) }
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(stringResource(R.string.timezone_section), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.timezone_hint),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = tzText,
|
|
||||||
onValueChange = { tzText = it },
|
|
||||||
label = { Text(stringResource(R.string.timezone_field)) },
|
|
||||||
placeholder = { Text(stringResource(R.string.timezone_auto)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
tzError?.let {
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(onClick = {
|
|
||||||
val id = tzText.trim()
|
|
||||||
val valid = id.isEmpty() || try {
|
|
||||||
java.util.TimeZone.getTimeZone(id)
|
|
||||||
.let { !it.id.equals("GMT", true) || id.equals("GMT", true) }
|
|
||||||
} catch (_: Exception) { false }
|
|
||||||
if (!valid) {
|
|
||||||
tzError = context.getString(R.string.timezone_invalid)
|
|
||||||
} else {
|
|
||||||
tzError = null
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setChartTimezone(id.ifBlank { null })
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
message = context.getString(R.string.saved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.save)) }
|
|
||||||
OutlinedButton(onClick = {
|
|
||||||
tzText = ""
|
|
||||||
tzError = null
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setChartTimezone(null)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
message = context.getString(R.string.saved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.timezone_auto)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
RemindersCard()
|
||||||
// --- Reminders / exact alarms ---
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(stringResource(R.string.reminders_section), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
if (!scheduler.canScheduleExact()) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.exact_alarm_needed),
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Button(onClick = {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
context.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.grant_exact_alarm)) }
|
|
||||||
} else {
|
|
||||||
Text(stringResource(R.string.exact_alarm_ok), style = MaterialTheme.typography.bodyMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
BackupCard(
|
||||||
// --- Backup ---
|
onExport = { exportLauncher.launch(ExportFileNames.backupFileName()) },
|
||||||
Card(Modifier.fillMaxWidth()) {
|
onImport = { importLauncher.launch(arrayOf("application/json")) }
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(stringResource(R.string.backup_section), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.backup_hint),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(onClick = {
|
|
||||||
exportLauncher.launch(ExportFileNames.backupFileName())
|
|
||||||
}) { Text(stringResource(R.string.export_json)) }
|
|
||||||
OutlinedButton(onClick = {
|
|
||||||
importLauncher.launch(arrayOf("application/json"))
|
|
||||||
}) { Text(stringResource(R.string.import_json)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
AutoBackupCard(
|
||||||
// --- Sauvegarde automatique (v1.7.0) : en PLUS de l'export manuel ---
|
enabled = autoBackupEnabled,
|
||||||
// Opt-in pur : désactivée par défaut ; nécessite un dossier choisi
|
treeUri = autoBackupTreeUri,
|
||||||
// (SAF tree persisté) ; un run immédiat est déclenché à l'activation.
|
keep = autoBackupKeep,
|
||||||
Card(Modifier.fillMaxWidth()) {
|
lastRun = autoBackupLastRun,
|
||||||
Column(Modifier.padding(12.dp)) {
|
onPickFolderToEnable = {
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.autobackup_section), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Switch(
|
|
||||||
checked = autoBackupEnabled,
|
|
||||||
onCheckedChange = { wantEnabled ->
|
|
||||||
if (!wantEnabled) {
|
|
||||||
// Désactivation : le périodique reste planifié
|
|
||||||
// mais le worker sort immédiatement (no-op)
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setAutoBackupEnabled(false)
|
|
||||||
}
|
|
||||||
} else if (autoBackupTreeUri == null) {
|
|
||||||
// Pas de dossier → le picker EST la suite :
|
|
||||||
// la sélection activera (+ premier run)
|
|
||||||
pickFolderToEnable = true
|
pickFolderToEnable = true
|
||||||
folderPickerLauncher.launch(null)
|
folderPickerLauncher.launch(null)
|
||||||
} else {
|
},
|
||||||
// Dossier existant → activation directe + run
|
onChooseFolder = {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setAutoBackupEnabled(true)
|
|
||||||
com.hormonetrack.data.backup.AutoBackupScheduler.runNow(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.autobackup_hint),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
|
|
||||||
// Dossier choisi : nom lisible (documents "primary:Download" →
|
|
||||||
// "Download") + bouton pour le changer (sans toucher au switch)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.autobackup_folder) + " : " +
|
|
||||||
(autoBackupTreeUri?.let { folderDisplayName(it) }
|
|
||||||
?: stringResource(R.string.autobackup_no_folder)),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
TextButton(onClick = {
|
|
||||||
pickFolderToEnable = false
|
pickFolderToEnable = false
|
||||||
folderPickerLauncher.launch(null)
|
folderPickerLauncher.launch(null)
|
||||||
}) { Text(stringResource(R.string.autobackup_choose_folder)) }
|
},
|
||||||
|
onMessage = showMessage
|
||||||
// Dernier run (statut alimenté par le worker) : réussi/échec + date
|
|
||||||
autoBackupLastRun?.let { (result, atMs) ->
|
|
||||||
if (atMs != null && atMs > 0L) {
|
|
||||||
val date = java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
|
|
||||||
.format(java.util.Date(atMs))
|
|
||||||
Text(
|
|
||||||
text = context.getString(
|
|
||||||
if (result == "ok") R.string.autobackup_last_ok
|
|
||||||
else R.string.autobackup_last_fail,
|
|
||||||
date
|
|
||||||
),
|
|
||||||
color = if (result == "ok") MaterialTheme.colorScheme.primary
|
|
||||||
else MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
// Rétention : copies conservées (1..30, borné au set) — Save
|
|
||||||
// persiste ; le worker relit DataStore à CHAQUE run (aucun
|
|
||||||
// re-enqueue nécessaire, cf AutoBackupScheduler)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = autoBackupKeepText,
|
|
||||||
onValueChange = { autoBackupKeepText = it },
|
|
||||||
label = { Text(stringResource(R.string.autobackup_keep)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Button(onClick = {
|
|
||||||
val keep = autoBackupKeepText.trim().toIntOrNull() ?: return@Button
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
container.settings.setAutoBackupKeep(keep) // borné [1;30]
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
message = context.getString(R.string.saved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.save)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
LogsCard(
|
||||||
// --- Logs de diagnostic (v1.3.1) ---
|
logLineCount = logLineCount,
|
||||||
Card(Modifier.fillMaxWidth()) {
|
onExport = {
|
||||||
Column(Modifier.padding(12.dp)) {
|
// Nom de fichier via ExportFileNames (cf §14 #48) : extrait
|
||||||
Text(stringResource(R.string.logs_section), style = MaterialTheme.typography.titleMedium)
|
// et testé — passer un LocalDate au pattern horaire crashait
|
||||||
Text(
|
// l'app au tap (bug v1.3.1→v1.3.3).
|
||||||
stringResource(R.string.logs_count, logLineCount),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedButton(onClick = {
|
|
||||||
// Nom de fichier via ExportFileNames (cf §14 #48) :
|
|
||||||
// extrait et testé — passer un LocalDate au pattern
|
|
||||||
// horaire crashait l'app au tap (bug v1.3.1→v1.3.3).
|
|
||||||
logExportLauncher.launch(ExportFileNames.diagnosticLogFileName())
|
logExportLauncher.launch(ExportFileNames.diagnosticLogFileName())
|
||||||
}) { Text(stringResource(R.string.logs_export)) }
|
},
|
||||||
TextButton(onClick = {
|
onClear = {
|
||||||
AppLog.clear()
|
AppLog.clear()
|
||||||
logLineCount = AppLog.count()
|
logLineCount = AppLog.count()
|
||||||
}) { Text(stringResource(R.string.logs_clear)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
AboutCard()
|
||||||
// --- About : version installée + lien releases Gitea ---
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(12.dp)) {
|
|
||||||
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.settings_version, com.hormonetrack.BuildConfig.VERSION_NAME),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
TextButton(onClick = {
|
|
||||||
context.startActivity(
|
|
||||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases"))
|
|
||||||
)
|
|
||||||
}) { Text(stringResource(R.string.releases_link)) }
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.disclaimer),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.models_credit),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -782,7 +337,7 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
it.settings?.let { us ->
|
it.settings?.let { us ->
|
||||||
container.settings.setAutoCalibrate(us.autoCalibrate ?: false)
|
container.settings.setAutoCalibrate(us.autoCalibrate ?: false)
|
||||||
container.settings.setAlertThresholds(
|
container.settings.setAlertThresholds(
|
||||||
com.hormonetrack.pk.Alerts.Thresholds(
|
Alerts.Thresholds(
|
||||||
e2High = us.alertE2High, e2Low = us.alertE2Low,
|
e2High = us.alertE2High, e2Low = us.alertE2Low,
|
||||||
tHigh = us.alertTHigh, tLow = us.alertTLow
|
tHigh = us.alertTHigh, tLow = us.alertTLow
|
||||||
)
|
)
|
||||||
@ -820,21 +375,3 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatDoubles(d: Double): String =
|
|
||||||
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nom lisible d'un dossier SAF tree URI (v1.7.0) : l'id d'un tree document
|
|
||||||
* est de la forme « primary:Download » (stockage principal) ou
|
|
||||||
* « <provider>:<chemin » pour les providers tiers (Owncloud…) — on affiche
|
|
||||||
* la partie APRÈS le ':', sinon l'ID brut. Pur et défensif : un URI non
|
|
||||||
* parsable s'affiche tel quel plutôt que de faire crasher l'écran.
|
|
||||||
*/
|
|
||||||
private fun folderDisplayName(treeUriString: String): String =
|
|
||||||
try {
|
|
||||||
val id = android.provider.DocumentsContract.getTreeDocumentId(Uri.parse(treeUriString))
|
|
||||||
id.substringAfter(':', id)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
treeUriString
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.BuildConfig
|
||||||
|
import com.hormonetrack.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte À PROPOS de l'écran Paramètres (v1.9.8 : extraite de
|
||||||
|
* SettingsScreen.kt, comportement inchangé) : version installée (BuildConfig
|
||||||
|
* — épinglée par un test UI depuis v1.9.8, le bug #46 des versions jamais
|
||||||
|
* bumpées ne peut plus revenir silencieusement), lien vers les releases
|
||||||
|
* Gitea, avertissement médical et crédit des modèles PK.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AboutCard() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.settings_version, BuildConfig.VERSION_NAME),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
TextButton(onClick = {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW, Uri.parse("https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases"))
|
||||||
|
)
|
||||||
|
}) { Text(stringResource(R.string.releases_link)) }
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.disclaimer),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.models_credit),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.pk.Alerts
|
||||||
|
import com.hormonetrack.reminder.AlertNotifier
|
||||||
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte SEUILS D'ALERTE de l'écran Paramètres (v1.4.2 ; v1.9.8 : extraite de
|
||||||
|
* SettingsScreen.kt, comportement inchangé). Limites E2 (pg/mL) / T (ng/mL)
|
||||||
|
* évaluées contre le taux ESTIMÉ courant de l'accueil (pk/Alerts.kt) et par
|
||||||
|
* le worker périodique de 15 min. Opt-in : champ vide = pas d'alerte sur ce
|
||||||
|
* côté ; incohérence haut ≤ bas ou saisie non numérique = message d'erreur
|
||||||
|
* local, rien n'est persisté.
|
||||||
|
*
|
||||||
|
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AlertThresholdsCard(
|
||||||
|
alertThresholds: Alerts.Thresholds,
|
||||||
|
onMessage: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val container = LocalAppContainer.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// Champs réinitialisés à chaque valeur persistée (clé du remember) ;
|
||||||
|
// null (pas de seuil) = champ vide.
|
||||||
|
var e2HighText by remember(alertThresholds.e2High) {
|
||||||
|
mutableStateOf(alertThresholds.e2High?.let { formatDoubles(it) } ?: "")
|
||||||
|
}
|
||||||
|
var e2LowText by remember(alertThresholds.e2Low) {
|
||||||
|
mutableStateOf(alertThresholds.e2Low?.let { formatDoubles(it) } ?: "")
|
||||||
|
}
|
||||||
|
var tHighText by remember(alertThresholds.tHigh) {
|
||||||
|
mutableStateOf(alertThresholds.tHigh?.let { formatDoubles(it) } ?: "")
|
||||||
|
}
|
||||||
|
var tLowText by remember(alertThresholds.tLow) {
|
||||||
|
mutableStateOf(alertThresholds.tLow?.let { formatDoubles(it) } ?: "")
|
||||||
|
}
|
||||||
|
var alertError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.alert_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.alert_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = e2HighText,
|
||||||
|
onValueChange = { e2HighText = it },
|
||||||
|
label = { Text(stringResource(R.string.alert_e2_high)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = e2LowText,
|
||||||
|
onValueChange = { e2LowText = it },
|
||||||
|
label = { Text(stringResource(R.string.alert_e2_low)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tHighText,
|
||||||
|
onValueChange = { tHighText = it },
|
||||||
|
label = { Text(stringResource(R.string.alert_t_high)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tLowText,
|
||||||
|
onValueChange = { tLowText = it },
|
||||||
|
label = { Text(stringResource(R.string.alert_t_low)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
alertError?.let {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
// Parsing tolérant (virgule décimale FR), champ vide = null
|
||||||
|
fun parse(s: String): Double? =
|
||||||
|
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
|
||||||
|
val e2High = parse(e2HighText)
|
||||||
|
val e2Low = parse(e2LowText)
|
||||||
|
val tHigh = parse(tHighText)
|
||||||
|
val tLow = parse(tLowText)
|
||||||
|
// Saisie invalide (texte non numérique) ou incohérence haut ≤ bas
|
||||||
|
val invalidSyntax = listOf(e2HighText, e2LowText, tHighText, tLowText)
|
||||||
|
.any { it.trim().isNotEmpty() && parse(it) == null }
|
||||||
|
val thresholds = Alerts.Thresholds(e2High, e2Low, tHigh, tLow)
|
||||||
|
when {
|
||||||
|
invalidSyntax || !thresholds.isCoherent() -> {
|
||||||
|
alertError = context.getString(R.string.alert_invalid)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
alertError = null
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setAlertThresholds(thresholds)
|
||||||
|
// v1.4.2 : check IMMÉDIAT (worker one-time) —
|
||||||
|
// si le seuil est déjà franchi, la notification
|
||||||
|
// part sans attendre le cycle périodique de
|
||||||
|
// 15 min (état anti-spam réinitialisé au besoin).
|
||||||
|
AlertNotifier.checkNow(context)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onMessage(context.getString(R.string.saved))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.save)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,164 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.data.backup.AutoBackupScheduler
|
||||||
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte SAUVEGARDE AUTOMATIQUE QUOTIDIENNE de l'écran Paramètres (v1.7.0 ;
|
||||||
|
* v1.9.8 : extraite de SettingsScreen.kt, comportement inchangé).
|
||||||
|
*
|
||||||
|
* Opt-in pur : désactivée par défaut ; nécessite un dossier choisi UNE fois
|
||||||
|
* (SAF tree persisté — ACTION_OPEN_DOCUMENT_TREE, cf orchestrateur) ; un run
|
||||||
|
* immédiat est déclenché à l'activation (feedback + validation directe).
|
||||||
|
* Rétention 1..30 copies (le worker purge les plus vieilles, ne touche
|
||||||
|
* JAMAIS les exports manuels — cf AutoBackupRetention).
|
||||||
|
*
|
||||||
|
* Sémantique des deux entrées du picker (v1.7.0) :
|
||||||
|
* - via le SWITCH quand aucun dossier n'existe → la sélection ACTIVE la
|
||||||
|
* sauvegarde (+ premier run immédiat) : le switch sans dossier ne peut
|
||||||
|
* rien faire, le picker est la suite logique ;
|
||||||
|
* - via « Choisir le dossier » (changement) → on enregistre le dossier
|
||||||
|
* SANS changer l'état activé/désactivé.
|
||||||
|
*
|
||||||
|
* @param onPickFolderToEnable ouvre le picker SAF dont la sélection ACTIVERA
|
||||||
|
* la sauvegarde (branche « switch sans dossier » — launcher externe).
|
||||||
|
* @param onChooseFolder ouvre le picker SAF pour CHANGER de dossier sans
|
||||||
|
* toucher à l'état activé/désactivé.
|
||||||
|
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AutoBackupCard(
|
||||||
|
enabled: Boolean,
|
||||||
|
treeUri: String?,
|
||||||
|
keep: Int,
|
||||||
|
lastRun: Pair<String?, Long?>?,
|
||||||
|
onPickFolderToEnable: () -> Unit,
|
||||||
|
onChooseFolder: () -> Unit,
|
||||||
|
onMessage: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val container = LocalAppContainer.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var keepText by remember(keep) { mutableStateOf(keep.toString()) }
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.autobackup_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Switch(
|
||||||
|
checked = enabled,
|
||||||
|
onCheckedChange = { wantEnabled ->
|
||||||
|
if (!wantEnabled) {
|
||||||
|
// Désactivation : le périodique reste planifié
|
||||||
|
// mais le worker sort immédiatement (no-op)
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setAutoBackupEnabled(false)
|
||||||
|
}
|
||||||
|
} else if (treeUri == null) {
|
||||||
|
// Pas de dossier → le picker EST la suite :
|
||||||
|
// la sélection activera (+ premier run)
|
||||||
|
onPickFolderToEnable()
|
||||||
|
} else {
|
||||||
|
// Dossier existant → activation directe + run
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setAutoBackupEnabled(true)
|
||||||
|
AutoBackupScheduler.runNow(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.autobackup_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|
||||||
|
// Dossier choisi : nom lisible (documents "primary:Download" →
|
||||||
|
// "Download") + bouton pour le changer (sans toucher au switch)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.autobackup_folder) + " : " +
|
||||||
|
(treeUri?.let { folderDisplayName(it) }
|
||||||
|
?: stringResource(R.string.autobackup_no_folder)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
TextButton(onClick = onChooseFolder) {
|
||||||
|
Text(stringResource(R.string.autobackup_choose_folder))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dernier run (statut alimenté par le worker) : réussi/échec + date
|
||||||
|
lastRun?.let { (result, atMs) ->
|
||||||
|
if (atMs != null && atMs > 0L) {
|
||||||
|
val date = java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
|
||||||
|
.format(java.util.Date(atMs))
|
||||||
|
Text(
|
||||||
|
text = context.getString(
|
||||||
|
if (result == "ok") R.string.autobackup_last_ok
|
||||||
|
else R.string.autobackup_last_fail,
|
||||||
|
date
|
||||||
|
),
|
||||||
|
color = if (result == "ok") MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
// Rétention : copies conservées (1..30, borné au set) — Save
|
||||||
|
// persiste ; le worker relit DataStore à CHAQUE run (aucun
|
||||||
|
// re-enqueue nécessaire, cf AutoBackupScheduler)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = keepText,
|
||||||
|
onValueChange = { keepText = it },
|
||||||
|
label = { Text(stringResource(R.string.autobackup_keep)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Button(onClick = {
|
||||||
|
val parsed = keepText.trim().toIntOrNull() ?: return@Button
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setAutoBackupKeep(parsed) // borné [1;30]
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onMessage(context.getString(R.string.saved))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.save)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte CALIBRATION AUTOMATIQUE de l'écran Paramètres (v1.9.8 : extraite de
|
||||||
|
* SettingsScreen.kt, comportement inchangé). UNE seule option (fusionnée
|
||||||
|
* v1.2.1) : facteurs d'échelle E2 par période d'ester + calibration du modèle
|
||||||
|
* T, recalculés depuis les labs. Désactivée par défaut — la calibration reste
|
||||||
|
* sous contrôle explicite de l'utilisatrice.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AutoCalibrationCard(autoCalibrate: Boolean) {
|
||||||
|
val container = LocalAppContainer.current
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.auto_calibrate), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Switch(
|
||||||
|
checked = autoCalibrate,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setAutoCalibrate(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.auto_calibrate_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte SAUVEGARDE MANUELLE (JSON) de l'écran Paramètres (v1.9.8 : extraite
|
||||||
|
* de SettingsScreen.kt, comportement inchangé). L'export passe par le picker
|
||||||
|
* SAF système (CreateDocument) — aucun accès disque direct ; l'import
|
||||||
|
* ouvre le backup via OpenDocument puis demande CONFIRMATION avant
|
||||||
|
* écrasement (dialog géré par l'orchestrateur SettingsScreen).
|
||||||
|
*
|
||||||
|
* @param onExport lance le picker SAF d'export (launcher de l'orchestrateur)
|
||||||
|
* @param onImport lance le picker SAF d'import (launcher de l'orchestrateur)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun BackupCard(onExport: () -> Unit, onImport: () -> Unit) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.backup_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.backup_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = onExport) { Text(stringResource(R.string.export_json)) }
|
||||||
|
OutlinedButton(onClick = onImport) { Text(stringResource(R.string.import_json)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte FUSEAU HORAIRE DU GRAPHIQUE de l'écran Paramètres (v1.4.5 ; v1.9.8 :
|
||||||
|
* extraite de SettingsScreen.kt, comportement inchangé). « auto » = fuseau
|
||||||
|
* du téléphone (champ vide) ; sinon un ID java.util.TimeZone (ex.
|
||||||
|
* « Europe/Paris ») — il pilote l'alignement minuit des labels X du
|
||||||
|
* graphique (fix #55/#67 : ticks ET labels dans le MÊME fuseau).
|
||||||
|
*
|
||||||
|
* La validité de l'ID est vérifiée AVANT persistance (un ID inconnu retombe
|
||||||
|
* sur « GMT » côté java.util.TimeZone : on le détecte et on refuse).
|
||||||
|
*
|
||||||
|
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ChartTimezoneCard(chartTimezone: String?, onMessage: (String) -> Unit) {
|
||||||
|
val container = LocalAppContainer.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var tzText by remember(chartTimezone) {
|
||||||
|
mutableStateOf(chartTimezone ?: "")
|
||||||
|
}
|
||||||
|
var tzError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.timezone_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.timezone_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tzText,
|
||||||
|
onValueChange = { tzText = it },
|
||||||
|
label = { Text(stringResource(R.string.timezone_field)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.timezone_auto)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
tzError?.let {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = {
|
||||||
|
val id = tzText.trim()
|
||||||
|
// Un ID inconnu retombe silencieusement sur « GMT » : on
|
||||||
|
// l'accepte seulement si l'utilisateur l'a VRAIMENT saisi.
|
||||||
|
val valid = id.isEmpty() || try {
|
||||||
|
java.util.TimeZone.getTimeZone(id)
|
||||||
|
.let { !it.id.equals("GMT", true) || id.equals("GMT", true) }
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
if (!valid) {
|
||||||
|
tzError = context.getString(R.string.timezone_invalid)
|
||||||
|
} else {
|
||||||
|
tzError = null
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setChartTimezone(id.ifBlank { null })
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onMessage(context.getString(R.string.saved))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.save)) }
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
tzText = ""
|
||||||
|
tzError = null
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setChartTimezone(null)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onMessage(context.getString(R.string.saved))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.timezone_auto)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte LANGUE de l'écran Paramètres (v1.9.8 : extraite de SettingsScreen.kt,
|
||||||
|
* comportement inchangé). La langue est indépendante du système : « system »
|
||||||
|
* suit la configuration du téléphone, « fr »/« en » forcent via les per-app
|
||||||
|
* locales d'AppCompat (recréent l'activité — le réglage survit au redémarrage,
|
||||||
|
* réappliqué par MainActivity.onCreate).
|
||||||
|
*
|
||||||
|
* Les libellés « Français » / « English » sont des LITTÉRAUX volontaires :
|
||||||
|
* chaque langue s'écrit dans sa propre langue (convention i18n).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LanguageCard(currentLanguage: String) {
|
||||||
|
val container = LocalAppContainer.current
|
||||||
|
|
||||||
|
/** Persiste la langue puis l'applique immédiatement (locales per-app). */
|
||||||
|
fun applyLanguage(code: String) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setLanguage(code)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
AppCompatDelegate.setApplicationLocales(
|
||||||
|
if (code == "system") LocaleListCompat.getEmptyLocaleList()
|
||||||
|
else LocaleListCompat.forLanguageTags(code)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.language), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = currentLanguage == "system",
|
||||||
|
onClick = { applyLanguage("system") },
|
||||||
|
label = { Text(stringResource(R.string.language_system)) }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = currentLanguage == "fr",
|
||||||
|
onClick = { applyLanguage("fr") },
|
||||||
|
label = { Text("Français") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = currentLanguage == "en",
|
||||||
|
onClick = { applyLanguage("en") },
|
||||||
|
label = { Text("English") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.util.AppLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte LOGS DE DIAGNOSTIC de l'écran Paramètres (v1.3.1 ; v1.9.8 : extraite
|
||||||
|
* de SettingsScreen.kt, comportement inchangé). Le buffer AppLog (500
|
||||||
|
* lignes, persisté) a permis le premier diagnostic À DISTANCE de l'historique
|
||||||
|
* du projet (§14 #50) — l'export est le canal de support de l'app.
|
||||||
|
*
|
||||||
|
* @param logLineCount compteur affiché (état de l'orchestrateur : il est
|
||||||
|
* rafraîchi APRÈS l'export, qui ajoute des lignes au journal).
|
||||||
|
* @param onExport exporte les logs via le picker SAF (nom de fichier sûr —
|
||||||
|
* helper ExportFileNames, cf bug §14 #48).
|
||||||
|
* @param onClear efface le buffer et rafraîchit le compteur.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LogsCard(
|
||||||
|
logLineCount: Int,
|
||||||
|
onExport: () -> Unit,
|
||||||
|
onClear: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.logs_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.logs_count, logLineCount),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = onExport) { Text(stringResource(R.string.logs_export)) }
|
||||||
|
TextButton(onClick = onClear) { Text(stringResource(R.string.logs_clear)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.reminder.AlarmScheduler
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte RAPPELS / ALARMES EXACTES de l'écran Paramètres (v1.9.8 : extraite de
|
||||||
|
* SettingsScreen.kt, comportement inchangé). Les rappels de doses utilisent
|
||||||
|
* des alarmes EXACTES (heure juste) : depuis Android 12 (S), l'autorisation
|
||||||
|
* SCHEDULE_EXACT_ALARM est opt-in par application. Sans elle, les alarmes
|
||||||
|
* deviennent inexactes (fenêtre ± quelques minutes) — la carte invite à
|
||||||
|
* l'accorder via les réglages système quand c'est le cas.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun RemindersCard() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scheduler = remember { AlarmScheduler(context) }
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.reminders_section), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
if (!scheduler.canScheduleExact()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.exact_alarm_needed),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.grant_exact_alarm)) }
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.exact_alarm_ok), style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers de formatage/affichage partagés par les cartes de l'écran
|
||||||
|
* Paramètres (v1.9.8 : extraits de SettingsScreen.kt lors du découpage en
|
||||||
|
* cartes — cf docs/DEVELOPPEMENT.md §10). Purs et défensifs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate un Double pour l'affichage dans un champ texte : les entiers sans
|
||||||
|
* décimales, les autres avec 3 décimales max (Locale.US : point décimal — la
|
||||||
|
* virgule FR est tolérée À LA SAISIE via replace(',', '.') dans les cartes).
|
||||||
|
*/
|
||||||
|
internal fun formatDoubles(d: Double): String =
|
||||||
|
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nom lisible d'un dossier SAF tree URI (v1.7.0) : l'id d'un tree document
|
||||||
|
* est de la forme « primary:Download » (stockage principal) ou
|
||||||
|
* « <provider>:<chemin » pour les providers tiers (Owncloud…) — on affiche
|
||||||
|
* la partie APRÈS le ':', sinon l'ID brut. Pur et défensif : un URI non
|
||||||
|
* parsable s'affiche tel quel plutôt que de faire crasher l'écran.
|
||||||
|
*/
|
||||||
|
internal fun folderDisplayName(treeUriString: String): String =
|
||||||
|
try {
|
||||||
|
val id = android.provider.DocumentsContract.getTreeDocumentId(Uri.parse(treeUriString))
|
||||||
|
id.substringAfter(':', id)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
treeUriString
|
||||||
|
}
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
package com.hormonetrack.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hormonetrack.R
|
||||||
|
import com.hormonetrack.pk.PharmacokineticEngine
|
||||||
|
import com.hormonetrack.pk.TConfig
|
||||||
|
import com.hormonetrack.ui.LocalAppContainer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carte ESTIMATION T de l'écran Paramètres (v1.9.8 : extraite de
|
||||||
|
* SettingsScreen.kt, comportement inchangé). Réglages MANUELS du modèle
|
||||||
|
* `T = plancher + (base − plancher) ÷ (1 + k·E2)` ; l'auto-calibration
|
||||||
|
* (carte du dessus) les REMPLACE à l'affichage quand elle est activée.
|
||||||
|
*
|
||||||
|
* « Calibrer depuis les labs » recalcule k par période d'ester depuis les
|
||||||
|
* analyses T enregistrées (PharmacokineticEngine.computeTConfigCalibration).
|
||||||
|
*
|
||||||
|
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun TModelCard(tConfig: TConfig, onMessage: (String) -> Unit) {
|
||||||
|
val container = LocalAppContainer.current
|
||||||
|
val repo = container.repository
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// Champs de saisie réinitialisés à chaque valeur persistée (clé du
|
||||||
|
// remember) — la virgule décimale FR est tolérée à la saisie.
|
||||||
|
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
|
||||||
|
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
|
||||||
|
var tKText by remember(tConfig.k) { mutableStateOf(formatDoubles(tConfig.k)) }
|
||||||
|
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.t_model_hint),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tBaseText,
|
||||||
|
onValueChange = { tBaseText = it },
|
||||||
|
label = { Text(stringResource(R.string.t_base)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tFloorText,
|
||||||
|
onValueChange = { tFloorText = it },
|
||||||
|
label = { Text(stringResource(R.string.t_floor)) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tKText,
|
||||||
|
onValueChange = { tKText = it },
|
||||||
|
label = { Text("k") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = {
|
||||||
|
val base = tBaseText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||||
|
val floor = tFloorText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||||
|
val k = tKText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
container.settings.setTConfig(TConfig(base, floor, k))
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onMessage(context.getString(R.string.saved))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.save)) }
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val labs = repo.allLabResultsOnce().filter { it.marker.equals("T", true) }
|
||||||
|
val trs = repo.allTreatmentsOnce()
|
||||||
|
val doses = repo.allDoseLogsOnce()
|
||||||
|
val calibrated = PharmacokineticEngine.computeTConfigCalibration(
|
||||||
|
labs, trs, doses, tConfig
|
||||||
|
)
|
||||||
|
calibrated?.let {
|
||||||
|
container.settings.setTConfig(it)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onMessage(context.getString(R.string.t_calibrated))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.calibrate_from_labs)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,40 @@
|
|||||||
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
||||||
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
|
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.4.10`, …).
|
||||||
|
|
||||||
|
## [1.9.8] — 2026-09-22 (versionCode 45)
|
||||||
|
|
||||||
|
### Ajouté — tests UI Compose, signature release dédiée, README anglais
|
||||||
|
|
||||||
|
- **13 tests UI instrumentés** (navigation 5 onglets, dialog « Nouveautés »
|
||||||
|
— épinglage du fix v1.3.2 « ne réapparaît pas après dismiss » —, sections
|
||||||
|
Paramètres, version installée = BuildConfig : le bug #46 « versions jamais
|
||||||
|
bumpées » ne peut plus revenir silencieusement). Déterministes : permission
|
||||||
|
de notifications pré-grantée, zéro dépendance aux données, indépendants de
|
||||||
|
la langue FR/EN. Exécution : `./gradlew connectedDebugAndroidTest`.
|
||||||
|
- **Signature release dédiée, prête à l'emploi** : `scripts/make-release-keystore.sh`
|
||||||
|
crée le keystore + `keystore.properties` (gitignorés) ; `assembleRelease`
|
||||||
|
l'utilise dès qu'il existe (sinon clé debug inchangée). Migration
|
||||||
|
téléphone documentée (export JSON → désinstallation → réinstallation →
|
||||||
|
réimport). Aucun changement pour l'utilisatrice tant que la bascule n'est
|
||||||
|
pas faite.
|
||||||
|
- **README anglais** ([README.en.md](README.en.md)) — lien FR/EN en tête de
|
||||||
|
chaque fichier ; la doc de développement reste en français.
|
||||||
|
|
||||||
|
### Modifié — écran Paramètres découpé (maintenance)
|
||||||
|
|
||||||
|
- `SettingsScreen.kt` (840 lignes) devient un orchestrateur de 377 lignes +
|
||||||
|
11 cartes thématiques dans `ui/screens/settings/` (langue, calibration,
|
||||||
|
modèle T, seuils, fuseau, rappels, sauvegarde, auto-backup, logs, à propos).
|
||||||
|
**Comportement et rendu strictement identiques** (déplacement pur) ;
|
||||||
|
validé par les tests UI + smoke test RELEASE sur émulateur (toutes les
|
||||||
|
sections, version 1.9.8, 0 crash — anti-bug #64).
|
||||||
|
- `ChartScreen` reste volontairement entier : sa garde de source (#54)
|
||||||
|
exige le bloc de gestes dans ce fichier.
|
||||||
|
|
||||||
|
Vérifié : 249 tests unitaires + 13 tests UI verts, lint vert, APK debug +
|
||||||
|
release (2,7 Mo) buildés, smoke test release complet (accueil, 5 onglets,
|
||||||
|
Paramètres, éditeur de traitement, dialog de dose — 0 crash).
|
||||||
|
|
||||||
## [1.9.7] — 2026-09-20 (versionCode 44)
|
## [1.9.7] — 2026-09-20 (versionCode 44)
|
||||||
|
|
||||||
### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection
|
### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection
|
||||||
|
|||||||
@ -11,12 +11,12 @@
|
|||||||
|
|
||||||
1. [Contexte & objectifs](#1-contexte--objectifs)
|
1. [Contexte & objectifs](#1-contexte--objectifs)
|
||||||
2. [Historique du projet](#2-historique-du-projet)
|
2. [Historique du projet](#2-historique-du-projet)
|
||||||
3. [Stack & versions (épinglées — à jour v1.8.1)](#3-stack--versions-épinglées--à-jour-v181)
|
3. [Stack & versions (épinglées — à jour v1.9.8)](#3-stack--versions-épinglées--à-jour-v198)
|
||||||
4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine)
|
4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine)
|
||||||
5. [Architecture générale](#5-architecture-générale)
|
5. [Architecture générale](#5-architecture-générale)
|
||||||
6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre)
|
6. [Modèle de données (Room)](#6-modèle-de-données-room) + [6.bis Sémantique isActive](#6bis-sémantique-isactive-v124--drapeau-administratif-jamais-un-filtre)
|
||||||
7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique) + [7.8 Modèle TFS V3C](#78-modèle-tfs-v3c-v140--pktransfemsciencemodelskt) + [7.9 Modèle WHSAH](#79-modèle-whsah-v146--pkwhsahmodelskt) + [7.10 « Tracé labs »](#710--tracé-labs--v150--pklabtrajectorymodelkt) + [7.10.bis Prolongation](#710bis-prolongation-au-delà-du-dernier-lab-v160--pklabtrajectorymodelkt) + [7.11 Prise de sang recommandée](#711-recommandation-de-prochaine-prise-de-sang-v180--pklabtimingkt) + [7.12 Estrannaise analytique](#712-modèle-estrannaise-analytique--nuage-mcmc-v190--pkestrannaisemodelskt-pkestrannaisecloudk t)
|
7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique) + [7.8 Modèle TFS V3C](#78-modèle-tfs-v3c-v140--pktransfemsciencemodelskt) + [7.9 Modèle WHSAH](#79-modèle-whsah-v146--pkwhsahmodelskt) + [7.10 « Tracé labs »](#710--tracé-labs--v150--pklabtrajectorymodelkt) + [7.10.bis Prolongation](#710bis-prolongation-au-delà-du-dernier-lab-v160--pklabtrajectorymodelkt) + [7.11 Prise de sang recommandée](#711-recommandation-de-prochaine-prise-de-sang-v180--pklabtimingkt) + [7.12 Estrannaise analytique](#712-modèle-estrannaise-analytique--nuage-mcmc-v190--pkestrannaisemodelskt-pkestrannaisecloudk t)
|
||||||
8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test réelles : HORS dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data)
|
8. [Tests unitaires](#8-tests-unitaires) + [8.bis Données de test réelles : HORS dépôt](#8bis-données-de-test-réelles--hors-dépôt-local-test-data) + [8.ter Tests UI Compose](#8ter-tests-ui-compose-appsrcandroidtest-v198)
|
||||||
9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil--reminderalertnotifierkt--worker-v142)
|
9. [Système de rappels](#9-système-de-rappels) + [9.bis Alertes de seuil](#9bis-alertes-de-seuil--reminderalertnotifierkt--worker-v142)
|
||||||
10. [UI & navigation](#10-ui--navigation)
|
10. [UI & navigation](#10-ui--navigation)
|
||||||
11. [Graphiques (CurveChart)](#11-graphiques-curvechart)
|
11. [Graphiques (CurveChart)](#11-graphiques-curvechart)
|
||||||
@ -24,7 +24,7 @@
|
|||||||
13. [Sauvegarde JSON](#13-sauvegarde-json) + [13.bis Auto-backup quotidien](#13bis-sauvegarde-automatique-quotidienne-v170--databackupautobackupworkerkt)
|
13. [Sauvegarde JSON](#13-sauvegarde-json) + [13.bis Auto-backup quotidien](#13bis-sauvegarde-automatique-quotidienne-v170--databackupautobackupworkerkt)
|
||||||
14. [Bugs corrigés (historique complet — à ne pas réintroduire)](#14-bugs-corrigés)
|
14. [Bugs corrigés (historique complet — à ne pas réintroduire)](#14-bugs-corrigés)
|
||||||
15. [Comment régénérer l'asset pk_profiles.json](#15-comment-régénérer-lasset-pk_profilesjson)
|
15. [Comment régénérer l'asset pk_profiles.json](#15-comment-régénérer-lasset-pk_profilesjson)
|
||||||
16. [Workflow build / test / install / git](#16-workflow-build--test--install--git) + [16.bis Releases Gitea](#16bis-releases-gitea-avec-apk-téléchargeable) + [16.ter Recette : test sur émulateur](#16ter-recette--test-manuel-sur-émulateur-reproductible--v134)
|
16. [Workflow build / test / install / git](#16-workflow-build--test--install--git) + [16.bis Releases Gitea](#16bis-releases-gitea-avec-apk-téléchargeable) + [16.quater Signature release : keystore dédié](#16quater-signature-release-keystore-dédié-v198) + [16.ter Recette : test sur émulateur](#16ter-recette--test-manuel-sur-émulateur-reproductible--v134)
|
||||||
17. [Montre : Gadgetbridge & options](#17-montre--gadgetbridge--options)
|
17. [Montre : Gadgetbridge & options](#17-montre--gadgetbridge--options)
|
||||||
18. [Espace disque & coûts](#18-espace-disque--coûts)
|
18. [Espace disque & coûts](#18-espace-disque--coûts)
|
||||||
19. [Limites connues](#19-limites-connues)
|
19. [Limites connues](#19-limites-connues)
|
||||||
@ -63,6 +63,7 @@ Fonctionnalités v1 :
|
|||||||
|
|
||||||
| Date | Événement |
|
| Date | Événement |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| 22 sept. 2026 (session v1.9.8) | **Socle qualité : tests UI Compose + signature release dédiée + découpage Settings** (revue de code du 21 sept. : « pas de CI, pas de tests UI, APK signé clé debug, écrans qui grandissent »). (a) **13 tests UI instrumentés** (`app/src/androidTest/uitest/`, §8.ter) : navigation 5 onglets (preuves = éléments inconditionnels : chips 24 h, FAB), dialog Nouveautés (épinglage du fix v1.3.2 : ne réapparaît pas après dismiss+restart), sections Paramètres + version installée = BuildConfig (le bug #46 ne peut plus revenir) ; déterminisme = pm grant shell pré-launch + markChangelogSeen + assertions via targetContext (indépendant FR/EN) + zéro dépendance aux données Room ; deps androidTest (BOM + ui-test-junit4 + ext:junit-ktx + junit explicite). (b) **Signature release conditionnelle** (§16.quater) : `scripts/make-release-keystore.sh` (shellcheck+shfmt) génère keystore + keystore.properties (gitignorés), `assembleRelease` l'utilise s'il existe, sinon fallback clé debug inchangé ; migration backup→désinstall→réimport documentée. (c) **SettingsScreen découpé** (840 → 377 + 11 fichiers 33–164 lignes dans `ui/screens/settings/`, §10) — déplacement pur, commentaires historiques conservés, ChartScreen volontairement entier (garde de source #54). **Vérifications** : 249 tests JVM + 13 UI verts, lint vert, assembleDebug/Release OK, **smoke test RELEASE émulateur** (accueil, 5 onglets, Paramètres complet scroll compris — version 1.9.8 visible, éditeur traitement, dialog dose + garde « crée un traitement », 0 crash logcat — anti-#64). **README EN** (README.en.md, liens FR/EN croisés ; DEVELOPPEMENT.md reste FR par choix assumé). |
|
||||||
| 20 sept. 2026 (session v1.9.7) | **Fix « la suggestion de prise de sang change tout le temps »** (remontée + données réelles : logs d'injection à heures variables) : la règle de stabilisation v1.8.1 comparait l'écart inter-doses EXACTEMENT à l'écart précédent — un log 30 min plus tard cassait le régime et repoussait la stabilisation de 5 × t½ à CHAQUE injection (creux fuyant). FIX : l'écart doit rester dans **l'intervalle de Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'interval THÉORIQUE — des logs 6,8 j puis 7,2 j ne se déstabilisent plus en cascade ; un vrai passage à 2 j reste hors fenêtre). Le créneau affiché suit toujours la dernière dose réelle (voulu). Tests : « interval change » ré-épinglé + 2 tests de tolérance. 249 verts + lint. Web miroir v1.9.7 (12 verts lab-timing). §7.11 mis à jour. |
|
| 20 sept. 2026 (session v1.9.7) | **Fix « la suggestion de prise de sang change tout le temps »** (remontée + données réelles : logs d'injection à heures variables) : la règle de stabilisation v1.8.1 comparait l'écart inter-doses EXACTEMENT à l'écart précédent — un log 30 min plus tard cassait le régime et repoussait la stabilisation de 5 × t½ à CHAQUE injection (creux fuyant). FIX : l'écart doit rester dans **l'intervalle de Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'interval THÉORIQUE — des logs 6,8 j puis 7,2 j ne se déstabilisent plus en cascade ; un vrai passage à 2 j reste hors fenêtre). Le créneau affiché suit toujours la dernière dose réelle (voulu). Tests : « interval change » ré-épinglé + 2 tests de tolérance. 249 verts + lint. Web miroir v1.9.7 (12 verts lab-timing). §7.11 mis à jour. |
|
||||||
| 20 sept. 2026 (session v1.9.6) | **Carte Accueil « Prochaines doses » — toutes les prises, triées** (remontée : le rappel quotidien CPA masquait le rappel hebdomadaire EEn — « je ne le vois quasiment pas ») : la carte liste UNE ligne par traitement TRIÉES par prochaine prise (1ʳᵉ en avant, suivantes en style secondaire), titre pluriel dès 2 lignes (`next_doses` FR/EN) ; helper PUR `nextDoseLine` (locale/zone paramétrables, template jours localisé `next_dose_days`) — 3 tests `NextDoseLineTest`. Notifications inchangées (une alarme par traitement). Web miroir v1.9.6 : helper pur `nextDoseLineText` (format.js) + même carte + 3 tests miroirs (misc) — 247 verts Android / 178 web. |
|
| 20 sept. 2026 (session v1.9.6) | **Carte Accueil « Prochaines doses » — toutes les prises, triées** (remontée : le rappel quotidien CPA masquait le rappel hebdomadaire EEn — « je ne le vois quasiment pas ») : la carte liste UNE ligne par traitement TRIÉES par prochaine prise (1ʳᵉ en avant, suivantes en style secondaire), titre pluriel dès 2 lignes (`next_doses` FR/EN) ; helper PUR `nextDoseLine` (locale/zone paramétrables, template jours localisé `next_dose_days`) — 3 tests `NextDoseLineTest`. Notifications inchangées (une alarme par traitement). Web miroir v1.9.6 : helper pur `nextDoseLineText` (format.js) + même carte + 3 tests miroirs (misc) — 247 verts Android / 178 web. |
|
||||||
| 20 sept. 2026 (session v1.9.5) | **Revue de code post-v1.9.4 — audit lecture-seule des deux dépôts puis corrections** (demande : « les logiques, choix techniques, commentaires, optimisations ») : (a) **#67** : ticks X alignés sur le fuseau APPAREIL pendant que les labels étaient formatés dans le fuseau CHOISI (zone manquante à `xLabelTicks`, contredisant #55) + pas fixe `+24 h` dérivant d'1 h aux transitions DST → zone passée + avancement Calendar, test DST (Europe/Paris 29/03) ; (b) `EstrannaiseCloud.compute` : `nowMs` mort supprimé (KDoc mensonger) ; (c) **perf** : `E2Context`/`prepareE2Context` — le tracé labs (720 pts) reconstruisait les maps moteur par point, diluant v1.8.2 (résultat bit-identique) ; Paints texte + `detectExtrema` + formatters mémoïsés dans le Canvas ; (d) **règles ré-appliquées** : `modelLabelRes` (anti-#59), `PKModels.*` partout (fini les literals), `scheduler2` supprimé ; (e) **doc rattrapée** : 3 commentaires périmés (ODS/lookup/t½-table), §7.2/§7.3 (asset historique, cutoff 10×t½). **Web miroir v1.9.5** : garde `hasModel` ESE dans `cutoffHours` (un backup importé ESE+ester non couvert levait TypeError → écran vide), portage des optimisations v1.8.2 (contexte préparé, cutoffs par traitement, Bateman paresseux), min/max en une passe (RangeError possible sur ~230 k pts avec le Nuage), init MCMC non bloquante (data-ready ⇒ asset chargé), `pk_profiles.json` exclu du zip (export-ignore, −548 Ko livrés), E2E version paramétrée (`data-version` — anti-bug v1.9.3), nettoyages (import mort lab-timing, JSDoc « troncature », dialogs ESE=3, COLORS accueil, JSDoc orpheline). 244 verts Android + lint · 175 verts web + E2E ✓. |
|
| 20 sept. 2026 (session v1.9.5) | **Revue de code post-v1.9.4 — audit lecture-seule des deux dépôts puis corrections** (demande : « les logiques, choix techniques, commentaires, optimisations ») : (a) **#67** : ticks X alignés sur le fuseau APPAREIL pendant que les labels étaient formatés dans le fuseau CHOISI (zone manquante à `xLabelTicks`, contredisant #55) + pas fixe `+24 h` dérivant d'1 h aux transitions DST → zone passée + avancement Calendar, test DST (Europe/Paris 29/03) ; (b) `EstrannaiseCloud.compute` : `nowMs` mort supprimé (KDoc mensonger) ; (c) **perf** : `E2Context`/`prepareE2Context` — le tracé labs (720 pts) reconstruisait les maps moteur par point, diluant v1.8.2 (résultat bit-identique) ; Paints texte + `detectExtrema` + formatters mémoïsés dans le Canvas ; (d) **règles ré-appliquées** : `modelLabelRes` (anti-#59), `PKModels.*` partout (fini les literals), `scheduler2` supprimé ; (e) **doc rattrapée** : 3 commentaires périmés (ODS/lookup/t½-table), §7.2/§7.3 (asset historique, cutoff 10×t½). **Web miroir v1.9.5** : garde `hasModel` ESE dans `cutoffHours` (un backup importé ESE+ester non couvert levait TypeError → écran vide), portage des optimisations v1.8.2 (contexte préparé, cutoffs par traitement, Bateman paresseux), min/max en une passe (RangeError possible sur ~230 k pts avec le Nuage), init MCMC non bloquante (data-ready ⇒ asset chargé), `pk_profiles.json` exclu du zip (export-ignore, −548 Ko livrés), E2E version paramétrée (`data-version` — anti-bug v1.9.3), nettoyages (import mort lab-timing, JSDoc « troncature », dialogs ESE=3, COLORS accueil, JSDoc orpheline). 244 verts Android + lint · 175 verts web + E2E ✓. |
|
||||||
@ -117,7 +118,7 @@ Leçon importante de la session build : **les erreurs de compilation et les bugs
|
|||||||
(bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant
|
(bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant
|
||||||
et en testant** — aucun build n'avait été lancé avant la session 3.
|
et en testant** — aucun build n'avait été lancé avant la session 3.
|
||||||
|
|
||||||
## 3. Stack & versions (épinglées — à jour v1.8.1)
|
## 3. Stack & versions (épinglées — à jour v1.9.8)
|
||||||
|
|
||||||
| Composant | Version | Où |
|
| Composant | Version | Où |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@ -131,7 +132,8 @@ et en testant** — aucun build n'avait été lancé avant la session 3.
|
|||||||
| AppCompat | 1.8.0 (langue par app) | idem |
|
| AppCompat | 1.8.0 (langue par app) | idem |
|
||||||
| DataStore Preferences | 1.2.1 | idem |
|
| DataStore Preferences | 1.2.1 | idem |
|
||||||
| Gson | 2.14.0 | idem |
|
| Gson | 2.14.0 | idem |
|
||||||
| JUnit | 4.13.2 (testImplementation) | idem |
|
| JUnit | 4.13.2 (testImplementation + androidTestImplementation explicite) | idem |
|
||||||
|
| Tests UI Compose | **ui-test-junit4 via le BOM** + `androidx.test.ext:junit-ktx:1.3.0` (androidTest, v1.9.8 — §8.ter) | idem |
|
||||||
| WorkManager | 2.11.2 (**UTILISÉ depuis v1.4.2** : worker périodique des seuils d'alerte, cf §9.bis) | idem |
|
| WorkManager | 2.11.2 (**UTILISÉ depuis v1.4.2** : worker périodique des seuils d'alerte, cf §9.bis) | idem |
|
||||||
| compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | app |
|
| compileSdk / targetSdk | **37** / **36** ; minSdk 26 ; Java target 17 | app |
|
||||||
| buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | app |
|
| buildFeatures | compose + **buildConfig** (VERSION_NAME pour l'app) | app |
|
||||||
@ -996,8 +998,46 @@ dépôt (ni maintenant, ni si le repo devient public un jour).
|
|||||||
|
|
||||||
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
|
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
|
||||||
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
|
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
|
||||||
**Toute modification du moteur passe par ces tests.** Suite envisageable : Robolectric
|
**Toute modification du moteur passe par ces tests.** Suite envisagée : Robolectric
|
||||||
(UI/logic Android), tests Compose, lint.
|
(UI/logic Android — les tests Compose existent depuis v1.9.8, cf §8.ter).
|
||||||
|
|
||||||
|
### 8.ter Tests UI Compose (`app/src/androidTest/`, v1.9.8)
|
||||||
|
|
||||||
|
**13 tests instrumentés** (`uitest/`) exécutés sur émulateur/appareil :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew connectedDebugAndroidTest # émulateur requis (ex. AVD hrt36)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`NavigationTest` (7)** : la barre d'onglets affiche les 5 entrées, chaque
|
||||||
|
onglet mène bien à son écran (preuves = éléments INCONDITIONNELS : chips de
|
||||||
|
plage « 24 h » du graphique, FAB « + » de Doses/Analyses/Traitements —
|
||||||
|
indépendants des données Room), et les Paramètres s'ouvrent depuis l'icône
|
||||||
|
de l'accueil ;
|
||||||
|
- **`ChangelogDialogTest` (3)** : dialog « Nouveautés » affiché quand la
|
||||||
|
version installée est plus récente que la dernière vue, absent sinon, et
|
||||||
|
**ne réapparaît pas après dismiss + redémarrage** (épinglage du fix v1.3.2
|
||||||
|
en test UI) ;
|
||||||
|
- **`SettingsScreenTest` (3)** : sections de l'écran Paramètres toutes
|
||||||
|
présentes + chips de langue littéraux (« Français »/« English ») +
|
||||||
|
**version installée = BuildConfig.VERSION_NAME** (le bug #46 — versions
|
||||||
|
jamais bumpées dans les tags — ne peut plus revenir silencieusement).
|
||||||
|
|
||||||
|
**Déterminisme** (les tests tournent contre l'app RÉELLE, données incluses) :
|
||||||
|
- permission `POST_NOTIFICATIONS` pré-grantée via `pm grant` shell AVANT le
|
||||||
|
lancement (sinon le dialog SYSTÈME masque l'app → timeout) ;
|
||||||
|
- dialog « Nouveautés » neutralisé en marquant la version vue
|
||||||
|
(`UiTestEnv.markChangelogSeen`) SAUF pour les tests qui le ciblent — ceux-ci
|
||||||
|
lancent l'activité MANUELLEMENT (`ActivityScenario` + `createEmptyComposeRule`)
|
||||||
|
pour régler le DataStore AVANT la lecture au démarrage ;
|
||||||
|
- assertions de texte via `targetContext.getString(...)` : résolues dans la
|
||||||
|
MÊME configuration que l'UI (per-app locales incluses) → indépendant de la
|
||||||
|
langue système et du réglage in-app FR/EN ;
|
||||||
|
- aucune assertion dépendante des données Room (listes/curbes) — les tests
|
||||||
|
restent verts sur émulateur vierge OU seedé (§16.ter).
|
||||||
|
|
||||||
|
⚠️ `assertExists`/`assertDoesNotExist` sont des MEMBRES de
|
||||||
|
`SemanticsNodeInteraction` depuis Compose 1.12 (plus des extensions à importer).
|
||||||
|
|
||||||
## 9. Système de rappels
|
## 9. Système de rappels
|
||||||
|
|
||||||
@ -1140,8 +1180,19 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
|
|||||||
`ChangelogHelper.sectionsSince`, asset `changelog.md` synchronisé par la
|
`ChangelogHelper.sectionsSince`, asset `changelog.md` synchronisé par la
|
||||||
tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ;
|
tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ;
|
||||||
fermable, ne réapparaît pas avant la prochaine mise à jour.
|
fermable, ne réapparaît pas avant la prochaine mise à jour.
|
||||||
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ;
|
- `SettingsScreen` (**v1.9.8 : découpé en cartes thématiques** — 840 lignes
|
||||||
**carte « Seuils d'alerte » (v1.4.2)** : 4 champs optionnels (E2 haut/bas pg/mL,
|
→ orchestrateur de 377 + 11 fichiers de 33–164 lignes dans le sous-package
|
||||||
|
`ui/screens/settings/`, un par carte : `LanguageCard`, `AutoCalibrationCard`,
|
||||||
|
`TModelCard`, `AlertThresholdsCard`, `ChartTimezoneCard`, `RemindersCard`,
|
||||||
|
`BackupCard`, `AutoBackupCard`, `LogsCard`, `AboutCard` + `SettingsFormat`
|
||||||
|
(helpers `formatDoubles`/`folderDisplayName`). L'orchestrateur garde ce qui
|
||||||
|
est PARTAGÉ : collecte DataStore, les 4 launchers SAF (leurs callbacks
|
||||||
|
écrivent `message`/dialog d'import), dialog de confirmation d'import,
|
||||||
|
ordre des cartes. Comportement et rendu IDENTIQUES (déplacement pur avec
|
||||||
|
les commentaires historiques — validé par les tests UI §8.ter + smoke
|
||||||
|
release §16.ter) ; contenu fonctionnel inchangé : langue
|
||||||
|
(Système/Français/English, chips reflétant l'état) ; **carte « Seuils
|
||||||
|
d'alerte » (v1.4.2)** : 4 champs optionnels (E2 haut/bas pg/mL,
|
||||||
T haut/bas ng/mL, vide = pas d'alerte), validation haut > bas (refus + message
|
T haut/bas ng/mL, vide = pas d'alerte), validation haut > bas (refus + message
|
||||||
rouge), Save → persiste + **check one-time immédiat** (`AlertNotifier.checkNow`) ;
|
rouge), Save → persiste + **check one-time immédiat** (`AlertNotifier.checkNow`) ;
|
||||||
**version installée + lien cliquable vers les releases Gitea** (v1.3.0,
|
**version installée + lien cliquable vers les releases Gitea** (v1.3.0,
|
||||||
@ -1149,7 +1200,12 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
|
|||||||
**« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T,
|
**« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T,
|
||||||
v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton
|
v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton
|
||||||
« Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ;
|
« Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ;
|
||||||
Export/Import JSON ; à propos
|
Export/Import JSON ; auto-backup (v1.7.0) ; logs ; à propos.
|
||||||
|
⚠️ **`ChartScreen` (790 lignes) reste volontairement ENTIER** : sa garde de
|
||||||
|
source (`ChartScreenSourceGuardTest`, fix #54) exige que le bloc
|
||||||
|
`detectTransformGestures` ET les alias `gesture*` vivent dans CE fichier —
|
||||||
|
le découper reviendrait à réécrire la garde et ré-exposer les captures
|
||||||
|
figées de pointerInput(Unit). À reconsidérer si une refonte chart a lieu.
|
||||||
- Composants : `CurveChart` (§11), `DateTimeField` (DatePicker+TimePicker Material3,
|
- Composants : `CurveChart` (§11), `DateTimeField` (DatePicker+TimePicker Material3,
|
||||||
LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester),
|
LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester),
|
||||||
`LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`)
|
`LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`)
|
||||||
@ -1782,8 +1838,9 @@ structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/projects/HormoneTrack
|
cd ~/projects/HormoneTrack
|
||||||
./gradlew assembleDebug testDebugUnitTest # build + 223 tests (193 sans données locales)
|
./gradlew assembleDebug testDebugUnitTest # build + 249 tests (219 sans données locales)
|
||||||
./gradlew assembleRelease # APK optimisé R8 (cf §16.bis)
|
./gradlew assembleRelease # APK optimisé R8 (cf §16.bis ; signature cf §16.quater)
|
||||||
|
./gradlew connectedDebugAndroidTest # 13 tests UI Compose (émulateur requis, §8.ter)
|
||||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -1868,15 +1925,49 @@ Le script :
|
|||||||
scope requis : `write:repository` (suffit pour releases + assets, pas pour
|
scope requis : `write:repository` (suffit pour releases + assets, pas pour
|
||||||
créer un repo — cf plus haut).
|
créer un repo — cf plus haut).
|
||||||
|
|
||||||
**Build release (v1.2.5)** : `./gradlew assembleRelease` — R8 full mode +
|
**Build release (v1.2.5, signature conditionnelle depuis v1.9.8)** :
|
||||||
`isShrinkResources` + signé avec la **clé debug** (même signature que les APK
|
`./gradlew assembleRelease` — R8 full mode + `isShrinkResources`
|
||||||
debug distribués → mise à jour par-dessus sans perte de données ; 20 Mo →
|
(20 Mo → 2,7 Mo). **Signature** : si `keystore.properties` existe à la racine
|
||||||
2,4 Mo). Garde-fous dans `proguard-rules.pro` : Gson lit les champs des
|
du dépôt (gitignoré, créé par `scripts/make-release-keystore.sh` — cf §16.quater),
|
||||||
modèles par réflexion → `-keep` explicites sur `data.model.**`,
|
la build release utilise CE keystore ; sinon elle reste signée avec la **clé
|
||||||
`BackupData`, `TConfig` (+ attributs `Signature`) — sinon l'export/import
|
debug** (même signature que les APK debug distribués → mise à jour par-dessus
|
||||||
JSON casse UNIQUEMENT en release. ⚠️ À chaque activation d'optimisation :
|
sans perte de données — comportement historique). Garde-fous dans
|
||||||
tester sur téléphone l'export/import de backup + les graphiques (R8 ne se
|
`proguard-rules.pro` : Gson lit les champs des modèles par réflexion →
|
||||||
vérifie pas en tests JVM).
|
`-keep` explicites sur `data.model.**`, `BackupData`, `TConfig` (+ attributs
|
||||||
|
`Signature`) — sinon l'export/import JSON casse UNIQUEMENT en release.
|
||||||
|
⚠️ À chaque activation d'optimisation : tester sur téléphone l'export/import
|
||||||
|
de backup + les graphiques (R8 ne se vérifie pas en tests JVM).
|
||||||
|
|
||||||
|
### 16.quater Signature release : keystore dédié (v1.9.8)
|
||||||
|
|
||||||
|
Historique : les APK release étaient signés avec la **clé debug** (choix
|
||||||
|
v1.2.5 — installer par-dessus l'APK debug sans perte de données). Le jour où
|
||||||
|
l'app est distribuée plus largement (dépôt public, F-Droid…), une vraie clé
|
||||||
|
de signature est requise — elle est désormais câblée :
|
||||||
|
|
||||||
|
- **Création (une fois)** : `scripts/make-release-keystore.sh` — génère
|
||||||
|
`release.keystore` (RSA 4096, alias `hormonetrack`, validité 10 000 j) +
|
||||||
|
`keystore.properties` à la racine, TOUS DEUX GITIGNORÉS (`.gitignore` :
|
||||||
|
`keystore.properties`, `*.keystore`, `*.jks`). Le script refuse
|
||||||
|
d'écraser une paire existante (une régénération = perte de la capacité
|
||||||
|
de mise à jour des installs existantes). Mots de passe saisis
|
||||||
|
interactivement (jamais en argument, jamais committés) ;
|
||||||
|
- **Effet** : dès que `keystore.properties` existe, `assembleRelease` signe
|
||||||
|
avec ce keystore (détection dans `app/build.gradle.kts`) ; sans le
|
||||||
|
fichier, fallback clé debug inchangé ;
|
||||||
|
- **⚠️ MIGRATION téléphone obligatoire** (signature ≠ clé debug ⇒ Android
|
||||||
|
refuse la mise à jour par-dessus) :
|
||||||
|
1. exporter le JSON (Paramètres → Sauvegarde → Exporter, ou auto-backup) ;
|
||||||
|
2. désinstaller l'app ;
|
||||||
|
3. installer l'APK release signé par le nouveau keystore ;
|
||||||
|
4. réimporter le JSON. La base Room ne voyage QUE par le JSON ;
|
||||||
|
- **Sauvegarde du keystore** : le copier hors du dépôt (coffre, second
|
||||||
|
disque) — sa perte = même migration subie par TOUS les utilisateurs à la
|
||||||
|
version suivante ;
|
||||||
|
- **Checklist avant release publiée avec la nouvelle clé** : build release →
|
||||||
|
`apksigner verify --print-certs` (l'empreinte doit être celle du NOUVEAU
|
||||||
|
keystore) → smoke test émulateur §16.ter → publication + note de migration
|
||||||
|
dans le corps de la release Gitea.
|
||||||
|
|
||||||
### Checklist de déploiement complète (de A à Z, pour une session sans contexte)
|
### Checklist de déploiement complète (de A à Z, pour une session sans contexte)
|
||||||
|
|
||||||
@ -2076,7 +2167,13 @@ Volontaires (v1) :
|
|||||||
- **APK release (R8)** : la réflexion Gson est couverte par des `-keep`
|
- **APK release (R8)** : la réflexion Gson est couverte par des `-keep`
|
||||||
explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur
|
explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur
|
||||||
téléphone** (export/import backup, graphiques) avant chaque publication ;
|
téléphone** (export/import backup, graphiques) avant chaque publication ;
|
||||||
signé clé debug → upgradable sans perte, mais pas une signature « officielle »
|
signature : clé debug PAR DÉFAUT, keystore dédié dès que `keystore.properties`
|
||||||
|
existe (v1.9.8, §16.quater — migration backup→désinstall→réimport requise)
|
||||||
|
- **ChartScreen reste un fichier unique (790 lignes)** : la garde de source
|
||||||
|
#54 (ChartScreenSourceGuardTest) exige le bloc de gestes + les alias
|
||||||
|
`gesture*` dans CE fichier — découpage = réécriture de la garde + risque
|
||||||
|
de réintroduction des captures figées. SettingsScreen a été découpé
|
||||||
|
(v1.9.8, §10) ; le chart attend une refonte dédiée
|
||||||
- **Grille de rappel sous-quotidienne** (v1.4.0) : une Posologie < 1 j
|
- **Grille de rappel sous-quotidienne** (v1.4.0) : une Posologie < 1 j
|
||||||
(ex. 2 injections/jour) + UNE heure de rappel → l'app ne peut exprimer
|
(ex. 2 injections/jour) + UNE heure de rappel → l'app ne peut exprimer
|
||||||
qu'un rappel/jour à heure fixe les jours de créneau (épinglé par
|
qu'un rappel/jour à heure fixe les jours de créneau (épinglé par
|
||||||
@ -2089,22 +2186,27 @@ Volontaires (v1) :
|
|||||||
|
|
||||||
## 20. Idées d'évolution
|
## 20. Idées d'évolution
|
||||||
|
|
||||||
1. **Robolectric + tests Compose** (VM Android en JVM — pas besoin d'appareil)
|
1. **Robolectric** (VM Android en JVM — pas besoin d'appareil ; les tests
|
||||||
2. **Émulateur local** pour smoke-tests UI (sur demande, ~2–3 Go)
|
Compose instrumentés existent depuis v1.9.8, §8.ter)
|
||||||
|
2. **Étendre les tests UI** : dialogs dose/lab avec données seedées (§16.ter),
|
||||||
|
parcours complet (créer un traitement → loguer une dose → courbe visible) —
|
||||||
|
les tests actuels sont volontairement data-indépendants
|
||||||
3. Mode « planifier les injections » (schedule récurrent → pré-remplir le log)
|
3. Mode « planifier les injections » (schedule récurrent → pré-remplir le log)
|
||||||
4. Import JSON : détection de doublons / mode fusion optionnel (l'écrasement est fait, v1.2.6)
|
4. Import JSON : détection de doublons / mode fusion optionnel (l'écrasement est fait, v1.2.6)
|
||||||
5. Verrou biométrique (BiometricPrompt), widget, export CSV
|
5. Verrou biométrique (BiometricPrompt), widget, export CSV
|
||||||
6. Charts : tooltip au toucher (le pan est fait v1.2.0, **le zoom v1.2.9**) ;
|
6. Charts : tooltip au toucher (le pan est fait v1.2.0, **le zoom v1.2.9**) ;
|
||||||
MaterialExpressiveTheme quand l'API passera publique (cf §3)
|
MaterialExpressiveTheme quand l'API passera publique (cf §3)
|
||||||
7. **Vrai keystore de distribution** (signature dédiée ≠ clé debug → nécessite
|
7. ~~Vrai keystore de distribution~~ **FAIT v1.9.8** (infra complète, §16.quater :
|
||||||
une migration : backup → désinstallation → installation signée → réimport)
|
script + détection Gradle + migration documentée — la bascule réelle se
|
||||||
|
fera quand la distribution s'élargira)
|
||||||
|
|
||||||
Fait (à ne pas refaire) : pan du chart (v1.2.0), **zoom du chart (v1.2.9)**,
|
Fait (à ne pas refaire) : pan du chart (v1.2.0), **zoom du chart (v1.2.9)**,
|
||||||
pics/creux (v1.2.3), prévision par fréquence (v1.2.0), calibration par période
|
pics/creux (v1.2.3), prévision par fréquence (v1.2.0), calibration par période
|
||||||
d'ester E2 **et** T (v1.2.1/v1.2.3), édition doses (v1.1.0) et labs (v1.2.2),
|
d'ester E2 **et** T (v1.2.1/v1.2.3), édition doses (v1.1.0) et labs (v1.2.2),
|
||||||
E2+T en une entrée (v1.2.2), migration Room v1→v2 sans fallback destructif
|
E2+T en une entrée (v1.2.2), migration Room v1→v2 sans fallback destructif
|
||||||
(v1.2.0), rooms v2→v3 (calendarEventId, v1.3.0), dépôt Gitea + releases APK
|
(v1.2.0), rooms v2→v3 (calendarEventId, v1.3.0), dépôt Gitea + releases APK
|
||||||
(push session).
|
(push session), **tests UI Compose + signature release dédiée + découpage
|
||||||
|
Settings (v1.9.8)**, **README anglais (v1.9.8)**.
|
||||||
8. Phase 2 montre : watchface `.hwt` custom, puis mini-app Lite Wearable (cf §17)
|
8. Phase 2 montre : watchface `.hwt` custom, puis mini-app Lite Wearable (cf §17)
|
||||||
|
|
||||||
### 20.bis Pistes d'optimisation écartées ou différées (audit v1.8.2)
|
### 20.bis Pistes d'optimisation écartées ou différées (audit v1.8.2)
|
||||||
|
|||||||
68
scripts/make-release-keystore.sh
Executable file
68
scripts/make-release-keystore.sh
Executable file
@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Génération du KEYSTORE de signature RELEASE + keystore.properties (v1.9.8).
|
||||||
|
#
|
||||||
|
# Les deux fichiers sont écrits à la RACINE du dépôt et sont GITIGNORÉS
|
||||||
|
# (.gitignore) : ne JAMAIS les committer — le keystore et ses mots de passe
|
||||||
|
# sont l'identité de l'app (une fuite = n'importe qui peut publier un APK qui
|
||||||
|
# se met à jour par-dessus le tien).
|
||||||
|
#
|
||||||
|
# Usage :
|
||||||
|
# scripts/make-release-keystore.sh
|
||||||
|
#
|
||||||
|
# Après génération, `./gradlew assembleRelease` signe automatiquement avec ce
|
||||||
|
# keystore (app/build.gradle.kts détecte keystore.properties).
|
||||||
|
#
|
||||||
|
# ⚠️ MIGRATION sur le téléphone (signature différente de la clé debug) :
|
||||||
|
# 1. Paramètres → Sauvegarde → Exporter (JSON) — OU auto-backup existant ;
|
||||||
|
# 2. désinstaller l'app (debug-signée) ;
|
||||||
|
# 3. installer l'APK release signé par le nouveau keystore ;
|
||||||
|
# 4. Paramètres → Sauvegarde → Importer.
|
||||||
|
# La base Room n'est PAS convertible entre signatures : seul le JSON voyage.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
KEYSTORE_FILE="release.keystore"
|
||||||
|
PROPERTIES_FILE="keystore.properties"
|
||||||
|
|
||||||
|
if [[ -f "$PROPERTIES_FILE" || -f "$KEYSTORE_FILE" ]]; then
|
||||||
|
echo "ERREUR : $PROPERTIES_FILE et/ou $KEYSTORE_FILE existent déjà." >&2
|
||||||
|
echo "Supprime-les d'abord si tu veux vraiment les régénérer (une régénération" >&2
|
||||||
|
echo "= perte de la capacité de mettre à jour les installs existantes)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -r -p "Mot de passe du keystore (>= 6 caractères) : " -s STORE_PASS
|
||||||
|
echo
|
||||||
|
read -r -p "Confirmation : " -s STORE_PASS_CONFIRM
|
||||||
|
echo
|
||||||
|
if [[ "$STORE_PASS" != "$STORE_PASS_CONFIRM" || ${#STORE_PASS} -lt 6 ]]; then
|
||||||
|
echo "ERREUR : les mots de passe ne correspondent pas ou font moins de 6 caractères." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# keyPassword = storePassword ici (un seul secret à retenir) ; keytool exige >= 6 car.
|
||||||
|
keytool -genkeypair \
|
||||||
|
-keystore "$KEYSTORE_FILE" \
|
||||||
|
-alias hormonetrack \
|
||||||
|
-keyalg RSA -keysize 4096 -validity 10000 \
|
||||||
|
-storepass "$STORE_PASS" \
|
||||||
|
-keypass "$STORE_PASS" \
|
||||||
|
-dname "CN=HormoneTrack, OU=HormoneTrack, O=HormoneTrack"
|
||||||
|
|
||||||
|
chmod 600 "$KEYSTORE_FILE"
|
||||||
|
|
||||||
|
cat >"$PROPERTIES_FILE" <<EOF
|
||||||
|
# Signature release de HormoneTrack — GITIGNORÉ, ne jamais committer.
|
||||||
|
storeFile=$KEYSTORE_FILE
|
||||||
|
storePassword=$STORE_PASS
|
||||||
|
keyAlias=hormonetrack
|
||||||
|
keyPassword=$STORE_PASS
|
||||||
|
EOF
|
||||||
|
chmod 600 "$PROPERTIES_FILE"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "OK : $KEYSTORE_FILE + $PROPERTIES_FILE créés (gitignorés)."
|
||||||
|
echo "Prochaine build : ./gradlew assembleRelease (signée avec ce keystore)"
|
||||||
|
echo "⚠️ Sur le téléphone : exporter le JSON AVANT d'installer (signature changée)"
|
||||||
|
echo " → cf docs/DEVELOPPEMENT.md §16.quater pour la migration pas à pas."
|
||||||
Loading…
x
Reference in New Issue
Block a user