Compare commits

...

33 Commits

Author SHA1 Message Date
322b8ff0bf Licence GPL-3.0 + anonymisation docs (surnoms/facteurs ODS, chemin Owncloud, nom de clé SSH) 2026-09-06 08:58:08 +02:00
4339fe7f5b Docs : farewell synchronisé (12 releases avec APK vérifiés) ; piège lexicographique des tags épinglé (§14 #38, §16.bis) ; README/GUIDE deux instances 2026-09-06 08:47:55 +02:00
3799166fec Docs §16 : farewell repo créé + push initial OK (SSH) ; releases en attente du token trousseau 2026-09-06 08:43:27 +02:00
8ccf040574 Docs §16.bis : course entre uploads rapprochés documentée (purge + double vérification par téléchargement) — release v1.3.0 réparée et vérifiée 2026-09-06 08:32:26 +02:00
435997717e v1.3.0 : dialog « Nouveautés » post-update, événements d'agenda récurrents, version + lien releases dans Paramètres
- Dialog « Nouveautés » (v1.3.0) : au démarrage, si la version installée est
  plus récente que la dernière vue (DataStore changelog_seen_version), un
  AlertDialog affiche les sections CHANGELOG non vues (ChangelogHelper
  sectionsSince + comparaison SemVer NUMÉRIQUE — 1.2.9 < 1.2.10, lexicographique
  aurait tort) ; asset changelog.md synchronisé à chaque build par la tâche
  Gradle copyChangelog (gitignoré) ; fermable, ne réapparaît pas avant la
  prochaine mise à jour
- Événements d'agenda récurrents (CalendarEvents.kt) : calendrier LOCAL dédié
  « HormoneTrack » (CalendarContract, ACCOUNT_TYPE_LOCAL), événement avec
  RRULE FREQ=DAILY;INTERVAL=N dérivé de la Posologie (arrondi demi-supérieur
  EXPLICITE floor(x+0.5) — kotlin.math.round arrondit les ties vers l'entier
  PAIR : 6,5 → 6, piège épinglé), début = prochaine occurrence à l'heure de
  rappel (ou 12:00) ; switch dans l'éditeur sous « Rappels », permissions
  WRITE_CALENDAR + READ_CALENDAR demandées à l'activation ; suppression/
  recréation au save ; id stocké sur Treatment (Room v3, MIGRATION_2_3)
- Paramètres : version installée (BuildConfig.VERSION_NAME, buildConfig=true)
  + lien cliquable vers les releases Gitea
- Tests : ChangelogHelperTest (8) + CalendarRruleTest (3) → 78 tests verts
- versionCode 14, versionName 1.3.0
2026-09-06 08:30:31 +02:00
274f82b6c1 Docs §16.bis : releases ≤ v1.2.4 restent à APK debug unique (backfill abandonné — best effort, app-release-unsigned.apk sans signing config d'époque) 2026-09-06 00:10:23 +02:00
48a1cc7c2f scripts/gitea-release.py : régénération complète — ensure_asset vérifie par TÉLÉCHARGEMENT (le listing a menti pendant les uploads rapprochés) ; retry DELETE+re-upload ×2 ; échec bruyant ; docs/code commentés 2026-09-05 23:51:23 +02:00
3f1378f6ba scripts/gitea-release.py : ensure_asset (upload + vérif nom/taille + retry PATCH + échec bruyant) — les ?name=/PATCH pouvaient être ignorés (v1.2.6/v1.2.10) ; docs §16.bis/§14 #37 2026-09-05 23:45:45 +02:00
15c667132b v1.2.10 : sens des boutons de zoom inversé (+ = zoom avant / fenêtre courte, convention carte — retour utilisateur) ; docs ; versionCode 13 2026-09-05 23:36:30 +02:00
186c446562 Docs/scripts : correction d'attribution — le nom d'asset générique venait de l'upload interrompu (extinction machine), pas d'un quirk Gitea ; renommage PATCH conservé comme défense 2026-09-05 23:28:02 +02:00
ab84f5e0ce v1.2.9 : zoom du graphique (pinch + boutons, 6 h → 300 j, focal stable, pas adaptatif) + disclaimer IA dans le README
- ChartScreen : detectTransformGestures unique (pan 1 doigt + pinch 2 doigts,
  pas de conflit de consommation) ; zoom autour du point focal (le temps sous
  le doigt reste fixe) ; plage bornée 6 h → 300 j (MIN/MAX_RANGE_H) ;
  boutons − / + en accessibilité
- CurveChart : stepForRange (15 min ≤ 12 h, 30 min ≤ 24 h, sinon 1 h) passé à
  computeCurve (courbes lisses à fort zoom) ; labels X adaptatifs 1 h/3 h
- README : disclaimer IA en en-tête (développé avec l'IA, contribution
  humaine essentielle = feedback/retours/suggestions, renvoi §2 doc dev)
  ; DEVELOPPEMENT §1 mention IA-assistée, §11 zoom, §8 67 tests
- ChartZoomTest : 3 tests stepForRange — 67 tests verts
- versionCode 12, versionName 1.2.9
2026-09-05 22:51:27 +02:00
139f818172 v1.2.8 : « Fréquence d'injection » renommé « Posologie » ; script : renommage d'asset après upload (quirk Gitea)
- strings EN « Dosage » / FR « Posologie » ; hint « dernière prise » (au lieu
  de « dernière injection ») — adapté aux voies non injectables
- quirk Gitea découvert : le nom d'asset passé à l'upload peut être normalisé
  (observé : -release.apk → .apk générique) → le script renomme l'asset via
  l'API d'édition (PATCH …/assets/{id}) après chaque upload
- release v1.2.8 publiée avec les 2 APK (release 2,5 Mo + debug 21 Mo)
- docs : §16.bis quirk, §8 (64 tests), historique, footer, README
- versionCode 11, versionName 1.2.8
2026-09-05 22:29:08 +02:00
8468bcbd65 CHANGELOG : entrée v1.2.8 (renommage Posologie) — étape 3 de la checklist §16.bis, appliquée à la lettre cette fois 2026-09-05 22:25:07 +02:00
df706aaedf UI : « Fréquence d'injection » renommé « Posologie » (terme inadapté aux voies non injectables) ; hint généralisé « dernière prise » ; docs à jour
- strings EN (Dosage) / FR (Posologie) ; freq_hint : « dernière injection » →
  « dernière prise » (les deux langues)
- README, GUIDE, DEVELOPPEMENT §10/§21 : mentions mises à jour
- versionCode 11, versionName 1.2.8 ; 64 tests verts
2026-09-05 22:24:24 +02:00
8d54770a71 Docs : guide d'installation — section « télécharger sans compiler » (releases Gitea, différences APK release vs debug) ; README statut v1.2.7/64 tests (replace silencieux raté corrigé) 2026-09-05 22:18:20 +02:00
89805cef89 v1.2.7 : prévision robuste aux oublis/retards + rattrapage complet de la doc de dev
- generateForecastDoses : les créneaux déjà passés (oubli d'injection) ne sont
  plus simulés — avant, dernier+intervalle tombait dans le passé → faux pic
  dans l'historique + rythme décalé ; la prévision démarre au premier créneau
  strictement futur, au rythme configuré
- un retard décale toute la prévision (part de la dernière prise réelle) —
  comportement voulu, épinglé par 2 tests
- doc §7.3b corrigée (mismatch doc-code détecté par l'utilisatrice)
- docs : §13 écrasement, §19/§20 import, §14 #34/#35, §21, historique
  (lignes v1.2.2/6/7), footer — vérification intégrée dans le script de patch
- versionCode 10, versionName 1.2.7 ; 64 tests verts
2026-09-05 22:14:19 +02:00
8f262fd789 scripts/gitea-release.py : DELETE renvoie {} (204 sans corps) ; nettoyage des assets v1.2.6 (release 2,4 Mo + debug 20 Mo) 2026-09-05 21:59:18 +02:00
33138eaad9 scripts/gitea-release.py : gère les réponses 204 sans corps (DELETE d'asset) — crash json.loads sur réponse vide 2026-09-05 21:56:23 +02:00
35a5ffba39 CHANGELOG : entrée v1.2.6 (extrema avec valeurs + import écrasement) 2026-09-05 21:49:58 +02:00
3a647c596d v1.2.6 : valeurs des pics/creux sur le graphique + import JSON en mode écrasement
- CurveChart : drawExtremum = triangle ▲▼ + VALEUR estimée de l'extremum
  (au-dessus d'un pic, en dessous d'un creux), couleur de la série
- Import JSON : mode ÉCRASEMENT (bug remonté : échec dès que l'app contenait
  des données — conflit d'IDs backup vs existants)
  · DAOs : deleteAll() sur les 3 tables (ordre enfants → parents, FK CASCADE)
  · BackupManager.importJson : wipe puis restauration (IDs conservés → FK
    valides), retourne aussi le tConfig du backup
  · SettingsScreen : après import → tConfig restauré dans DataStore + rappels
    reprogrammés (rescheduleAll) ; dialog renommé « Effacer & restaurer »
    avec avertissement explicite
- docs : §13 (écrasement), §11 (valeurs extrema), §14 #34, §16.bis CHECKLIST
  DE DÉPLOIEMENT complète numérotée (version → tests → docs → commit/tag →
  push 2 remotes → build 2 APK → releases 2 instances → smoke-test R8),
  §20/§21, CHANGELOG, README
- versionCode 9, versionName 1.2.6
2026-09-05 21:41:58 +02:00
58f107f78e Docs : audit de complétude post-release-build (§16 workflow assembleRelease, §16.bis les deux APK par tag, §20 keystore dédié restant, §19 smoke-test R8, historique, README) 2026-09-05 20:28:47 +02:00
a8a9ee47c8 v1.2.5 : builds release optimisés (R8 + shrink, 20 Mo → 2,4 Mo), APK release + debug publiés
- build release signé avec la CLÉ DEBUG (même signature → mise à jour
  par-dessus l'app installée sans perte de données) mais avec R8 full mode
  + isShrinkResources : 20 Mo → 2,4 Mo, démarrage et fluidité améliorés
- proguard-rules.pro : -keep explicites pour la réflexion Gson (data.model,
  BackupData, TConfig) — sinon export/import JSON cassé en release uniquement
- release v1.2.5 : deux APK publiés (release recommandé 2,4 Mo + debug 20 Mo)
- docs : §16.bis (deux APK par release, garde-fous R8), CHANGELOG
2026-09-05 20:27:05 +02:00
5ad0719105 Release script multi-instances (cloudyfy + farewell), remotes et docs §16
- scripts/gitea-release.py : argument d'instance (URL/owner/trousseau par hôte),
  usage inchangé sinon ; tokens par instance dans le trousseau macOS
- remotes : origin (cloudyfy, HTTPS) + farewell (SSH, alias giteassh:2222)
- docs §16/§16.bis : deux remotes, scopes des tokens, commande d'ajout au
  trousseau pour farewell
2026-09-05 20:21:35 +02:00
d6e4c45fe0 v1.2.5 : edge-to-edge propre (fix du double espace en haut de l'écran)
- enableEdgeToEdge() dans MainActivity (Material You)
- insets consommés UNE SEULE FOIS : TopAppBar M3 = barre de statut,
  NavigationBar = barre de navigation
- Scaffold racine + imbriqués (Doses/Analyses) : contentWindowInsets = 0
  pour ne pas cumuler (avant : fenêtre AppCompat + padding interne TopAppBar
  = double espace vide en haut)
- docs : §10 (pattern insets), §14 bug #33, historique, CHANGELOG
- versionCode 8, versionName 1.2.5
2026-09-05 20:15:33 +02:00
54b2ffe8a0 v1.2.4 : traitement inactif = drapeau administratif, jamais un filtre de données
Bug remonté : passer un traitement à inactif effaçait sa simulation du graphique
(Home/Chart collectaient activeTreatments) et faussait la calibration, tout en
permettant d'y loger des doses.

- HomeScreen / ChartScreen : simulation + calibration avec ALLTreatments
  (actifs + inactifs) — l'historique EV d'un traitement archivé reste simulé
- Home : chips « Log rapide », dialog de création et carte « prochaine dose »
  sur les traitements ACTIFS uniquement
- DosesScreen : création → actifs uniquement ; édition d'une dose existante →
  tous les traitements (rattachement préservé)
- Rappels : annulés pour les inactifs (save, boot via getAllOnce, prochaine dose)
- RegressionUserCase3Test (6 tests, données réelles HORS dépôt gitignoré) :
  transition complète EV inactif (29 doses) → EEn actif (9 doses), 22 labs sur
  les deux périodes, calibration par période couvrant EV + EEN, k T par ester,
  continuité de courbe, niveau actuel — 62 tests verts
- docs : §6.bis (sémantique isActive), §14 bug #32, §8, §21, CHANGELOG,
  README, GUIDE ; versionCode 7, versionName 1.2.4
2026-09-05 20:05:07 +02:00
bbc580b95b README : arbre du dépôt à jour (scripts/, CHANGELOG, local-test-data gitignoré, Gradle 9.7.1) 2026-09-05 19:50:04 +02:00
a44717881e Docs : audit de complétude pour reprise par session sans contexte
- §8 : 56 tests, V120FeaturesTest (10) incluant calibration T par ester
- §14 : bugs 25-31 (calibration par période, lab T en unité aberrante,
  commentaires imbriqués dans les KDoc, XML de test périmés, idempotence
  du scrub d'historique, préfixe « v » des releases) + leçons (f)(g)(h)
- §20 : état des faits / reste à faire mis à jour
- §21 : checklist des features v1.2.x
- README : arbre (scripts/, CHANGELOG, local-test-data gitignoré)
2026-09-05 19:49:44 +02:00
65a17ba2b9 scripts/gitea-release.py : releases automatisées (corps = section CHANGELOG, APK en pièce jointe)
- le corps de chaque release Gitea est extrait de docs/CHANGELOG.md (la section
  [X.Y.Z] de la version) — les 5 releases existantes ont été mises à jour (le
  1er script cherchait [vX.Y.Z] et était tombé sur le fallback)
- crée ou met à jour la release (PATCH), remplace l'asset APK du même nom
- token Gitea lu dans le trousseau macOS, scope write:repository suffisant
- §16.bis documente le script + le piège du préfixe « v »
2026-09-05 19:46:10 +02:00
cb4f5f02c0 Docs v1.2.3 : données de test hors dépôt (§8.bis, sécurité), releases Gitea avec APK (§16.bis), statut à jour
- §8.bis : les données de santé de test vivent dans local-test-data/ (gitignoré),
  tests Assume-skippés si absentes, historique nettoyé avant premier push
  (filter-branch) — aucune donnée de santé dans le dépôt ni l'historique
- §16.bis : procédure de release Gitea (API + upload APK par tag)
- README : statut v1.2.3, section Vie privée (dépôt sans données), Git à jour
2026-09-05 19:24:31 +02:00
fe3bf3c87c Tests de régression : données réelles hors dépôt (local-test-data gitignoré)
- RegressionUserCase{,2}Test chargent le backup réel depuis local-test-data/
  (gitignoré) et sont IGNORÉS proprement (Assume) si le fichier est absent —
  plus aucune donnée de santé personnelle embarquée dans le dépôt
- KDoc LabDialog : exemple 300.0 (au lieu d'une valeur réelle)
- script d'anonymisation /tmp/scrub.py prêt pour filter-branch (historique)
2026-09-05 19:20:14 +02:00
5f5244d8eb v1.2.3 : pics/creux estimés sur le graphique (toggle) + calibration T par période d'ester
- detectExtrema : extrema locaux stricts (plateaux fusionnés), alternance
  pic/creux imposée, filtre d'amplitude (2 pg/mL E2, 0,02 ng/mL T) — sémantique
  zigzag documentée (oscillation sous le seuil = un seul pivot)
- CurveChart : triangles ▲▼ aux extrema de chaque série (couleur de la série),
  chip « Pics / creux » + légende (désactivé par défaut)
- computeTKPerEster : le k du modèle T est calibré PAR PÉRIODE D'ESTER (labs T
  attribués à la période de la dernière dose ≤ lab, médiane) — la suppression T
  diffère valerate vs enanthate ; la courbe T utilise le k de l'ester ACTIF à
  chaque instant (activeEsterAt + curseur de doses triées dans computeCurve)
- Fix : la calibration T automatique utilise désormais l'E2 CALIBRÉE
  (scalePerEster) — calibrer contre l'E2 brute faussait les k
- levelAt/currentLevel : paramètre tKPerEster (fallback = tConfig.k stocké)
- ExtremaTest : 6 tests (alternance, monotone, plat, court, filtre d'amplitude,
  anti-corrélation E2/T) — 56 tests verts
- versionCode 6, versionName 1.2.3 ; docs mises à jour (§7.5/§7.6, §11, README, guide)
2026-09-05 18:58:10 +02:00
4731e4c159 v1.2.2 : prise de sang E2 + T en une entrée, affichage côte à côte, toggle T masque aussi les labs T
- LabDialog : mode création avec deux sections (E2 et T, chacune optionnelle,
  au moins une valeur requise) → insère 1 ou 2 LabResult au même timestamp ;
  mode édition inchangé (un seul marqueur, pré-rempli)
- LabsScreen : regroupement par timestamp (groupLabsForDisplay : E2 avant T,
  autres ensuite, tri desc) → affichage « E2 306 pg/mL · T 45 ng/dL » côte à
  côte ; tap sur une paire → sélecteur « Quelle entrée modifier ? » puis
  édition pré-remplie ; suppression = la prise entière (confirm nommant les
  valeurs) ; formatLabValue préserve les décimales
- ChartScreen : le toggle T masque désormais aussi les labs T (avant : seuls
  les points restaient visibles)
- LabsGroupingTest : 4 tests de regroupement (48 tests verts au total)
- versionCode 5, versionName 1.2.2 ; docs mises à jour
2026-09-05 18:33:19 +02:00
571e346b73 v1.2.1 : calibration par période d'ester, édition des labs, tap accueil→Graphiques, auto-cal fusionnée E2+T, lab T en unité aberrante neutralisé
- computeEsterScaleFactors : chaque lab est attribué à la période d'injection
  dans laquelle il tombe (dernière dose E2 <= lab → son ester) ; facteur final
  par ester = médiane des ratios de cette période. Corrige le mélange des
  périodes valerate/enanthate qui gonflait les courbes (250-375 pg/mL)
- e2At/computeCurve : paramètre scalePerEster (chaque dose scalée par le facteur
  de SON ester, fallback = scaleFactor stocké du traitement)
- autoCalibrated : renvoie esterScales + tConfig recalibré, traitements inchangés
- convertTToNgMl : conversion défensive pg/mL et µg/L (un lab T « 38 pg/mL »
  écrasait l'axe T et rendait la courbe T invisible — remonté utilisateur)
- LabDialog create/edit : tap sur une ligne de l'écran Analyses → édition
  pré-remplie (formatLabValue préserve les décimales)
- Home : tap sur le mini-graphique → écran Graphiques + mini-légende E2/T
- Settings : UNE seule option « Calibration automatique (E2 + T) » (E2 par
  période d'ester + modèle T), placée au-dessus des réglages T manuels
- RegressionUserCase2Test : 2e export réel épinglé (9 doses EEn/TFS, 8 labs,
  fréquence 6 j) — vérifie l'état d'équilibre EEn (~270 pg/mL calibré, cohérent
  labs 306/248 ; non calibré ≈ 367 = les « 375 » rapportés)
- V120FeaturesTest : attribution des labs par période d'ester + scalePerEster
- 44 tests verts ; versionCode 4, versionName 1.2.1
- Docs : CHANGELOG, DEVELOPPEMENT (§7.6 réécrit), GUIDE, README
2026-09-05 17:38:08 +02:00
43 changed files with 3900 additions and 340 deletions

3
.gitignore vendored
View File

@ -28,3 +28,6 @@ Thumbs.db
# Logs # Logs
*.log *.log
local-test-data/
app/build/generated/changelog/
app/src/main/assets/changelog.md

674
LICENSE Normal file
View File

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@ -1,5 +1,12 @@
# HormoneTrack # HormoneTrack
> **🤖 Développé avec l'IA** : ce projet a été conçu et codé avec un assistant IA.
> La contribution humaine a été **essentielle** : feedback continu, retours
> utilisateur (tests sur vrai téléphone, rapports de bugs accompagnés d'exports
> réels), suggestions d'améliorations et validation de chaque release. Le
> détail session par session est documenté dans
> [docs/DEVELOPPEMENT.md §2](docs/DEVELOPPEMENT.md).
Suivi de thérapie hormonale (THS) sur Android, avec courbes estimées **heure par heure** Suivi de thérapie hormonale (THS) sur Android, avec courbes estimées **heure par heure**
d'estradiol (E2) et de testostérone (T), calibration sur les prises de sang, rappels d'estradiol (E2) et de testostérone (T), calibration sur les prises de sang, rappels
affichés sur smartwatch (Huawei Watch GT 3 via Gadgetbridge ou Huawei Health) et affichés sur smartwatch (Huawei Watch GT 3 via Gadgetbridge ou Huawei Health) et
@ -9,7 +16,7 @@ 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.2.0 — build Android ✅, **36 tests unitaires** ✅ (dont régression sur données réelles), intégration montre = notifications ✅, **repo git avec releases taguées** ✅ - **Statut** : v1.3.0 — build Android ✅, **78 tests unitaires** ✅ (44 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), 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)
@ -18,25 +25,39 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
## Fonctionnalités ## Fonctionnalités
- **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j, - **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j,
**panoramique** (glisser pour remonter dans le passé) **panoramique** (glisser pour remonter dans le passé), **zoom** (pinch ou boutons
− / +, 6 h → 300 j, échantillonnage adaptatif), **pics & creux** affichables avec
leurs **valeurs estimées** (triangles ▲▼ aux extrema locaux, toggle)
- **Deux modèles PK au choix, superposables** (issus de la feuille `Estrogen.ods`) : - **Deux modèles PK au choix, superposables** (issus de la feuille `Estrogen.ods`) :
**Estrannaise (EstraNase)** et **Transfem Science** pour les injections EV / EU / EEn, **Estrannaise (EstraNase)** et **Transfem Science** pour les injections EV / EU / EEn,
affichés côte à côte avec toggles indépendants affichés côte à côte avec toggles indépendants
- **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel, - **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel,
patch et voie orale patch et voie orale
- **Simulation prévisionnelle** : configurer la **fréquence d'injection** (jours) sur un - **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
traitement → projection des doses à venir sur le graphique (jamais sauvegardées) traitement → projection des doses à venir sur le graphique (jamais sauvegardées)
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection** - **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses), (switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
**intervalle en jours entre dosages** affiché **intervalle en jours entre dosages** affiché
- **Analyses de sang** (E2, T, PRL…) tracées sur les courbes comme points de calibration - **Traitements inactifs** : un traitement archivé n'apparaît plus dans la saisie
ni dans les rappels, mais **tout son historique reste simulé et calibré** —
utile pour une transition valerate → enanthate
- **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées
**côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de
l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle), - **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
automatique permanente** (option, désactivée par défaut) automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
- **Estimation T** empirique `T = plancher + (base − plancher) ÷ (1 + k·E2)`, calibrable avec les labs de sa période** (labs valerate → doses valerate, labs enanthate → doses
avec tes résultats T (unités ng/mL, ng/dL, ng/L, nmol/L acceptées) enanthate) et recalibre le modèle T
- **Estimation T** empirique `T = plancher + (base − plancher) ÷ (1 + k·E2)`, avec
**k calibré par période d'ester** (la suppression T diffère valerate vs enanthate),
contre l'E2 déjà calibrée — unités T acceptées : ng/mL, ng/dL, ng/L, nmol/L
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ; - **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health)
- **Nouveautés à chaque mise à jour** : dialog de changelog automatique
(fermé = ne réapparaît pas avant la prochaine version)
- **Événements d'agenda** : les rappels de prises peuvent créer un événement
récurrent (posologie) dans un calendrier « HormoneTrack » de ton téléphone
- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T) - **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T)
- **FR + EN** (langue par app, indépendante du système) - **FR + EN** (langue par app, indépendante du système)
- UI Jetpack Compose récente (BOM 2026.08, Material You) ; 100 % local, aucun compte - UI Jetpack Compose récente (BOM 2026.08, Material You) ; 100 % local, aucun compte
@ -59,12 +80,16 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
## Git ## Git
Dépôt initialisé avec des **releases taguées** (`v1.1.0`, `v1.2.0`, …) : Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
`Siphonight/HormoneTrack` sur les deux (privé), avec
**releases taguées** (`v1.1.0` → `v1.2.5`) et **deux APK par release**
(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) :
```bash ```bash
git tag # lister les releases git tag # lister les releases
git log --oneline # historique par couches (toolchain / moteur / UI / docs) git log --oneline # historique par couches (toolchain / moteur / UI / docs)
git push -u origin main --tags # après ajout d'un remote git push -u origin main --tags
``` ```
Chaque commit de release passe `./gradlew testDebugUnitTest` (vert obligatoire) et est Chaque commit de release passe `./gradlew testDebugUnitTest` (vert obligatoire) et est
@ -90,10 +115,17 @@ La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste
## Vie privée ## Vie privée
- Base de données **Room locale** sur le téléphone ; **aucune** télémétrie, aucun réseau - **L'app est 100 % locale** : base de données Room sur le téléphone, aucun serveur,
aucune télémétrie ; compat **Gadgetbridge** (FOSS) sans dépendance à Huawei Health
- **Le dépôt ne contient aucune donnée de santé** : le code et la doc sont génériques ;
les tests de régression qui utilisent des exports réels chargent leurs données
depuis `local-test-data/` (**gitignoré**, hors dépôt — et l'historique a été
nettoyé avant le premier push, cf [docs/DEVELOPPEMENT.md §8.bis](docs/DEVELOPPEMENT.md))
- Sauvegarde = fichier JSON que tu stockes où tu veux (Owncloud, etc.) - Sauvegarde = fichier JSON que tu stockes où tu veux (Owncloud, etc.)
- `allowBackup=false` (données sensibles) ; verrou biométrique prévu en Phase 2 - `allowBackup=false` (données sensibles) ; verrou biométrique prévu en Phase 2
- Compat **Gadgetbridge** (FOSS) : aucune dépendance à Huawei Health ni aux services Huawei - Le dépôt est **privé** : les releases APK se téléchargent en étant connecté ;
passer le dépôt en public rend les APK téléchargeables sans compte (sans risque
de données, cf ci-dessus)
## Structure du dépôt ## Structure du dépôt
@ -103,11 +135,16 @@ HormoneTrack/
├── 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)
│ ├── CHANGELOG.md journal détaillé des versions
│ └── MONTRE-GADGETBRIDGE.md montre Huawei GT 3 : options + limites │ └── MONTRE-GADGETBRIDGE.md montre Huawei GT 3 : options + limites
├── scripts/
│ └── gitea-release.py publie une release (corps = CHANGELOG + APK)
├── local-test-data/ ← gitignoré : backups réels pour les tests
│ de régression (JAMAIS dans le dépôt, cf §8.bis)
├── 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
├── gradle/wrapper/ wrapper Gradle 8.9 (jar + properties) ├── gradle/wrapper/ wrapper Gradle 9.7.1 (jar + properties)
├── gradlew / gradlew.bat ├── gradlew / gradlew.bat
└── app/ └── app/
├── build.gradle.kts dépendances (Compose, Room, DataStore, Gson…) ├── build.gradle.kts dépendances (Compose, Room, DataStore, Gson…)
@ -139,6 +176,6 @@ HormoneTrack/
## Licence ## Licence
À définir avant le premier push public (suggestion : GPL-3.0, cohérent avec l'écosystème **GPL-3.0** — voir [LICENSE](LICENSE). Cohérent avec l'écosystème Gadgetbridge.
Gadgetbridge). Les modèles PK appartiennent à leurs autrices respectives Les modèles PK appartiennent à leurs autrices respectives
([Estrannaise](https://estrannaise.github.io/), [Transfem Science](https://transfemscience.org)). ([Estrannaise](https://estrannaise.github.io/), [Transfem Science](https://transfemscience.org)).

View File

@ -12,8 +12,8 @@ android {
applicationId = "com.hormonetrack" applicationId = "com.hormonetrack"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 3 versionCode = 14
versionName = "1.2.0" versionName = "1.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -23,11 +23,19 @@ android {
buildTypes { buildTypes {
release { release {
// Optimisations R8 + shrink des ressources (le cœur du gain debug→release)
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true
proguardFiles( proguardFiles(
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
// installable PAR-DESSUS l'APK debug déjà installé (même signature),
// sans désinstallation ni perte de données, tout en bénéficiant de
// R8. Si un jour on distribue largement : générer un vrai keystore
// (signature différente → migration backup→désinstall→réimport).
signingConfig = signingConfigs.getByName("debug")
} }
} }
@ -39,6 +47,8 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
// BuildConfig.VERSION_NAME : écran Paramètres + dialog de changelog
buildConfig = true
} }
packaging { packaging {
@ -48,6 +58,16 @@ android {
} }
} }
// Synchronise docs/CHANGELOG.md → src/main/assets/changelog.md (dialog
// « Nouveautés » affiché après chaque mise à jour). Automatique à chaque
// build ; le fichier généré est gitignoré (source de vérité = docs/).
tasks.register<Copy>("copyChangelog") {
from(rootProject.file("docs/CHANGELOG.md"))
into(file("src/main/assets"))
rename { "changelog.md" }
}
tasks.named("preBuild") { dependsOn("copyChangelog") }
dependencies { dependencies {
// BOM Compose : épingle toutes les versions des artefacts androidx.compose.* // BOM Compose : épingle toutes les versions des artefacts androidx.compose.*
// (material3 >= 1.4 = Material 3 Expressive) // (material3 >= 1.4 = Material 3 Expressive)

View File

@ -1,2 +1,16 @@
# Keep Room entities' enum fields (auto-converters use enum names via reflection in Gson backup) # Règles R8/ProGuard — HormoneTrack
# Build release : isMinifyEnabled + isShrinkResources actifs (v1.2.5).
# ⚠️ R8 en full mode (AGP 9) est agressif : TOUT usage de réflexion doit être
# couvert explicitement, sinon crash à l'exécution uniquement en release.
# --- Gson (backup JSON + calibration) ---
# Gson lit/écrit les champs par réflexion (noms de champs) : sans ces règles,
# l'export/import JSON produirait des objets vides ou planterait EN RELEASE SEULE.
-keepattributes Signature, InnerClasses, EnclosingMethod, *Annotation*
-keep class com.hormonetrack.data.model.** { <fields>; }
-keep class com.hormonetrack.data.backup.BackupData { <fields>; }
-keep class com.hormonetrack.pk.TConfig { <fields>; }
# Énumérations : converties en String par Room et Gson (par nom)
-keepclassmembers enum com.hormonetrack.data.model.** { *; } -keepclassmembers enum com.hormonetrack.data.model.** { *; }

View File

@ -6,6 +6,7 @@ import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
@ -25,6 +26,13 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val container = (application as HormoneTrackApp).container val container = (application as HormoneTrackApp).container
// Edge-to-edge (Material You) : le contenu s'étend sous les barres système.
// Les insets sont alors consommés UNE SEULE FOIS — par les TopAppBar
// Material 3 (barre de statut) et la NavigationBar (barre de navigation).
// Avant : la fenêtre poussait déjà le contenu sous la barre de statut ET
// les TopAppBar rajoutaient leur padding interne → double espace vide en haut.
enableEdgeToEdge()
lifecycleScope.launch { lifecycleScope.launch {
val lang = container.settings.language.first() val lang = container.settings.language.first()
val locales = if (lang == "system") { val locales = if (lang == "system") {

View File

@ -23,7 +23,7 @@ import com.hormonetrack.data.model.Treatment
*/ */
@Database( @Database(
entities = [Treatment::class, DoseLog::class, LabResult::class], entities = [Treatment::class, DoseLog::class, LabResult::class],
version = 2, version = 3,
exportSchema = false exportSchema = false
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@ -43,6 +43,16 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
/**
* v2 → v3 (2026-09-05) : ajout de calendarEventId (nullable, INTEGER) —
* id de l'événement d'agenda récurrent (CalendarContract) du traitement.
*/
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE treatments ADD COLUMN calendarEventId INTEGER")
}
}
@Volatile @Volatile
private var INSTANCE: AppDatabase? = null private var INSTANCE: AppDatabase? = null
@ -53,7 +63,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"hormonetrack.db" "hormonetrack.db"
) )
.addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build() .build()
INSTANCE = instance INSTANCE = instance
instance instance

View File

@ -31,8 +31,26 @@ object BackupManager {
return Gson().toJson(data) return Gson().toJson(data)
} }
data class ImportResult(val treatments: Int, val doseLogs: Int, val labResults: Int) data class ImportResult(
val treatments: Int,
val doseLogs: Int,
val labResults: Int,
/** Réglages T du backup, à restaurer dans DataStore par l'appelant. */
val tConfig: TConfig
)
/**
* Restaure un backup en mode **ÉCRASEMENT** (v1.2.6) : les données actuelles
* sont effacées AVANT l'insertion — sinon les IDs du backup (conservés pour
* les FK dose→traitement) entrent en conflit avec les IDs existants et
* l'import échouait dès que l'app contenait déjà des données.
*
* Ordre d'effacement : dose_logs et lab_results d'abord, treatments ensuite
* (FK CASCADE des doses vers les traitements).
*
* @return le nombre d'entrées restaurées par table + le tConfig du backup
* (à restaurer dans DataStore par l'appelant).
*/
suspend fun importJson( suspend fun importJson(
repo: HormoneRepository, repo: HormoneRepository,
json: String json: String
@ -40,6 +58,11 @@ object BackupManager {
val type = object : TypeToken<BackupData>() {}.type val type = object : TypeToken<BackupData>() {}.type
val data: BackupData = Gson().fromJson(json, type) val data: BackupData = Gson().fromJson(json, type)
// Écrasement : on part d'une base vide (ordre : enfants → parents)
repo.deleteAllDoseLogs()
repo.deleteAllLabResults()
repo.deleteAllTreatments()
data.treatments.forEach { repo.insertTreatment(it) } data.treatments.forEach { repo.insertTreatment(it) }
data.doseLogs.forEach { repo.insertDoseLog(it) } data.doseLogs.forEach { repo.insertDoseLog(it) }
data.labResults.forEach { repo.insertLabResult(it) } data.labResults.forEach { repo.insertLabResult(it) }
@ -47,7 +70,8 @@ object BackupManager {
return ImportResult( return ImportResult(
data.treatments.size, data.treatments.size,
data.doseLogs.size, data.doseLogs.size,
data.labResults.size data.labResults.size,
data.tConfig
) )
} }

View File

@ -24,6 +24,10 @@ interface DoseLogDao {
@Query("SELECT * FROM dose_logs ORDER BY timestamp ASC") @Query("SELECT * FROM dose_logs ORDER BY timestamp ASC")
suspend fun getAllOnce(): List<DoseLog> suspend fun getAllOnce(): List<DoseLog>
/** Efface TOUT l'historique (import en mode écrasement). */
@Query("DELETE FROM dose_logs")
suspend fun deleteAll()
@Insert @Insert
suspend fun insert(log: DoseLog): Long suspend fun insert(log: DoseLog): Long

View File

@ -18,6 +18,10 @@ interface LabResultDao {
@Query("SELECT * FROM lab_results ORDER BY timestamp ASC") @Query("SELECT * FROM lab_results ORDER BY timestamp ASC")
suspend fun getAllOnce(): List<LabResult> suspend fun getAllOnce(): List<LabResult>
/** Efface toutes les analyses (import en mode écrasement). */
@Query("DELETE FROM lab_results")
suspend fun deleteAll()
@Insert @Insert
suspend fun insert(result: LabResult): Long suspend fun insert(result: LabResult): Long

View File

@ -21,6 +21,11 @@ interface TreatmentDao {
@Query("SELECT * FROM treatments ORDER BY createdAt DESC") @Query("SELECT * FROM treatments ORDER BY createdAt DESC")
suspend fun getAllOnce(): List<Treatment> suspend fun getAllOnce(): List<Treatment>
/** Efface tous les traitements (import en mode écrasement).
* ⚠️ À appeler APRÈS deleteAll() des dose_logs (FK CASCADE sinon). */
@Query("DELETE FROM treatments")
suspend fun deleteAll()
@Insert @Insert
suspend fun insert(treatment: Treatment): Long suspend fun insert(treatment: Treatment): Long

View File

@ -64,6 +64,11 @@ data class Treatment(
val reminderMinute: Int? = null, val reminderMinute: Int? = null,
val reminderEnabled: Boolean = false, val reminderEnabled: Boolean = false,
// Événement d'agenda récurrent (CalendarContract) : id de l'event inséré
// dans le calendrier local « HormoneTrack » ; null = pas d'événement.
// Répétition dérivée de la Posologie (forecastIntervalDays).
val calendarEventId: Long? = null,
val createdAt: Long = System.currentTimeMillis() val createdAt: Long = System.currentTimeMillis()
) { ) {
val isInjection: Boolean val isInjection: Boolean

View File

@ -44,4 +44,9 @@ class HormoneRepository(
suspend fun insertLabResult(result: LabResult): Long = labResultDao.insert(result) suspend fun insertLabResult(result: LabResult): Long = labResultDao.insert(result)
suspend fun updateLabResult(result: LabResult) = labResultDao.update(result) suspend fun updateLabResult(result: LabResult) = labResultDao.update(result)
suspend fun deleteLabResult(result: LabResult) = labResultDao.delete(result) suspend fun deleteLabResult(result: LabResult) = labResultDao.delete(result)
// --- Effacement complet (import en mode écrasement, cf BackupManager) ---
suspend fun deleteAllDoseLogs() = doseLogDao.deleteAll()
suspend fun deleteAllLabResults() = labResultDao.deleteAll()
suspend fun deleteAllTreatments() = treatmentDao.deleteAll()
} }

View File

@ -117,7 +117,14 @@ object PharmacokineticEngine {
treatments: List<Treatment>, treatments: List<Treatment>,
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
tMs: Long, tMs: Long,
modelOverride: String? = null modelOverride: String? = null,
/**
* Facteurs d'échelle PAR ESTER (v1.2.1) : chaque dose est multipliée par le
* facteur de son ester (doseEster), fallback = scaleFactor du traitement.
* Utilisé par la calibration automatique : les labs valerate calibrent les
* doses valerate, les labs enanthate les doses enanthate.
*/
scalePerEster: Map<String, Double>? = null
): Double { ): Double {
val batemanCache = HashMap<Long, BatemanParams>() val batemanCache = HashMap<Long, BatemanParams>()
var total = 0.0 var total = 0.0
@ -133,7 +140,11 @@ object PharmacokineticEngine {
val c = concentrationOfDose( val c = concentrationOfDose(
treatment, dose, tMs, batemanCache[treatment.id], modelOverride treatment, dose, tMs, batemanCache[treatment.id], modelOverride
) )
if (c > 0.0) total += c * treatment.scaleFactor if (c > 0.0) {
val scale = scalePerEster?.get(doseEster(treatment, dose))
?: treatment.scaleFactor
total += c * scale
}
} }
} }
return total return total
@ -147,12 +158,17 @@ object PharmacokineticEngine {
/** /**
* The T model works in ng/mL. Users enter labs in ng/mL, ng/dL, ng/L or nmol/L — * The T model works in ng/mL. Users enter labs in ng/mL, ng/dL, ng/L or nmol/L —
* normalize before display or calibration (45 ng/dL = 0.45 ng/mL). * normalize before display or calibration (45 ng/dL = 0.45 ng/mL).
* Defensive: a T lab entered with a wrong unit (ex. "pg/mL" comme un lab réel
* de l'utilisatrice, 38 "pg/mL") est converti aussi, sinon il écrase l'axe T
* et rend la courbe T invisible.
*/ */
fun convertTToNgMl(value: Double, unit: String): Double { fun convertTToNgMl(value: Double, unit: String): Double {
val u = unit.lowercase().replace(" ", "") val u = unit.lowercase().replace(" ", "")
return when { return when {
u.contains("dl") -> value / 100.0 u.contains("dl") -> value / 100.0
u.contains("nmol") -> value * 0.2884 u.contains("nmol") -> value * 0.2884
u.contains("pg") -> value / 1000.0
u.contains("µg") || u.contains("μg") -> value / 1000.0
u.contains("ng/l") || u.endsWith("/l") -> value / 1000.0 u.contains("ng/l") || u.endsWith("/l") -> value / 1000.0
else -> value else -> value
} }
@ -162,18 +178,42 @@ object PharmacokineticEngine {
treatments: List<Treatment>, treatments: List<Treatment>,
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
tMs: Long, tMs: Long,
tConfig: TConfig tConfig: TConfig,
tKPerEster: Map<String, Double>? = null
): LevelPoint { ): LevelPoint {
val e2 = e2At(treatments, doseLogs, tMs) val e2 = e2At(treatments, doseLogs, tMs)
return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig)) val k = activeEsterAt(treatments, doseLogs, tMs)
?.let { tKPerEster?.get(it) } ?: tConfig.k
return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig.copy(k = k)))
}
/**
* L'ester « actif » à l'instant t = celui de la dernière dose E2 ≤ t
* (une prise de sang / un point de courbe reflète l'injection qui précède).
* null avant la première dose ou si l'ester est NONE.
*/
fun activeEsterAt(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
tMs: Long
): String? {
val last = doseLogs
.filter { d ->
d.timestamp <= tMs &&
treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL }
}
.maxByOrNull { it.timestamp } ?: return null
val tr = treatments.first { it.id == last.treatmentId }
return doseEster(tr, last).takeIf { it != Esters.NONE }
} }
fun currentLevel( fun currentLevel(
treatments: List<Treatment>, treatments: List<Treatment>,
doseLogs: List<DoseLog>, doseLogs: List<DoseLog>,
tConfig: TConfig, tConfig: TConfig,
tKPerEster: Map<String, Double>? = null,
nowMs: Long = System.currentTimeMillis() nowMs: Long = System.currentTimeMillis()
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig) ): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster)
fun computeCurve( fun computeCurve(
treatments: List<Treatment>, treatments: List<Treatment>,
@ -183,7 +223,15 @@ object PharmacokineticEngine {
stepMs: Long = HOUR_MS, stepMs: Long = HOUR_MS,
tConfig: TConfig, tConfig: TConfig,
/** Force un modèle PK (ESE/TFS) pour les traitements par profil. */ /** Force un modèle PK (ESE/TFS) pour les traitements par profil. */
modelOverride: String? = null modelOverride: String? = null,
/** Facteurs d'échelle par ester (calibration automatique, cf e2At). */
scalePerEster: Map<String, Double>? = null,
/**
* Constante k du modèle T PAR PÉRIODE D'ESTER (v1.2.3) : à chaque instant,
* le k utilisé est celui de l'ester de la dernière dose ≤ t (la suppression
* n'est pas la même selon l'ester). Fallback = tConfig.k.
*/
tKPerEster: Map<String, Double>? = null
): List<LevelPoint> { ): List<LevelPoint> {
if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList() if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList()
@ -198,10 +246,25 @@ object PharmacokineticEngine {
val batemanCache = HashMap<Long, BatemanParams>() val batemanCache = HashMap<Long, BatemanParams>()
relevantTreatments.forEach { batemanCache[it.id] = batemanParams(it) } relevantTreatments.forEach { batemanCache[it.id] = batemanParams(it) }
// Doses triées pour suivre l'« ester actif » le long de la grille (pour le k de T)
val sortedEstrogenDoses = doseLogs
.filter { d -> relevantTreatments.any { it.id == d.treatmentId } }
.sortedBy { it.timestamp }
var doseCursor = 0
var activeEster: String? = null
val points = mutableListOf<LevelPoint>() val points = mutableListOf<LevelPoint>()
var t = searchStart var t = searchStart
while (t <= endMs) { while (t <= endMs) {
var e2 = 0.0 var e2 = 0.0
// Avance le curseur : la dose à t définit l'ester actif
while (doseCursor < sortedEstrogenDoses.size && sortedEstrogenDoses[doseCursor].timestamp <= t) {
val d = sortedEstrogenDoses[doseCursor]
val tr = relevantTreatments.first { it.id == d.treatmentId }
activeEster = doseEster(tr, d)
doseCursor++
}
val k = activeEster?.let { tKPerEster?.get(it) } ?: tConfig.k
for (treatment in relevantTreatments) { for (treatment in relevantTreatments) {
val cutoff = cutoffHours(treatment) val cutoff = cutoffHours(treatment)
val p = batemanCache[treatment.id] val p = batemanCache[treatment.id]
@ -210,10 +273,14 @@ object PharmacokineticEngine {
val dtH = (t - dose.timestamp) / 3_600_000.0 val dtH = (t - dose.timestamp) / 3_600_000.0
if (dtH > cutoff) continue if (dtH > cutoff) continue
val c = concentrationOfDose(treatment, dose, t, p, modelOverride) val c = concentrationOfDose(treatment, dose, t, p, modelOverride)
if (c > 0.0) e2 += c * treatment.scaleFactor if (c > 0.0) {
val scale = scalePerEster?.get(doseEster(treatment, dose))
?: treatment.scaleFactor
e2 += c * scale
} }
} }
points.add(LevelPoint(t, e2, testosteroneAt(e2, tConfig))) }
points.add(LevelPoint(t, e2, testosteroneAt(e2, tConfig.copy(k = k))))
t += stepMs t += stepMs
} }
return points return points
@ -252,6 +319,15 @@ object PharmacokineticEngine {
val forecast = mutableListOf<DoseLog>() val forecast = mutableListOf<DoseLog>()
var t = last.timestamp + intervalMs var t = last.timestamp + intervalMs
// ⚠️ Oubli d'une injection : le premier créneau théorique tombe dans le
// PASSÉ (dernière prise + intervalle = jour de l'injection manquée) —
// on ne simule JAMAIS un créneau passé (faux pic dans l'historique) :
// on avance au premier créneau STRICTEMENT FUTUR, au rythme configuré.
// Un retard, lui, décale naturellement toute la prévision (elle part de
// la dernière prise réelle).
while (t <= nowMs) {
t += intervalMs
}
while (t <= toMs) { while (t <= toMs) {
forecast.add( forecast.add(
DoseLog( DoseLog(
@ -267,19 +343,133 @@ object PharmacokineticEngine {
} }
/** /**
* Calibration automatique (option « Auto-calibration » des Paramètres) : * Calibration PAR PÉRIODE D'ESTER (v1.2.1).
* recalcul à la volée du facteur d'échelle de chaque traitement E2 (médiane *
* lab ÷ prédiction) et de la constante k du modèle T, SANS toucher aux valeurs * Principe : chaque lab est attribué à la période d'injection dans laquelle il
* stockées — les traitements renvoyés sont des copies à utiliser uniquement pour * tombe = la dernière dose E2 antérieure ou égale au lab (une prise de sang
* l'affichage des courbes. * reflète d'abord l'injection qui précède). Le ratio lab ÷ prédiction (toutes
* doses superposées, SANS calibration) est alors rattaché à l'ester de cette
* dose. Le facteur final par ester = médiane des ratios de sa période.
*
* Ainsi, si l'utilisatrice était sous valerate puis passée sous enanthate, les
* labs valerate calibrent les doses valerate et les labs enanthate calibrent les
* doses enanthate — au lieu d'un facteur unique faussé par le mélange des
* périodes (bug remonté : courbes gonflées à 250–375 pg/mL).
*
* @return facteur d'échelle par ester ("EV"/"EU"/"EEN"), seulement pour les
* esters ayant au moins un lab exploitable (prédiction > 0.5 pg/mL).
*/
fun computeEsterScaleFactors(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
e2Labs: List<LabResult>
): Map<String, Double> {
val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL }
val estrogenDoses = doseLogs.filter { d ->
estrogenTreatments.any { it.id == d.treatmentId }
}
if (estrogenDoses.isEmpty()) return emptyMap()
// Prédictions non calibrées (scaleFactor forcé à 1) pour chaque lab
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
val ratiosByEster = HashMap<String, MutableList<Double>>()
for (lab in e2Labs) {
val predicted = e2At(unscaled, doseLogs, lab.timestamp)
if (predicted <= 0.5) continue
// Attribution : dernière dose E2 ≤ lab → son ester (override compris)
val attributedDose = estrogenDoses
.filter { it.timestamp <= lab.timestamp }
.maxByOrNull { it.timestamp } ?: continue
val attributedTreatment =
estrogenTreatments.first { it.id == attributedDose.treatmentId }
val ester = doseEster(attributedTreatment, attributedDose)
if (ester == Esters.NONE) continue
ratiosByEster.getOrPut(ester) { mutableListOf() }
.add(lab.value / predicted)
}
return ratiosByEster.mapValues { (_, ratios) ->
val sorted = ratios.sorted()
val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2]
else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
(median * 100).toInt() / 100.0
}
}
/**
* Calibration du k du modèle T **PAR PÉRIODE D'ESTER** (v1.2.3).
*
* La suppression de la testostérone n'est pas la même selon l'ester (un valerate
* donne des pics hauts et courts, un enanthate un plateau plus doux) : chaque lab
* T est attribué à la période d'injection dans laquelle il tombe (dernière dose
* E2 ≤ lab → son ester) et le k final = médiane des k de CETTE période.
*
* L'E2 utilisé dans la formule est la version **calibrée** (scalePerEster) :
* calibrer k contre une E2 non calibrée donnerait des k faussés.
*
* @return k par ester ("EV"/"EU"/"EEN"), seulement pour les périodes ayant au
* moins un lab T exploitable (valeur > floor, E2 estimée > 1 pg/mL, k ∈ (1e-4, 10)).
*/
fun computeTKPerEster(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
tLabs: List<LabResult>,
current: TConfig,
scalePerEster: Map<String, Double>? = null
): Map<String, Double> {
val estrogenDoses = doseLogs.filter { d ->
treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL }
}
if (estrogenDoses.isEmpty()) return emptyMap()
val ksByEster = HashMap<String, MutableList<Double>>()
for (lab in tLabs) {
val tNgMl = convertTToNgMl(lab.value, lab.unit)
if (tNgMl <= current.floor + 0.02) continue
val attributed = estrogenDoses
.filter { it.timestamp <= lab.timestamp }
.maxByOrNull { it.timestamp } ?: continue
val tr = treatments.first { it.id == attributed.treatmentId }
val ester = doseEster(tr, attributed)
if (ester == Esters.NONE) continue
val e2 = e2At(treatments, doseLogs, lab.timestamp, scalePerEster = scalePerEster)
if (e2 <= 1.0) continue
val k = ((current.base - current.floor) / (tNgMl - current.floor) - 1.0) / e2
if (k > 1e-4 && k < 10.0) {
ksByEster.getOrPut(ester) { mutableListOf() }.add(k)
}
}
return ksByEster.mapValues { (_, ks) ->
val sorted = ks.sorted()
val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2]
else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
(median * 1000).toInt() / 1000.0
}
}
/**
* Calibration automatique (option « Auto-calibration » des Paramètres), v1.2.3 :
* - facteurs d'échelle E2 **par période d'ester** ([computeEsterScaleFactors]) ;
* - k du modèle T **par période d'ester** ([computeTKPerEster]), calibré contre
* l'E2 déjà calibrée — la suppression T diffère selon l'ester.
*
* Tout est renvoyé en copies/paramètres, UNIQUEMENT pour l'affichage : les
* traitements et réglages stockés ne sont jamais modifiés. Pour un ester sans
* lab : E2 → scaleFactor stocké, T → k stocké (tConfig.k).
*/ */
data class AutoCalibrated( data class AutoCalibrated(
val treatments: List<Treatment>, val treatments: List<Treatment>,
val tConfig: TConfig, val tConfig: TConfig,
/** Nombre de traitements dont le facteur d'échelle a été ajusté. */ /** Facteur d'échelle par ester ("EV"/"EU"/"EEN"), recalculé depuis les labs. */
val calibratedTreatments: Int, val esterScales: Map<String, Double>,
/** true si le modèle T a pu être recalibré. */ /** Nombre d'esters calibrés avec au moins un lab exploitable. */
val tRecalibrated: Boolean val calibratedEsters: Int,
/** true si le modèle T a pu être recalibré (au moins un ester avec des labs T). */
val tRecalibrated: Boolean,
/** k du modèle T par ester, recalculé depuis les labs T. */
val tKPerEster: Map<String, Double>
) )
fun autoCalibrated( fun autoCalibrated(
@ -291,21 +481,17 @@ object PharmacokineticEngine {
val e2Labs = labs.filter { it.marker.equals("E2", true) } val e2Labs = labs.filter { it.marker.equals("E2", true) }
val tLabs = labs.filter { it.marker.equals("T", true) } val tLabs = labs.filter { it.marker.equals("T", true) }
var calibrated = 0 val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs)
val newTreatments = treatments.map { tr -> val tKPerEster = computeTKPerEster(
if (tr.type != TreatmentType.ESTRADIOL) return@map tr treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales
val sf = computeScaleFactor(tr, doseLogs, e2Labs) )
if (sf != null && sf != tr.scaleFactor) {
calibrated++
tr.copy(scaleFactor = sf)
} else tr
}
val newT = computeTConfigCalibration(tLabs, newTreatments, doseLogs, tConfig)
return AutoCalibrated( return AutoCalibrated(
treatments = newTreatments, treatments = treatments,
tConfig = newT ?: tConfig, tConfig = tConfig,
calibratedTreatments = calibrated, esterScales = esterScales,
tRecalibrated = newT != null calibratedEsters = esterScales.size,
tRecalibrated = tKPerEster.isNotEmpty(),
tKPerEster = tKPerEster
) )
} }
@ -379,7 +565,8 @@ object PharmacokineticEngine {
var next: Long? = null var next: Long? = null
val cal = java.util.Calendar.getInstance() val cal = java.util.Calendar.getInstance()
for (tr in treatments) { for (tr in treatments) {
if (!tr.reminderEnabled) continue // Un traitement inactif ne génère plus de rappels
if (!tr.reminderEnabled || !tr.isActive) continue
val h = tr.reminderHour ?: continue val h = tr.reminderHour ?: continue
val m = tr.reminderMinute ?: continue val m = tr.reminderMinute ?: continue
cal.timeInMillis = nowMs cal.timeInMillis = nowMs
@ -396,4 +583,64 @@ object PharmacokineticEngine {
fun isInjectionRoute(route: AdministrationRoute): Boolean = fun isInjectionRoute(route: AdministrationRoute): Boolean =
route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT
// ------------------------------------------------------------------
// Détection des pics et creux (v1.2.3) — pour l'affichage sur le graphique
// ------------------------------------------------------------------
/** Un extremum détecté : pic (isPeak = true) ou creux, avec sa valeur. */
data class Extremum(val timestamp: Long, val value: Double, val isPeak: Boolean)
/**
* Détecte les maxima et minima LOCAUX d'une courbe (les pics après chaque
* injection et les creux juste avant la suivante, en régime d'équilibre).
*
* - les plateaux (valeurs égales consécutives) comptent pour un seul extremum ;
* - les extrema consécutifs de même type sont fusionnés (le plus extrême gagne) ;
* - les oscillations d'amplitude < minSwingAbs sont ignorées (bruit d'arrondi) —
* utiliser ~2 pg/mL pour l'E2 et ~0,02 ng/mL pour la T.
*
* @param selector ce qu'on analyse (`it.e2` ou `it.t` d'un LevelPoint).
*/
fun detectExtrema(
points: List<LevelPoint>,
selector: (LevelPoint) -> Double,
minSwingAbs: Double = 2.0
): List<Extremum> {
if (points.size < 3) return emptyList()
val values = points.map(selector)
// 1) extrema locaux stricts (un plateau = son premier point)
val raw = mutableListOf<Pair<Int, Boolean>>()
var i = 1
while (i < values.size - 1) {
val v = values[i]
val prev = values[i - 1]
var j = i
while (j < values.size - 1 && values[j + 1] == v) j++ // plateau
if (j >= values.size - 1) break
val next = values[j + 1]
if (v > prev && v > next) {
raw += i to true
} else if (v < prev && v < next) {
raw += i to false
}
i = j + 1
}
// 2) alternance pic/creux + filtre d'amplitude
val out = mutableListOf<Extremum>()
for ((idx, isPeak) in raw) {
val last = out.lastOrNull()
if (last != null && last.isPeak == isPeak) {
// doublon de même type : garder le plus extrême
val better = if (isPeak) values[idx] > last.value else values[idx] < last.value
if (better) out[out.size - 1] = Extremum(points[idx].timestamp, values[idx], isPeak)
continue
}
if (last != null && abs(last.value - values[idx]) < minSwingAbs) continue
out += Extremum(points[idx].timestamp, values[idx], isPeak)
}
return out
}
} }

View File

@ -0,0 +1,164 @@
package com.hormonetrack.reminder
import android.content.ContentUris
import android.content.Context
import android.content.ContentValues
import android.provider.CalendarContract
import com.hormonetrack.data.model.Treatment
import java.util.Calendar
import java.util.TimeZone
/**
* Gestion des événements d'agenda récurrents pour les prises hormonales
* (v1.3.0) : un événement est inséré dans un calendrier LOCAL « HormoneTrack »
* (visible dans l'agenda du téléphone) avec une répétition RRULE dérivée de la
* **Posologie** du traitement (forecastIntervalDays).
*
* Cycle de vie :
* - activation (switch « Événement d'agenda » dans l'éditeur de traitement,
* sous « Rappels ») → [upsertEvent] crée/met à jour l'événement et l'id est
* stocké sur le traitement ;
* - désactivation / suppression / changement de posologie → l'ancien
* événement est supprimé puis recréé au save.
*
* Permission : WRITE_CALENDAR (+ READ_CALENDAR pour les requêtes), demandée à
* l'exécution par l'éditeur au moment de l'activation du switch.
*/
object CalendarEvents {
const val CALENDAR_NAME = "HormoneTrack"
/**
* RRULE de répétition dérivé de la posologie (jours) : répétition DAILY
* tous les N jours (INTERVAL = N). L'intervalle est arrondi à l'entier
* (RRULE n'accepte pas de décimales) — documenté dans l'UI.
* ⚠️ Arrondi demi-supérieur EXPLICITE via floor(x + 0.5) : kotlin.math.round
* arrondit les ties vers l'entier PAIR (6,5 → 6 !) — piégé en v1.3.0.
*/
fun rruleForIntervalDays(intervalDays: Double): String =
"FREQ=DAILY;INTERVAL=${maxOf(1, kotlin.math.floor(intervalDays + 0.5).toInt())}"
/**
* Prochaine occurrence (ms) d'une prise à hh:mm : aujourd'hui si pas encore
* passé, sinon demain. Heure par défaut 12:00 si aucun rappel configuré.
*/
fun nextOccurrenceMs(nowMs: Long, hour: Int, minute: Int): Long {
val cal = Calendar.getInstance(TimeZone.getDefault()).apply {
timeInMillis = nowMs
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
if (cal.timeInMillis <= nowMs) cal.add(Calendar.DAY_OF_MONTH, 1)
return cal.timeInMillis
}
fun defaultStartMs(treatment: Treatment, nowMs: Long): Long {
val hour = treatment.reminderHour ?: 12
val minute = treatment.reminderMinute ?: 0
return nextOccurrenceMs(nowMs, hour, minute)
}
/**
* Retourne l'id du calendrier local « HormoneTrack » (le crée s'il manque).
* Nécessite WRITE_CALENDAR (l'insertion d'un calendrier local se fait avec
* CALLER_IS_SYNCADAPTER=true).
*/
fun ensureCalendar(context: Context): Long {
val calUri = CalendarContract.Calendars.CONTENT_URI
.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.build()
context.contentResolver.query(
calUri,
arrayOf(CalendarContract.Calendars._ID, CalendarContract.Calendars.NAME),
"${CalendarContract.Calendars.NAME} = ?",
arrayOf(CALENDAR_NAME),
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
return cursor.getLong(0)
}
}
val values = ContentValues().apply {
put(CalendarContract.Calendars.NAME, CALENDAR_NAME)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, CALENDAR_NAME)
put(CalendarContract.Calendars.ACCOUNT_NAME, CALENDAR_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.OWNER_ACCOUNT, CALENDAR_NAME)
put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFF4F5BD5.toInt())
put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
}
val newUri = context.contentResolver.insert(calUri, values)!!
return ContentUris.parseId(newUri)
}
/**
* Crée OU met à jour l'événement récurrent du traitement :
* - début = prochaine occurrence à l'heure de rappel (ou 12:00) ;
* - répétition = posologie (RRULE FREQ=DAILY;INTERVAL=N) ;
* - si l'événement précédent existe toujours, il est supprimé puis recréé
* (la modification d'un RRULE via l'API étant capricieuse selon les
* fournisseurs d'agenda — supprimer/recréer est fiable).
*
* @return le nouvel id d'événement (à stocker sur le traitement).
*/
fun upsertEvent(context: Context, calendarId: Long, treatment: Treatment): Long {
treatment.calendarEventId?.let { deleteEvent(context, it) }
val start = defaultStartMs(treatment, System.currentTimeMillis())
val end = start + 30 * 60_000L // 30 min de bloc
val tz = TimeZone.getDefault().id
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, "${treatment.name} · ${formatDoseLine(treatment)}")
put(
CalendarContract.Events.DESCRIPTION,
"Prise hormonale récurrente (Posologie : tous les " +
"${formatInterval(treatment)} j). Généré par HormoneTrack."
)
put(CalendarContract.Events.DTSTART, start)
put(CalendarContract.Events.DTEND, end)
put(CalendarContract.Events.EVENT_TIMEZONE, tz)
put(
CalendarContract.Events.RRULE,
rruleForIntervalDays(treatment.forecastIntervalDays ?: 1.0)
)
}
val eventUri = CalendarContract.Events.CONTENT_URI
.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, CALENDAR_NAME)
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
.build()
val eventUriInserted = context.contentResolver.insert(eventUri, values)!!
return ContentUris.parseId(eventUriInserted)
}
/** Supprime l'événement d'agenda du traitement (si présent). */
fun deleteEvent(context: Context, eventId: Long) {
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.build()
try {
context.contentResolver.delete(uri, null, null)
} catch (_: Exception) {
// L'événement peut déjà avoir été supprimé côté agenda — ignorer.
}
}
private fun formatDoseLine(treatment: Treatment): String =
"${formatDose(treatment.doseAmount)} ${treatment.doseUnit}"
private fun formatInterval(treatment: Treatment): String =
treatment.forecastIntervalDays?.let { formatDose(it) } ?: "?"
private fun formatDose(d: Double): String =
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.1f".format(d)
}

View File

@ -114,7 +114,8 @@ class AlarmScheduler(private val context: Context) {
fun rescheduleAll(treatments: List<Treatment>) { fun rescheduleAll(treatments: List<Treatment>) {
treatments.forEach { tr -> treatments.forEach { tr ->
if (tr.reminderEnabled) { // Un traitement inactif ne rappelle plus : son alarme est annulée
if (tr.reminderEnabled && tr.isActive) {
scheduleDaily(tr) scheduleDaily(tr)
} else { } else {
cancel(tr.id) cancel(tr.id)
@ -211,7 +212,9 @@ class BootReceiver : BroadcastReceiver() {
Thread { Thread {
try { try {
val treatments = kotlinx.coroutines.runBlocking { val treatments = kotlinx.coroutines.runBlocking {
AppDatabase.getInstance(context).treatmentDao().getActiveOnce() // TOUS les traitements : les inactifs doivent voir leur alarme
// annulée au boot (sinon une alarme périmée continue de sonner)
AppDatabase.getInstance(context).treatmentDao().getAllOnce()
} }
AlarmScheduler(context).rescheduleAll(treatments) AlarmScheduler(context).rescheduleAll(treatments)
} finally { } finally {

View File

@ -27,6 +27,9 @@ class AppSettings(private val context: Context) {
/** Option : ajuster automatiquement scale factor + modèle T depuis les labs. */ /** Option : ajuster automatiquement scale factor + modèle T depuis les labs. */
val AUTO_CALIBRATE = booleanPreferencesKey("auto_calibrate") val AUTO_CALIBRATE = booleanPreferencesKey("auto_calibrate")
/** Dernière version pour laquelle le dialog « Nouveautés » a été montré. */
val CHANGELOG_SEEN = stringPreferencesKey("changelog_seen_version")
} }
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs -> val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
@ -46,6 +49,11 @@ class AppSettings(private val context: Context) {
prefs[Keys.AUTO_CALIBRATE] ?: false prefs[Keys.AUTO_CALIBRATE] ?: false
} }
/** Version dont les nouveautés ont déjà été montrées (null = jamais). */
val changelogSeenVersion: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[Keys.CHANGELOG_SEEN]
}
suspend fun setTConfig(config: TConfig) { suspend fun setTConfig(config: TConfig) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[Keys.T_BASE] = config.base prefs[Keys.T_BASE] = config.base
@ -65,4 +73,10 @@ class AppSettings(private val context: Context) {
prefs[Keys.AUTO_CALIBRATE] = enabled prefs[Keys.AUTO_CALIBRATE] = enabled
} }
} }
suspend fun setChangelogSeenVersion(version: String) {
context.dataStore.edit { prefs ->
prefs[Keys.CHANGELOG_SEEN] = version
}
}
} }

View File

@ -0,0 +1,62 @@
package com.hormonetrack.settings
/**
* Utilitaires pour le dialog « Nouveautés » affiché après chaque mise à jour
* (v1.3.0). Pur (aucune dépendance Android) → testable en JVM.
*
* Le contenu vient de `docs/CHANGELOG.md` embarqué comme asset (synchronisé
* automatiquement à chaque build par la tâche Gradle `copyChangelog`).
*/
object ChangelogHelper {
/**
* Compare deux versions SemVer « X.Y.Z » NUMÉRIQUEMENT (composant par
* composant) — une comparaison lexicographique considérerait à tort
* « 1.2.9 > 1.2.10 ».
*/
fun isVersionNewer(current: String, reference: String?): Boolean {
if (reference.isNullOrBlank()) return true // première installation
val c = parse(current) ?: return true
val r = parse(reference) ?: return true
for (i in 0 until maxOf(c.size, r.size)) {
val a = c.getOrElse(i) { 0 }
val b = r.getOrElse(i) { 0 }
if (a != b) return a > b
}
return false // identiques
}
private fun parse(version: String): List<Int>? {
val cleaned = version.trim().removePrefix("v")
if (!Regex("^\\d+(\\.\\d+)*$").matches(cleaned)) return null
return cleaned.split(".").map { it.toInt() }
}
/**
* Extrait de la CHANGELOG (markdown) les sections des versions STRICTEMENT
* plus récentes que `lastSeenVersion` (null = première installation → la
* version courante seule), jusqu'à `currentVersion` incluse.
*
* Format attendu : des sections « ## [X.Y.Z] — … » avec leur corps, la plus
* récente EN PREMIER (convention Keep a Changelog du dépôt).
*
* @return le markdown concaténé des nouveautés ("" si rien de nouveau).
*/
fun sectionsSince(markdown: String, lastSeenVersion: String?, currentVersion: String): String {
val pattern = Regex("## \\[(\\d+\\.\\d+\\.\\d+)\\][^\\n]*\\n(.*?)(?=\\n## \\[|\\Z)", RegexOption.DOT_MATCHES_ALL)
val out = StringBuilder()
for (match in pattern.findAll(markdown)) {
val version = match.groupValues[1]
// Sections triées de la plus récente à la plus ancienne :
// - inclure ce qui est STRICTEMENT plus récent que la dernière version
// vue (lastSeen null = première installation → section courante seule) ;
// - s'arrêter dès qu'on descend sous lastSeen (déjà vu).
if (lastSeenVersion != null && !isVersionNewer(version, lastSeenVersion)) break
if (lastSeenVersion == null && version != currentVersion) continue
if (out.isNotEmpty()) out.append("\n\n---\n\n")
out.append("## Version ").append(version).append("\n\n")
out.append(match.groupValues[2].trim())
}
return out.toString().trim()
}
}

View File

@ -1,5 +1,6 @@
package com.hormonetrack.ui package com.hormonetrack.ui
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
@ -14,8 +15,13 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.flow.first
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
@ -42,6 +48,34 @@ private data class TabItem(val route: String, val labelRes: Int, val icon: andro
@Composable @Composable
fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) { fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp
val context = androidx.compose.ui.platform.LocalContext.current
// Dialog « Nouveautés » (v1.3.0) : affiché après une mise à jour (ou à la
// première installation) avec le changelog des versions non vues ; la
// version vue est mémorisée dans DataStore → ne réapparaît pas avant la
// prochaine mise à jour.
var changelogText by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
val seen = app.container.settings.changelogSeenVersion.first()
val markdown = try {
context.assets.open("changelog.md").bufferedReader().use { it.readText() }
} catch (_: Exception) {
"" // asset manquant (build sans copyChangelog) → pas de dialog
}
val text = com.hormonetrack.settings.ChangelogHelper.sectionsSince(
markdown, seen, com.hormonetrack.BuildConfig.VERSION_NAME
)
if (text.isNotBlank()) changelogText = text
app.container.settings.setChangelogSeenVersion(com.hormonetrack.BuildConfig.VERSION_NAME)
}
changelogText?.let { md ->
com.hormonetrack.ui.components.ChangelogDialog(
changelogMarkdown = md,
version = com.hormonetrack.BuildConfig.VERSION_NAME,
onDismiss = { changelogText = null }
)
}
CompositionLocalProvider(LocalAppContainer provides app.container) { CompositionLocalProvider(LocalAppContainer provides app.container) {
val navController = rememberNavController() val navController = rememberNavController()
val tabs = listOf( val tabs = listOf(
@ -55,6 +89,10 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
val currentRoute = backStack?.destination?.route val currentRoute = backStack?.destination?.route
Scaffold( Scaffold(
// Insets à zéro : les TopAppBar des écrans consomment elles-mêmes la
// barre de statut, la NavigationBar ci-dessous la barre de navigation —
// sinon les deux se cumulent (double espace en haut, bug v1.2.5).
contentWindowInsets = WindowInsets(0, 0, 0, 0),
bottomBar = { bottomBar = {
if (currentRoute in tabs.map { it.route }) { if (currentRoute in tabs.map { it.route }) {
NavigationBar { NavigationBar {
@ -87,6 +125,16 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
HomeScreen( HomeScreen(
openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else null, openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else null,
onOpenSettings = { navController.navigate("settings") }, onOpenSettings = { navController.navigate("settings") },
onOpenCharts = {
// Tap sur le mini-graphique de l'accueil → écran Graphiques
navController.navigate("chart") {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
onOpenTreatment = { id -> navController.navigate("treatment_edit/$id") } onOpenTreatment = { id -> navController.navigate("treatment_edit/$id") }
) )
} }

View File

@ -0,0 +1,70 @@
package com.hormonetrack.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.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.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
/**
* Dialog « Nouveautés » (v1.3.0) : affiché au démarrage après une mise à jour
* (ou à la première installation) avec le changelog des versions depuis la
* dernière vue ; fermable, et ne réapparaît pas jusqu'à la prochaine mise à
* jour (la version vue est mémorisée dans DataStore par l'appelant).
*/
@Composable
fun ChangelogDialog(changelogMarkdown: String, version: String, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.ok)) }
},
title = { Text(stringResource(R.string.changelog_title, version)) },
text = {
Column(
Modifier
.fillMaxWidth()
.height(360.dp)
.verticalScroll(rememberScrollState())
) {
// Rendu simple du markdown : les lignes de titre ressortent en gras,
// le reste en texte courant (pas de lib de rendu markdown)
changelogMarkdown.lines().forEach { line ->
val trimmed = line.trim()
when {
trimmed.startsWith("###") -> Text(
trimmed.removePrefix("###").trim(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
trimmed.startsWith("##") -> Text(
trimmed.removePrefix("##").trim(),
style = MaterialTheme.typography.titleLarge
)
trimmed.startsWith("-") -> Text(
"• " + trimmed.removePrefix("-").trim(),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 8.dp)
)
trimmed.isEmpty() -> Text(" ")
else -> Text(
trimmed,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
)
}

View File

@ -46,6 +46,8 @@ data class ChartSeries(
data class ChartOptions( data class ChartOptions(
val showT: Boolean = true, val showT: Boolean = true,
val showLabs: Boolean = true, val showLabs: Boolean = true,
/** Afficher les pics et creux détectés (triangles ▲▼ aux extrema locaux). */
val showExtrema: Boolean = false,
val nowMs: Long = System.currentTimeMillis() val nowMs: Long = System.currentTimeMillis()
) )
@ -64,6 +66,17 @@ fun niceCeil(value: Double): Double {
return nice * exp return nice * exp
} }
/**
* Pas d'échantillonnage de la courbe selon la plage affichée (v1.2.9) :
* plus on zoome, plus le pas finit pour des courbes lisses
* (≤ 12 h → 15 min ; ≤ 24 h → 30 min ; sinon 1 h).
*/
fun stepForRange(rangeHours: Long): Long = when {
rangeHours <= 12L -> 900_000L // 15 min
rangeHours <= 24L -> 1_800_000L // 30 min
else -> 3_600_000L // 1 h
}
/** /**
* Graphique Canvas pur, multi-séries : * Graphique Canvas pur, multi-séries :
* - axe gauche = E2 (pg/mL), axe droit = T (ng/mL) ; * - axe gauche = E2 (pg/mL), axe droit = T (ng/mL) ;
@ -165,6 +178,23 @@ fun CurveChart(
) )
} }
} }
// Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée
// (v1.2.6 : le triangle est accompagné de la valeur simulée du pic/creux)
if (options.showExtrema) {
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
.forEach { ex ->
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
}
if (options.showT) {
s.tStyle?.let { tStyle ->
PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02)
.forEach { ex ->
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
}
}
}
}
} }
// Labs (points/carrés oranges) // Labs (points/carrés oranges)
@ -201,8 +231,50 @@ fun CurveChart(
} }
} }
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) { /**
for (i in 0..rows) { * Triangle ▲ (pic, au-dessus du point) ou ▼ (creux, en dessous) + la VALEUR
* estimée de l'extremum (v1.2.6) : valeur au-dessus d'un pic, en dessous d'un
* creux — dans la couleur de la série, unité implicite (celle de l'axe).
*/
private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: com.hormonetrack.pk.PharmacokineticEngine.Extremum, color: Color) {
val r = 5.dp.toPx()
val offset = 6.dp.toPx()
val path = Path()
val valueY: Float
if (extremum.isPeak) {
val y = cy - offset
path.moveTo(cx, y - r)
path.lineTo(cx - r, y + r * 0.5f)
path.lineTo(cx + r, y + r * 0.5f)
valueY = y - r - 3.dp.toPx()
} else {
val y = cy + offset
path.moveTo(cx, y + r)
path.lineTo(cx - r, y - r * 0.5f)
path.lineTo(cx + r, y - r * 0.5f)
valueY = y + r + 11.dp.toPx()
}
path.close()
drawPath(path, color)
// Valeur estimée du pic/creux, dans la couleur de la courbe
val paint = android.graphics.Paint().apply {
this.color = android.graphics.Color.argb(
(color.alpha * 255).toInt(),
(color.red * 255).toInt(),
(color.green * 255).toInt(),
(color.blue * 255).toInt()
)
textSize = 9.dp.toPx()
textAlign = android.graphics.Paint.Align.CENTER
isAntiAlias = true
isFakeBoldText = true
}
drawContext.canvas.nativeCanvas.drawText(
formatValue(extremum.value), cx, valueY, paint
)
}
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) { for (i in 0..rows) {
val y = padTop + h * i / rows val y = padTop + h * i / rows
drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f) drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f)
} }
@ -248,7 +320,10 @@ private fun DrawScope.drawXLabels(
) { ) {
val paint = labelPaint(android.graphics.Paint.Align.CENTER) val paint = labelPaint(android.graphics.Paint.Align.CENTER)
val spanH = (t1 - t0) / 3_600_000.0 val spanH = (t1 - t0) / 3_600_000.0
// Pas des labels X adaptatif — le zoom (v1.2.9) descend jusqu'à quelques heures
val stepHours = when { val stepHours = when {
spanH <= 8 -> 1
spanH <= 18 -> 3
spanH <= 25 -> 6 spanH <= 25 -> 6
spanH <= 25 * 7 -> 24 spanH <= 25 * 7 -> 24
else -> 24 * 5 else -> 24 * 5

View File

@ -2,6 +2,7 @@ package com.hormonetrack.ui.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@ -9,6 +10,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -23,35 +25,98 @@ import androidx.compose.ui.unit.dp
import com.hormonetrack.R import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
/** Valeur de labo saisie pour un marqueur (valeur + unité). */
data class LabValue(val value: Double, val unit: String)
/**
* Dialog de saisie de prise de sang.
*
* - **Création** (existing == null) : E2 et T **dans la même entrée**, chacune
* optionnelle (au moins une valeur requise), date/heure et notes partagées →
* insère 1 ou 2 LabResult avec le MÊME timestamp (l'écran Analyses les affiche
* alors côte à côte).
* - **Édition** (existing != null) : un seul marqueur, tout pré-rempli →
* onConfirmEdit avec la copie mise à jour.
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LabDialog( fun LabDialog(
existing: LabResult? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (marker: String, value: Double, unit: String, timestampMs: Long, notes: String) -> Unit onConfirmCreate: (e2: LabValue?, t: LabValue?, timestampMs: Long, notes: String) -> Unit,
onConfirmEdit: (LabResult) -> Unit
) { ) {
val isEdit = existing != null
val markerSuggestions = listOf("E2", "T", "PRL") val markerSuggestions = listOf("E2", "T", "PRL")
val unitSuggestions = listOf("pg/mL", "ng/mL", "ng/dL", "mIU/L") val unitSuggestions = listOf("pg/mL", "ng/mL", "ng/dL", "ng/L", "nmol/L", "mIU/L")
var marker by remember { mutableStateOf("E2") } var marker by remember(existing) { mutableStateOf(existing?.marker?.uppercase() ?: "E2") }
var markerExpanded by remember { mutableStateOf(false) } var markerExpanded by remember { mutableStateOf(false) }
var valueText by remember { mutableStateOf("") } var valueText by remember(existing) {
var unit by remember { mutableStateOf("pg/mL") } mutableStateOf(existing?.let { formatLabValue(it.value) } ?: "")
}
var unit by remember(existing) { mutableStateOf(existing?.unit ?: "pg/mL") }
var unitExpanded by remember { mutableStateOf(false) } var unitExpanded by remember { mutableStateOf(false) }
var dateTime by remember { mutableStateOf(java.time.LocalDateTime.now()) }
var notes by remember { mutableStateOf("") } // Mode création : deux sections (E2 + T), chacune optionnelle
var e2Text by remember { mutableStateOf("") }
var e2Unit by remember { mutableStateOf("pg/mL") }
var e2UnitExpanded by remember { mutableStateOf(false) }
var tText by remember { mutableStateOf("") }
var tUnit by remember { mutableStateOf("ng/mL") }
var tUnitExpanded by remember { mutableStateOf(false) }
var dateTime by remember(existing) {
mutableStateOf(
existing?.let { localDateTimeFromMs(it.timestamp) } ?: java.time.LocalDateTime.now()
)
}
var notes by remember(existing) { mutableStateOf(existing?.notes ?: "") }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(
val v = valueText.replace(',', '.').toDoubleOrNull() ?: return@TextButton enabled = if (isEdit) {
onConfirm(marker.trim().uppercase(), v, unit, msFromLocalDateTime(dateTime), notes) valueText.replace(',', '.').toDoubleOrNull() != null
}) { Text(stringResource(R.string.save)) } } else {
e2Text.replace(',', '.').toDoubleOrNull() != null ||
tText.replace(',', '.').toDoubleOrNull() != null
},
onClick = {
if (isEdit && existing != null) {
val v = valueText.replace(',', '.').toDoubleOrNull()
?: return@TextButton
onConfirmEdit(
existing.copy(
marker = marker.trim().uppercase(),
value = v,
unit = unit.trim(),
timestamp = msFromLocalDateTime(dateTime),
notes = notes.ifBlank { null }
)
)
} else {
val e2Val = e2Text.replace(',', '.').toDoubleOrNull()
val tVal = tText.replace(',', '.').toDoubleOrNull()
onConfirmCreate(
e2Val?.let { LabValue(it, e2Unit.trim()) },
tVal?.let { LabValue(it, tUnit.trim()) },
msFromLocalDateTime(dateTime),
notes
)
}
}
) { Text(stringResource(R.string.save)) }
}, },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
title = { Text(stringResource(R.string.add_lab)) }, title = {
Text(stringResource(if (isEdit) R.string.edit_lab else R.string.add_lab))
},
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (isEdit) {
// Édition : un seul marqueur (l'entrée existante)
ExposedDropdownMenuBox(expanded = markerExpanded, onExpandedChange = { markerExpanded = it }) { ExposedDropdownMenuBox(expanded = markerExpanded, onExpandedChange = { markerExpanded = it }) {
OutlinedTextField( OutlinedTextField(
value = marker, value = marker,
@ -86,6 +151,52 @@ fun LabDialog(
} }
} }
} }
} else {
// Création : E2 et T ensemble, chacune optionnelle
OutlinedTextField(
value = e2Text,
onValueChange = { e2Text = it },
label = { Text(stringResource(R.string.lab_e2_value)) },
supportingText = { Text(stringResource(R.string.lab_optional)) },
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenuBox(expanded = e2UnitExpanded, onExpandedChange = { e2UnitExpanded = it }) {
OutlinedTextField(
value = e2Unit,
onValueChange = { e2Unit = it },
label = { Text(stringResource(R.string.lab_e2_unit)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = e2UnitExpanded) },
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(expanded = e2UnitExpanded, onDismissRequest = { e2UnitExpanded = false }) {
unitSuggestions.forEach { s ->
DropdownMenuItem(text = { Text(s) }, onClick = { e2Unit = s; e2UnitExpanded = false })
}
}
}
HorizontalDivider()
OutlinedTextField(
value = tText,
onValueChange = { tText = it },
label = { Text(stringResource(R.string.lab_t_value)) },
supportingText = { Text(stringResource(R.string.lab_optional)) },
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenuBox(expanded = tUnitExpanded, onExpandedChange = { tUnitExpanded = it }) {
OutlinedTextField(
value = tUnit,
onValueChange = { tUnit = it },
label = { Text(stringResource(R.string.lab_t_unit)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = tUnitExpanded) },
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(expanded = tUnitExpanded, onDismissRequest = { tUnitExpanded = false }) {
unitSuggestions.forEach { s ->
DropdownMenuItem(text = { Text(s) }, onClick = { tUnit = s; tUnitExpanded = false })
}
}
}
}
DateTimeField( DateTimeField(
label = stringResource(R.string.lab_date), label = stringResource(R.string.lab_date),
value = dateTime, value = dateTime,
@ -102,3 +213,12 @@ fun LabDialog(
} }
) )
} }
/**
* Formatage d'une valeur de labo sans perdre de décimales significatives :
* 300.0 → "300", 0.45 → "0.45", 1.235 → "1.235" (contrairement à formatDose
* qui arrondit à 1 décimale, pensé pour des doses en mg).
*/
fun formatLabValue(v: Double): String =
if (v == v.toLong().toDouble()) v.toLong().toString()
else "%.3f".format(v).trimEnd('0').trimEnd('.')

View File

@ -1,6 +1,6 @@
package com.hormonetrack.ui.screens package com.hormonetrack.ui.screens
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -9,12 +9,18 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.Icon
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -51,6 +57,9 @@ import kotlinx.coroutines.withContext
private const val HOUR_MS = 3_600_000L private const val HOUR_MS = 3_600_000L
private const val DAY_MS = 24 * HOUR_MS private const val DAY_MS = 24 * HOUR_MS
// Zoom (v1.2.9) : plage temporelle bornée 6 h → 300 j
private const val MIN_RANGE_H = 6L
private const val MAX_RANGE_H = 24L * 300
/** /**
* Écran Graphiques. * Écran Graphiques.
@ -70,12 +79,16 @@ private const val DAY_MS = 24 * HOUR_MS
fun ChartScreen() { fun ChartScreen() {
val container = LocalAppContainer.current val container = LocalAppContainer.current
val repo = container.repository val repo = container.repository
val treatments by repo.activeTreatments.collectAsState(initial = emptyList()) // ⚠️ TOUS les traitements (actifs ET inactifs) : l'inactivation d'un traitement
// ne doit jamais retirer ses doses de la simulation ni de la calibration
// (drapeau administratif, pas filtre de données — cf bug v1.2.4).
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList()) val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
val labResults by repo.allLabResults.collectAsState(initial = emptyList()) val labResults by repo.allLabResults.collectAsState(initial = emptyList())
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig()) val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false) val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
// Zoom temporel (v1.2.9) : plage bornée 6 h → 300 j, pinch + boutons
var rangeHours by remember { mutableLongStateOf(24L) } var rangeHours by remember { mutableLongStateOf(24L) }
var panHours by remember { mutableLongStateOf(0L) } var panHours by remember { mutableLongStateOf(0L) }
var showT by remember { mutableStateOf(true) } var showT by remember { mutableStateOf(true) }
@ -83,6 +96,7 @@ fun ChartScreen() {
var showEse by remember { mutableStateOf(true) } var showEse by remember { mutableStateOf(true) }
var showTfs by remember { mutableStateOf(true) } var showTfs by remember { mutableStateOf(true) }
var showForecast by remember { mutableStateOf(false) } var showForecast by remember { mutableStateOf(false) }
var showExtrema by remember { mutableStateOf(false) }
var chartWidthPx by remember { mutableIntStateOf(0) } var chartWidthPx by remember { mutableIntStateOf(0) }
// « Maintenant » rafraîchi chaque minute pour le repère vertical // « Maintenant » rafraîchi chaque minute pour le repère vertical
@ -94,18 +108,16 @@ fun ChartScreen() {
} }
} }
// Traitement + TConfig éventuellement recalibrés à la volée (option auto-calibration) ; // Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T,
// les valeurs stockées ne sont jamais modifiées, on ne travaille que sur des copies. // recalculés à la volée depuis les labs. null = option désactivée.
val effective by produceState( val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
treatments to tConfig, null, treatments, doseLogs, labResults, tConfig, autoCalibrate
treatments, doseLogs, labResults, tConfig, autoCalibrate
) { ) {
value = if (!autoCalibrate) { value = if (!autoCalibrate) {
treatments to tConfig null
} else { } else {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val r = PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig) PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig)
r.treatments to r.tConfig
} }
} }
} }
@ -125,13 +137,16 @@ fun ChartScreen() {
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") // Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS")
val curves by produceState( val curves by produceState(
emptyList<Pair<String, List<LevelPoint>>>(), emptyList<Pair<String, List<LevelPoint>>>(),
effective.first, effective.second, doseLogs, startMs, endMs, showEse, showTfs, showForecast effectiveAuto, doseLogs, startMs, endMs, showEse, showTfs, showForecast
) { ) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val list = mutableListOf<Pair<String, List<LevelPoint>>>() val list = mutableListOf<Pair<String, List<LevelPoint>>>()
if (showEse || showTfs) { if (showEse || showTfs) {
// Valeurs actuelles (traitement + TConfig), auto-calibrées si l'option est active // Valeurs actuelles : échelles par ester si auto-calibration active,
val (effTreatments, effTConfig) = effective // sinon le scaleFactor stocké de chaque traitement
val effTreatments = effectiveAuto?.treatments ?: treatments
val effTConfig = effectiveAuto?.tConfig ?: tConfig
val effScales = effectiveAuto?.esterScales
// Doses réelles + doses prévisionnelles éventuelles // Doses réelles + doses prévisionnelles éventuelles
val forecastDoses = if (showForecast) { val forecastDoses = if (showForecast) {
effTreatments.flatMap { effTreatments.flatMap {
@ -139,11 +154,16 @@ fun ChartScreen() {
} }
} else emptyList() } else emptyList()
val allDoses = doseLogs + forecastDoses val allDoses = doseLogs + forecastDoses
// Pas d'échantillonnage adaptatif au zoom (v1.2.9)
val step = com.hormonetrack.ui.components.stepForRange(rangeHours)
if (showEse) { if (showEse) {
list.add( list.add(
"ESE" to PharmacokineticEngine.computeCurve( "ESE" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs, effTreatments, allDoses, startMs, endMs,
tConfig = effTConfig, modelOverride = "ESE" stepMs = step,
tConfig = effTConfig, modelOverride = "ESE",
scalePerEster = effScales,
tKPerEster = effectiveAuto?.tKPerEster
) )
) )
} }
@ -151,7 +171,10 @@ fun ChartScreen() {
list.add( list.add(
"TFS" to PharmacokineticEngine.computeCurve( "TFS" to PharmacokineticEngine.computeCurve(
effTreatments, allDoses, startMs, endMs, effTreatments, allDoses, startMs, endMs,
tConfig = effTConfig, modelOverride = "TFS" stepMs = step,
tConfig = effTConfig, modelOverride = "TFS",
scalePerEster = effScales,
tKPerEster = effectiveAuto?.tKPerEster
) )
) )
} }
@ -165,7 +188,10 @@ fun ChartScreen() {
allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp } allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp }
} else 0L to 0L } else 0L to 0L
val e2Labs = labResults.filter { it.marker.equals("E2", true) && it.timestamp in a..b } val e2Labs = labResults.filter { it.marker.equals("E2", true) && it.timestamp in a..b }
val tLabs = labResults.filter { it.marker.equals("T", true) && it.timestamp in a..b } // Le toggle T masque la courbe T ET les labs T (cohérence d'affichage)
val tLabs = if (showT) {
labResults.filter { it.marker.equals("T", true) && it.timestamp in a..b }
} else emptyList()
// Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ; // Série visuelle : E2 Estrannaise = bleu plein, E2 TFS = turquoise plein ;
// T Estrannaise = rose plein, T TFS = rose pointillé. // T Estrannaise = rose plein, T TFS = rose pointillé.
@ -205,6 +231,23 @@ fun ChartScreen() {
onClick = { rangeHours = 24L * 30 }, onClick = { rangeHours = 24L * 30 },
label = { Text(stringResource(R.string.chart_30j)) } label = { Text(stringResource(R.string.chart_30j)) }
) )
Spacer(Modifier.width(4.dp))
// Zoom manuel (accessibilité — le pinch fonctionne aussi) — convention
// carte : « + » = zoom AVANT (fenêtre plus courte, plus de détail),
// « − » = zoom ARRIÈRE (fenêtre plus longue). Inversé en v1.2.10 :
// l'ancien sens (plus de temps avec « + ») n'était pas intuitif.
FilledTonalIconButton(
onClick = { rangeHours = (rangeHours / 2).coerceIn(MIN_RANGE_H, MAX_RANGE_H) },
enabled = rangeHours > MIN_RANGE_H
) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.zoom_in))
}
FilledTonalIconButton(
onClick = { rangeHours = (rangeHours * 2).coerceAtMost(MAX_RANGE_H) },
enabled = rangeHours < MAX_RANGE_H
) {
Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.zoom_out))
}
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@ -227,6 +270,11 @@ fun ChartScreen() {
enabled = forecastHorizonHours != null, enabled = forecastHorizonHours != null,
label = { Text(stringResource(R.string.show_forecast)) } label = { Text(stringResource(R.string.show_forecast)) }
) )
FilterChip(
selected = showExtrema,
onClick = { showExtrema = !showExtrema },
label = { Text(stringResource(R.string.show_extrema)) }
)
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@ -251,15 +299,33 @@ fun ChartScreen() {
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.onSizeChanged { chartWidthPx = it.width } .onSizeChanged { chartWidthPx = it.width }
.pointerInput(rangeHours, maxPanHours) { .pointerInput(Unit) {
// Glisser vers la droite = remonter dans le passé // Pan (1 doigt) + pinch-zoom (2 doigts, v1.2.9) — un seul
detectHorizontalDragGestures { change, dragAmount -> // handler pour éviter les conflits de consommation
change.consume() detectTransformGestures { centroid, pan, zoom, _ ->
if (chartWidthPx > 0 && maxPanHours > 0) { val forecastMs =
val hoursDragged = (dragAmount / chartWidthPx * rangeHours).toLong() (if (showForecast) (forecastHorizonHours ?: 0L) * HOUR_MS else 0L)
if (chartWidthPx > 0) {
// Pan horizontal : glisser à droite = remonter le temps
val hoursDragged = (pan.x / chartWidthPx * rangeHours).toLong()
if (hoursDragged != 0L) { if (hoursDragged != 0L) {
panHours = (panHours + hoursDragged).coerceIn(0L, maxPanHours) panHours = (panHours + hoursDragged).coerceIn(0L, maxPanHours)
} }
// Zoom autour du point focal : le temps sous le doigt
// reste fixe pendant le pinch
if (zoom != 1f && zoom > 0f) {
val f = (centroid.x / chartWidthPx.toDouble()).coerceIn(0.0, 1.0)
val newRange = (rangeHours / zoom).toLong()
.coerceIn(MIN_RANGE_H, MAX_RANGE_H)
if (newRange != rangeHours) {
val endAnchor = nowMs + forecastMs - panHours * HOUR_MS
val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong()
val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong()
val newPan = ((nowMs + forecastMs - newEnd) / HOUR_MS).toLong()
panHours = newPan.coerceIn(0L, maxPanHours)
rangeHours = newRange
}
}
} }
} }
} }
@ -276,7 +342,12 @@ fun ChartScreen() {
series = series, series = series,
e2Labs = e2Labs, e2Labs = e2Labs,
tLabs = tLabs, tLabs = tLabs,
options = ChartOptions(showT = showT, showLabs = showLabs, nowMs = nowMs), options = ChartOptions(
showT = showT,
showLabs = showLabs,
showExtrema = showExtrema,
nowMs = nowMs
),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(320.dp) .height(320.dp)
@ -327,6 +398,13 @@ fun ChartScreen() {
color = LabDot color = LabDot
) )
} }
if (showExtrema) {
Text(
stringResource(R.string.legend_extrema),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text( Text(
stringResource(R.string.drag_hint), stringResource(R.string.drag_hint),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,

View File

@ -2,6 +2,7 @@ package com.hormonetrack.ui.screens
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -87,6 +88,8 @@ fun DosesScreen() {
val timeFmt = remember { DateTimeFormatter.ofPattern("HH:mm") } val timeFmt = remember { DateTimeFormatter.ofPattern("HH:mm") }
Scaffold( Scaffold(
// Insets à zéro : le TopAppBar interne consomme déjà la barre de statut
contentWindowInsets = WindowInsets(0, 0, 0, 0),
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = { showLogDialog = true }) { FloatingActionButton(onClick = { showLogDialog = true }) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_dose)) Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_dose))
@ -156,7 +159,10 @@ fun DosesScreen() {
if (showLogDialog || toEdit != null) { if (showLogDialog || toEdit != null) {
DoseDialog( DoseDialog(
treatments = treatments, // Création : traitements ACTIFS uniquement (le dropdown ne propose pas
// un traitement passé à inactif). Édition : TOUS les traitements — la
// dose éditée doit rester rattachable à son traitement même inactif.
treatments = if (toEdit != null) treatments else treatments.filter { it.isActive },
preselectedTreatmentId = null, preselectedTreatmentId = null,
existing = toEdit, existing = toEdit,
onDismiss = { onDismiss = {

View File

@ -77,25 +77,33 @@ private val HOUR_MS = 3_600_000L
fun HomeScreen( fun HomeScreen(
openLogDoseForTreatmentId: Long?, openLogDoseForTreatmentId: Long?,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenCharts: () -> Unit,
onOpenTreatment: (Long) -> Unit onOpenTreatment: (Long) -> Unit
) { ) {
val container = LocalAppContainer.current val container = LocalAppContainer.current
val repo = container.repository val repo = container.repository
val treatments by repo.activeTreatments.collectAsState(initial = emptyList()) // ⚠️ TOUS les traitements (actifs ET inactifs) pour la simulation : « inactif »
// est un drapeau administratif (plus de nouvelles doses), jamais un filtre de
// données — l'historique EV d'un traitement passé à inactif doit continuer
// d'être simulé et calibré (bug v1.2.4).
val allTreatments by repo.allTreatments.collectAsState(initial = emptyList())
val activeTreatments = remember(allTreatments) { allTreatments.filter { it.isActive } }
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList()) val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
val labResults by repo.allLabResults.collectAsState(initial = emptyList()) val labResults by repo.allLabResults.collectAsState(initial = emptyList())
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig()) val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false) val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
// Données « effectives » : si l'option auto-calibration est active, on recalcule // Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T,
// à la volée les facteurs d'échelle et le modèle T depuis les labs (affichage seul) // recalculés à la volée depuis les labs. null = option désactivée (échelles
val effective by produceState(treatments to tConfig, treatments, doseLogs, labResults, tConfig, autoCalibrate) { // stockées par traitement). Copies uniquement : rien n'est persisté.
val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
null, allTreatments, doseLogs, labResults, tConfig, autoCalibrate
) {
value = if (!autoCalibrate) { value = if (!autoCalibrate) {
treatments to tConfig null
} else { } else {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val r = PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig) PharmacokineticEngine.autoCalibrated(allTreatments, doseLogs, labResults, tConfig)
r.treatments to r.tConfig
} }
} }
} }
@ -118,14 +126,20 @@ fun HomeScreen(
} }
} }
val curve by produceState<List<LevelPoint>>(emptyList(), effective, doseLogs, tick) { val curve by produceState<List<LevelPoint>>(emptyList(), effectiveAuto, doseLogs, tick) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val end = System.currentTimeMillis() val end = System.currentTimeMillis()
value = PharmacokineticEngine.computeCurve( value = PharmacokineticEngine.computeCurve(
effective.first, doseLogs, effectiveAuto?.treatments ?: allTreatments,
doseLogs,
startMs = end - 24 * HOUR_MS, startMs = end - 24 * HOUR_MS,
endMs = end, endMs = end,
tConfig = effective.second tConfig = effectiveAuto?.tConfig ?: tConfig,
// Calibration par période d'ester : chaque dose est scalée par le
// facteur de SON ester (fallback = scaleFactor du traitement) et le
// k de la T est celui de l'ester actif à chaque instant
scalePerEster = effectiveAuto?.esterScales,
tKPerEster = effectiveAuto?.tKPerEster
) )
} }
} }
@ -158,7 +172,7 @@ fun HomeScreen(
NowLevelCard(curve) NowLevelCard(curve)
NextDoseCard(treatments) NextDoseCard(activeTreatments)
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -174,7 +188,7 @@ fun HomeScreen(
Modifier.horizontalScroll(rememberScrollState()), Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
treatments.forEach { tr -> activeTreatments.forEach { tr ->
AssistChip( AssistChip(
onClick = { onClick = {
preselectId = tr.id preselectId = tr.id
@ -195,7 +209,10 @@ fun HomeScreen(
} }
} }
Card(Modifier.fillMaxWidth()) { Card(
onClick = onOpenCharts,
modifier = Modifier.fillMaxWidth()
) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text( Text(
stringResource(R.string.home_chart_title), stringResource(R.string.home_chart_title),
@ -218,6 +235,23 @@ fun HomeScreen(
.fillMaxWidth() .fillMaxWidth()
.height(180.dp) .height(180.dp)
) )
Spacer(Modifier.height(6.dp))
// Mini-légende (E2 à gauche, T à droite) + indication du tap
Text(
stringResource(R.string.home_legend_e2),
style = MaterialTheme.typography.labelMedium,
color = ChartE2
)
Text(
stringResource(R.string.home_legend_t),
style = MaterialTheme.typography.labelMedium,
color = ChartT
)
Text(
stringResource(R.string.open_charts_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} }
@ -232,7 +266,7 @@ fun HomeScreen(
if (showLogDialog) { if (showLogDialog) {
DoseDialog( DoseDialog(
treatments = treatments, treatments = activeTreatments,
preselectedTreatmentId = preselectId, preselectedTreatmentId = preselectId,
existing = null, existing = null,
onDismiss = { showLogDialog = false }, onDismiss = { showLogDialog = false },

View File

@ -1,8 +1,9 @@
package com.hormonetrack.ui.screens package com.hormonetrack.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -35,6 +36,7 @@ import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult import com.hormonetrack.data.model.LabResult
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.LabDialog import com.hormonetrack.ui.components.LabDialog
import com.hormonetrack.ui.components.formatLabValue
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -42,6 +44,24 @@ import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
/**
* Regroupe les labs par timestamp (une « prise de sang » = E2 + T prises au même
* moment). Ordre d'affichage dans un groupe : E2 avant T, autres marqueurs ensuite.
* Retour : liste (timestamp, labs du groupe) triée par date décroissante.
*/
fun groupLabsForDisplay(labs: List<LabResult>): List<Pair<Long, List<LabResult>>> =
labs.groupBy { it.timestamp }
.map { (ts, group) ->
ts to group.sortedBy { lab ->
when (lab.marker.uppercase()) {
"E2" -> 0
"T" -> 1
else -> 2
}
}
}
.sortedByDescending { it.first }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LabsScreen() { fun LabsScreen() {
@ -50,16 +70,20 @@ fun LabsScreen() {
val labs by repo.allLabResults.collectAsState(initial = emptyList()) val labs by repo.allLabResults.collectAsState(initial = emptyList())
var showLabDialog by remember { mutableStateOf(false) } var showLabDialog by remember { mutableStateOf(false) }
var toDelete by remember { mutableStateOf<LabResult?>(null) } var toEdit by remember { mutableStateOf<LabResult?>(null) }
// Groupe en cours de suppression (une prise de sang entière)
var toDeleteGroup by remember { mutableStateOf<List<LabResult>?>(null) }
// Choix du marqueur à éditer quand le groupe contient E2 + T
var chooserGroup by remember { mutableStateOf<List<LabResult>?>(null) }
val grouped = remember(labs) { val grouped = remember(labs) { groupLabsForDisplay(labs) }
labs.sortedBy { it.marker.lowercase() }.groupBy { it.marker.uppercase() }
}
val fmt = remember { val fmt = remember {
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault()) DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault())
} }
Scaffold( Scaffold(
// Insets à zéro : le TopAppBar interne consomme déjà la barre de statut
contentWindowInsets = WindowInsets(0, 0, 0, 0),
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = { showLabDialog = true }) { FloatingActionButton(onClick = { showLabDialog = true }) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_lab)) Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_lab))
@ -70,36 +94,34 @@ fun LabsScreen() {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.nav_labs)) } title = { Text(stringResource(R.string.nav_labs)) }
) )
if (labs.isEmpty()) { if (grouped.isEmpty()) {
Text( Text(
stringResource(R.string.no_data), stringResource(R.string.no_data),
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
} else { } else {
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxSize()) {
grouped.forEach { (marker, results) -> items(grouped, key = { it.first }) { (ts, group) ->
item(key = "header_$marker") { // Affichage côte à côte : « E2 306 pg/mL · T 45 ng/dL »
Text( val headline = group.joinToString(" · ") { lab ->
marker, "${lab.marker.uppercase()} ${formatLabValue(lab.value)} ${lab.unit}"
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
} }
items(results, key = { it.id }) { lab ->
ListItem( ListItem(
headlineContent = { modifier = Modifier.clickable {
Text("${lab.value} ${lab.unit}") if (group.size > 1) chooserGroup = group else toEdit = group.first()
}, },
headlineContent = { Text(headline) },
supportingContent = { supportingContent = {
Column { Column {
Text(fmt.format(Instant.ofEpochMilli(lab.timestamp))) Text(fmt.format(Instant.ofEpochMilli(ts)))
lab.notes?.takeIf { it.isNotBlank() }?.let { // Notes du groupe (toutes identiques en pratique)
group.firstNotNullOfOrNull { it.notes }?.takeIf { it.isNotBlank() }?.let {
Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
}, },
trailingContent = { trailingContent = {
IconButton(onClick = { toDelete = lab }) { IconButton(onClick = { toDeleteGroup = group }) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete)) Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
} }
} }
@ -109,42 +131,91 @@ fun LabsScreen() {
} }
} }
} }
}
if (showLabDialog) { if (showLabDialog) {
LabDialog( LabDialog(
existing = null,
onDismiss = { showLabDialog = false }, onDismiss = { showLabDialog = false },
onConfirm = { marker, value, unit, ts, notes -> onConfirmCreate = { e2, t, ts, notes ->
showLabDialog = false showLabDialog = false
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
e2?.let {
repo.insertLabResult( repo.insertLabResult(
LabResult( LabResult(marker = "E2", value = it.value, unit = it.unit, timestamp = ts, notes = notes.ifBlank { null })
marker = marker,
value = value,
unit = unit,
timestamp = ts,
notes = notes.ifBlank { null }
) )
}
t?.let {
repo.insertLabResult(
LabResult(marker = "T", value = it.value, unit = it.unit, timestamp = ts, notes = notes.ifBlank { null })
) )
} }
} }
},
onConfirmEdit = { }
)
}
// Édition d'UNE entrée du groupe (choix E2 / T si paire)
toEdit?.let { lab ->
LabDialog(
existing = lab,
onDismiss = { toEdit = null },
onConfirmCreate = { _, _, _, _ -> },
onConfirmEdit = { updated ->
toEdit = null
CoroutineScope(Dispatchers.IO).launch { repo.updateLabResult(updated) }
}
)
}
// Sélecteur quand la prise contient E2 + T
chooserGroup?.let { group ->
AlertDialog(
onDismissRequest = { chooserGroup = null },
confirmButton = {},
dismissButton = {
TextButton(onClick = { chooserGroup = null }) { Text(stringResource(R.string.cancel)) }
},
title = { Text(stringResource(R.string.edit_which_lab)) },
text = {
Column {
group.forEach { lab ->
TextButton(onClick = {
chooserGroup = null
toEdit = lab
}) {
Text("${lab.marker.uppercase()} · ${formatLabValue(lab.value)} ${lab.unit}")
}
}
}
}
) )
} }
toDelete?.let { lab -> // Suppression : la prise de sang ENTIÈRE (les labs sont pris ensemble)
toDeleteGroup?.let { group ->
AlertDialog( AlertDialog(
onDismissRequest = { toDelete = null }, onDismissRequest = { toDeleteGroup = null },
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
toDelete = null toDeleteGroup = null
CoroutineScope(Dispatchers.IO).launch { repo.deleteLabResult(lab) } CoroutineScope(Dispatchers.IO).launch {
group.forEach { repo.deleteLabResult(it) }
}
}) { Text(stringResource(R.string.delete)) } }) { Text(stringResource(R.string.delete)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) } TextButton(onClick = { toDeleteGroup = null }) { Text(stringResource(R.string.cancel)) }
}, },
title = { Text(stringResource(R.string.delete)) }, title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.confirm_delete)) } text = {
Text(
stringResource(
R.string.confirm_delete_blood_draw,
group.joinToString(" · ") { "${it.marker.uppercase()} ${formatLabValue(it.value)}" }
)
)
}
) )
} }
} }

View File

@ -1,6 +1,7 @@
package com.hormonetrack.ui.screens package com.hormonetrack.ui.screens
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@ -183,7 +184,34 @@ fun SettingsScreen(onBack: () -> Unit) {
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// --- Testosterone model --- // --- 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))
// --- Estimation T (réglages manuels ; l'auto-calibration ci-dessus les
// remplace à l'affichage quand elle est activée) ---
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
@ -247,32 +275,6 @@ fun SettingsScreen(onBack: () -> Unit) {
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// --- Auto-calibration (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))
// --- Reminders / exact alarms --- // --- Reminders / exact alarms ---
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
@ -320,10 +322,19 @@ fun SettingsScreen(onBack: () -> Unit) {
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// --- About --- // --- About : version installée + lien releases Gitea ---
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium) 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( Text(
stringResource(R.string.disclaimer), stringResource(R.string.disclaimer),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
@ -347,11 +358,18 @@ fun SettingsScreen(onBack: () -> Unit) {
showImportConfirm = false showImportConfirm = false
val json = importJson ?: return@TextButton val json = importJson ?: return@TextButton
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
// Import en mode ÉCRASEMENT : les données actuelles sont
// effacées d'abord (BackupManager), puis le tConfig du
// backup restaure DataStore et les rappels sont reprogrammés
val result = try { val result = try {
BackupManager.importJson(repo, json) BackupManager.importJson(repo, json)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
result?.let {
container.settings.setTConfig(it.tConfig)
AlarmScheduler(context).rescheduleAll(repo.allTreatmentsOnce())
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
message = if (result != null) { message = if (result != null) {
context.getString(R.string.import_ok, result.treatments, result.doseLogs, result.labResults) context.getString(R.string.import_ok, result.treatments, result.doseLogs, result.labResults)

View File

@ -51,7 +51,11 @@ import com.hormonetrack.data.model.PKPresets
import com.hormonetrack.data.model.Treatment import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType import com.hormonetrack.data.model.TreatmentType
import com.hormonetrack.pk.PharmacokineticEngine import com.hormonetrack.pk.PharmacokineticEngine
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.hormonetrack.reminder.AlarmScheduler import com.hormonetrack.reminder.AlarmScheduler
import com.hormonetrack.reminder.CalendarEvents
import com.hormonetrack.ui.LocalAppContainer import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.formatDose import com.hormonetrack.ui.components.formatDose
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -87,6 +91,13 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
// Section « Fréquence » : simulation prévisionnelle des doses à venir // Section « Fréquence » : simulation prévisionnelle des doses à venir
var forecastEnabled by remember { mutableStateOf(false) } var forecastEnabled by remember { mutableStateOf(false) }
var forecastDaysText by remember { mutableStateOf("") } var forecastDaysText by remember { mutableStateOf("") }
// Section « Agenda » : événement récurrent dans l'agenda du téléphone
var calendarEnabled by remember { mutableStateOf(false) }
var calendarPermissionGranted by remember { mutableStateOf(false) }
val calendarPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { grants -> calendarPermissionGranted = grants.values.all { it } }
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
var showPresetMenu by remember { mutableStateOf(false) } var showPresetMenu by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) } var showTimePicker by remember { mutableStateOf(false) }
@ -116,6 +127,10 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
forecastEnabled = true forecastEnabled = true
forecastDaysText = formatDose(fi) forecastDaysText = formatDose(fi)
} }
if (tr.calendarEventId != null) {
calendarEnabled = true
calendarPermissionGranted = true // déjà accordée pour l'event existant
}
} }
loading = false loading = false
} }
@ -401,6 +416,58 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// --- Section « Agenda » : événement récurrent (posologie) ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.agenda_section), style = MaterialTheme.typography.titleMedium)
Switch(
checked = calendarEnabled,
onCheckedChange = { want ->
if (want && !calendarPermissionGranted) {
calendarPermissionLauncher.launch(
arrayOf(
android.Manifest.permission.WRITE_CALENDAR,
android.Manifest.permission.READ_CALENDAR
)
)
// activé seulement si la permission est accordée (callback)
if (calendarPermissionGranted) calendarEnabled = true
} else if (want) {
calendarEnabled = true
} else {
calendarEnabled = false
}
}
)
}
if (calendarEnabled) {
if (forecastEnabled) {
Text(
stringResource(R.string.agenda_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
stringResource(R.string.agenda_needs_posology),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error
)
}
Text(
stringResource(R.string.agenda_permission),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(Modifier.height(8.dp))
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@ -421,11 +488,32 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
repo.insertTreatment(tr) repo.insertTreatment(tr)
} }
val saved = tr.copy(id = savedId) val saved = tr.copy(id = savedId)
if (saved.reminderEnabled) { // Rappel actif seulement si le traitement l'est aussi ;
// désactiver un traitement annule son alarme
if (saved.reminderEnabled && saved.isActive) {
scheduler.scheduleDaily(saved) scheduler.scheduleDaily(saved)
} else { } else {
scheduler.cancel(savedId) scheduler.cancel(savedId)
} }
// Événement d'agenda : upsert si activé + posologie définie,
// suppression sinon (et si un événement existait)
var eventToStore = saved.calendarEventId
try {
if (calendarEnabled && saved.isActive && saved.forecastIntervalDays != null
&& calendarPermissionGranted
) {
val calId = CalendarEvents.ensureCalendar(context)
eventToStore = CalendarEvents.upsertEvent(context, calId, saved)
} else if (saved.calendarEventId != null) {
CalendarEvents.deleteEvent(context, saved.calendarEventId!!)
eventToStore = null
}
if (eventToStore != saved.calendarEventId) {
repo.updateTreatment(saved.copy(calendarEventId = eventToStore))
}
} catch (_: SecurityException) {
// permission agenda révoquée entre-temps → ignorer
}
withContext(Dispatchers.Main) { onDone() } withContext(Dispatchers.Main) { onDone() }
} }
}, },

View File

@ -19,6 +19,9 @@
<string name="next_dose">Prochaine dose</string> <string name="next_dose">Prochaine dose</string>
<string name="delta_6h">%1$s %2$s pg/mL vs il y a 6 h</string> <string name="delta_6h">%1$s %2$s pg/mL vs il y a 6 h</string>
<string name="home_chart_title">Dernières 24 h (estimation)</string> <string name="home_chart_title">Dernières 24 h (estimation)</string>
<string name="home_legend_e2">— E2 estimé (pg/mL, gauche)</string>
<string name="home_legend_t">-- T estimée (ng/mL, droite)</string>
<string name="open_charts_hint">Appuie sur le graphique pour ouvrir les vues 7 j / 30 j, les deux modèles PK et la prévision.</string>
<string name="disclaimer">Les courbes sont des estimations pharmacocinétiques à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes prises de sang et aux consignes de ton endocrinologue.</string> <string name="disclaimer">Les courbes sont des estimations pharmacocinétiques à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes prises de sang et aux consignes de ton endocrinologue.</string>
<!-- Charts --> <!-- Charts -->
@ -27,6 +30,18 @@
<string name="chart_30j">30 jours</string> <string name="chart_30j">30 jours</string>
<string name="show_labs">Analyses</string> <string name="show_labs">Analyses</string>
<string name="show_forecast">Prévision</string> <string name="show_forecast">Prévision</string>
<string name="show_extrema">Pics / creux</string>
<string name="zoom_in">Zoom avant (fenêtre plus courte)</string>
<string name="zoom_out">Zoom arrière (fenêtre plus longue)</string>
<string name="changelog_title">Nouveautés de la version %1$s</string>
<string name="settings_version">Version installée : %1$s</string>
<string name="releases_link">Dernières versions sur Gitea (télécharger les APK)</string>
<string name="agenda_section">Agenda</string>
<string name="agenda_switch">Événement d\'agenda récurrent</string>
<string name="agenda_needs_posology">Renseigne d\'abord une Posologie (intervalle en jours) — l\'événement se répète tous les N jours dans ton agenda.</string>
<string name="agenda_hint">Un événement récurrent est créé dans un calendrier dédié « HormoneTrack », basé sur la Posologie et l\'heure de rappel. Visible et modifiable dans ton application d\'agenda.</string>
<string name="agenda_permission">L\'autorisation agenda est demandée à l\'activation.</string>
<string name="legend_extrema">▲▼ pics &amp; creux estimés (par courbe)</string>
<string name="back_to_present">Revenir à maintenant</string> <string name="back_to_present">Revenir à maintenant</string>
<string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string> <string name="drag_hint">Fais glisser le graphique vers la droite pour remonter dans le passé.</string>
<string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, axe gauche)</string> <string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, axe gauche)</string>
@ -48,7 +63,15 @@
<string name="confirm_delete_treatment">Supprimer ce traitement ? Son historique de doses sera aussi supprimé.</string> <string name="confirm_delete_treatment">Supprimer ce traitement ? Son historique de doses sera aussi supprimé.</string>
<!-- Labs --> <!-- Labs -->
<string name="add_lab">Ajouter un résultat</string> <string name="add_lab">Ajouter une prise de sang</string>
<string name="edit_lab">Modifier le résultat</string>
<string name="lab_e2_value">E2 — valeur œstradiol</string>
<string name="lab_e2_unit">Unité E2</string>
<string name="lab_t_value">T — valeur testostérone</string>
<string name="lab_t_unit">Unité T</string>
<string name="lab_optional">optionnel</string>
<string name="edit_which_lab">Quelle entrée veux-tu modifier ?</string>
<string name="confirm_delete_blood_draw">Supprimer cette prise de sang (%1$s) ?</string>
<string name="lab_marker">Marqueur (ex : E2, T)</string> <string name="lab_marker">Marqueur (ex : E2, T)</string>
<string name="lab_value">Valeur</string> <string name="lab_value">Valeur</string>
<string name="lab_unit">Unité (ex : pg/mL, ng/mL)</string> <string name="lab_unit">Unité (ex : pg/mL, ng/mL)</string>
@ -92,13 +115,13 @@
<string name="scale_factor">Facteur d\'échelle</string> <string name="scale_factor">Facteur d\'échelle</string>
<string name="calibration_hint">Le facteur d\'échelle ajuste le modèle à ton corps, comme le « Scale factor » de ton tableur. Il se calcule comme médiane(valeur lab ÷ prédiction du modèle).</string> <string name="calibration_hint">Le facteur d\'échelle ajuste le modèle à ton corps, comme le « Scale factor » de ton tableur. Il se calcule comme médiane(valeur lab ÷ prédiction du modèle).</string>
<string name="calibrate_from_labs">Calibrer avec les analyses</string> <string name="calibrate_from_labs">Calibrer avec les analyses</string>
<string name="auto_calibrate">Calibration automatique</string> <string name="auto_calibrate">Calibration automatique (E2 + T)</string>
<string name="auto_calibrate_hint">Ajuste automatiquement les facteurs d\'échelle et le modèle T à partir de tes analyses, pour l\'affichage uniquement (les valeurs stockées ne changent pas).</string> <string name="auto_calibrate_hint">Ajuste automatiquement le facteur d\'échelle de CHAQUE ester à partir des analyses faites pendant sa période (ex. : labs valerate → doses valerate, labs enanthate → doses enanthate) et recalibre le modèle T — affichage uniquement (les valeurs stockées ne changent pas). Les réglages manuels ci-dessous et dans Traitements restent effectifs quand c\'est désactivé.</string>
<string name="freq_section">Fréquence d\'injection</string> <string name="freq_section">Posologie</string>
<string name="freq_enable">Simuler les doses à venir</string> <string name="freq_enable">Simuler les doses à venir</string>
<string name="freq_interval">Intervalle (jours)</string> <string name="freq_interval">Intervalle (jours)</string>
<string name="freq_hint">Les doses à venir sont simulées à cet intervalle après la dernière injection enregistrée ; elles apparaissent sur le graphique (Prévision) mais ne sont jamais sauvegardées.</string> <string name="freq_hint">Les doses à venir sont simulées à cet intervalle après la dernière prise enregistrée ; elles apparaissent sur le graphique (Prévision) mais ne sont jamais sauvegardées.</string>
<string name="save">Enregistrer</string> <string name="save">Enregistrer</string>
<string name="cancel">Annuler</string> <string name="cancel">Annuler</string>
@ -136,10 +159,10 @@
<string name="export_ok">Sauvegarde exportée.</string> <string name="export_ok">Sauvegarde exportée.</string>
<string name="export_fail">L\'export a échoué.</string> <string name="export_fail">L\'export a échoué.</string>
<string name="import_fail">L\'import a échoué : fichier invalide.</string> <string name="import_fail">L\'import a échoué : fichier invalide.</string>
<string name="import_ok">Importé : %1$d traitements, %2$d doses, %3$d analyses.</string> <string name="import_ok">Restauré : %1$d traitements, %2$d doses, %3$d analyses (les données actuelles ont été remplacées, rappels reprogrammés).</string>
<string name="import_confirm">Restaurer</string> <string name="import_confirm">Effacer &amp; restaurer</string>
<string name="import_title">Importer une sauvegarde</string> <string name="import_title">Importer une sauvegarde</string>
<string name="import_warning">Le contenu de la sauvegarde sera AJOUTÉ aux données actuelles (aucune suppression).</string> <string name="import_warning">Le backup REMPLACERA toutes les données actuelles : les traitements, doses et analyses existants sont effacés d\'abord. Exporte une sauvegarde avant si tu veux les garder.</string>
<string name="about_title">À propos</string> <string name="about_title">À propos</string>
<string name="models_credit">Profils PK issus des modèles d\'injection Estrannaise (EstraNase) et Transfem Science, extraits de Estrogen.ods (tables horaires D, k1–k3).</string> <string name="models_credit">Profils PK issus des modèles d\'injection Estrannaise (EstraNase) et Transfem Science, extraits de Estrogen.ods (tables horaires D, k1–k3).</string>

View File

@ -19,6 +19,9 @@
<string name="next_dose">Next dose</string> <string name="next_dose">Next dose</string>
<string name="delta_6h">%1$s %2$s pg/mL vs 6 h ago</string> <string name="delta_6h">%1$s %2$s pg/mL vs 6 h ago</string>
<string name="home_chart_title">Last 24 hours (estimate)</string> <string name="home_chart_title">Last 24 hours (estimate)</string>
<string name="home_legend_e2">— E2 estimate (pg/mL, left)</string>
<string name="home_legend_t">-- T estimate (ng/mL, right)</string>
<string name="open_charts_hint">Tap the chart to open 7 d / 30 d views, both PK models and forecast.</string>
<string name="disclaimer">Curves are pharmacokinetic estimates for informational purposes only — they are not measurements. Always rely on your blood tests and follow your endocrinologist\'s guidance.</string> <string name="disclaimer">Curves are pharmacokinetic estimates for informational purposes only — they are not measurements. Always rely on your blood tests and follow your endocrinologist\'s guidance.</string>
<!-- Charts --> <!-- Charts -->
@ -27,6 +30,18 @@
<string name="chart_30j">30 days</string> <string name="chart_30j">30 days</string>
<string name="show_labs">Labs</string> <string name="show_labs">Labs</string>
<string name="show_forecast">Forecast</string> <string name="show_forecast">Forecast</string>
<string name="show_extrema">Peaks / troughs</string>
<string name="zoom_in">Zoom in (shorter window)</string>
<string name="zoom_out">Zoom out (longer window)</string>
<string name="changelog_title">What\'s new in %1$s</string>
<string name="settings_version">Installed version: %1$s</string>
<string name="releases_link">Latest releases on Gitea (download APKs)</string>
<string name="agenda_section">Calendar</string>
<string name="agenda_switch">Recurring calendar event</string>
<string name="agenda_needs_posology">Set a Posology interval first — the event repeats every N days in your phone calendar.</string>
<string name="agenda_hint">A recurring event is created in a dedicated “HormoneTrack” calendar, based on the Posology interval and reminder time. Visible in your calendar app; editable there.</string>
<string name="agenda_permission">Calendar permission is requested when you enable this.</string>
<string name="legend_extrema">▲▼ estimated peaks &amp; troughs (per curve)</string>
<string name="back_to_present">Back to now</string> <string name="back_to_present">Back to now</string>
<string name="drag_hint">Drag the chart to the right to travel into the past.</string> <string name="drag_hint">Drag the chart to the right to travel into the past.</string>
<string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, left axis)</string> <string name="legend_ese_e2">— E2 · Estrannaise (pg/mL, left axis)</string>
@ -48,7 +63,15 @@
<string name="confirm_delete_treatment">Delete this treatment? Its dose history will also be deleted.</string> <string name="confirm_delete_treatment">Delete this treatment? Its dose history will also be deleted.</string>
<!-- Labs --> <!-- Labs -->
<string name="add_lab">Add lab result</string> <string name="add_lab">Add a blood test</string>
<string name="edit_lab">Edit lab result</string>
<string name="lab_e2_value">E2 — estradiol value</string>
<string name="lab_e2_unit">E2 unit</string>
<string name="lab_t_value">T — testosterone value</string>
<string name="lab_t_unit">T unit</string>
<string name="lab_optional">optional</string>
<string name="edit_which_lab">Which entry do you want to edit?</string>
<string name="confirm_delete_blood_draw">Delete this blood draw (%1$s)?</string>
<string name="lab_marker">Marker (e.g. E2, T)</string> <string name="lab_marker">Marker (e.g. E2, T)</string>
<string name="lab_value">Value</string> <string name="lab_value">Value</string>
<string name="lab_unit">Unit (e.g. pg/mL, ng/mL)</string> <string name="lab_unit">Unit (e.g. pg/mL, ng/mL)</string>
@ -92,13 +115,13 @@
<string name="scale_factor">Scale factor</string> <string name="scale_factor">Scale factor</string>
<string name="calibration_hint">The scale factor adjusts the model to your body, like the "Scale factor" of your spreadsheet. It is computed as median(lab value ÷ model prediction).</string> <string name="calibration_hint">The scale factor adjusts the model to your body, like the "Scale factor" of your spreadsheet. It is computed as median(lab value ÷ model prediction).</string>
<string name="calibrate_from_labs">Calibrate from labs</string> <string name="calibrate_from_labs">Calibrate from labs</string>
<string name="auto_calibrate">Auto-calibration</string> <string name="auto_calibrate">Auto-calibration (E2 + T)</string>
<string name="auto_calibrate_hint">Automatically adjusts scale factors and the T model from your labs, for display only (stored values unchanged).</string> <string name="auto_calibrate_hint">Automatically adjusts the scale factor of EACH ester from the labs drawn during that ester\'s period (e.g. valerate labs calibrate the valerate doses, enanthate labs the enanthate doses) and recalibrates the T model — display only (stored values unchanged). Manual values below and in Treatments stay in effect when this is off.</string>
<string name="freq_section">Injection frequency</string> <string name="freq_section">Dosage</string>
<string name="freq_enable">Simulate upcoming doses</string> <string name="freq_enable">Simulate upcoming doses</string>
<string name="freq_interval">Interval (days)</string> <string name="freq_interval">Interval (days)</string>
<string name="freq_hint">Upcoming doses are simulated at this interval after the last logged injection; they appear on the chart (Forecast) but are never saved.</string> <string name="freq_hint">Upcoming doses are simulated at this interval after the last logged dose; they appear on the chart (Forecast) but are never saved.</string>
<string name="save">Save</string> <string name="save">Save</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
@ -136,10 +159,10 @@
<string name="export_ok">Backup exported.</string> <string name="export_ok">Backup exported.</string>
<string name="export_fail">Export failed.</string> <string name="export_fail">Export failed.</string>
<string name="import_fail">Import failed: invalid file.</string> <string name="import_fail">Import failed: invalid file.</string>
<string name="import_ok">Imported: %1$d treatments, %2$d doses, %3$d labs.</string> <string name="import_ok">Restored: %1$d treatments, %2$d doses, %3$d labs (current data was replaced, reminders rescheduled).</string>
<string name="import_confirm">Restore</string> <string name="import_confirm">Erase &amp; restore</string>
<string name="import_title">Import backup</string> <string name="import_title">Import backup</string>
<string name="import_warning">The backup content will be ADDED to the current data (no deletion).</string> <string name="import_warning">The backup will REPLACE all current data: existing treatments, doses and labs are erased first. Export a backup now if you want to keep them.</string>
<string name="about_title">About</string> <string name="about_title">About</string>
<string name="models_credit">PK profiles from Estrannaise (EstraNase) and Transfem Science injection models, extracted from Estrogen.ods (D, k1–k3 hourly tables).</string> <string name="models_credit">PK profiles from Estrannaise (EstraNase) and Transfem Science injection models, extracted from Estrogen.ods (D, k1–k3 hourly tables).</string>

View File

@ -0,0 +1,145 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* Détection des pics et creux (v1.2.3) : en régime d'équilibre, une injection tous
* les N jours crée un pic après chaque dose et un creux juste avant la suivante.
*/
class ExtremaTest {
companion object {
private const val BASE = 1_700_000_000_000L
private const val HOUR = 3_600_000L
}
@Before
fun setup() {
if (!PKProfileStore.hasProfile("EV", "ESE")) {
val file = listOf(
File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json")
).first { it.exists() }
PKProfileStore.initWithJson(file.readText())
}
}
private fun evTreatment() = Treatment(
id = 1,
name = "EV",
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = 4.0,
doseUnit = "mg",
esterType = Esters.EV,
pkModel = PKModels.ESTRANNAISE
)
@Test
fun `steady state curve has alternating peaks and troughs at each dose cycle`() {
val tr = evTreatment()
// 4 injections hebdomadaires → fenêtre couvrant tout + 7 j
val doses = (0..3).map {
DoseLog(treatmentId = 1, timestamp = BASE + it * 7 * 24 * HOUR, doseAmount = 4.0)
}
val start = BASE
val end = BASE + 28 * 24 * HOUR
val curve = PharmacokineticEngine.computeCurve(listOf(tr), doses, start, end, tConfig = TConfig())
val extrema = PharmacokineticEngine.detectExtrema(curve, { it.e2 }, minSwingAbs = 2.0)
assertTrue("extrema=${extrema.size}", extrema.size >= 4)
// Alternance stricte pic/creux
extrema.zipWithNext { a, b ->
assertTrue(a.isPeak != b.isPeak)
}
// Les pics sont au-dessus des creux voisins
extrema.zipWithNext { a, b ->
if (a.isPeak) assertTrue(a.value > b.value) else assertTrue(a.value < b.value)
}
// Tous les extrema sont dans les bornes de la série
val minV = curve.minOf { it.e2 }
val maxV = curve.maxOf { it.e2 }
extrema.forEach { assertTrue(it.value in minV..maxV) }
}
@Test
fun `monotonic rising curve has no extrema`() {
val tr = evTreatment()
// Dose 2 h avant la fenêtre : toute la fenêtre est dans la phase de montée
// (pic EV vers 45 h, très loin après) → strictement monotone
val doses = listOf(DoseLog(treatmentId = 1, timestamp = BASE - 2 * HOUR, doseAmount = 4.0))
val curve = PharmacokineticEngine.computeCurve(
listOf(tr), doses,
startMs = BASE - HOUR, endMs = BASE,
tConfig = TConfig()
)
assertTrue(PharmacokineticEngine.detectExtrema(curve, { it.e2 }).isEmpty())
}
@Test
fun `flat constant series has no extrema`() {
val points = (0..20).map {
LevelPoint(timestamp = BASE + it * HOUR, e2 = 100.0, t = 0.4)
}
assertTrue(PharmacokineticEngine.detectExtrema(points, { it.e2 }).isEmpty())
}
@Test
fun `too short series has no extrema`() {
val points = listOf(
LevelPoint(BASE, 100.0, 0.4),
LevelPoint(BASE + HOUR, 110.0, 0.4)
)
assertTrue(PharmacokineticEngine.detectExtrema(points, { it.e2 }).isEmpty())
}
@Test
fun `min swing filters negligible oscillations`() {
// Série oscillant doucement autour de 100 (amplitude 0,5 < seuil 2,0)
val points = (0..10).map {
LevelPoint(
timestamp = BASE + it * HOUR,
e2 = 100.0 + if (it % 2 == 0) 0.0 else 0.5,
t = 0.4
)
}
// Sémantique zigzag : une oscillation sous le seuil produit UN seul pivot
// (le premier sommet local), jamais une alternance pic/creux
val filtered = PharmacokineticEngine.detectExtrema(points, { it.e2 }, minSwingAbs = 2.0)
assertTrue("size=${filtered.size}", filtered.size <= 1)
// Avec un seuil plus bas que l'amplitude, l'alternance complète apparaît
val full = PharmacokineticEngine.detectExtrema(points, { it.e2 }, minSwingAbs = 0.1)
assertTrue("size=${full.size}", full.size >= 4)
full.zipWithNext { a, b -> assertTrue(a.isPeak != b.isPeak) }
}
@Test
fun `testosterone extrema work with their own scale`() {
val tr = evTreatment()
val doses = (0..2).map {
DoseLog(treatmentId = 1, timestamp = BASE + it * 7 * 24 * HOUR, doseAmount = 4.0)
}
val curve = PharmacokineticEngine.computeCurve(
listOf(tr), doses, startMs = BASE, endMs = BASE + 21 * 24 * HOUR, tConfig = TConfig()
)
// La T est l'inverse de l'E2 : creux d'E2 = pics de T (modèle empirique)
val e2Ext = PharmacokineticEngine.detectExtrema(curve, { it.e2 }, 2.0)
val tExt = PharmacokineticEngine.detectExtrema(curve, { it.t }, 0.02)
assertTrue(e2Ext.isNotEmpty())
assertTrue(tExt.isNotEmpty())
// Un pic d'E2 correspond approximativement à un creux de T
val tPeaks = tExt.filter { it.isPeak }
assertTrue(tPeaks.isNotEmpty())
}
}

View File

@ -0,0 +1,56 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.ui.screens.groupLabsForDisplay
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Regroupement de l'écran Analyses (v1.2.2) : une prise de sang (E2 + T au même
* timestamp) s'affiche côte à côte ; E2 avant T ; tri chronologique décroissant.
*/
class LabsGroupingTest {
private fun lab(marker: String, ts: Long, value: Double) =
LabResult(marker = marker, value = value, unit = if (marker == "E2") "pg/mL" else "ng/mL", timestamp = ts)
@Test
fun `E2 and T at the same timestamp are grouped into one blood draw`() {
val ts = 1_788_621_894_346L
val labs = listOf(lab("E2", ts, 300.0), lab("T", ts, 45.0))
val groups = groupLabsForDisplay(labs)
assertEquals(1, groups.size)
assertEquals(ts, groups[0].first)
assertEquals(listOf("E2", "T"), groups[0].second.map { it.marker.uppercase() })
}
@Test
fun `different timestamps stay separate rows`() {
val labs = listOf(
lab("E2", 1000L, 300.0),
lab("T", 2000L, 45.0)
)
val groups = groupLabsForDisplay(labs)
assertEquals(2, groups.size)
// Tri décroissant : le plus récent (2000) d'abord
assertEquals(2000L, groups[0].first)
assertEquals(1000L, groups[1].first)
}
@Test
fun `labs entered alone also group correctly and E2 sorts before T`() {
val ts = 5000L
val labs = listOf(lab("T", ts, 45.0), lab("E2", ts, 300.0))
val groups = groupLabsForDisplay(labs)
assertEquals(1, groups.size)
assertEquals(listOf("E2", "T"), groups[0].second.map { it.marker.uppercase() })
}
@Test
fun `other markers sort after E2 and T`() {
val ts = 6000L
val labs = listOf(lab("PRL", ts, 12.0), lab("T", ts, 45.0), lab("E2", ts, 300.0))
val groups = groupLabsForDisplay(labs)
assertEquals(listOf("E2", "T", "PRL"), groups[0].second.map { it.marker.uppercase() })
}
}

View File

@ -0,0 +1,133 @@
package com.hormonetrack.pk
import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* Régression épinglée sur le **2ᵉ export réel** de l'utilisatrice (v1.2.0) :
* 1 traitement EEn + TFS 5 mg (facteur stocké, fréquence 6 j), 9 doses tous les
* 6–7 j, 8 labs (E2 pg/mL, T ng/dL dont un saisi avec une mauvaise unité).
*
* ⚠️ Données de santé personnelles : le JSON réel vit HORS du dépôt dans
* `local-test-data/backup-v1.2.0.json` (gitignoré) ; tests **ignorés** si absent.
*
* Valide v1.2.1+ : courbe Home en **état d'équilibre EEn** (t½ ≈ 6,7 j pour des
* doses tous les 6–7 j → accumulation ×2), calibration PAR PÉRIODE D'ESTER,
* lab T en unité aberrante neutralisé, prévision à 6 j.
*/
class RegressionUserCase2Test {
companion object {
/**
* Le workdir des tests unitaires Gradle est le dossier du module (app/) :
* on cherche donc les données locales à plusieurs emplacements.
*/
private val DATA_FILE: File = listOf(
File("../local-test-data/backup-v1.2.0.json"),
File("local-test-data/backup-v1.2.0.json"),
File("app/local-test-data/backup-v1.2.0.json")
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.2.0.json")
}
@Before
fun setup() {
assumeTrue(
"local-test-data/backup-v1.2.0.json absent — tests ignorés (données locales)",
DATA_FILE.exists()
)
if (!PKProfileStore.hasProfile("EEN", "TFS")) {
val file = listOf(
File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json")
).first { it.exists() }
PKProfileStore.initWithJson(file.readText())
}
}
private fun importUserBackup(): BackupData =
Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
@Test
fun `second user backup parses with forecast interval and stored scale factor`() {
val data = importUserBackup()
assertEquals(9, data.doseLogs.size)
assertEquals(8, data.labResults.size)
val tr = data.treatments.single()
assertEquals("TFS", tr.pkModel)
assertEquals("EEN", tr.esterType)
assertEquals(6.0, tr.forecastIntervalDays!!, 1e-9)
}
@Test
fun `home 24h estimate is plausible in steady state with the stored scale factor`() {
val data = importUserBackup()
val curve = PharmacokineticEngine.computeCurve(
data.treatments, data.doseLogs,
startMs = data.exportedAt - 24 * PharmacokineticEngine.HOUR_MS,
endMs = data.exportedAt,
tConfig = data.tConfig
)
assertTrue(curve.isNotEmpty())
val last = curve.last()
// État d'équilibre EEn : t½ ≈ 6,7 j, doses tous les 6–7 j → accumulation ×2 ;
// avec le facteur stocké (~0,7) : ~240–280 pg/mL, cohérent avec les labs
assertTrue("e2=${last.e2}", last.e2 in 200.0..350.0)
assertTrue("t=${last.t}", last.t in 0.3..0.55)
}
@Test
fun `per-ester calibration produces a single factor from the labs of that period`() {
val data = importUserBackup()
val scales = PharmacokineticEngine.computeEsterScaleFactors(
data.treatments, data.doseLogs,
data.labResults.filter { it.marker.equals("E2", true) }
)
// Toutes les doses sont du même ester → un seul facteur calibré
assertTrue("esters=${scales.keys}", scales.keys == setOf("EEN"))
assertTrue("sf=${scales["EEN"]}", scales["EEN"]!! in 0.2..2.0)
}
@Test
fun `mistyped T lab unit does not destroy the T axis`() {
// Un lab T avec une unité aberrante (pg/mL) doit être converti, pas brut
assertEquals(0.04, PharmacokineticEngine.convertTToNgMl(40.0, "pg/mL"), 1e-9)
}
@Test
fun `forecast doses follow the configured interval`() {
val data = importUserBackup()
val intervalDays = data.treatments.single().forecastIntervalDays!!
val forecast = PharmacokineticEngine.generateForecastDoses(
data.treatments.single(), data.doseLogs,
toMs = data.exportedAt + 30 * 24 * 3_600_000L,
nowMs = data.exportedAt
)
val lastReal = data.doseLogs.maxOf { it.timestamp }
assertTrue(forecast.isNotEmpty())
assertEquals(lastReal + (intervalDays * 24 * 3_600_000L).toLong(), forecast.first().timestamp)
forecast.zipWithNext { prev, cur ->
assertEquals((intervalDays * 24 * 3_600_000L).toLong(), cur.timestamp - prev.timestamp)
}
}
@Test
fun `auto calibration is consistent on the real data`() {
val data = importUserBackup()
val result = PharmacokineticEngine.autoCalibrated(
data.treatments, data.doseLogs, data.labResults, data.tConfig
)
assertTrue(result.calibratedEsters == 1)
assertTrue(result.esterScales.values.single() in 0.2..2.0)
// Un seul ester → le k T recalibré vit dans la map par ester ;
// le k stocké reste le fallback (inchangé dans les réglages)
assertTrue(result.tRecalibrated)
assertTrue(result.tKPerEster.values.single() in 0.01..1.0)
assertEquals(data.tConfig.k, result.tConfig.k, 1e-9)
}
}

View File

@ -0,0 +1,146 @@
package com.hormonetrack.pk
import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import java.io.File
/**
* Régression épinglée sur le **3ᵉ export réel** (v1.2.3) — le scénario complet de
* transition : 2 traitements, **EV inactif** (« Injection EV - old », 29 doses de
* fin janvier à début juillet, doses 2–8 mg) puis **EEn actif** (9 doses, 5 mg
* tous les ~6-7 j), 22 labs couvrant les DEUX périodes.
*
* ⚠️ Données de santé personnelles : fichier HORS dépôt
* (`local-test-data/backup-v1.2.3.json`, gitignoré) ; tests ignorés si absent.
*
* Cas de bug v1.2.4 : passer un traitement à `isActive = false` faisait disparaître
* sa simulation (et faussait la calibration) — « inactif » est un drapeau
* administratif (saisie), JAMAIS un filtre de données. Ce test épinglle le
* contrat : **le moteur reçoit TOUS les traitements et simule tout l'historique**.
*/
class RegressionUserCase3Test {
companion object {
private val DATA_FILE: File = listOf(
File("../local-test-data/backup-v1.2.3.json"),
File("local-test-data/backup-v1.2.3.json"),
File("app/local-test-data/backup-v1.2.3.json")
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.2.3.json")
}
@Before
fun setup() {
assumeTrue(
"local-test-data/backup-v1.2.3.json absent — tests ignorés (données locales)",
DATA_FILE.exists()
)
if (!PKProfileStore.hasProfile("EV", "TFS")) {
val file = listOf(
File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json")
).first { it.exists() }
PKProfileStore.initWithJson(file.readText())
}
}
private fun importUserBackup(): BackupData =
Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
@Test
fun `third backup parses with an inactive EV treatment and an active EEn one`() {
val data = importUserBackup()
assertEquals(2, data.treatments.size)
assertEquals(38, data.doseLogs.size)
assertEquals(22, data.labResults.size)
val ev = data.treatments.single { it.esterType == "EV" }
val een = data.treatments.single { it.esterType == "EEN" }
// Le traitement EV est INACTIF : ses doses doivent néanmoins être simulées
assertTrue(!ev.isActive)
assertTrue(een.isActive)
assertTrue(data.doseLogs.count { it.treatmentId == ev.id } > 20)
assertTrue(data.doseLogs.count { it.treatmentId == een.id } > 5)
}
@Test
fun `inactive treatment doses are still simulated (bug v1_2_4 contract)`() {
val data = importUserBackup()
// La fenêtre couvre la PÉRIODE EV : si le code consommateur filtrait sur
// isActive, la courbe serait vide ici — le moteur, lui, simule tout ce
// qu'on lui passe (les écrans passent TOUS les traitements, cf §10).
val evTreatment = data.treatments.single { it.esterType == "EV" }
val evDoses = data.doseLogs.filter { it.treatmentId == evTreatment.id }
val firstEv = evDoses.minOf { it.timestamp }
val curve = PharmacokineticEngine.computeCurve(
data.treatments, data.doseLogs,
startMs = firstEv,
endMs = firstEv + 48 * PharmacokineticEngine.HOUR_MS,
tConfig = data.tConfig
)
assertTrue(curve.isNotEmpty())
assertTrue("e2=${curve.last().e2} pendant la période EV (traitement inactif)", curve.last().e2 > 50.0)
}
@Test
fun `per-ester calibration covers BOTH periods (EV and EEn)`() {
val data = importUserBackup()
val scales = PharmacokineticEngine.computeEsterScaleFactors(
data.treatments, data.doseLogs,
data.labResults.filter { it.marker.equals("E2", true) }
)
// Les labs des 2 périodes (valerate ET enanthate) calibrent chacune leur ester
assertEquals(setOf("EV", "EEN"), scales.keys)
// EV : facteur plausible (l'utilisatrice avait calibré manuellement à 1,4)
assertTrue("EV=${scales["EV"]}", scales["EV"]!! in 0.5..2.5)
assertTrue("EEN=${scales["EEN"]}", scales["EEN"]!! in 0.2..2.0)
}
@Test
fun `T calibration k is also per ester period`() {
val data = importUserBackup()
val result = PharmacokineticEngine.autoCalibrated(
data.treatments, data.doseLogs, data.labResults, data.tConfig
)
assertTrue(result.tKPerEster.keys.containsAll(setOf("EV", "EEN")))
result.tKPerEster.values.forEach { k ->
assertTrue("k=$k", k in 0.001..1.0)
}
// Les labs des deux périodes existent (7 labs T en période EV, 4 en EEn)
val tLabs = data.labResults.filter { it.marker.equals("T", true) }
assertTrue(tLabs.size >= 10)
}
@Test
fun `current level at export time reflects the active EEn treatment`() {
val data = importUserBackup()
val point = PharmacokineticEngine.levelAt(
data.treatments, data.doseLogs, data.exportedAt, data.tConfig
)
// ~122 h après 5 mg EEn (TFS) × SF 0,73 → état d'équilibre ~240-280 pg/mL
assertTrue("e2=${point.e2}", point.e2 in 200.0..350.0)
// k stocké 0,068 → T ≈ 0,2 + 5,8/(1+0,068×268) ≈ 0,48
assertTrue("t=${point.t}", point.t in 0.35..0.6)
}
@Test
fun `transition window superposes the tail of EV and the start of EEn`() {
val data = importUserBackup()
// Fenêtre à cheval sur la transition (EV dernier dose début juillet,
// EEn première dose ~5 j après) : la courbe doit rester continue
val curve = PharmacokineticEngine.computeCurve(
data.treatments, data.doseLogs,
startMs = data.exportedAt - 70 * 24 * PharmacokineticEngine.HOUR_MS,
endMs = data.exportedAt - 60 * 24 * PharmacokineticEngine.HOUR_MS,
tConfig = data.tConfig
)
assertTrue(curve.isNotEmpty())
// Pas de trou : chaque point de la fenêtre a un E2 > 0
curve.forEach { p ->
assertTrue("e2=0 à ${p.timestamp} — trou de simulation pendant la transition", p.e2 > 0.0)
}
}
}

View File

@ -3,44 +3,41 @@ package com.hormonetrack.pk
import com.google.gson.Gson import com.google.gson.Gson
import com.hormonetrack.data.backup.BackupData import com.hormonetrack.data.backup.BackupData
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.File import java.io.File
/** /**
* Regression test pinned to the user's real exported data (v1.0.0 backup JSON): * Régression épinglée sur le **1er export réel** de l'utilisatrice (v1.0.0).
* 1 EEn injection 5 mg (~5 days before export), 4 labs (2× E2 pg/mL, 2× T ng/dL), *
* default T config. Original bug report: "charts don't generate". * ⚠️ Données de santé personnelles : le JSON réel vit HORS du dépôt dans
* `local-test-data/backup-v1.0.0.json` (gitignoré). Si le fichier est absent
* (clone neuf, CI), les tests de cette classe sont **ignorés** — jamais échoués.
* Pour les lancer : exporter son backup depuis l'app et le déposer à cet emplacement.
*/ */
class RegressionUserCaseTest { class RegressionUserCaseTest {
companion object { companion object {
// Exact export provided by the user (formatted for readability) /**
private val USER_JSON = """ * Le workdir des tests unitaires Gradle est le dossier du module (app/) :
{"doseLogs":[{"doseAmount":5.0,"id":3,"timestamp":1790766120000,"treatmentId":1}], * on cherche donc les données locales à plusieurs emplacements.
"exportedAt":1791207257381, */
"labResults":[ private val DATA_FILE: File = listOf(
{"id":3,"marker":"E2","timestamp":1789028820000,"unit":"pg/mL","value":300.0}, File("../local-test-data/backup-v1.0.0.json"),
{"id":4,"marker":"T","timestamp":1789028820000,"unit":"ng/dL","value":33.0}, File("local-test-data/backup-v1.0.0.json"),
{"id":1,"marker":"E2","timestamp":1790242440000,"unit":"pg/mL","value":250.0}, File("app/local-test-data/backup-v1.0.0.json")
{"id":2,"marker":"T","timestamp":1790242440000,"unit":"ng/dL","value":44.0}], ).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.0.0.json")
"tConfig":{"base":6.0,"floor":0.2,"k":0.19},
"treatments":[{
"absorptionHours":152.0,"bioavailabilityFraction":1.0,"createdAt":1791206539840,
"doseAmount":5.0,"doseUnit":"mg","eliminationHalfLifeHours":150.0,"esterType":"EEN",
"id":1,"isActive":true,"name":"Injection EEn — Estrannaise","pkModel":"ESE",
"reminderEnabled":false,"route":"INJECTION_IM","scaleFactor":1.0,"type":"ESTRADIOL"}],
"version":1}
""".trimIndent()
private const val EXPORT_TIME = 1_791_207_257_381L
} }
@Before @Before
fun setup() { fun setup() {
assumeTrue(
"local-test-data/backup-v1.0.0.json absent — tests ignorés (données locales)",
DATA_FILE.exists()
)
if (!PKProfileStore.hasProfile("EEN", "ESE")) { if (!PKProfileStore.hasProfile("EEN", "ESE")) {
val file = listOf( val file = listOf(
File("src/main/assets/pk_profiles.json"), File("src/main/assets/pk_profiles.json"),
@ -51,38 +48,32 @@ class RegressionUserCaseTest {
} }
private fun importUserBackup(): BackupData = private fun importUserBackup(): BackupData =
Gson().fromJson(USER_JSON, BackupData::class.java) Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
@Test @Test
fun `user backup JSON parses into the expected data`() { fun `user backup parses into the expected data`() {
val data = importUserBackup() val data = importUserBackup()
assertEquals(1, data.treatments.size) assertEquals(1, data.treatments.size)
assertEquals(1, data.doseLogs.size) assertEquals(1, data.doseLogs.size)
assertEquals(4, data.labResults.size) assertEquals(4, data.labResults.size)
assertEquals("EEN", data.treatments[0].esterType) assertEquals("EEN", data.treatments[0].esterType)
assertEquals("ESE", data.treatments[0].pkModel) assertEquals("ESE", data.treatments[0].pkModel)
assertEquals(1790766120000, data.doseLogs[0].timestamp) assertEquals(4, data.labResults.size)
assertEquals(2, data.labResults.count { it.marker.equals("E2", true) })
assertEquals(2, data.labResults.count { it.marker.equals("T", true) })
} }
@Test @Test
fun `charts generate for every range with the user data`() { fun `charts generate for every range with the user data`() {
val data = importUserBackup() val data = importUserBackup()
val tConfig = data.tConfig
for (rangeHours in listOf(24L, 24L * 7, 24L * 30)) { for (rangeHours in listOf(24L, 24L * 7, 24L * 30)) {
val curve = PharmacokineticEngine.computeCurve( val curve = PharmacokineticEngine.computeCurve(
data.treatments, data.doseLogs, data.treatments, data.doseLogs,
startMs = EXPORT_TIME - rangeHours * PharmacokineticEngine.HOUR_MS, startMs = data.exportedAt - rangeHours * PharmacokineticEngine.HOUR_MS,
endMs = EXPORT_TIME, endMs = data.exportedAt,
tConfig = tConfig tConfig = data.tConfig
) )
assertTrue("24h range=$rangeHours curve must not be empty", curve.isNotEmpty()) assertTrue("rangeHours=$rangeHours curve must not be empty", curve.isNotEmpty())
// grid is hour-aligned: the last point may land up to 1 h before endMs
assertTrue(
"last=${curve.last().timestamp} rangeHours=$rangeHours",
curve.last().timestamp in (EXPORT_TIME - rangeHours * PharmacokineticEngine.HOUR_MS)..EXPORT_TIME
)
// E2 must be positive and in a physiologically plausible band for
// 5 mg EEn at ~120 h (peak 31.4 pg/mL/mg around 152 h)
val last = curve.last() val last = curve.last()
assertTrue("e2=${last.e2} at rangeHours=$rangeHours", last.e2 > 50.0 && last.e2 < 400.0) assertTrue("e2=${last.e2} at rangeHours=$rangeHours", last.e2 > 50.0 && last.e2 < 400.0)
assertTrue("t=${last.t}", last.t > 0.0 && last.t < 1.0) assertTrue("t=${last.t}", last.t > 0.0 && last.t < 1.0)
@ -92,8 +83,9 @@ class RegressionUserCaseTest {
@Test @Test
fun `current level at export time is plausible`() { fun `current level at export time is plausible`() {
val data = importUserBackup() val data = importUserBackup()
val point = PharmacokineticEngine.levelAt(data.treatments, data.doseLogs, EXPORT_TIME, data.tConfig) val point = PharmacokineticEngine.levelAt(
// ~121 h after 5 mg EEn: profile ≈ 27-30 pg/mL/mg → 135-150 pg/mL data.treatments, data.doseLogs, data.exportedAt, data.tConfig
)
assertTrue("e2=${point.e2}", point.e2 in 100.0..200.0) assertTrue("e2=${point.e2}", point.e2 in 100.0..200.0)
} }
@ -109,28 +101,29 @@ class RegressionUserCaseTest {
@Test @Test
fun `T labs in ng per dL convert to ng per mL`() { fun `T labs in ng per dL convert to ng per mL`() {
assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(44.0, "ng/dL"), 1e-9) // Constantes choisies idempotentes face au script d'anonymisation d'historique
assertEquals(0.32, PharmacokineticEngine.convertTToNgMl(33.0, "ng/dL"), 1e-9) assertEquals(0.44, PharmacokineticEngine.convertTToNgMl(44.0, "ng/dL"), 1e-9)
assertEquals(0.33, PharmacokineticEngine.convertTToNgMl(33.0, "ng/dL"), 1e-9)
assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(0.45, "ng/mL"), 1e-9) assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(0.45, "ng/mL"), 1e-9)
assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(450.0, "ng/L"), 1e-9) assertEquals(4.5, PharmacokineticEngine.convertTToNgMl(4500.0, "ng/L"), 1e-9)
assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(0.45, ""), 1e-9) assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(0.45, ""), 1e-9)
} }
@Test @Test
fun `T calibration works with ng per dL labs`() { fun `T calibration works with ng per dL labs`() {
val data = importUserBackup() val data = importUserBackup()
// synthetic post-dose T labs expressed in ng/dL // Lab T synthétique post-dose exprimé en ng/dL, k planté à 0,25
val cfg = data.tConfig val cfg = data.tConfig
val e2 = PharmacokineticEngine.e2At(data.treatments, data.doseLogs, EXPORT_TIME) val e2 = PharmacokineticEngine.e2At(data.treatments, data.doseLogs, data.exportedAt)
val trueK = 0.25 val trueK = 0.25
val tNgMl = cfg.floor + (cfg.base - cfg.floor) / (1.0 + trueK * e2) val tNgMl = cfg.floor + (cfg.base - cfg.floor) / (1.0 + trueK * e2)
val lab = com.hormonetrack.data.model.LabResult( val lab = com.hormonetrack.data.model.LabResult(
marker = "T", value = tNgMl * 100.0, unit = "ng/dL", timestamp = EXPORT_TIME marker = "T", value = tNgMl * 100.0, unit = "ng/dL", timestamp = data.exportedAt
) )
val calibrated = PharmacokineticEngine.computeTConfigCalibration( val calibrated = PharmacokineticEngine.computeTConfigCalibration(
listOf(lab), data.treatments, data.doseLogs, cfg listOf(lab), data.treatments, data.doseLogs, cfg
) )
assertNotNull(calibrated) assertTrue(calibrated != null)
assertEquals(trueK, calibrated!!.k, trueK * 0.15) assertEquals(trueK, calibrated!!.k, trueK * 0.15)
} }
} }

View File

@ -50,6 +50,46 @@ class V120FeaturesTest {
forecastIntervalDays = intervalDays forecastIntervalDays = intervalDays
) )
@Test
fun `forecast skips past slots after a missed dose (oubli)`() {
// Rythme 6 j, dernier rappel... dernière PRISE il y a 12 j (une injection
// a été manquée il y a 6 j) : le créneau théorique BASE-6j est passé.
val tr = eenTreatment(intervalDays = 6.0)
val doses = listOf(
DoseLog(treatmentId = 1, timestamp = BASE - 18 * DAY, doseAmount = 5.0),
DoseLog(treatmentId = 1, timestamp = BASE - 12 * DAY, doseAmount = 5.0)
)
val forecast = PharmacokineticEngine.generateForecastDoses(
tr, doses, toMs = BASE + 30 * DAY, nowMs = BASE
)
// Aucun créneau simulé dans le passé : premier = BASE+6 j (dernière+12 j
// est le premier créneau strictement futur au rythme de 6 j)
assertTrue(forecast.isNotEmpty())
forecast.forEach { f ->
assertTrue("créneau passé simulé : ${f.timestamp}", f.timestamp > BASE)
}
assertEquals(BASE + 6 * DAY, forecast.first().timestamp)
forecast.zipWithNext { prev, cur ->
assertEquals(6 * DAY, cur.timestamp - prev.timestamp)
}
}
@Test
fun `forecast follows a delayed last dose (retard d un jour)`() {
// Rythme 6 j, dernière prise faite avec 1 jour de retard (à J-5 au lieu
// de J-6) : TOUTE la prévision se décale d'un jour, en partant de la
// dernière prise réelle.
val tr = eenTreatment(intervalDays = 6.0)
val doses = listOf(
DoseLog(treatmentId = 1, timestamp = BASE - 5 * DAY, doseAmount = 5.0)
)
val forecast = PharmacokineticEngine.generateForecastDoses(
tr, doses, toMs = BASE + 30 * DAY, nowMs = BASE
)
assertEquals(BASE + 1 * DAY, forecast.first().timestamp)
assertTrue(forecast.all { it.timestamp > BASE })
}
@Test @Test
fun `forecast doses follow the configured interval from the last real dose`() { fun `forecast doses follow the configured interval from the last real dose`() {
val tr = eenTreatment(intervalDays = 7.0) val tr = eenTreatment(intervalDays = 7.0)
@ -104,13 +144,13 @@ class V120FeaturesTest {
} }
@Test @Test
fun `auto calibration replaces scale factors and T model for display only`() { fun `auto calibration computes per-ester scales and leaves stored values untouched`() {
val tr = eenTreatment(intervalDays = 7.0, scale = 1.0) val tr = eenTreatment(intervalDays = 7.0, scale = 1.0)
val doses = listOf( val doses = listOf(
DoseLog(treatmentId = 1, timestamp = BASE - 7 * DAY, doseAmount = 5.0), DoseLog(treatmentId = 1, timestamp = BASE - 7 * DAY, doseAmount = 5.0),
DoseLog(treatmentId = 1, timestamp = BASE - 20 * DAY, doseAmount = 5.0) DoseLog(treatmentId = 1, timestamp = BASE - 20 * DAY, doseAmount = 5.0)
) )
// Un lab E2 à 0,73 de la prédiction → le scale factor recalculé doit valoir ~0,73 // Un lab E2 à 0,73 de la prédiction → le facteur de l'ester EEN doit valoir ~0,73
val t = BASE - 2 * DAY val t = BASE - 2 * DAY
val predicted = PharmacokineticEngine.e2At(listOf(tr), doses, t) val predicted = PharmacokineticEngine.e2At(listOf(tr), doses, t)
val labs = listOf( val labs = listOf(
@ -119,11 +159,18 @@ class V120FeaturesTest {
) )
val cfg = TConfig() val cfg = TConfig()
val result = PharmacokineticEngine.autoCalibrated(listOf(tr), doses, labs, cfg) val result = PharmacokineticEngine.autoCalibrated(listOf(tr), doses, labs, cfg)
assertEquals(0.73, result.treatments.first().scaleFactor, 0.01) assertEquals(0.73, result.esterScales["EEN"]!!, 0.01)
assertTrue(result.calibratedTreatments == 1) assertTrue(result.calibratedEsters == 1)
assertTrue(result.tRecalibrated) assertTrue(result.tRecalibrated)
// Le traitement d'origine n'est PAS modifié (affichage seul) // Le traitement d'origine n'est PAS modifié (affichage seul) : le scaleFactor
// stocké reste 1.0, la calibration passe par scalePerEster au tracé
assertEquals(1.0, tr.scaleFactor, 1e-9) assertEquals(1.0, tr.scaleFactor, 1e-9)
assertEquals(1.0, result.treatments.first().scaleFactor, 1e-9)
// e2At avec scalePerEster applique le facteur par ester
val scaled = PharmacokineticEngine.e2At(
listOf(tr), doses, t, scalePerEster = result.esterScales
)
assertEquals(predicted * 0.73, scaled, predicted * 0.01)
} }
@Test @Test
@ -131,6 +178,98 @@ class V120FeaturesTest {
val tr = eenTreatment() val tr = eenTreatment()
val result = PharmacokineticEngine.autoCalibrated(listOf(tr), emptyList(), emptyList(), TConfig()) val result = PharmacokineticEngine.autoCalibrated(listOf(tr), emptyList(), emptyList(), TConfig())
assertEquals(1.0, result.treatments.first().scaleFactor, 1e-9) assertEquals(1.0, result.treatments.first().scaleFactor, 1e-9)
assertTrue(result.calibratedTreatments == 0) assertTrue(result.calibratedEsters == 0)
assertTrue(result.esterScales.isEmpty())
}
@Test
fun `per-ester T calibration attributes k by ester period`() {
// Scénario valerate → enanthate : la suppression T n'est pas la même selon
// l'ester, donc k doit être calibré séparément par période.
val tr = eenTreatment().copy(esterType = Esters.EV)
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV)
val evDose2 = DoseLog(treatmentId = 1, timestamp = BASE - 23 * DAY, doseAmount = 4.0, esterType = Esters.EV)
val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 10 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
val eenDose2 = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
val doses = listOf(evDose, evDose2, eenDose, eenDose2)
val cfg = TConfig()
// Labs T plantés : k_EV = 0,10 pendant la période EV, k_EEN = 0,30 pendant EEn
val evLabTime = BASE - 25 * DAY
val eenLabTime = BASE - 5 * DAY
val e2Ev = PharmacokineticEngine.e2At(listOf(tr), doses, evLabTime)
val e2Een = PharmacokineticEngine.e2At(listOf(tr), doses, eenLabTime)
fun tFor(k: Double, e2: Double) = cfg.floor + (cfg.base - cfg.floor) / (1.0 + k * e2)
val labs = listOf(
LabResult(marker = "T", value = tFor(0.10, e2Ev), unit = "ng/mL", timestamp = evLabTime),
LabResult(marker = "T", value = tFor(0.30, e2Een), unit = "ng/mL", timestamp = eenLabTime)
)
val kMap = PharmacokineticEngine.computeTKPerEster(listOf(tr), doses, labs, cfg)
assertEquals(0.10, kMap["EV"]!!, 0.10 * 0.15)
assertEquals(0.30, kMap["EEN"]!!, 0.30 * 0.15)
}
@Test
fun `T curve uses the k of the active ester at each point`() {
val tr = eenTreatment().copy(esterType = Esters.EV)
val doses = listOf(
DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV),
DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
)
val tKPerEster = mapOf("EV" to 0.10, "EEN" to 0.30)
val cfg = TConfig()
val curve = PharmacokineticEngine.computeCurve(
listOf(tr), doses,
startMs = BASE - 25 * DAY, endMs = BASE,
tConfig = cfg, tKPerEster = tKPerEster
)
// Point en pleine période EV (25 j avant BASE + 24 h ≈ J-29) : k = 0,10
val evPoint = curve.first()
val evE2 = evPoint.e2
assertEquals(cfg.floor + (cfg.base - cfg.floor) / (1.0 + 0.10 * evE2), evPoint.t, 1e-6)
// Point en pleine période EEn (fin de fenêtre) : k = 0,30
val eenPoint = curve.last()
val eenE2 = eenPoint.e2
assertEquals(cfg.floor + (cfg.base - cfg.floor) / (1.0 + 0.30 * eenE2), eenPoint.t, 1e-6)
}
@Test
fun `per-ester calibration attributes labs by ester period`() {
// Scénario de l'utilisatrice : valerate avant, enanthate ensuite.
// Même traitement, ester override par dose (comme dans le .ods).
val tr = eenTreatment().copy(esterType = Esters.EV)
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV)
val evDose2 = DoseLog(treatmentId = 1, timestamp = BASE - 23 * DAY, doseAmount = 4.0, esterType = Esters.EV)
val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 10 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
val eenDose2 = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
val doses = listOf(evDose, evDose2, eenDose, eenDose2)
// Un lab pendant la période EV (à 0,6 de la prédiction) et un lab pendant
// la période EEn (à 1,2 de la prédiction)
val evLabTime = BASE - 25 * DAY
val eenLabTime = BASE - 5 * DAY
val predictedEv = PharmacokineticEngine.e2At(listOf(tr), doses, evLabTime)
val predictedEen = PharmacokineticEngine.e2At(listOf(tr), doses, eenLabTime)
val labs = listOf(
LabResult(marker = "E2", value = predictedEv * 0.6, unit = "pg/mL", timestamp = evLabTime),
LabResult(marker = "E2", value = predictedEen * 1.2, unit = "pg/mL", timestamp = eenLabTime)
)
val scales = PharmacokineticEngine.computeEsterScaleFactors(listOf(tr), doses, labs)
// Chaque ester est calibré UNIQUEMENT avec les labs de sa période
assertEquals(0.6, scales["EV"]!!, 0.01)
assertEquals(1.2, scales["EEN"]!!, 0.01)
}
@Test
fun `scalePerEster scales each dose by its own ester factor`() {
val tr = eenTreatment().copy(esterType = Esters.EV, scaleFactor = 999.0) // ne doit PAS être utilisé
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV)
val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
val scales = mapOf("EV" to 0.5, "EEN" to 2.0)
val t = BASE
val scaled = PharmacokineticEngine.e2At(listOf(tr), listOf(evDose, eenDose), t, scalePerEster = scales)
val expected = PharmacokineticEngine.concentrationOfDose(tr, evDose, t) * 0.5 +
PharmacokineticEngine.concentrationOfDose(tr, eenDose, t) * 2.0
assertEquals(expected, scaled, expected * 1e-6)
} }
} }

View File

@ -0,0 +1,26 @@
package com.hormonetrack.reminder
import org.junit.Assert.assertEquals
import org.junit.Test
/** RRULE de l'événement d'agenda : répétition DAILY tous les N jours. */
class CalendarRruleTest {
@Test
fun `intervalle entier donne un RRULE exact`() {
assertEquals("FREQ=DAILY;INTERVAL=6", CalendarEvents.rruleForIntervalDays(6.0))
assertEquals("FREQ=DAILY;INTERVAL=14", CalendarEvents.rruleForIntervalDays(14.0))
}
@Test
fun `intervalle decimal arrondi a l entier le plus proche`() {
assertEquals("FREQ=DAILY;INTERVAL=7", CalendarEvents.rruleForIntervalDays(6.5))
assertEquals("FREQ=DAILY;INTERVAL=6", CalendarEvents.rruleForIntervalDays(6.4))
}
@Test
fun `intervalle nul ou negatif est clampe a 1 jour`() {
assertEquals("FREQ=DAILY;INTERVAL=1", CalendarEvents.rruleForIntervalDays(0.0))
assertEquals("FREQ=DAILY;INTERVAL=1", CalendarEvents.rruleForIntervalDays(-2.0))
}
}

View File

@ -0,0 +1,80 @@
package com.hormonetrack.settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** Helper « Nouveautés » : comparaison SemVer + extraction de sections. */
class ChangelogHelperTest {
private val markdown = """
## [1.3.0] — changelog 1.3.0
### Ajouté
- truc 1.3.0
## [1.2.10] — changelog 1.2.10
- truc 1.2.10
## [1.2.9] — changelog 1.2.9
- truc 1.2.9
""".trim()
@Test
fun `comparaison semver numerique - 1_2_9 plus vieux que 1_2_10`() {
// une comparaison lexicographique donnerait 1.2.9 > 1.2.10 — piège épinglé
assertTrue(ChangelogHelper.isVersionNewer("1.2.10", "1.2.9"))
assertFalse(ChangelogHelper.isVersionNewer("1.2.9", "1.2.10"))
}
@Test
fun `versions identiques ne sont pas plus recentes`() {
assertFalse(ChangelogHelper.isVersionNewer("1.2.10", "1.2.10"))
}
@Test
fun `premiere installation - reference nulle est toujours plus ancienne`() {
assertTrue(ChangelogHelper.isVersionNewer("1.3.0", null))
}
@Test
fun `sections extraites depuis la derniere version vue`() {
val out = ChangelogHelper.sectionsSince(markdown, "1.2.10", "1.3.0")
assertTrue(out.contains("1.3.0"))
assertFalse(out.contains("1.2.10"))
assertFalse(out.contains("1.2.9"))
}
@Test
fun `mise a jour de plusieurs versions - toutes les sections intermediaires`() {
// dernière vue 1.2.8 (absente de la markdown) → tout ce qui est plus
// récent jusqu'à la version courante est inclus
val out = ChangelogHelper.sectionsSince(markdown, "1.2.8", "1.3.0")
assertTrue(out.contains("1.3.0"))
assertTrue(out.contains("1.2.10"))
assertTrue(out.contains("1.2.9"))
}
@Test
fun `premiere installation - section courante seule`() {
val out = ChangelogHelper.sectionsSince(markdown, null, "1.3.0")
assertTrue(out.contains("1.3.0"))
assertFalse(out.contains("1.2.10"))
}
@Test
fun `aucune nouveauté - version vue égale à la courante`() {
assertEquals("", ChangelogHelper.sectionsSince(markdown, "1.3.0", "1.3.0"))
}
@Test
fun `versions non numeriques ignorees proprement`() {
// markdown avec une section exotique entre les deux
val md = markdown.replace("## [1.2.10]", "## [1.2.10-beta]")
val out = ChangelogHelper.sectionsSince(md, "1.2.9", "1.3.0")
assertTrue(out.contains("1.3.0"))
}
}

View File

@ -0,0 +1,31 @@
package com.hormonetrack.ui
import com.hormonetrack.ui.components.stepForRange
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pas d'échantillonnage adaptatif au zoom (v1.2.9) : plus la fenêtre est
* courte, plus le pas est fin — sinon les courbes paraissent anguleuses.
*/
class ChartZoomTest {
@Test
fun `deep zoom uses 15 minute steps`() {
assertEquals(900_000L, stepForRange(6L))
assertEquals(900_000L, stepForRange(12L))
}
@Test
fun `mid zoom uses 30 minute steps`() {
assertEquals(1_800_000L, stepForRange(13L))
assertEquals(1_800_000L, stepForRange(24L))
}
@Test
fun `wide ranges use hourly steps`() {
assertEquals(3_600_000L, stepForRange(25L))
assertEquals(3_600_000L, stepForRange(24L * 30))
assertEquals(3_600_000L, stepForRange(24L * 300))
}
}

View File

@ -1,7 +1,215 @@
# Changelog — HormoneTrack # Changelog — HormoneTrack
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.2.0`, …). Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.3.0`, …).
## [1.3.0] — 2026-09-06 (versionCode 14)
### Ajouté
- **Dialog « Nouveautés »** : après chaque mise à jour (et à la première
installation), l'app affiche le changelog des versions non vues dans une
fenêtre fermable — ne réapparaît pas avant la prochaine mise à jour
(version vue mémorisée). Contenu = `docs/CHANGELOG.md` embarqué en asset,
synchronisé automatiquement à chaque build (tâche Gradle `copyChangelog`).
- **Événements d'agenda récurrents** : dans l'éditeur de traitement, sous
« Rappels », un switch « Événement d'agenda récurrent » crée un événement
dans un calendrier local dédié « HormoneTrack » (visible dans l'agenda du
téléphone) avec répétition RRULE dérivée de la **Posologie** (tous les N
jours, arrondi demi-supérieur). Permission agenda demandée à l'activation ;
suppression/recréation automatique au save.
- **Paramètres : version installée + lien cliquable** vers les releases Gitea
(télécharger les APK sans attendre le rappel de mise à jour).
- 11 nouveaux tests (78 au total) : ChangelogHelper (8 : comparaison SemVer
numérique 1.2.9 < 1.2.10 — lexicographique aurait tort, extraction de
sections, première installation), CalendarRruleTest (3 : RRULE, arrondi
demi-supérieur — **kotlin.math.round arrondit les ties vers l'entier PAIR
(6,5 → 6)**, piège documenté).
## [1.2.10] — 2026-09-05 (versionCode 13)
### Corrigé
- **Sens des boutons de zoom inversé** (retour utilisateur) : convention
carte — « **+** » = zoom **avant** (fenêtre plus courte, plus de détail) et
« **−** » = zoom **arrière** (fenêtre plus longue, plus de temps). Avant,
le « + » dézoomait (montrait plus de temps) — pas intuitif. Le pinch
(écarter = zoom avant) était déjà correct.
## [1.2.9] — 2026-09-05 (versionCode 12)
### Ajouté
- **Zoom du graphique de simulation** : pinch à 2 doigts **et** boutons − / +
(accessibilité), plage bornée **6 h → 300 j** ; le point focal du pinch reste
fixe dans le temps ; échantillonnage adaptatif (15 min ≤ 12 h, 30 min ≤
24 h, sinon 1 h — courbes lisses à fort zoom) ; labels X adaptatifs
(1 h / 3 h). Un seul gestionnaire de gestes gère pan + zoom (pas de
conflit de consommation).
- **README : disclaimer IA** en en-tête — projet développé avec l'IA,
contribution humaine essentielle (feedback, retours utilisateur, suggestions) ;
mention factuelle reprise en §1/§2 de la doc de développement.
- 3 tests `stepForRange` (67 au total).
## [1.2.8] — 2026-09-05 (versionCode 11)
### Modifié
- **« Fréquence d'injection » renommé « Posologie »** (section Traitements) :
le terme « fréquence d'injection » n'avait pas de sens pour les prises
d'hormones non injectables (gel, patch, oral). L'astuce d'intervalle est
inchangée ; les textes d'aide disent désormais « dernière **prise**
enregistrée » au lieu de « dernière injection » (EN : « Dosage »).
## [1.2.7] — 2026-09-05 (versionCode 10)
### Corrigé
- **Prévision cassée après un oubli d'injection** : le premier créneau projeté
tombait dans le PASSÉ (dernière prise + intervalle = jour de l'injection
manquée) → la courbe peignait un **faux pic dans l'historique** et tout le
rythme projeté était décalé. Désormais les créneaux déjà passés ne sont
jamais simulés : la prévision avance au premier créneau **strictement
futur** au rythme configuré. Un simple **retard**, lui, décale naturellement
toute la prévision (elle part de la dernière prise réelle — comportement
voulu, désormais testé).
- 2 nouveaux tests (64 au total) : oubli (créneaux passés sautés, espacement
conservé) et retard (décalage complet de la prévision).
## [1.2.6] — 2026-09-05 (versionCode 9)
### Ajouté
- **Valeurs estimées sur les pics/creux** : chaque triangle ▲▼ du graphique est
accompagné de la valeur simulée de l'extremum (au-dessus d'un pic, en dessous
d'un creux), dans la couleur de la courbe.
### Corrigé
- **Import JSON en mode ÉCRASEMENT** : l'import échouait dès que l'app
contenait déjà des données (conflit d'IDs du backup avec les IDs existants).
Désormais l'import efface d'abord les données actuelles (ordre enfants →
parents), restaure le backup (IDs conservés → FK valides), restaure le
réglage T du backup et **reprogramme les rappels**. Le dialog d'import
prévient clairement : « Effacer & restaurer ».
## [1.2.5] — 2026-09-05 (versionCode 8)
### Corrigé
- **Espace vide en haut de l'écran** (contenu trop bas) : double consommation des
insets — la fenêtre poussait déjà le contenu sous la barre de statut (thème
AppCompat) ET les `TopAppBar` Material 3 rajoutaient leur padding de barre de
statut interne. Passage en **edge-to-edge propre** (`enableEdgeToEdge()`) :
- les `TopAppBar` consomment la barre de statut (une seule fois) ;
- les `Scaffold` (racine + imbriqués Doses/Analyses) ont
`contentWindowInsets = 0` pour ne pas cumuler ;
- la `NavigationBar` consomme la barre de navigation système.
Le contenu remonte, les listes défilent visuellement sous la barre de statut
(comportement Material You standard).
- **Builds release optimisés** (R8 + shrink des ressources, signés avec la clé
debug → installables par-dessus sans perte de données) : l'APK passe de
**20 Mo à 2,4 Mo** et gagne en fluidité. Depuis cette release, chaque version
est publiée en deux fichiers :
- `HormoneTrack-vX.Y.Z-release.apk` ← **recommandé** (optimisé R8) ;
- `HormoneTrack-vX.Y.Z-debug.apk` (comme avant, plus lourd, pour diagnostic).
## [1.2.4] — 2026-09-05 (versionCode 7)
### Corrigé
- **Traitement inactif = drapeau administratif, pas filtre de données** (bug
remonté) : passer un traitement à inactif faisait disparaître sa simulation du
graphique **et** sa contribution à la calibration, tout en permettant encore
d'y loger des doses (incohérence). Désormais :
- Home et Graphiques simulent avec **TOUS les traitements** (actifs + inactifs)
→ l'historique EV d'un traitement passé à inactif reste simulé et calibré ;
- l'inactivation retire uniquement : les chips « Log rapide », le traitement
dans le dropdown des **nouvelles** doses (l'édition d'une dose existante
garde son traitement même inactif), et les **rappels** (alarme annulée au
save, au boot et dans le calcul de la prochaine dose) ;
- la suppression d'une dose reste possible depuis l'historique.
### Ajouté
- **Régression n°3** épinglée sur le 3ᵉ export réel (HORS dépôt, gitignoré) :
le scénario complet de transition — 1 traitement **EV inactif** (29 doses,
2–8 mg, janvier→juillet) + 1 traitement **EEn actif** (9 doses), 22 labs
couvrant les deux périodes. Vérifie : simulation de l'inactif, calibration
par période sur les DEUX esters (EV **et** EEN), k T par ester, continuité
de la courbe pendant la transition, niveau actuel. 6 nouveaux tests (62 au
total, tous verts).
## [1.2.3] — 2026-09-05 (versionCode 6)
### Ajouté
- **Pics et creux estimés sur le graphique** : chip « Pics / creux » (désactivé par
défaut) qui affiche des triangles ▲▼ aux extrema locaux de CHAQUE courbe
(E2 et T, pour les deux modèles), colorés comme leur série. Détection dans le
moteur (`detectExtrema`) : extrema locaux stricts, plateaux fusionnés,
alternance pic/creux imposée, oscillations d'amplitude < seuil ignorées
(2 pg/mL pour l'E2, 0,02 ng/mL pour la T — sémantique zigzag : une oscillation
sous le seuil produit un seul pivot).
- **Calibration T PAR PÉRIODE D'ESTER** : la suppression de la testostérone n'est
pas la même selon l'ester (valerate = pics hauts et courts, enanthate = plateau
doux) → le k du modèle T est désormais calibré **par période** comme le facteur
d'échelle E2 (`computeTKPerEster`) : labs T valerate → k des doses valerate,
labs T enanthate → k des doses enanthate. La courbe T utilise à chaque instant
le k de l'ester **actif** (dernière dose ≤ t, `activeEsterAt`), fallback = k stocké.
Le k T est calibré contre l'E2 **déjà calibrée** (scalePerEster appliqué).
- **Données de test hors dépôt (vie privée)** : les tests de régression épinglés
sur les exports réels chargent désormais leurs données depuis `local-test-data/`
(**gitignoré**) et s'ignorent proprement si le fichier est absent — **aucune
donnée de santé personnelle dans le dépôt ni dans son historique** (réécriture
d'historique effectuée avant le premier push).
- 14 nouveaux tests (56 au total) : détection d'extrema (6) + attribution du k T
par période et courbe T utilisant le k de l'ester actif (2), regroupement des
prises de sang (4), régression n°2 en version data-driven (6).
### Corrigé
- La calibration automatique de la T utilisait l'E2 **non calibrée** → les k
étaient faussés ; elle utilise maintenant l'E2 calibrée par période d'ester.
## [1.2.2] — 2026-09-05 (versionCode 5)
### Ajouté
- **Prise de sang E2 + T en UNE entrée** : le dialog d'ajout propose les deux
marqueurs (chacun **optionnel** — on peut ne saisir que E2 ou que T), avec
date/heure et notes partagées ; les entrées sont insérées au même timestamp et
s'affichent **côte à côte** dans l'écran Analyses (« E2 306 pg/mL · T 44 ng/dL »).
- **Tap sur une paire → sélecteur** (« Quelle entrée veux-tu modifier ? » E2 / T)
puis édition pré-remplie de l'entrée choisie.
- **Suppression par prise de sang** : la corbeille supprime la prise entière
(confirmation listant les valeurs).
- **Toggle T du graphique** : masque désormais aussi les **labs T** (avant : seule
la courbe T disparaissait, les points restaient).
- 4 tests de regroupement (48 au total).
## [1.2.1] — 2026-09-05 (versionCode 4)
### Corrigé
- **Calibration par PÉRIODE D'ESTER** (le fond du problème « courbes à 250–375 ») :
la calibration automatique calculait UN facteur par traitement, mélangeant les labs
de toutes les périodes — un lab valerate comparé à une prédiction enanthate donne un
ratio aberrant qui gonfle la courbe. Désormais chaque lab est **attribué à la période
d'injection dans laquelle il tombe** (dernière dose E2 ≤ lab → son ester) et le
facteur est la médiane des ratios de CETTE période : labs valerate → doses
valerate, labs enanthate → doses enanthate (`computeEsterScaleFactors` +
paramètre `scalePerEster` dans `e2At`/`computeCurve`).
Vérifié sur les données réelles : l'état d'équilibre EEn (t½ ≈ 6,7 j, doses tous
les 6–7 j → accumulation ×2) donne ~270 pg/mL calibré — cohérent avec les labs
300/250 ; les « 375 » rapportés = le même calcul **non calibré** (268/0,72 ≈ 367).
- **Lab T saisi en "pg/mL"** (38 "pg/mL" — faute de frappe) : renvoyé brut, il
écrasait l'axe T (~40) et **rendait la courbe T invisible** (écrasée en bas).
Conversion défensive ajoutée (`pg` → ÷1000, `µg/L` → ÷1000).
### Ajouté
- **Édition des résultats de labo** (comme les doses) : appuyer sur une ligne de
l'écran Analyses ouvre le dialog pré-rempli (marqueur, valeur, unité, date/heure,
notes). Nouveau formateur `formatLabValue` (préserve les décimales : 0,44 reste
0,44, contrairement à formatDose).
- **Tap sur le graphique de l'accueil → écran Graphiques** + mini-légende (E2 bleu /
T rose) + indication.
- **Calibration automatique fusionnée** : UNE seule option dans Paramètres qui couvre
**E2 (par période d'ester) ET T** — plus de réglage T isolé ; le bouton manuel
« Calibrer avec les analyses » (T) reste comme action ponctuelle.
- **8 nouveaux tests** (44 au total) dont `RegressionUserCase2Test` épinglé sur le
2ᵉ export réel (9 doses EEn/TFS, 8 labs, fréquence 6 j) — inclut la vérification
de l'état d'équilibre et du lab T en unité aberrante.
### Modifié
- `AutoCalibrated` : `treatments` inchangés (plus de copies avec SF modifié), nouveau
champ `esterScales: Map<String, Double>` + `calibratedEsters`.
## [1.2.0] — 2026-09-05 (versionCode 3) ## [1.2.0] — 2026-09-05 (versionCode 3)

View File

@ -45,6 +45,11 @@ Android, apps tierces au poignet quasi impossibles (cf §17). Utilisatrice équi
**Gadgetbridge** (FOSS) sur son téléphone → v1 = **app téléphone + notifications miroir **Gadgetbridge** (FOSS) sur son téléphone → v1 = **app téléphone + notifications miroir
sur la montre** via GB (ou Huawei Health). sur la montre** via GB (ou Huawei Health).
> **Développement IA-assisté** : le code a été produit avec un assistant IA ;
> la contribution humaine = feedback continu, retours utilisateur (tests réels
> sur téléphone, bugs avec exports), suggestions et validation des releases
> (détail §2 — factuel, session par session).
Fonctionnalités v1 : Fonctionnalités v1 :
- Courbes estimées heure par heure : E2 (pg/mL) + T (ng/mL) — 24 h / 7 j / 30 j - Courbes estimées heure par heure : E2 (pg/mL) + T (ng/mL) — 24 h / 7 j / 30 j
- Deux modèles PK du `.ods` (Estrannaise / TFS) pour injections EV/EU/EEn ; - Deux modèles PK du `.ods` (Estrannaise / TFS) pour injections EV/EU/EEn ;
@ -64,6 +69,19 @@ Fonctionnalités v1 :
| 5 sept. 2026 (session tests/docs) | Correction de toutes les erreurs de compilation (dont 3 vrais bugs logiciels trouvés par les tests), **24 tests unitaires verts**, APK debug généré (18 MB), documentation complète (README + docs/), préparation repo git. | | 5 sept. 2026 (session tests/docs) | Correction de toutes les erreurs de compilation (dont 3 vrais bugs logiciels trouvés par les tests), **24 tests unitaires verts**, APK debug généré (18 MB), documentation complète (README + docs/), préparation repo git. |
| 5 sept. 2026 (session v1.1.0) | Bugs remontés par l'utilisatrice : courbes vides (casse EEn) + pas d'édition des doses → corrigés ; régression épinglée sur ses données réelles ; APK v1.1.0. | | 5 sept. 2026 (session v1.1.0) | Bugs remontés par l'utilisatrice : courbes vides (casse EEn) + pas d'édition des doses → corrigés ; régression épinglée sur ses données réelles ; APK v1.1.0. |
| 5 sept. 2026 (session v1.2.0) | **Repo git initialisé** (commits par couche + tags de release) ; montée toolchain AGP 9.4/Gradle 9.7.1/Kotlin 2.3.21/BOM 2026.08.00/compileSdk 37 ; panoramique du chart, superposition des deux modèles, prévision par « Fréquence », calibration automatique optionnelle, intervalles entre doses, TimePicker centré ; **36 tests verts**, APK v1.2.0 (23 MB). | | 5 sept. 2026 (session v1.2.0) | **Repo git initialisé** (commits par couche + tags de release) ; montée toolchain AGP 9.4/Gradle 9.7.1/Kotlin 2.3.21/BOM 2026.08.00/compileSdk 37 ; panoramique du chart, superposition des deux modèles, prévision par « Fréquence », calibration automatique optionnelle, intervalles entre doses, TimePicker centré ; **36 tests verts**, APK v1.2.0 (23 MB). |
| 5 sept. 2026 (session builds release) | Question debug vs release → **builds release optimisés** (R8 full mode + shrinkResources, 20 Mo → **2,4 Mo**, signés avec la clé debug → upgradables sans perte) ; garde-fous Gson dans `proguard-rules.pro` (réflexion) ; releases publiées avec les **deux APK** (release recommandé + debug) ; 62 tests verts. |
| 5 sept. 2026 (session v1.2.5) | Bug graphique : double espace en haut (double insets) → **edge-to-edge propre** (`enableEdgeToEdge` + insets consommés une seule fois) ; 62 tests verts, APK v1.2.5 + release. |
| 5 sept. 2026 (session v1.2.4) | Bug `isActive` (simulation effacée) → sémantique « drapeau administratif » (§6.bis) ; régression n°3 sur le 3ᵉ export réel (transition EV inactif → EEn actif, 38 doses, 22 labs) ; **62 tests verts**, APK v1.2.4. |
| 5 sept. 2026 (session push Gitea + v1.2.3) | Push initial vers **gitea.cloudyfy.fr/Siphonight/HormoneTrack** (privé) après **anonymisation de l'historique** (filter-branch : les premiers commits embarquaient les valeurs réelles des tests) ; **données de test réelles déplacées hors dépôt** (`local-test-data/` gitignoré, tests `Assume`-skippés) ; **releases avec APK en pièce jointe** ; pics/creux + calibration T par période d'ester ; **56 tests verts**. |
| 5 sept. 2026 (session v1.2.1) | Remontées UX/utilisateur : calibration **par période d'ester** (labs EV → doses EV, labs EEn → doses EEn — corrige les courbes gonflées), lab T "pg/mL" neutralisé, **édition des labs**, tap accueil → Graphiques + mini-légende, auto-cal fusionnée (E2+T, un seul switch) ; 2ᵉ régression épinglée sur le nouvel export ; **44 tests verts**, APK v1.2.1. |
| 5 sept. 2026 (session v1.2.2) | Toggle T masque aussi les labs T ; **prise de sang E2 + T en une entrée** (chacune optionnelle) affichée **côte à côte** ; sélecteur d'édition par marqueur ; suppression par prise ; 4 tests de regroupement ; **48 tests verts**, APK v1.2.2. |
| 5 sept. 2026 (session v1.2.6) | Valeurs estimées affichées sur les pics/creux (drawExtremum) ; **import JSON en mode écrasement** (bug : conflit d'IDs si données existantes) avec restauration du tConfig et reprogrammation des rappels ; docs ; 62 tests verts, APK v1.2.6 + releases. |
| 6 sept. 2026 (session farewell) | Repo créé côté farewell → **push SSH (alias `farewell` : giteassh:2222) + 12 releases publiées avec APK vérifiés par téléchargement** ; piège lexicographique `v1.2.10 < v1.2.5` en comparaison de chaînes épinglé (§14 #38) ; les deux instances Gitea sont synchrones. |
| 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. |
| 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. |
| 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. |
| 5 sept. 2026 (session v1.2.8) | « Fréquence d'injection » → **« Posologie »** (terme adapté à toutes les voies) ; quirk Gitea découvert (noms d'assets normalisés à l'upload) → renommage PATCH dans le script ; releases avec les 2 APK ; 64 tests verts. |
| 5 sept. 2026 (session v1.2.7) | Prévision : les créneaux passés ne sont plus simulés (bug oubli → faux pic historique) ; retard = décalage voulu ; mismatch doc-code §7.3b corrigé ; 2 tests ; **64 tests verts**, APK v1.2.7 + releases. |
Leçon importante de la session build : **les erreurs de compilation et les bugs sémantiques Leçon importante de la session build : **les erreurs de compilation et les bugs sémantiques
(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
@ -176,16 +194,36 @@ l'utilisatrice tape autre chose, la calibration ignorera ces labs.
`Flow` pour l'UI + one-shots `suspend *Once()` pour backup/boot/calibration : `Flow` pour l'UI + one-shots `suspend *Once()` pour backup/boot/calibration :
`TreatmentDao.getActiveOnce/getAllOnce`, `DoseLogDao.getAllOnce`, `LabResultDao.getAllOnce`. `TreatmentDao.getActiveOnce/getAllOnce`, `DoseLogDao.getAllOnce`, `LabResultDao.getAllOnce`.
### 6.bis Sémantique `isActive` (v1.2.4) — drapeau administratif, JAMAIS un filtre
`Treatment.isActive = false` signifie « ce traitement ne produit plus de nouvelles
doses ». **Ce n'est PAS un filtre de données** — passer un traitement à inactif ne
doit rien effacer (bug v1.2.4 : Home/Chart collectaient `activeTreatments`, la
simulation de tout l'historique EV disparaissait) :
| Effet de l'inactivation | Où |
|---|---|
| ✅ Retiré des chips « Log rapide » (accueil) | `HomeScreen` → `activeTreatments` |
| ✅ Retiré du dropdown des **nouvelles** doses | `DosesScreen` : création → actifs only ; **édition → tous** (une dose existante reste rattachée à son traitement inactif) |
| ✅ Rappels annulés (au save, au boot, au calcul de « prochaine dose ») | `rescheduleAll` + `nextReminderFireMs` (filtre `isActive`) ; `BootReceiver` lit `getAllOnce()` pour annuler les alarmes des inactifs |
| ❌ JAMAIS retiré de la simulation | Home/Chart passent **`allTreatments`** au moteur — les écrans ne filtrent jamais |
| ❌ JAMAIS retiré de la calibration (auto) | `autoCalibrated(allTreatments, …)` |
| ❌ JAMAIS supprimé de l'historique des doses | `DosesScreen` liste tout, suppression manuelle uniquement |
Épinglé par la régression n°3 (`RegressionUserCase3Test`, cf §8) : EV inactif + EEn
actif en même temps, les 22 labs couvrant les deux périodes doivent rester simulés
et calibrés.
## 7. Moteur pharmacocinétique ## 7. Moteur pharmacocinétique
`pk/PharmacokineticEngine.kt` + `pk/PKProfileStore.kt`. `pk/PharmacokineticEngine.kt` + `pk/PKProfileStore.kt`.
### 7.1 Source : `Estrogen.ods` ### 7.1 Source : `Estrogen.ods`
- Fichier : `/Users/siphonight/owncloud/Shared_Spreadsheets/Estrogen.ods` (30 MB, 13 tables) - Fichier : le tableur `Estrogen.ods` de l'utilisatrice (Owncloud, 30 MB, 13 tables)
- Tables nominatives (« Jenny », « Océane », « Elden », « Alice », « Marie », « Dana ») : - 6 tables nominatives (surnoms anonymisés) : historique injections (datetime, cuisse
historique injections (datetime, cuisse L/R, ester, dose mg, Z-track) + labs (E2 pg/mL, L/R, ester, dose mg, Z-track) + labs (E2 pg/mL, T ng/mL) + **facteur d'échelle**
T ng/mL) + **facteur d'échelle** manuel (Jenny 0,9 ; Océane 0,55 ; Elden 0,72 ; Alice 0,5) manuel par profil (valeurs entre 0,5 et 1,4)
- Table **« Models »** : paramètres D, k1, k2, k3 par ester×modèle + **profils horaires - Table **« Models »** : paramètres D, k1, k2, k3 par ester×modèle + **profils horaires
normalisés (pg/mL par mg) sur 8001 h** — ce sont ces tables qui sont consommées normalisés (pg/mL par mg) sur 8001 h** — ce sont ces tables qui sont consommées
- Les profils affichent 2 décimales → **plancher 0,01 / 0,00** en queue (conséquence - Les profils affichent 2 décimales → **plancher 0,01 / 0,00** en queue (conséquence
@ -231,14 +269,17 @@ Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils,
dessine les deux modèles côte à côte depuis le même traitement (Bateman non concerné : dessine les deux modèles côte à côte depuis le même traitement (Bateman non concerné :
les deux séries y sont identiques). les deux séries y sont identiques).
- **`generateForecastDoses(treatment, doseLogs, toMs, nowMs)`** : projette les doses à - **`generateForecastDoses(treatment, doseLogs, toMs, nowMs)`** : projette les doses à
venir = dernière dose réelle + k × `forecastIntervalDays` jusqu'à `toMs`, strictement venir = dernière dose réelle + k × `forecastIntervalDays`, **strictement après
après `nowMs` ; dose = standard du traitement, ester = override de la dernière `nowMs`** (v1.2.7 : les créneaux déjà passés — cas d'un OUBLI — sont sautés au
injection. Jamais persistées : uniquement passées à `computeCurve` par le ChartScreen rythme configuré, sinon faux pic dans l'historique ; un RETARD décale toute la
quand le chip « Prévision » est actif. prévision) ; dose = standard du traitement, ester = override de la dernière
injection. Jamais persistées : uniquement passées à `computeCurve` par le
ChartScreen quand le chip « Prévision » est actif.
- **`autoCalibrated(treatments, doseLogs, labs, tConfig)`** : option « Calibration - **`autoCalibrated(treatments, doseLogs, labs, tConfig)`** : option « Calibration
automatique » — renvoie des **copies** de traitements avec les scale factors automatique » (v1.2.1+) — renvoie `esterScales` (facteur **par ester**, cf §7.6)
recalculés (médiane lab ÷ prédiction) + TConfig recalibré. Les valeurs stockées ne + `tKPerEster` (k T **par ester**, cf §7.5) recalculés depuis les labs ;
bougent jamais ; HomeScreen et ChartScreen branchent dessus quand l'option est active. `treatments` et `tConfig` **inchangés** (fallback par ester absent = valeurs
stockées). HomeScreen et ChartScreen branchent dessus quand l'option est active.
### 7.4 Bateman (gel/patch/oral) ### 7.4 Bateman (gel/patch/oral)
@ -249,24 +290,54 @@ Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils,
### 7.5 Courbe T (empirique) ### 7.5 Courbe T (empirique)
`T(t) = floor + (base − floor) / (1 + k·E2(t))` [ng/mL]. Défauts `TConfig` : `T(t) = floor + (base − floor) / (1 + k(ester actif) · E2_calibrée(t))` [ng/mL].
base 6.0, floor 0.2, k 0.19 (→ T≈0,4 à E2≈150). **Non issu du `.ods`** (qui ne modélise Défauts `TConfig` : base 6.0, floor 0.2, k 0.19 (→ T≈0,4 à E2≈150). **Non issu du
pas la T) — modèle d'inhibition simple, étiqueté « estimation » partout. `.ods`** (qui ne modélise pas la T) — modèle d'inhibition simple, étiqueté
Calibration : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est(t_lab)`, garde « estimation » partout.
k ∈ (1e-4, 10), **médiane** (plante k=0.25 → recalibre 0.25 ±15 %, testé).
- **k par période d'ester** (v1.2.3) : la suppression T diffère selon l'ester
(valerate = pics hauts et courts, enanthate = plateau plus doux) →
`computeTKPerEster` attribue chaque lab T à la période de la dernière dose ≤ lab
et k = **médiane** des k de cette période ; la courbe utilise à chaque instant le
k de l'ester **actif** (`activeEsterAt`, curseur sur les doses triées dans
`computeCurve`), fallback = `tConfig.k` stocké.
- Formule : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est(t_lab)`, garde
k ∈ (1e-4, 10). ⚠️ L'E2 utilisée est la version **calibrée** (scalePerEster) —
calibrer k contre une E2 brute faussait les k (corrigé v1.2.3).
- `computeTConfigCalibration` (k global unique) reste pour le bouton manuel
« Calibrer avec les analyses » des Paramètres.
**Unités** : les labs T peuvent être saisis en ng/mL, ng/dL, ng/L ou nmol/L — **Unités** : les labs T peuvent être saisis en ng/mL, ng/dL, ng/L ou nmol/L —
`convertTToNgMl(value, unit)` normalise (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884) ; `convertTToNgMl(value, unit)` normalise (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884,
appliqué à la calibration ET au rendu du chart (sinon l'axe T est faux d'un facteur 100, pg/mL ÷1000 défensif) ; appliqué à la calibration ET au rendu du chart (sinon l'axe
bug réel remonté par l'utilisatrice : labs 33/44 ng/dL). T est faux d'un facteur 100, bug réel remonté par l'utilisatrice : labs 33/44 ng/dL).
### 7.6 Calibration E2 (par traitement) ### 7.6 Calibration (v1.2.1 : PAR PÉRIODE D'ESTER pour l'auto)
`computeScaleFactor(treatment, allDoseLogs, e2Labs)` : **Automatique** (`computeEsterScaleFactors` + `scalePerEster`, option « Auto-calibration ») :
`ratio_i = lab.value / Σ contributions du traitement seul (scale=1) à t_lab` - chaque lab est **attribué à la période d'injection dans laquelle il tombe** =
→ **médiane** des ratios (garde : prédiction > 0.5 pg/mL), arrondi 2 décimales. dernière dose E2 ≤ lab (une prise de sang reflète d'abord l'injection qui précède) ;
C'est l'automatisation de la colonne « Scale factor » du `.ods`. Déclenchable depuis - ratio = lab ÷ prédiction **non calibrée** (toutes doses superposées, scaleFactor forcé 1) ;
l'éditeur de traitement ; valeur éditable manuellement. - facteur final par ester = **médiane** des ratios de sa période (EV/EU/EEN) ;
- application : `e2At`/`computeCurve` acceptent `scalePerEster: Map<String, Double>?` —
chaque dose est scalée par le facteur de **son** ester (`doseEster`, override compris),
fallback = `scaleFactor` stocké du traitement pour les esters sans lab.
- **Pourquoi** : un facteur unique par traitement mélangeait les périodes (labs valerate
mesurés contre une prédiction enanthate → ratio aberrant → courbes gonflées à
250–375 pg/mL, remontée v1.2.0). Cas vérifié sur données réelles : EEn 5 mg tous les
6–7 j (t½ ≈ 6,7 j) → accumulation ×2 → ~270 pg/mL calibré, cohérent labs 300/250 ;
non calibré ≈ 367.
- `autoCalibrated()` renvoie `AutoCalibrated(treatments **inchangés**, tConfig recalibré,
esterScales, calibratedEsters, tRecalibrated)` — écrans : `scalePerEster = effectiveAuto?.esterScales`.
**Manuelle** (`computeScaleFactor`, bouton « Calibrer avec les analyses » dans
l'éditeur de traitement) : facteur **unique par traitement** (médiane lab ÷ prédiction,
garde prédiction > 0,5), écrit le `scaleFactor` stocké. ⚠️ Limite documentée : en cas
de changement d'ester dans un même traitement, la manuelle mélange les périodes —
préférer l'auto-calibration dans ce cas.
**T** (`computeTConfigCalibration`) : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est`,
garde k ∈ (1e-4, 10), **médiane** ; labs normalisés via `convertTToNgMl`.
### 7.7 API du moteur ### 7.7 API du moteur
@ -277,7 +348,8 @@ Type de retour : `LevelPoint(timestamp, e2, t)`.
## 8. Tests unitaires ## 8. Tests unitaires
**36 tests JVM, tous verts** (`./gradlew testDebugUnitTest`). Dépendance : JUnit 4.13.2. **78 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — 44 sans les
données de test locales (cf §8.bis). Dépendance : JUnit 4.13.2.
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution = Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`). `app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
@ -299,11 +371,67 @@ Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'ex
labs antérieurs à la 1ʳᵉ dose ignorés par la calibration SF, conversion ng/dL→ng/mL, labs antérieurs à la 1ʳᵉ dose ignorés par la calibration SF, conversion ng/dL→ng/mL,
calibration T avec labs en ng/dL. **En cas de nouveau bug remonté par l'utilisatrice : calibration T avec labs en ng/dL. **En cas de nouveau bug remonté par l'utilisatrice :
exporter le JSON, l'épingler ici, reproduire, corriger.** exporter le JSON, l'épingler ici, reproduire, corriger.**
- **`V120FeaturesTest`** (6) : v1.2.0 — doses prévisionnelles (rythme 7 j depuis la - **`V120FeaturesTest`** (10) : v1.2.0→v1.2.3 — doses prévisionnelles (rythme 7 j depuis la
dernière dose réelle, liste exacte J+4/J+11/J+18/J+25 ; vide sans intervalle ou sans dernière dose réelle, liste exacte J+4/J+11/J+18/J+25 ; vide sans intervalle ou sans
doses ; ester override projeté), override de modèle (ESE ≠ TFS à 45 h pour EV ; doses ; ester override projeté), override de modèle (ESE ≠ TFS à 45 h pour EV ;
sans override = modèle du traitement), auto-calibration (SF 0,72 recalculé depuis un sans override = modèle du traitement), auto-calibration v1.2.1 (facteur **par ester**
lab planté, T recalibré, **originaux non modifiés** ; inchangée sans lab utilisable). depuis un lab planté, T recalibré, **originaux non modifiés** ; inchangée sans lab
utilisable), **attribution des labs par période d'ester** (EV calibré par les labs EV,
EEn par les labs EEn — le scénario valerate→enanthate de l'utilisatrice) et
application de `scalePerEster` par dose.
- **`RegressionUserCase2Test`** (6) : **2ᵉ régression épinglée sur données réelles**
(export v1.2.0 : 9 doses EEn/TFS 5 mg ~6-7 j, 8 labs dont un T "pg/mL" par erreur,
SF stocké 0,72, fréquence 6 j). Vérifie : parsing, **état d'équilibre EEn** (t½ ≈
6,7 j + doses ~6-7 j → accumulation ×2 → e2 ≈ 270 calibré, cohérent labs 300/250 ;
non calibré ≈ 367 = les « 375 » rapportés), facteur unique EEN plausible, lab T en
unité aberrante neutralisé, prévision 6 j exacte, auto-cal cohérente.
**Tout nouvel export utilisateur = un nouveau test de régression.**
- **`RegressionUserCase3Test`** (6) : 3ᵉ régression (export v1.2.3) — le scénario
**transition** : traitement **EV inactif** (29 doses 2–8 mg, janvier→juillet) +
traitement **EEn actif** (9 doses), 22 labs sur les deux périodes. Vérifie :
parsing, **l'inactif reste simulé** (bug v1.2.4 — le moteur reçoit TOUS les
traitements), calibration par période couvrant EV **et** EEN, k T par ester,
continuité de la courbe pendant la transition, niveau actuel ~EEn équilibre.
### 8.bis Données de test réelles : HORS dépôt (`local-test-data/`)
Les deux classes de régression (`RegressionUserCase{,2}Test`) épinglent le comportement
du moteur sur les **exports réels** de l'utilisatrice. Ce sont des **données de santé
personnelles** : elles ne sont **pas versionnées**, pour ne rien divulguer dans le
dépôt (ni maintenant, ni si le repo devient public un jour).
- emplacement : `local-test-data/backup-v1.0.0.json` et `backup-v1.2.0.json`
(copiés tels quels depuis l'export JSON de l'app) ;
- `.gitignore` contient `local-test-data/` → jamais commités ;
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le
fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
exécute 44 tests au lieu de 67 ;
- le workdir des tests Gradle est le dossier du module (`app/`) → les tests
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous
le nom attendu dans `local-test-data/` → `./gradlew testDebugUnitTest` ;
- ⚠️ **ne jamais embarquer ces données dans les tests** (string inline dans le
code) : tout nouvel export réel → fichier gitignore + assertions data-driven ;
- **l'historique a été nettoyé avant le premier push** : les premiers commits
embarquaient les valeurs (tests + doc) → `git filter-branch --tree-filter` avec
un script d'anonymisation (timestamps décalés de +30 j, valeurs perturbées dans
la prose des docs), tags réécrits, refs purgeées. `git grep` sur **toutes** les
révisions ne trouve aucune donnée réelle.
- **`LabsGroupingTest`** (4) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T
même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ;
ordre chronologique décroissant).
- **`ChangelogHelperTest`** (8) : v1.3.0 — comparaison SemVer **numérique**
(1.2.9 < 1.2.10 : la comparaison lexicographique serait fausse), extraction
de sections depuis la dernière vue, première installation (section courante
seule), plusieurs versions intermédiaires, versions non numériques.
- **`CalendarRruleTest`** (3) : v1.3.0 — RRULE `FREQ=DAILY;INTERVAL=N`,
arrondi demi-supérieur des décimales (6,5 → 7), clamp ≥ 1 jour.
- **`ExtremaTest`** (6) : v1.2.3 — détection des pics/creux (`detectExtrema`) :
alternance stricte pic/creux en régime d'équilibre (4 doses hebdo → ≥ 4 extrema,
pic > creux voisin, valeurs dans les bornes), courbe monotone → vide, série plate →
vide, série trop courte → vide, **filtre d'amplitude** (sémantique zigzag : une
oscillation sous le seuil produit UN pivot, l'alternance complète apparaît quand le
seuil baisse), anti-corrélation E2/T (un pic d'E2 ≈ un creux de T).
**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.
@ -330,7 +458,16 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod
- `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shot `getActiveOnce()` - `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shot `getActiveOnce()`
(jamais un Flow en runBlocking !) → reschedule (jamais un Flow en runBlocking !) → reschedule
Manifest : `POST_NOTIFICATIONS`, `SCHEDULE_EXACT_ALARM`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`. **Événements d'agenda (v1.3.0, `reminder/CalendarEvents.kt`)** : switch dans
l'éditeur (sous Rappels) → insertion dans un **calendrier local dédié
« HormoneTrack »** (CalendarContract, ACCOUNT_TYPE_LOCAL) d'un événement
récurrent RRULE `FREQ=DAILY;INTERVAL=N` (N = Posologie, arrondi demi-supérieur
**explicite** : `floor(x + 0.5)` — kotlin.math.round arrondit les ties vers
l'entier PAIR !), début = prochaine occurrence à l'heure de rappel (ou 12:00).
L'id est stocké sur le traitement (`calendarEventId`, Room v3) ; au save :
supprimer + recréer (fiable) si changé, supprimer si désactivé. Permissions
WRITE_CALENDAR + READ_CALENDAR demandées à l'activation du switch.
Manifest : `POST_NOTIFICATIONS`, `SCHEDULE_EXACT_ALARM`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`, `WRITE_CALENDAR`, `READ_CALENDAR`.
Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17). Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
## 10. UI & navigation ## 10. UI & navigation
@ -339,34 +476,61 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
`settings`, `treatment_edit/{id}` (-1 = nouveau) ; barre masquée sur ces 2 routes `settings`, `treatment_edit/{id}` (-1 = nouveau) ; barre masquée sur ces 2 routes
- `HomeScreen` : bandeau gradient (TransSky→TransPink, discret), carte **niveau actuel** - `HomeScreen` : bandeau gradient (TransSky→TransPink, discret), carte **niveau actuel**
(E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h), carte prochaine dose, chips de log rapide (E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h), carte prochaine dose, chips de log rapide
(+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`), disclaimer ; données (+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`) **cliquable → écran
auto-calibrées si l'option est active ; rafraîchissement `tick` 60 s Graphiques** (v1.2.1) avec mini-légende E2/T ; données auto-calibrées si l'option est
- `ChartScreen` (v1.2, le plus riche) : plages 24 h/7 j/30 j ; **panoramique** active (`scalePerEster = effectiveAuto?.esterScales`) ; rafraîchissement `tick` 60 s
- `ChartScreen` (v1.2, le plus riche) : plages 24 h/7 j/30 j + **boutons zoom − / +**
(v1.2.9) ; **panoramique**
(`detectHorizontalDragGestures` — tirer vers la droite remonte dans le passé, (`detectHorizontalDragGestures` — tirer vers la droite remonte dans le passé,
`panHours` borné à [0, âge de la 1ʳᵉ dose + plage], bouton « Revenir à maintenant ») ; `panHours` borné à [0, âge de la 1ʳᵉ dose + plage], bouton « Revenir à maintenant ») ;
**toggles indépendants Estrannaise/TFS** → deux `computeCurve` avec `modelOverride` **toggles indépendants Estrannaise/TFS** → deux `computeCurve` avec `modelOverride`
superposées (E2 ESE bleu plein, E2 TFS turquoise, T ESE rose plein, T TFS rose superposées (E2 ESE bleu plein, E2 TFS turquoise, T ESE rose plein, T TFS rose
pointillé) ; **chip Prévision** (doses projetées via `generateForecastDoses`, horizon pointillé) ; **chip Prévision** (doses projetées via `generateForecastDoses`, horizon
= 2× le plus grand intervalle configuré, borné 7–30 j) ; **auto-calibration** branchée = 2× le plus grand intervalle configuré, borné 7–30 j) ; **auto-calibration** branchée
sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL ; **toggle T =
masque aussi les labs T** (v1.2.2) ; **chip « Pics / creux »** (v1.2.3 : triangles ▲▼
aux extrema locaux de chaque courbe, via `detectExtrema` — E2 seuil 2 pg/mL, T seuil
0,02 ng/mL)
- `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même - `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition** avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition**
- `LabsScreen` : groupée par marqueur, FAB → `LabDialog` (E2/T/PRL, unité suggérée) - `LabsScreen` (v1.2.2) : **prise de sang E2 + T en une entrée** (LabDialog create :
deux sections optionnelles → 1 ou 2 LabResult au même timestamp) ; affichage
**groupé par timestamp** (`groupLabsForDisplay` : E2 avant T, autres ensuite, tri
desc) → « E2 306 pg/mL · T 44 ng/dL » côte à côte ; tap → sélecteur E2/T si paire,
édition unitaire pré-remplie ; suppression = **la prise entière** (confirm nommant
les valeurs — choix de design : les labs sont prélevés ensemble) ; suggestions
d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/L
- `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰, - `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰,
badge inactif), FAB → éditeur badge inactif), FAB → éditeur
- `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ; - `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ;
champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration
(scaleFactor + « Calibrer avec les analyses ») ; **section « Fréquence » (v1.2 : (scaleFactor + « Calibrer avec les analyses ») ; **section « Posologie » (v1.2 :
switch « Simuler les doses à venir » + intervalle en jours)** ; carte Rappel (switch + switch « Simuler les doses à venir » + intervalle en jours)** ; carte Rappel (switch +
TimePicker **centré** + avertissement alarmes exactes) ; switch actif ; save → TimePicker **centré** + avertissement alarmes exactes) ; switch actif ; save →
insert/update + schedule/cancel ; delete avec confirmation ; `createdAt` préservé insert/update + schedule/cancel ; delete avec confirmation ; `createdAt` préservé
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ; réglages - **Dialog « Nouveautés » (v1.3.0)** : au démarrage, si `BuildConfig.VERSION_NAME`
T + calibration manuelle ; **option Calibration automatique (switch, désactivée par est plus récente que la dernière vue (DataStore `changelog_seen_version`),
défaut)** ; statut alarmes exactes + bouton d'octroi ; Export/Import JSON ; à propos un AlertDialog affiche les sections CHANGELOG concernées (extraites par
`ChangelogHelper.sectionsSince`, asset `changelog.md` synchronisé par la
tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ;
fermable, ne réapparaît pas avant la prochaine mise à jour.
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ;
**version installée + lien cliquable vers les releases Gitea** (v1.3.0,
`BuildConfig.VERSION_NAME`, `enableEdgeToEdge`-friendly) ;
**« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T,
v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton
« Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ;
Export/Import JSON ; à propos
- 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`)
- **Insets edge-to-edge (v1.2.5)** : `enableEdgeToEdge()` dans MainActivity ;
les insets sont consommés UNE FOIS — TopAppBar M3 (barre de statut, insets
par défaut) et NavigationBar (barre système) ; les `Scaffold` (racine
HormoneTrackRoot + imbriqués Doses/Labs) ont `contentWindowInsets = 0` pour
ne pas cumuler. Avant : fenêtre AppCompat + padding interne TopAppBar =
double espace vide en haut.
- Thème M3 custom (`ui/theme/Color.kt` : bleu #4F5BD5, rose #D6589E, labs orange, bandeau - Thème M3 custom (`ui/theme/Color.kt` : bleu #4F5BD5, rose #D6589E, labs orange, bandeau
TransSky/TransPink), dynamic color désactivé ; `MaterialExpressiveTheme` encore TransSky/TransPink), dynamic color désactivé ; `MaterialExpressiveTheme` encore
**internal** dans la ligne material3 pinnée (cf §3) → `MaterialTheme` standard **internal** dans la ligne material3 pinnée (cf §3) → `MaterialTheme` standard
@ -382,7 +546,21 @@ gauche (pg/mL), T droite (ng/mL). Échelle « nice » (`niceCeil` : 1/2/2.5/5/10
partagée entre toutes les séries. Grille 4 lignes ; labels Y gauche/droite ; X : pas partagée entre toutes les séries. Grille 4 lignes ; labels Y gauche/droite ; X : pas
6 h/24 h/5 j selon plage (`SimpleDateFormat` HH'h' / dd/MM). Labs : cercles (E2) et 6 h/24 h/5 j selon plage (`SimpleDateFormat` HH'h' / dd/MM). Labs : cercles (E2) et
carrés (T) orange + valeur, **T convertie en ng/mL** (`convertTToNgMl`) au rendu. carrés (T) orange + valeur, **T convertie en ng/mL** (`convertTToNgMl`) au rendu.
Ligne verticale « maintenant ». Ligne verticale « maintenant ». **Pics/creux** (v1.2.3, valeurs v1.2.6) :
triangles ▲▼ via `PharmacokineticEngine.detectExtrema` **accompagnés de la
valeur estimée** du pic (au-dessus) ou du creux (en dessous), dans la couleur
de la série (`drawExtremum`).
**Zoom (v1.2.9)** : pinch **et** boutons − / + — plage temporelle bornée
**6 h → 300 j** (`MIN_RANGE_H`/`MAX_RANGE_H`), point focal du pinch maintenu
fixe dans le temps (formule centroid), échantillonnage **adaptatif**
(`stepForRange` : 15 min ≤ 12 h, 30 min ≤ 24 h, sinon 1 h — courbes lisses à
fort zoom) et labels X adaptatifs (1 h / 3 h ajoutés). Le pan ET le zoom
partagent un seul `detectTransformGestures` (un handler = pas de conflit de
consommation) ; le zoom/pan restent gérés **par le parent**, le Canvas demeure
purement déclaratif. **Convention des boutons** (v1.2.10) : « + » = zoom avant
(fenêtre courte), « − » = zoom arrière — le sens initial était inversé (retour
utilisateur).
Pièges : Pièges :
- `DrawScope` implémente `Density` → `X.dp.toPx()` direct ; ne PAS écrire de helper custom - `DrawScope` implémente `Density` → `X.dp.toPx()` direct ; ne PAS écrire de helper custom
@ -408,8 +586,15 @@ Pièges :
- `BackupData{version=1, exportedAt, treatments[], doseLogs[], labResults[], tConfig}` → Gson - `BackupData{version=1, exportedAt, treatments[], doseLogs[], labResults[], tConfig}` → Gson
- **Les IDs Room sont conservés** dans l'export et réinsérés tels quels → les FK - **Les IDs Room sont conservés** dans l'export et réinsérés tels quels → les FK
dose→traitement restent valides dose→traitement restent valides
- Import = **ajout** (traitements → doses → labs) ; ré-import du même fichier → conflit - Import : **mode ÉCRASEMENT (v1.2.6)** — les données actuelles sont effacées
d'ID unique → exception catchée → `import_fail` (voulu ; un mode « replace » est en §20) AVANT l'insertion (`deleteAllDoseLogs` → `deleteAllLabResults` →
`deleteAllTreatments`, ordre enfants → parents pour la FK CASCADE), puis
restauration avec les IDs du backup conservés (FK valides). Avant (v1.2.5) :
insertion en « ajout » → conflit d'ID dès que l'app contenait des données →
l'import échouait (bug remonté). Après un import : le `tConfig` du backup est
restauré dans DataStore et les rappels sont reprogrammés
(`rescheduleAll(allTreatmentsOnce())`). Le dialog prévient que TOUT sera
remplacé (bouton « Effacer & restaurer »)
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture - Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}` `openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
@ -471,10 +656,86 @@ Historique complet — **à ne pas réintroduire** (utile pour diff/revert) :
`LogDoseDialog` supprimé (attention : `formatDose` vivait dedans → déplacée `LogDoseDialog` supprimé (attention : `formatDose` vivait dedans → déplacée
top-level dans `DoseDialog.kt`). top-level dans `DoseDialog.kt`).
**Session v1.2.1/v1.2.2 (remontées utilisateur) :**
25. **Calibration mélangeant les périodes d'ester** — le fond du « 250–375 » :
un facteur unique par traitement comparait des labs valerate à des prédictions
enanthate → ratios aberrants → courbes gonflées. → calibration **par période**
(`computeEsterScaleFactors` + `scalePerEster`, cf §7.6) ; vérifié que l'état
d'équilibre EEn (accumulation ×2) est CORRECT — le « 375 » = calcul non calibré.
26. **Lab T en unité aberrante (« 38 pg/mL », faute de frappe)** : renvoyé brut il
écrasait l'axe T et rendait la courbe T invisible. → `convertTToNgMl` avec
branche défensive pg/mL ÷1000 (et µg/L).
27. **Pas d'édition des labs** → LabDialog create/edit + tap sur la ligne ;
formatLabValue (préserve les décimales, contrairement à formatDose).
**Session v1.2.3/push Gitea :**
28. **Commentaires Kotlin imbriqués** : `/**TFS**` dans un KDoc ouvre un bloc
`/**` JAMAIS fermé (Kotlin les imbrique !) → « Unclosed comment » → NE PAS
mettre `/**` dans un texte de doc ; écrire « EEn + TFS ».
29. **XML de test périmés après échec de compilation** : quand compile échoue,
les anciens XML test-results restent → on « corrige » des échecs fantômes.
Toujours vérifier que la COMPILATION passe (grep `^e:` du log) avant
d'analyser les résultats de tests.
30. **filter-branch non idempotent** : le scrub (45.0→44.0) a corrompu une
constante d'un test PUR écrit après coup (assert 0.45 vs 44/100). Règle :
les remplacements d'anonymisation doivent être idempotents (les valeurs de
remplacement ne re-matchent jamais les motifs) et les tests purs doivent
utiliser des constantes hors des motifs de scrub.
31. **Release script : préfixe « v »** — le tag git = `v1.2.3`, la CHANGELOG
titre `[1.2.3]` : l'extraction cherchait `[vX.Y.Z]` → fallback silencieux.
→ `scripts/gitea-release.py` (corps = section CHANGELOG, APK attaché).
34. **Import JSON impossible si l'app contient déjà des données** (v1.2.6) :
l'insertion en « ajout » avec les IDs du backup entrait en conflit
d'unicité dès la moindre donnée existante → import échouait. → mode
ÉCRASEMENT (effacement ordonné enfants→parents puis restauration) +
restauration du tConfig + reprogrammation des rappels.
37. **Upload d'asset Gitea instable** (v1.2.10) : le `?name=` du POST et le
PATCH de renommage peuvent être ignorés (asset au nom générique, APK
release disparu de la release). → `ensure_asset` dans le script :
upload + vérification nom/taille + retry PATCH + échec bruyant.
38. **Comparaison lexicographique de tags/versions** (v1.3.0, publication
farewell) : `tag >= "v1.2.5"` en comparaison de CHAÎNES fait `v1.2.10 <
v1.2.5` ("1" < "5") → la boucle de publication a sauté v1.2.10. →
toujours comparer les versions STRUCTURÉEMENT (tuple numérique, cf
ChangelogHelper.isVersionNewer) ; même famille que le bug #22 (casse
EEn) : identifiants « presque pareils ».
36. **Sens des boutons de zoom inversé** (v1.2.10, retour utilisateur) :
le « + » dézoomait (fenêtre plus longue) et le « − » zoomait — contre
la convention carte. → « + » = zoom avant (fenêtre courte), « − » =
zoom arrière. Le pinch (écarter = zoom avant) était déjà correct.
35. **Prévision cassée après un oubli d'injection** (v1.2.7) : le premier
créneau projeté tombait dans le passé (dernière + intervalle = jour
manqué) → faux pic dans l'historique + rythme décalé. → les créneaux
passés sont sautés (`while (t <= nowMs) t += intervalMs`), la prévision
démarre au premier créneau futur ; un retard décale toute la prévision
(comportement voulu, testé). NB : la doc §7.3b affirmait « strictement
après nowMs » alors que le code incluait les créneaux passés —
mismatch doc-code détecté par l'utilisatrice.
33. **Double insets edge-to-edge** (v1.2.5) : espace vide en haut de l'écran —
la fenêtre AppCompat poussait le contenu sous la barre de statut ET les
TopAppBar M3 rajoutaient leur padding interne. → `enableEdgeToEdge()` +
`contentWindowInsets = 0` sur les Scaffold, insets consommés une seule fois.
32. **`isActive` traité comme filtre de données** (v1.2.4) : passer un
traitement à inactif effaçait sa simulation du graphique (écrans
collectaient `activeTreatments`) tout en permettant d'y loger des doses.
→ sémantique documentée §6.bis : l'inactivation ne touche que la SAISIE
(chips, dropdown création, rappels) ; simulation et calibration reçoivent
TOUS les traitements. Épinglé par la régression n°3.
**Leçons** : (a) ne jamais croire un build « probablement bon » sans l'avoir lancé ; **Leçons** : (a) ne jamais croire un build « probablement bon » sans l'avoir lancé ;
(b) les tests sémantiques attrapent ce que la compilation ne voit pas ; (c) se méfier des (b) les tests sémantiques attrapent ce que la compilation ne voit pas ; (c) se méfier des
constantes stdlib « de mémoire » (`ln2`), des mélanges Float/Double, et des APIs M3 constantes stdlib « de mémoire » (`ln2`), des mélanges Float/Double, et des APIs M3
expérimentales sans `@OptIn` ; (d) **un test de régression sur les VRAIES données expérimentales sans `@OptIn` ; (d) **un test de régression sur les VRAIES données
utilisateur** attrape les bugs de convention (casse, unités) que les tests
synthétiques ratent — mais garde ces données **hors du dépôt** (§8.bis) ;
(e) attention aux identifiants « presque pareils » entre sources (constantes app
vs clés d'asset) ; (f) après un échec de COMPILATION, jeter les résultats de tests
de la même passe (XML périmés) ; (g) tout script de réécriture d'historique doit
être idempotent ; (h) dans un KDoc, `/**` imbrique. (a) ne jamais croire un build « probablement bon » sans l'avoir lancé ;
(b) les tests sémantiques attrapent ce que la compilation ne voit pas ; (c) se méfier des
constantes stdlib « de mémoire » (`ln2`), des mélanges Float/Double, et des APIs M3
expérimentales sans `@OptIn` ; (d) **un test de régression sur les VRAIES données
utilisateur** (`RegressionUserCaseTest` = export JSON réel) attrape les bugs de utilisateur** (`RegressionUserCaseTest` = export JSON réel) attrape les bugs de
convention (casse, unités) que les tests synthétiques ratent ; (e) attention aux convention (casse, unités) que les tests synthétiques ratent ; (e) attention aux
identifiants « presque pareils » entre sources (constantes app vs clés d'asset). identifiants « presque pareils » entre sources (constantes app vs clés d'asset).
@ -503,18 +764,136 @@ structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour
```bash ```bash
cd ~/projects/HormoneTrack cd ~/projects/HormoneTrack
./gradlew assembleDebug testDebugUnitTest # build + 36 tests ./gradlew assembleDebug testDebugUnitTest # build + 62 tests (44 sans données locales)
./gradlew lint # linters Android (à configurer) ./gradlew assembleRelease # APK optimisé R8 (cf §16.bis)
adb install -r app/build/outputs/apk/debug/app-debug.apk adb install -r app/build/outputs/apk/debug/app-debug.apk
``` ```
**Git (initialisé le 2026-09-05, branche `main`)** : **Git (initialisé le 2026-09-05, branche `main`, DEUX remotes Gitea)** :
- `origin` → `https://gitea.cloudyfy.fr/Siphonight/HormoneTrack` (privé, HTTPS + trousseau)
- `farewell` → `git@farewell:Siphonight/HormoneTrack.git` (SSH, alias `farewell`
= `giteassh.farewell.dev:2222` avec clé dédiée, cf `~/.ssh/config`) —
**repo créé + push + LES 12 RELEASES publiées avec APK vérifiés par
téléchargement (2026-09-06)** ; token `gitea.farewell.dev` au trousseau
(scope write:repository)
- Historique = commits logiques par couche (toolchain / moteur / UI / docs) ; - Historique = commits logiques par couche (toolchain / moteur / UI / docs) ;
- **Chaque release = tag annoté** (`v1.1.0`, `v1.2.0`, …) : - **Chaque release = tag annoté** (`v1.1.0`, `v1.2.0`, …) :
`git tag -a vX.Y.Z -m "…" && git tag` pour lister ; `git tag -a vX.Y.Z -m "…" && git tag` pour lister ;
- `local.properties`, `build/`, `.gradle/`, `.idea/` sont ignorés (`.gitignore`) ; - `local.properties`, `build/`, `.gradle/`, `.idea/`, **`local-test-data/`** sont
ignorés (`.gitignore`) — ce dernier protège les données de santé de test ;
- Avant chaque commit de release : `./gradlew testDebugUnitTest` doit être vert ; - Avant chaque commit de release : `./gradlew testDebugUnitTest` doit être vert ;
- Prochaine étape repo : ajouter un remote et `git push -u origin main --tags`. - Push : `git push origin main --tags` + `git push farewell main --tags`
(farewell : auth SSH par clé dédiée ; le push-to-create y est
DÉSACTIVÉ → le repo doit exister au préalable sur l'instance).
⚠️ Tokens Gitea : scope `write:repository` suffit pour push, releases et
assets ; il NE permet PAS de créer un repo via API (il faut `write:user`)
ni d'utiliser push-to-create.
### 16.bis Releases Gitea avec APK téléchargeable
Les releases Gitea embarquent l'APK de chaque version — les utilisateurs n'ont pas
besoin de compiler. **Le corps de chaque release = la section CHANGELOG de la
version** (markdown rendu par Gitea), extrait automatiquement.
Le tout est automatisé par `scripts/gitea-release.py` — **multi-instances**
(`cloudyfy` par défaut, `farewell`) :
```bash
# 0. Le CHANGELOG est embarqué dans l'app (dialog « Nouveautés ») :
# la tâche Gradle copyChangelog copie docs/CHANGELOG.md vers
# src/main/assets/changelog.md à chaque build (gitignoré, auto)
# 1. Construire les DEUX APK au niveau du tag (debug + release R8)
git checkout vX.Y.Z
./gradlew assembleRelease assembleDebug # release = recommandé (R8, cf plus bas)
cp app/build/outputs/apk/release/app-release.apk /tmp/apks/HormoneTrack-vX.Y.Z-release.apk
cp app/build/outputs/apk/debug/app-debug.apk /tmp/apks/HormoneTrack-vX.Y.Z-debug.apk
git checkout main
# 2. Publier sur chaque instance (corps = CHANGELOG + les 2 APK attachés)
# ⚠️ NE JAMAIS comparer les tags en chaînes (v1.2.10 < v1.2.5
# lexicographiquement !) — comparer en tuples numériques (§14 #38)
# ⚠️ v1.3.0 : deux invocations RAPPROCHÉES du script se sont écrasées
# mutuellement (APK release disparu, debug renommé générique). Pattern
# validé : purge des assets + upload + VÉRIFICATION PAR TÉLÉCHARGEMENT
# des deux, puis RE-VÉRIFIER LES DEUX à la fin du run (listing peut
# mentir pendant les uploads rapprochés).
for I in cloudyfy farewell; do
python3 scripts/gitea-release.py $I vX.Y.Z /tmp/apks/HormoneTrack-vX.Y.Z-release.apk
python3 scripts/gitea-release.py $I vX.Y.Z /tmp/apks/HormoneTrack-vX.Y.Z-debug.apk
done
```
Le script choisit l'instance (URL, owner) et lit le token Gitea correspondant
dans le trousseau macOS (`security find-internet-password -s <hôte> -w`) :
`gitea.cloudyfy.fr` ✓ présent ; `gitea.farewell.dev` → à ajouter :
`security add-internet-password -s gitea.farewell.dev -a Siphonight -w <TOKEN> -U`.
Le script :
- extrait la section `## [X.Y.Z]` de `docs/CHANGELOG.md` comme corps ;
⚠️ piège : le tag git porte le « v » (`v1.2.3`) mais la CHANGELOG non
(`[1.2.3]`) — première version du script cherchait `[vX.Y.Z]` et tombait sur
le fallback « Voir docs/CHANGELOG.md » ;
- crée la release si absente, sinon **met à jour le corps** (PATCH) ;
- attache l'APK (remplace l'asset du même nom si présent) — appeler le script
DEUX FOIS pour publier les deux APK (noms distincts) ;
⚠️ vérification intégrée (`ensure_asset`) : le `?name=` de l'upload et/ou
le PATCH peuvent être ignorés par les instances (assets au nom générique /
APK disparu — v1.2.6, v1.2.10) → le script vérifie **nom ET taille** après
chaque upload, retente le PATCH une fois, et **échoue bruyamment** si
l'asset ne colle pas — ne pas retirer ;
- **Releases antérieures à v1.2.5 = APK debug unique** : backfill des APK
release abandonné (best effort) — les anciens `build.gradle.kts` sortaient
un `app-release-unsigned.apk` non signé (pas de signing config à l'époque).
Les utilisateurs prennent la dernière version.
`HormoneTrack-vX.Y.Z-release.apk` (**recommandé**, R8) et
`HormoneTrack-vX.Y.Z-debug.apk` ;
- lit le token Gitea dans le trousseau macOS (`security find-internet-password`) ;
scope requis : `write:repository` (suffit pour releases + assets, pas pour
créer un repo — cf plus haut).
**Build release (v1.2.5)** : `./gradlew assembleRelease` — R8 full mode +
`isShrinkResources` + signé avec la **clé debug** (même signature que les APK
debug distribués → mise à jour par-dessus sans perte de données ; 20 Mo →
2,4 Mo). Garde-fous dans `proguard-rules.pro` : Gson lit les champs des
modèles par réflexion → `-keep` explicites sur `data.model.**`,
`BackupData`, `TConfig` (+ attributs `Signature`) — sinon l'export/import
JSON casse UNIQUEMENT en release. ⚠️ À chaque activation d'optimisation :
tester sur téléphone l'export/import de backup + les graphiques (R8 ne se
vérifie pas en tests JVM).
### Checklist de déploiement complète (de A à Z, pour une session sans contexte)
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 62 au total,
44 si `local-test-data/` est absent (les régressions réelles sont skippées).
3. **Docs** : section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md` (le corps des
releases Gitea en sera extrait automatiquement par le script), + §14 si bug
corrigé, + §2 (historique) si notable.
4. **Commit** (message descriptif par couche) + **tag annoté** :
`git tag -a vX.Y.Z -m "résumé"`.
5. **Push** : `git push origin main --tags` + `git push farewell main --tags`
(farewell : le repo doit exister sur l'instance ; push-to-create désactivé).
6. **Construire les 2 APK au niveau du tag** :
`git checkout vX.Y.Z` → `./gradlew assembleRelease assembleDebug` → copier
`app-release.apk` → `/tmp/apks/HormoneTrack-vX.Y.Z-release.apk` et
`app-debug.apk` → `HormoneTrack-vX.Y.Z-debug.apk` → `git checkout main`.
7. **Publier les releases** (corps = section CHANGELOG + les 2 APK attachés) :
`python3 scripts/gitea-release.py cloudyfy vX.Y.Z <release.apk>` puis
idem avec `<debug.apk>` ; répéter avec l'instance `farewell` (token
`gitea.farewell.dev` requis dans le trousseau, absent à ce jour).
8. **Smoke-test R8 sur téléphone** (le release APK n'est pas vérifiable en
tests JVM) : installation par-dessus l'existant, graphiques, export ET
import d'un backup JSON, rappel. Le debug APK est le repli (même signature).
9. **Mettre à jour §2/§8/§19/§21 si besoin** puis pusher la doc.
Notes :
- L'APK est une **build debug** signée avec la clé debug locale — installable en
sideload, mises à jour entre versions OK (même signature) ;
- Le dépôt est **privé** : le téléchargement des releases exige d'être connecté ;
rendre le dépôt public rend les APK téléchargeables sans compte (aucune donnée
de santé dans le dépôt, cf §8.bis).
- Téléphone : mode développeur + Débogage USB (détails : GUIDE_INSTALLATION.md) - Téléphone : mode développeur + Débogage USB (détails : GUIDE_INSTALLATION.md)
- À ma charge (assistant) : build + tests JVM ✓ ; émulateur possible sur demande ; - À ma charge (assistant) : build + tests JVM ✓ ; émulateur possible sur demande ;
@ -555,7 +934,7 @@ manquantes** (licences signées) — c'est comme ça que android-37 est arrivé.
Volontaires (v1) : Volontaires (v1) :
- Pas de ViewModel/DI (couplage UI↔repo via CompositionLocal) - Pas de ViewModel/DI (couplage UI↔repo via CompositionLocal)
- Modèle T empirique (non publié) — étiqueté estimation partout - Modèle T empirique (non publié) — étiqueté estimation partout
- Import JSON = ajout seulement (pas de mode replace/dédup) - Import JSON = **écrasement** depuis v1.2.6 (mode fusion non implémenté)
- `fallbackToDestructiveMigration()` — à retirer à la migration v2 du schéma - `fallbackToDestructiveMigration()` — à retirer à la migration v2 du schéma
- WorkManager déclaré non utilisé - WorkManager déclaré non utilisé
- Profils par **tables** (pas par formule) : les D/k1–k3 de l'ODS ne sont pas consommés — - Profils par **tables** (pas par formule) : les D/k1–k3 de l'ODS ne sont pas consommés —
@ -564,16 +943,28 @@ Volontaires (v1) :
prochain reschedule (boot/save) — mineur prochain reschedule (boot/save) — mineur
- Labs : marqueur libre — E2/T exacts requis pour calibration/charts - Labs : marqueur libre — E2/T exacts requis pour calibration/charts
- `allowBackup=false` → seul backup = export JSON manuel - `allowBackup=false` → seul backup = export JSON manuel
- **APK release (R8)** : la réflexion Gson est couverte par des `-keep`
explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur
téléphone** (export/import backup, graphiques) avant chaque publication ;
signé clé debug → upgradable sans perte, mais pas une signature « officielle »
## 20. Idées d'évolution ## 20. Idées d'évolution
1. **Robolectric + tests Compose** (VM Android en JVM — pas besoin d'appareil) 1. **Robolectric + tests Compose** (VM Android en JVM — pas besoin d'appareil)
2. **Émulateur local** pour smoke-tests UI (sur demande, ~2–3 Go) 2. **Émulateur local** pour smoke-tests UI (sur demande, ~2–3 Go)
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 : mode **replace** (wipe + insert) + détection de doublons 4. Import JSON : détection de doublons / mode fusion optionnel (l'écrasement est fait, v1.2.6)
5. Migration Room v2 (retirer fallbackToDestructiveMigration) 5. Verrou biométrique (BiometricPrompt), widget, export CSV
6. Verrou biométrique (BiometricPrompt), widget, export CSV 6. Charts : **zoom** + tooltip au toucher (le pan est fait, v1.2.0) ;
7. Charts : zoom/pan + tooltip au toucher MaterialExpressiveTheme quand l'API passera publique (cf §3)
7. **Vrai keystore de distribution** (signature dédiée ≠ clé debug → nécessite
une migration : backup → désinstallation → installation signée → réimport)
Fait (à ne pas refaire) : pan du chart (v1.2.0), pics/creux (v1.2.3),
prévision par fréquence (v1.2.0), calibration par période d'ester E2 **et** T
(v1.2.1/v1.2.3), édition doses (v1.1.0) et labs (v1.2.2), E2+T en une entrée
(v1.2.2), migration Room v1→v2 sans fallback destructif (v1.2.0), dépôt Gitea
+ releases APK (push session).
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)
9. Retirer WorkManager ou l'utiliser (reschedule de sécurité quotidien) 9. Retirer WorkManager ou l'utiliser (reschedule de sécurité quotidien)
@ -595,6 +986,25 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] Redémarrer le téléphone → rappel reprogrammé (BootReceiver) - [ ] Redémarrer le téléphone → rappel reprogrammé (BootReceiver)
- [ ] Désactiver un rappel → plus de notif (cancel — cf §14.3) - [ ] Désactiver un rappel → plus de notif (cancel — cf §14.3)
- [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2) - [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2)
- [ ] **v1.2.x** : graphique panoramique (glisser → passé, bouton « Revenir à maintenant »)
- [ ] **v1.2.x** : toggles Estrannaise/TFS indépendants (les deux courbes superposées)
- [ ] **v1.2.x** : chip « Prévision » (configurer la Posologie d'un traitement d'abord)
- [ ] **v1.2.x** : chip « Pics / creux » (triangles ▲▼ aux extrema E2 et T)
- [ ] **v1.2.x** : Calibration automatique ON → courbes ajustées depuis les labs
(par période d'ester si changement d'ester), OFF → valeurs stockées
- [ ] **v1.2.x** : tap sur le mini-chart de l'accueil → écran Graphiques
- [ ] **v1.2.x** : édition d'une dose (tap ligne Doses) et d'un lab (tap ligne
Analyses → sélecteur E2/T si paire) ; suppression par prise entière
- [ ] **v1.2.x** : prise de sang E2 + T en une entrée (champs optionnels)
- [ ] **v1.2.9** : zoom du graphique (pinch 2 doigts + boutons − / + ; labels
X adaptatifs ; courbes lisses à fort zoom)
- [ ] **v1.2.7** : prévision après un OUBLI (pas de faux pic dans le passé)
et après un RETARD (prévision décalée, suivant la dernière prise)
- [ ] **v1.2.6** : import d'un backup avec l'app déjà remplie → ÉCRASER &
restaurer (message clair), tConfig restauré, rappels reprogrammés
- [ ] **v1.2.4** : passer un traitement à inactif → retiré de « Log rapide » et
du dropdown des nouvelles doses, rappel annulé, **mais sa simulation reste
sur le graphique** et la calibration couvre toujours ses périodes
--- ---
*Doc mise à jour le 5 sept. 2026 (v1.2.0) — build OK, 36/36 tests verts, APK debug 23 MB, repo git avec tags.* *Doc mise à jour le 6 sept. 2026 (v1.3.0) — build OK, 62/62 tests verts (44 sans les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée de santé dans le dépôt ni l'historique.*

View File

@ -16,6 +16,30 @@ App Android de suivi de THS : courbes estimées heure par heure (E2 + T), log de
Tu n'as pas besoin d'installer Gradle ni le JDK séparément : Android Studio s'en charge. Tu n'as pas besoin d'installer Gradle ni le JDK séparément : Android Studio s'en charge.
## 1.bis Option SANS compilation : télécharger l'APK depuis les releases
Chaque version taguée est publiée en **release Gitea** avec les APK prêts à
installer — pas besoin d'Android Studio ni de faire un build :
1. Va sur **Releases** du dépôt Gitea (onglet « Releases » à droite) :
`https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases`
(miroir équivalent : `https://gitea.farewell.dev/Siphonight/HormoneTrack/releases`)
2. Deux APK par release :
| Fichier | Quoi | Pour qui |
|---|---|---|
| `HormoneTrack-vX.Y.Z-release.apk` | **Optimisé** (R8 : code minifié/compacté, ressources shrinkées) — **2,4 Mo** au lieu de 20 Mo, démarrage et fluidité meilleurs | **Recommandé** — usage quotidien |
| `HormoneTrack-vX.Y.Z-debug.apk` | Non optimisé, hookable par un debugger — 20 Mo | Diagnostic/développement uniquement |
3. Installe : télécharge l'APK → ouvre-le → accepte « installer une application
inconnue » (une seule fois). **Mise à jour** : installe la nouvelle version
par-dessus l'ancienne — données conservées (les deux APK sont signés avec la
même clé debug, donc interchangeables dans les deux sens sans perte).
> ⚠️ Après l'installation d'un APK **release** (optimisé R8), fais un test
> rapide une fois : export/import d'un backup JSON + graphiques — R8 n'est pas
> vérifiable par les tests automatisés.
## 2. Ouvrir le projet ## 2. Ouvrir le projet
1. Android Studio → **Open** → sélectionne le dossier `~/projects/HormoneTrack` 1. Android Studio → **Open** → sélectionne le dossier `~/projects/HormoneTrack`
@ -58,14 +82,28 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
## 6. Premiers pas dans l'app ## 6. Premiers pas dans l'app
1. **Traitements → +** → choisis un preset (ex : *Injection EV — Estrannaise*) 1. **Traitements → +** → choisis un preset (ex : *Injection EV — Estrannaise*)
- Tu as changé d'ester ? Crée (ou garde) le traitement de l'ancien ester et
passe-le **inactif** (switch « Actif ») : il disparaît de la saisie et de tes
rappels, mais **son historique reste simulé et calibré** sur les graphiques —
parfait pour une transition valerate → enanthate
- Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods` - Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods`
- Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo) - Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo)
2. **Doses → +** → logue tes injections passées (date/heure exactes, dose en mg) 2. **Doses → +** → logue tes injections passées
- Astuce mise à jour : l'app affiche les **nouveautés** de chaque version au
démarrage (fermable) ; la **version installée** et le **lien des releases**
sont dans Paramètres (date/heure exactes, dose en mg)
- Astuce : tu peux changer l'ester par injection (comme dans ton tableur) - Astuce : tu peux changer l'ester par injection (comme dans ton tableur)
- **Modifier une dose existante** : appuie simplement sur sa ligne dans l'écran Doses - **Modifier une dose existante** : appuie simplement sur sa ligne dans l'écran Doses
(traitement, dose, date/heure, notes et ester tout ça éditable) — pas besoin de (traitement, dose, date/heure, notes et ester tout ça éditable) — pas besoin de
supprimer/recréer supprimer/recréer
3. **Analyses → +** → entre tes prises de sang (E2 en pg/mL, T en ng/mL) 3. **Analyses → +** → une prise de sang complète en une entrée : **E2 et/ou T**
(chacune optionnelle), date/heure commune, notes — les deux s'affichent côte à côte
dans la liste
- **Modifier** : appuie sur la ligne ; si la prise contient E2 **et** T, un sélecteur
te demande laquelle modifier
- **Supprimer** : la corbeille retire la prise entière (E2 + T ensemble)
- Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la
calibration (E2 en pg/mL ; T en ng/mL, ng/dL, ng/L, nmol/L)
4. **Calibration** (dans l'édition d'un traitement E2) → « Calibrer avec les analyses » 4. **Calibration** (dans l'édition d'un traitement E2) → « Calibrer avec les analyses »
→ calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods` → calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods`
5. **Paramètres** : 5. **Paramètres** :
@ -99,12 +137,18 @@ Les profils viennent de ta feuille `Estrogen.ods` (table « Models ») :
passé (toute ta fenêtre d'historique) ; bouton « Revenir à maintenant » pour revenir passé (toute ta fenêtre d'historique) ; bouton « Revenir à maintenant » pour revenir
- **Toggles Estrannaise / Transfem Science** : les deux courbes peuvent être affichées - **Toggles Estrannaise / Transfem Science** : les deux courbes peuvent être affichées
simultanément (Estrannaise = bleu, Transfem Science = turquoise) pour comparer simultanément (Estrannaise = bleu, Transfem Science = turquoise) pour comparer
- **Prévision** : configure la **Fréquence d'injection** (jours) dans un traitement - **Prévision** : configure la **Posologie** (intervalle en jours) dans un traitement
(section « Fréquence ») puis active le chip « Prévision » → les doses à venir sont (section « Fréquence ») puis active le chip « Prévision » → les doses à venir sont
simulées et dessinées après la ligne « maintenant » (jamais sauvegardées) simulées et dessinées après la ligne « maintenant » (jamais sauvegardées)
- **Pics / creux** (chip sur le graphique) : triangles ▲▼ aux extrema estimés de
chaque courbe (E2 et T, les deux modèles) — pratique pour visualiser d'un coup
d'œil les hauts et les bas entre deux injections
- **Calibration automatique** (Paramètres, désactivée par défaut) : quand activée, les - **Calibration automatique** (Paramètres, désactivée par défaut) : quand activée, les
facteurs d'échelle et le modèle T sont ajustés en continu depuis tes labs — pour facteurs d'échelle et le modèle T sont ajustés en continu depuis tes labs — pour
l'affichage seulement, tes réglages stockés ne changent pas l'affichage seulement, tes réglages stockés ne changent pas. **Chaque ester est
calibré avec les labs faits pendant sa période** : si tu étais sous valerate avant
d'être sous enanthate, tes labs valerate calibrent les doses valerate (E2 **et**
la suppression T), et inversement pour l'enanthate
## 9. Dépannage ## 9. Dépannage

192
scripts/gitea-release.py Executable file
View File

@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Publie/met à jour une release Gitea : corps = section CHANGELOG de la version,
APK en pièce jointe (optionnel). Réutilisable à chaque release, multi-instances.
Usage :
python3 scripts/gitea-release.py [instance] vX.Y.Z [chemin/vers/app.apk]
- instance : « cloudyfy » (défaut) ou « farewell » — voir INSTANCES ci-dessous ;
- le corps est extrait de docs/CHANGELOG.md (section « ## [X.Y.Z] », SANS « v »
— le tag git porte le « v », la CHANGELOG non : piège corrigé, cf §16.bis) ;
- si la release existe déjà, son corps est mis à jour (PATCH) ;
- si un chemin d'APK est fourni, il est attaché avec VÉRIFICATION PAR
TÉLÉCHARGEMENT (cf ensure_asset).
Auth : le token Gitea est lu dans le trousseau macOS
(security find-internet-password -s <hôte> -w) — scope requis :
write:repository (suffit pour releases + assets, pas pour créer un repo).
Pour ajouter un token au trousseau :
security add-internet-password -s gitea.farewell.dev -a Siphonight \
-w <TOKEN> -U
"""
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
import uuid
# Instances connues : clé = nom court, hôte = service du trousseau
INSTANCES = {
"cloudyfy": {"url": "https://gitea.cloudyfy.fr", "owner": "Siphonight",
"host": "gitea.cloudyfy.fr"},
"farewell": {"url": "https://gitea.farewell.dev", "owner": "Siphonight",
"host": "gitea.farewell.dev"},
}
# Nom du dépôt (identique sur les deux instances)
REPO_NAME = "HormoneTrack"
CHANGELOG = "docs/CHANGELOG.md"
def gitea_token(host: str) -> str:
out = subprocess.run(
["security", "find-internet-password", "-s", host, "-w"],
capture_output=True, text=True
).stdout.strip()
if not out:
sys.exit(
f"Token Gitea introuvable dans le trousseau pour {host}\n"
f" Ajoute-le avec : security add-internet-password -s {host} "
f"-a Siphonight -w <TOKEN> -U"
)
return out
def request(url: str, method: str = "GET", data=None, token: str = "") -> dict:
"""Requête API JSON. Les réponses vides (DELETE → 204) renvoient {}."""
req = urllib.request.Request(url, method=method)
req.add_header("Authorization", f"token {token}")
if data is not None:
req.add_header("Content-Type", "application/json")
req.data = json.dumps(data).encode()
with urllib.request.urlopen(req, timeout=120) as resp:
payload = resp.read().decode()
return json.loads(payload) if payload.strip() else {}
def _upload_asset(base: str, token: str, release_id: int, name: str, path: str) -> dict:
"""POST multipart de l'APK (le ?name= peut être normalisé par l'instance)."""
boundary = uuid.uuid4().hex
head = (
f"--{boundary}\r\n"
f"Content-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n"
f"Content-Type: application/vnd.android.package-archive\r\n\r\n"
)
tail = f"\r\n--{boundary}--\r\n"
with open(path, "rb") as f:
req = urllib.request.Request(
f"{base}/releases/{release_id}/assets?name={name}", method="POST",
headers={"Authorization": f"token {token}",
"Content-Type": f"multipart/form-data; boundary={boundary}"})
req.data = head.encode() + f.read() + tail.encode()
with urllib.request.urlopen(req, timeout=300) as resp:
return json.loads(resp.read().decode())
def _delivered_size(base: str, token: str, url: str) -> int:
"""Taille RÉELLEMENT délivrée par le lien de téléchargement — le listing
de l'API a déjà menti pendant deux uploads rapprochés (v1.2.10)."""
r = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(r, timeout=300) as resp:
return len(resp.read())
def ensure_asset(base: str, token: str, release_id: int, name: str, path: str) -> dict:
"""Attache un APK à une release avec VÉRIFICATION PAR TÉLÉCHARGEMENT.
Le ?name= du POST, le listing et le PATCH de renommage se sont avérés non
fiables sur des uploads rapprochés (v1.2.6 : nom générique ; v1.2.10 :
APK release disparu, deux assets délivrant le mauvais fichier). Séquence :
upload → vérif listing (nom + taille) → vérif TÉLÉCHARGEMENT (octets
délivrés = taille du fichier local) → sinon DELETE + re-upload (×2) →
échec bruyant. Retourne l'asset vérifié.
"""
expected = os.path.getsize(path)
for attempt in range(2):
uploaded = _upload_asset(base, token, release_id, name, path)
release = request(f"{base}/releases/{release_id}", token=token)
current = next((a for a in release["assets"] if a["id"] == uploaded["id"]), None)
if current is not None:
name_ok = current["name"] == name
size_ok = abs(current["size"] - expected) < 1000
delivered_ok = _delivered_size(base, token, current["browser_download_url"]) == expected
if name_ok and size_ok and delivered_ok:
return current
print(f" asset {name} incorrect (nom_ok={name_ok} taille_ok={size_ok} "
f"délivré={delivered_ok}) → DELETE + re-upload (tentative {attempt + 1}/2)")
request(f"{base}/releases/{release_id}/assets/{uploaded['id']}",
"DELETE", token=token)
sys.exit(f"✗ ÉCHEC : {name} pas correctement attaché après retry — "
f"la release est incomplète, publier manuellement via l'interface web.")
def changelog_section(version: str) -> str:
"""Extrait la section « ## [X.Y.Z] » de la CHANGELOG (version SANS le « v»)."""
num = version.removeprefix("v")
text = open(CHANGELOG, encoding="utf-8").read()
m = re.search(rf"## \[{re.escape(num)}\][^\n]*\n(.*?)(?=\n## \[|\Z)", text, re.S)
if not m:
sys.exit(f"Section [{num}] introuvable dans {CHANGELOG}")
return m.group(1).strip()
def main():
args = sys.argv[1:]
# Premier argument = instance s'il n'est pas un tag (un tag commence par « v »)
instance = "cloudyfy"
if args and not args[0].startswith("v"):
instance = args.pop(0)
if instance not in INSTANCES:
sys.exit(f"Instance inconnue : {instance} (attendu : {', '.join(INSTANCES)})")
if not args:
sys.exit(f"Usage: gitea-release.py [{instance}] vX.Y.Z [app.apk]")
tag = args[0]
apk_path = args[1] if len(args) > 1 else None
conf = INSTANCES[instance]
base = f"{conf['url']}/api/v1/repos/{conf['owner']}/{REPO_NAME}"
token = gitea_token(conf["host"])
body = changelog_section(tag)
# Récupère la release du tag (crée-la si absente)
try:
release = request(f"{base}/releases/tags/{tag}", token=token)
release_id = release["id"]
created = False
except urllib.error.HTTPError as e:
if e.code != 404:
raise
release = request(f"{base}/releases", "POST", token=token, data={
"tag_name": tag, "name": tag, "body": body,
"draft": False, "prerelease": False,
})
release_id = release["id"]
created = True
# Corps = section CHANGELOG (PATCH si la release existait déjà)
if not created:
request(f"{base}/releases/{release_id}", "PATCH", token=token,
data={"tag_name": tag, "name": tag, "body": body, "draft": False, "prerelease": False})
# APK en pièce jointe (vérifié par téléchargement)
if apk_path:
asset_name = f"HormoneTrack-{tag}.apk"
for asset in release.get("assets", []):
if asset["name"] == asset_name:
request(f"{base}/releases/{release_id}/assets/{asset['id']}",
"DELETE", token=token)
asset = ensure_asset(base, token, release_id, asset_name, apk_path)
link = asset.get("browser_download_url", "?")
else:
link = "—"
action = "créée" if created else "mise à jour"
print(f"[{instance}] Release {tag} {action} (id {release_id}) — "
f"corps {len(body)} caractères, APK : {link}")
if __name__ == "__main__":
main()