Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0571ae44e | |||
| ae14cee07b | |||
| 4eb8519d9d | |||
| 458f7cc9ef | |||
| b3372a80e4 | |||
| 45ba0bdc62 | |||
| a0f3ac59f9 | |||
| 5cd1023af1 | |||
| d531ecae24 | |||
| 0bb259d12d | |||
| 386693379e | |||
| a4f859f854 | |||
| 2548c6207b | |||
| a3a9c7652c | |||
| 09cddfc8ee | |||
| d6d95080b4 | |||
| a40d9bc076 | |||
| 0f0ecc3d23 | |||
| e4f52bc6da | |||
| 8fc6d07772 | |||
| 03fdbf61ed | |||
| 96a0dbc56a | |||
| 8078d5cbed | |||
| dd220e8933 | |||
| f6be3d86a9 | |||
| 2ec9f63f93 | |||
| 8c19c8f295 | |||
| 23c62e85f2 | |||
| 2c58418604 | |||
| 76a9c0eb63 | |||
| 0768b83a88 | |||
| 3e2bf3992e | |||
| dd5a9e3013 | |||
| 04f6b8bd64 | |||
| e508607919 | |||
| 3e4c7c44b3 | |||
| 4045614f3b | |||
| 9b83533d72 | |||
| 22d4c66d81 | |||
| 1c9d2e4e6a | |||
| ca917ba772 | |||
| 15a7e6614c | |||
| 045ab5d060 | |||
| 755d8f0007 | |||
| 3fa7cf97ea | |||
| 633c6debf4 | |||
| 922d49841f | |||
| c1c2386ab2 | |||
| 208ba56843 | |||
| fb942e4a24 | |||
| 71659b5647 | |||
| e5fefc90df | |||
| cb64f0e57e | |||
| 88c365f4d0 | |||
| f5e58364a3 | |||
| af8d40726a | |||
| 6c7f2fa2e2 | |||
| c15500caeb | |||
| 58bc55bd0d | |||
| fa5d2df5a5 | |||
| d456cfa093 | |||
| dcf51d1d15 | |||
| 506b86f5f4 | |||
| 7e84bc2d19 | |||
| fb4c477b25 | |||
| 14f15e69cf | |||
| d07d668606 | |||
| 7e8a2f6d6f | |||
| f695d3b99e | |||
| 95ccb1cf9e | |||
| e2218fd3f1 | |||
| c888a718d5 | |||
| 0eaaea40c1 | |||
| 7e707b2a39 | |||
| 55d0e5134f | |||
| b96571856a | |||
| 9c27c4d9e2 | |||
| 1cd3d0f2e4 | |||
| 9c6c414d9c | |||
| 6e193db969 | |||
| e16d370b2f | |||
| fb9806afd3 | |||
| a77fd283e3 | |||
| 1a06c3c844 | |||
| eb7c4c1110 | |||
| 5d04667ea9 | |||
| af83d8449e | |||
| 6e40696b5d | |||
| 89d93f763d | |||
| e1b155b7fc | |||
| fd7e61b1a2 | |||
| 34a7b588be | |||
| c71a50429d | |||
| d9471cadaf | |||
| e8f87c096d | |||
| 8ab97d4ed4 | |||
| a4ec1fa71f | |||
| e2aa3ecbbf |
11
.gitignore
vendored
11
.gitignore
vendored
@ -5,6 +5,13 @@ build/
|
||||
# Local configuration (machine-specific, never commit)
|
||||
local.properties
|
||||
|
||||
# Release signing (v1.9.8) — created by scripts/make-release-keystore.sh.
|
||||
# NEVER commit: the keystore + its passwords are the identity of the app
|
||||
# (leaking them = anyone can ship an APK that upgrades over yours).
|
||||
keystore.properties
|
||||
*.keystore
|
||||
*.jks
|
||||
|
||||
# IntelliJ / Android Studio
|
||||
.idea/
|
||||
*.iml
|
||||
@ -28,3 +35,7 @@ Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
local-test-data/
|
||||
app/build/generated/changelog/
|
||||
app/src/main/assets/changelog.md
|
||||
scripts/confidentiality-scan.py
|
||||
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal 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>.
|
||||
305
README.en.md
Normal file
305
README.en.md
Normal file
@ -0,0 +1,305 @@
|
||||
# HormoneTrack
|
||||
|
||||
> **Language**: [Français](README.md) · **English** (this file)
|
||||
|
||||
> **🤖 AI-assisted development**: this project was designed and coded with an
|
||||
> AI assistant. The human contribution was **essential**: continuous feedback,
|
||||
> user reports (testing on a real phone, bug reports with real data exports),
|
||||
> improvement suggestions and validation of every release. The session-by-session
|
||||
> detail is documented in
|
||||
> [docs/DEVELOPPEMENT.md §2](docs/DEVELOPPEMENT.md) (French).
|
||||
|
||||
Hormone therapy (HRT) tracker on Android, with **hour-by-hour** estimated
|
||||
curves of estradiol (E2) and testosterone (T), calibration against blood
|
||||
tests, reminders mirrored on a smartwatch (Huawei Watch GT 3 via Gadgetbridge
|
||||
or Huawei Health) and JSON backup. **100% local, no account, no server.**
|
||||
|
||||
> **⚠️ Medical disclaimer**: the curves are **pharmacokinetic estimates**
|
||||
> for informational purposes — they are not measurements. Always rely on your
|
||||
> blood tests and on your endocrinologist's guidance.
|
||||
|
||||
- **Status**: v1.13.0 — Android build ✅, **lint clean** ✅, **284 unit tests** ✅
|
||||
(250 without the local test data; 7 regressions pinned on real data
|
||||
**not committed to the repo**), **14 Compose UI tests** ✅ (emulator),
|
||||
smartwatch integration = notifications ✅, **private Gitea repo + releases
|
||||
with APK** ✅
|
||||
- **Changelog**: [docs/CHANGELOG.md](docs/CHANGELOG.md) (French)
|
||||
- **User guide**: [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md) (French)
|
||||
- **Development docs** (architecture, math, decisions, bugs): [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md) (French)
|
||||
- **Watch / Gadgetbridge**: [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md) (French)
|
||||
|
||||
## Features
|
||||
|
||||
- **Hour-by-hour estimated curves**: E2 (pg/mL) and T (ng/mL), 24 h / 7 d / 30 d views,
|
||||
**dose markers** (forecast and actual), **configurable chart timezone** (v1.4.5),
|
||||
**pan** (drag to scroll back in time), **zoom** (pinch or − / + buttons,
|
||||
6 h → 300 d, adaptive sampling), displayable **peaks & troughs** with their
|
||||
**estimated values** (▲▼ triangles at local extrema, toggleable)
|
||||
- **Two PK models, superimposable**: **Estrannaise (EstraNase)** (hourly tables
|
||||
from the `.ods` spreadsheet) and **Transfem Science** — since v1.4.0 the TFS
|
||||
model is the **official 3-compartment meta-analysis** (exact closed form,
|
||||
parameters from the [TFS simulator](https://transfemscience.org/misc/injectable-e2-simulator/),
|
||||
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
||||
covering **7 esters** (EV, EU, EEn, EB, EC oil, EC suspension, PEP) — WHSAH
|
||||
covers 6 (no PEP). Displayed side by side with independent toggles (3 colors) —
|
||||
**pre-checked according to your treatments' models** (v1.4.7) and **each model
|
||||
calibrated separately** by your labs (v1.4.8 — scale factors adapt to each profile)
|
||||
- **"Lab track" curve (v1.5.0, extended in v1.6.0)**: the curve anchored on your
|
||||
blood tests (`Lab track` chip, off by default) — the PK model's shape between
|
||||
labs, with the amplitude rescaled to pass EXACTLY through every E2 lab value;
|
||||
log-linear transition from one lab to the next, keeps out-of-action-window labs
|
||||
(#61); **`Extend` chip (v1.6.0)**: the curve extends beyond the last lab
|
||||
(model × last lab ratio, horizon = model extinction, estimated part drawn
|
||||
faded + dedicated legend) — the curve is a COMPARISON reference, never an
|
||||
estimate of action (it never feeds the home screen or the alerts)
|
||||
- **Analytical Estrannaise model (v1.9.0)**: the ESE model now uses the 3C
|
||||
closed form published by estrannaise.js (fidelity to the old tables pinned at
|
||||
RMS 0) — **MCMC uncertainty cloud** (`Cloud` chip, ESE-exclusive: 32 posterior
|
||||
curves showing the imprecision range, like the estrannaise website); **6
|
||||
injectable esters** (incl. EUCS, exclusive); the historical ODS is no longer
|
||||
used at runtime (fidelity = tests only); analytical terminal t½
|
||||
(blood draw recommendation)
|
||||
- **Configurable Bateman model** (time to peak, half-life, bioavailability) for
|
||||
gel, patch and oral routes
|
||||
- **Forecast simulation**: set a **Dosage interval** (days) on a treatment →
|
||||
upcoming doses are projected on the chart (never saved); **enabling forecast
|
||||
does not move the chart start** but EXTENDS the window to the next dose
|
||||
(v1.4.4), and the projection is browsed by dragging left, up to **1 year**
|
||||
- **Inactive treatments**: an archived treatment disappears from input and
|
||||
reminders, but **its whole history stays simulated and calibrated** — useful
|
||||
for a valerate → enanthate transition; **grouped at the very bottom** of the
|
||||
Treatments page under a dedicated header, dimmed cards (v1.11.0);
|
||||
**no future projection anymore** (v1.12.0 — its slots disappear from the
|
||||
Forecast chart)
|
||||
- **Dose log** with exact date/time, dose in mg, **per-injection ester**
|
||||
(EV↔EU↔EEn switch like in the spreadsheet), **editable** (tap a row in Doses),
|
||||
**interval in days between doses** displayed; **route icon per dose row**
|
||||
(oral / IM-SC injection / gel / patch, v1.12.0) and **same-day blood test
|
||||
markers** (🧪↑ before the dose, 🧪↓ after)
|
||||
- **Blood tests**: **E2 + T in a single entry** (each optional), displayed
|
||||
**side by side** when they share the same date/time, editable (tap → pick the
|
||||
entry); T units: ng/mL, ng/dL, ng/L, nmol/L
|
||||
- **Suggested next blood draw (v1.8.0, extended v1.8.1 to the Labs and Home pages)**:
|
||||
the Labs page recommends the **estimated trough just before the next injection**
|
||||
(the most comparable moment), **at the first trough where your regimen is
|
||||
stabilized** (~5 half-lives after your last change — dose, ester or interval) —
|
||||
derived from the curves and the Dosage interval; requires an active injectable
|
||||
E2 with a Dosage (a prompt suggests setting one otherwise); estimates, never
|
||||
medical advice;
|
||||
**v1.13.0**: the card shows the **expected E2 at that trough** (when
|
||||
auto-calibration is ON) and compares it to your personal **trough target**
|
||||
(opt-in — distinct from the alert thresholds, never notifies)
|
||||
- **Calibration**: per-treatment scale factor = median(lab ÷ model prediction),
|
||||
computed automatically (the `.ods` "Scale factor", automated) — or **permanent
|
||||
automatic calibration** (option, off by default) which calibrates **each ester
|
||||
with the labs of its own period** (valerate labs → valerate doses, enanthate
|
||||
labs → enanthate doses) and recalibrates the T model
|
||||
- **Empirical T estimation** `T = floor + (base − floor) ÷ (1 + k·E2)`, with
|
||||
**k calibrated per ester period** (T suppression differs valerate vs enanthate),
|
||||
against the already-calibrated E2 — accepted T units: ng/mL, ng/dL, ng/L, nmol/L
|
||||
- **Daily reminders** with **"Taken" / "Snooze 1 h"** actions right in the
|
||||
notification; notifications are mirrored to the GT 3 watch (Gadgetbridge or
|
||||
Huawei Health); **reminders follow the Dosage interval** (v1.4.0): a treatment
|
||||
injected every 7 days only rings on injection day, not every day; **a dose
|
||||
already logged on the day skips the notification** (v1.10.0 — the next
|
||||
reminder resumes at the following slot)
|
||||
- **Configurable alert thresholds** (v1.4.2): E2 (pg/mL) and T (ng/mL) high/low
|
||||
limits in Settings → warning card on the home screen + **notification every
|
||||
15 min even with the app closed** (WorkManager, anti-spam, dedicated channel) —
|
||||
evaluated on the **estimated** level, opt-in
|
||||
- **Full JSON backup**: treatments + doses + labs + T settings
|
||||
**+ parameters (language, auto-calibration, alert thresholds)** (v1.4.2)
|
||||
- **Daily automatic backup (v1.7.0, opt-in)**: IN ADDITION to the manual export —
|
||||
every day, a full JSON backup (same format, directly importable) is written to
|
||||
**the folder you choose** (e.g. a synchronized Owncloud folder). No storage
|
||||
permission (SAF, folder picked once, persistent revocable permission),
|
||||
configurable retention (1–30 copies, default 7 — timestamped file per run,
|
||||
never overwritten; manual exports and foreign files **never touched**), last
|
||||
run status in Settings, first backup immediately on activation
|
||||
- **What's new on every update**: automatic changelog dialog (dismissed =
|
||||
won't show again before the next version)
|
||||
- **Time on HRT** displayed at the top of the Doses page (since the first dose);
|
||||
**next dose in days** on the home screen when it is more than 24 h away (v1.4.1)
|
||||
- **Exportable diagnostic logs** (Settings) — useful for support
|
||||
- **Calendar events**: dose reminders can create a recurring (posology) event in
|
||||
a "HormoneTrack" calendar on your phone
|
||||
- **Full JSON backup/restore** (treatments + doses + labs + T settings + parameters, v1.4.2)
|
||||
- **FR + EN** (per-app language, independent of the system)
|
||||
- Jetpack Compose UI (BOM 2026.08, Material You); 100% local, no account
|
||||
|
||||
## Quick start (building from source)
|
||||
|
||||
Prerequisites: JDK 17+ (Java 21 OK), Android SDK (platform 37 will be
|
||||
auto-downloaded by AGP if the licenses are signed). The wrapper downloads
|
||||
Gradle 9.7.1.
|
||||
|
||||
```bash
|
||||
git clone <repo> && cd HormoneTrack
|
||||
echo "sdk.dir=/path/to/android-sdk" > local.properties # or ANDROID_HOME
|
||||
./gradlew assembleDebug # APK: app/build/outputs/apk/debug/app-debug.apk
|
||||
./gradlew testDebugUnitTest # 284 tests (250 without local test data)
|
||||
./gradlew connectedDebugAndroidTest # 14 UI tests (emulator/device required)
|
||||
./gradlew lint # clean lint required before a release
|
||||
```
|
||||
|
||||
Installing on a phone: developer mode + USB debugging, then Android Studio
|
||||
(**Run ▶️**) or `adb install -r app/build/outputs/apk/debug/app-debug.apk`.
|
||||
No Play Store: the app is sideloaded. Step-by-step details (French):
|
||||
[docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md).
|
||||
|
||||
## Git
|
||||
|
||||
Repositories: **gitea.cloudyfy.fr** and **gitea.farewell.dev** (mirror) —
|
||||
`Siphonight/HormoneTrack` on both (private), with **tagged releases**
|
||||
(`v1.1.0` → `v1.10.0`) and **two APKs per release** (since v1.2.5)
|
||||
(downloadable without building, cf
|
||||
[docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)):
|
||||
`-release.apk` (**recommended**, R8-optimized, 2.7 MB) and `-debug.apk` (20 MB):
|
||||
|
||||
```bash
|
||||
git tag # list releases
|
||||
git log --oneline # layer-by-layer history (toolchain / engine / UI / docs)
|
||||
git push -u origin main --tags
|
||||
```
|
||||
|
||||
**Web port**: since v1.4.10, the browser version lives in its own repository
|
||||
`Siphonight/HormoneTrack-web` (same instances) — **aligned versions**
|
||||
(web v1.4.10 = port of Android v1.4.10), synced releases (checklist §16 step
|
||||
4.bis), separate changelogs.
|
||||
|
||||
Every release commit passes `./gradlew testDebugUnitTest` (green required) and
|
||||
is an annotated tag. See [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
|
||||
|
||||
**Release signing**: the release APK is signed with the debug key as long as
|
||||
no `keystore.properties` exists at the repo root; once created via
|
||||
`scripts/make-release-keystore.sh` (both files gitignored), `assembleRelease`
|
||||
signs with that keystore — phone migration documented in
|
||||
[docs/DEVELOPPEMENT.md §16.quater](docs/DEVELOPPEMENT.md).
|
||||
|
||||
## The models in short
|
||||
|
||||
Each injection contributes `dose_mg × profile(dt)` where `profile` is the
|
||||
normalized response (pg/mL per mg); contributions superimpose. **Three
|
||||
superimposable models**:
|
||||
|
||||
- **Estrannaise** = hourly tables from the `.ods` (8001 h), EV/EU/EEn
|
||||
- **Transfem Science** = 3-compartment meta-analysis (V3C, closed form,
|
||||
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
||||
7 esters (EV, EU, EEn, EB, EC, EC suspension, PEP)
|
||||
- **WHSAH** (v1.4.6) = "license-free" fit of the [WHSAH Collective via
|
||||
Mona](https://github.com/mona-hrt/mona) — same mathematical family but
|
||||
independent parameters with explicit bioavailability F < 1: faster rise
|
||||
at D+1 (EEn ~70 pg/mL at D+1 for 5 mg vs ~22 for TFS) and longer decay
|
||||
(EEn t½ 7.3 d vs 4.5 d). 6 esters (no PEP)
|
||||
|
||||
Reference peaks (pg/mL per mg):
|
||||
|
||||
| Profile | Model | Peak (pg/mL/mg) | Tmax | Terminal t½ |
|
||||
|----------|------------------|-----------------|--------|-------------|
|
||||
| EV | Estrannaise | 61.1 | ~45 h | — |
|
||||
| EU | Estrannaise | 3.4 | ~55 h (long plateau) | — |
|
||||
| EEn | Estrannaise | 31.4 | ~152 h | — |
|
||||
| EV | Transfem Science | 59.0 | ~51 h | 3.0 d |
|
||||
| EU | Transfem Science | 10.1 | ~198 h | — |
|
||||
| EEn | Transfem Science | 32.0 | ~156 h | 4.5 d |
|
||||
| EB | Transfem Science | 194.2 | ~16 h | 1.2 d |
|
||||
| EC (oil) | Transfem Science | 31.1 | ~103 h | 6.7 d |
|
||||
| EC (susp.) | Transfem Science | 48.2 | ~29 h | 5.1 d |
|
||||
| PEP | Transfem Science | 1.03 (~6.5× dose) | ~18 d | 28.4 d |
|
||||
| EV | WHSAH | 73.5 | ~41 h | 3.1 d |
|
||||
| EEn | WHSAH | 37.6 | ~120 h | 7.3 d |
|
||||
| EB | WHSAH | 260.1 | ~12 h | 1.3 d |
|
||||
| EC (oil) | WHSAH | 25.0 | ~81 h | 7.9 d |
|
||||
| EC (susp.) | WHSAH | 53.5 | ~16 h | 7.1 d |
|
||||
| EU | WHSAH | 4.9 | ~67 h | 31.7 d |
|
||||
|
||||
**Calibration (v1.4.8)**: the scale factor applies per ESTER and per
|
||||
injection PERIOD (median of lab ÷ prediction ratios, like the original
|
||||
spreadsheet's "Scale factor" column) — and per MODEL: every displayed curve
|
||||
(Estrannaise / Transfem Science / WHSAH) is calibrated with ITS OWN model's
|
||||
prediction → they all stick to your labs, whatever their shape.
|
||||
Auto-calibration is optional (off by default).
|
||||
|
||||
## Privacy
|
||||
|
||||
- **The app is 100% local**: Room database on the phone, no server, no
|
||||
telemetry; Gadgetbridge-compatible (FOSS) with no Huawei Health dependency
|
||||
- **The repository contains no health data**: code and docs are generic; the
|
||||
regression tests that use real exports load their data from
|
||||
`local-test-data/` (**gitignored**, outside the repo — and the history was
|
||||
cleaned before the first push, cf
|
||||
[docs/DEVELOPPEMENT.md §8.bis](docs/DEVELOPPEMENT.md))
|
||||
- Backups = JSON files you store wherever you want (Owncloud, etc.)
|
||||
- **The daily auto-backup (v1.7.0) only writes to the folder YOU picked
|
||||
yourself** (system SAF picker, revocable permission) — nothing ever leaves
|
||||
the phone, no server
|
||||
- `allowBackup=false` (sensitive data); biometric lock planned for Phase 2
|
||||
- The repository is **private**: release APKs download while logged in;
|
||||
making the repo public would make the APKs downloadable without an account
|
||||
(no data risk, see above)
|
||||
|
||||
## Repository structure
|
||||
|
||||
```
|
||||
HormoneTrack/
|
||||
├── README.md ← this file's French twin
|
||||
├── docs/
|
||||
│ ├── GUIDE_INSTALLATION.md user guide (phone + watch, French)
|
||||
│ ├── DEVELOPPEMENT.md full dev docs (architecture, math, bugs, tests, French)
|
||||
│ ├── CHANGELOG.md detailed version history (French)
|
||||
│ └── MONTRE-GADGETBRIDGE.md Huawei GT 3 watch: options + limits (French)
|
||||
├── scripts/
|
||||
│ ├── gitea-release.py publishes a release body (CHANGELOG + APK)
|
||||
│ ├── publish-release.py publishes a tag's 2 APKs (verified by download)
|
||||
│ ├── seed-emulator.py injects a JSON backup into an emulator DB
|
||||
│ └── make-release-keystore.sh generates the release keystore + properties (v1.9.8)
|
||||
├── local-test-data/ ← gitignored: real backups for regression
|
||||
│ tests (NEVER in the repo, cf §8.bis)
|
||||
├── keystore.properties ← gitignored: release signing (optional, §16.quater)
|
||||
├── build.gradle.kts root Gradle config (AGP/Kotlin/KSP pinned)
|
||||
├── settings.gradle.kts
|
||||
├── gradle.properties
|
||||
├── gradle/wrapper/ Gradle 9.7.1 wrapper (jar + properties)
|
||||
├── gradlew / gradlew.bat
|
||||
└── app/
|
||||
├── build.gradle.kts dependencies (Compose, Room, DataStore, Gson…)
|
||||
├── proguard-rules.pro
|
||||
└── src/
|
||||
├── main/
|
||||
│ ├── AndroidManifest.xml
|
||||
│ ├── assets/pk_profiles.json ← hourly tables (Estrannaise/TFS)
|
||||
│ ├── java/com/hormonetrack/
|
||||
│ │ ├── data/ (Room: models, DAOs, repository, backup)
|
||||
│ │ ├── pk/ (PK engine + profiles: Estrannaise/TFS/WHSAH, alerts, export)
|
||||
│ │ ├── reminder/ (exact alarms, notifs + actions, boot, alert worker)
|
||||
│ │ ├── settings/ (DataStore: TConfig, language)
|
||||
│ │ ├── ui/ (Compose: screens, components, theme)
|
||||
│ │ │ └── screens/settings/ ← Settings cards (v1.9.8 split)
|
||||
│ │ ├── HormoneTrackApp.kt
|
||||
│ │ └── MainActivity.kt
|
||||
│ └── res/ (FR/EN strings, theme, icons)
|
||||
├── test/java/com/hormonetrack/ ← JVM unit tests
|
||||
│ ├── pk/ (engine, ESE/TFS/WHSAH profiles, per-model
|
||||
│ │ calibration, reminders, data-driven regressions)
|
||||
│ ├── data/backup/ (Gson round-trip + parameters)
|
||||
│ ├── ui/ (chart window, source guard)
|
||||
│ ├── util/ (export file names, durations)
|
||||
│ └── reminder/ + settings/ (calendar RRULE, changelog)
|
||||
└── androidTest/java/com/hormonetrack/ ← Compose UI tests (v1.9.8)
|
||||
└── uitest/ (navigation, changelog dialog, settings)
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] v1: E2/T curves, dose log, labs + calibration, reminders, JSON backup, FR/EN
|
||||
- [x] Compose UI tests (13) + dedicated release signing infrastructure (v1.9.8)
|
||||
- [ ] Biometric lock, widget, CSV export
|
||||
- [ ] Watch Phase 2: custom watchface and/or Lite Wearable mini-app (see
|
||||
[docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md))
|
||||
|
||||
## License
|
||||
|
||||
**GPL-3.0** — see [LICENSE](LICENSE). Consistent with the Gadgetbridge
|
||||
ecosystem. The PK models belong to their respective authors
|
||||
([Estrannaise](https://estrannaise.github.io/),
|
||||
[Transfem Science](https://transfemscience.org)).
|
||||
247
README.md
247
README.md
@ -1,5 +1,14 @@
|
||||
# HormoneTrack
|
||||
|
||||
> **Langue** : Français (ce fichier) · [English](README.en.md)
|
||||
|
||||
> **🤖 Développé avec l'IA** : ce projet a été conçu et codé avec un assistant IA.
|
||||
> La contribution humaine a été **essentielle** : feedback continu, retours
|
||||
> utilisateur (tests sur vrai téléphone, rapports de bugs accompagnés d'exports
|
||||
> 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**
|
||||
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
|
||||
@ -9,84 +18,241 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
||||
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
|
||||
> sang et aux consignes de ton endocrinologue.
|
||||
|
||||
- **Statut** : v1.1.0 — build Android ✅, 30 tests unitaires ✅ (dont régression sur données réelles), intégration montre = notifications ✅
|
||||
- **Statut** : v1.13.0 — build Android ✅, **lint vert** ✅, **284 tests unitaires** ✅ (250 sans les données de test locales ; 7 régressions épinglées sur données réelles **non versionnées**), **14 tests UI Compose** ✅ (émulateur), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
|
||||
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
||||
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
|
||||
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
|
||||
- **Montre / Gadgetbridge** : [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md)
|
||||
- **README anglais** : [README.en.md](README.en.md)
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j
|
||||
- **Deux modèles PK au choix** (issus de la feuille `Estrogen.ods` de l'autrice) :
|
||||
**Estrannaise (EstraNase)** et **Transfem Science**, pour les injections EV / EU / EEn
|
||||
- **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j,
|
||||
**doses marquées** (prévisionnelles et réelles), **fuseau horaire du graphique
|
||||
configurable** (v1.4.5),
|
||||
**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** : **Estrannaise (EstraNase)** (tables
|
||||
horaires du `.ods`) et **Transfem Science** — depuis v1.4.0, le modèle TFS est la
|
||||
**méta-analyse officielle à 3 compartiments** (forme close exacte, params du
|
||||
[simulateur TFS](https://transfemscience.org/misc/injectable-e2-simulator/),
|
||||
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
||||
avec les **7 esters** (EV, EU, EEn, EB, EC huile, EC suspension, PEP) — et WHSAH
|
||||
en couvre 6 (sans PEP). Affichés côte à côte avec toggles indépendants (3 couleurs) — **pré-cochés
|
||||
selon les modèles de tes traitements** (v1.4.7) et **chaque modèle calibré
|
||||
séparément** par tes labs (v1.4.8 — les facteurs s'adaptent à chaque profil)
|
||||
- **« Tracé labs » (v1.5.0, prolongé en v1.6.0)** : la courbe ancrée sur tes
|
||||
prises de sang (chip `Tracé labs`, off par défaut) — la forme du modèle PK
|
||||
entre les labs, mais l'amplitude recalée pour passer EXACTEMENT sur chaque
|
||||
lab E2 ; transition log-linéaire d'un lab au suivant, garde des labs hors
|
||||
fenêtre d'action (#61) ; **chip `Prolonger` (v1.6.0)** : la courbe se
|
||||
prolonge au-delà du dernier lab (modèle × ratio du dernier lab, horizon =
|
||||
extinction du modèle, partie estimée dessinée atténuée + légende dédiée) —
|
||||
la courbe est une référence de COMPARAISON, jamais une estimation d'action
|
||||
(elle n'entre jamais dans l'accueil/les alertes)
|
||||
- **Modèle Estrannaise ANALYTIQUE (v1.9.0)** : le modèle ESE utilise désormais
|
||||
la forme close 3C publiée par estrannaise.js (fidélité aux anciennes tables
|
||||
épinglée RMS 0) — **nuage d'incertitude MCMC** (chip `Nuage`, exclusif ESE :
|
||||
32 courbes du posterior montrant la plage d'imprécision, comme sur le site
|
||||
estrannaise) ; **6 esters injectables** (dont EUCS, exclusif) ; l'ODS
|
||||
historique n'est plus utilisé au runtime (fidélité = tests uniquement) ;
|
||||
t½ terminale analytique (recommandation de prise de sang)
|
||||
- **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel,
|
||||
patch et voie orale
|
||||
- **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
|
||||
traitement → projection des doses à venir sur le graphique (jamais sauvegardées) ;
|
||||
**l'activation ne déplace pas le début du graphique** mais ÉTEND la fenêtre
|
||||
jusqu'à la prochaine dose (v1.4.4) et la projection se parcourt en tirant
|
||||
vers la gauche, jusqu'à **1 an**
|
||||
- **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)
|
||||
- **Analyses de sang** (E2, T, PRL…) tracées sur les courbes comme points de calibration
|
||||
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses),
|
||||
**intervalle en jours entre dosages** affiché ; **icône de forme de prise**
|
||||
(oral / injection IM-SC / gel / patch, v1.12.0) et **encart « prise de sang
|
||||
le même jour »** (🧪↑ avant la dose, 🧪↓ après)
|
||||
- **Traitements inactifs** : un traitement archivé n'apparaît plus dans la saisie
|
||||
ni dans les rappels, mais **tout son historique reste simulé et calibré** —
|
||||
utile pour une transition valerate → enanthate ; **regroupés tout en bas** de la
|
||||
page Traitements sous un en-tête dédié, cartes atténuées (v1.11.0)
|
||||
- **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées
|
||||
**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
|
||||
- **Prochaine prise de sang suggérée (v1.8.0, étendu v1.8.1 aux pages Analyses et Accueil)** : la page Analyses recommande le
|
||||
**creux estimé juste avant l'injection suivante** (moment le plus comparable),
|
||||
**au premier creux où ton régime est stabilisé** (~5 demi-vies après ton dernier
|
||||
changement — dose, ester ou intervalle) — déduit des courbes et de la Posologie ; requiert un
|
||||
injectable E2 actif à Posologie (une invite propose de la renseigner sinon) ;
|
||||
estimations, jamais un avis médical ;
|
||||
**v1.13.0** : la carte affiche la **valeur E2 attendue au creux** (si l'auto-calibration
|
||||
est ON) et la compare à ta **cible de creux** personnelle (opt-in — distinct des
|
||||
seuils d'alerte, jamais de notification)
|
||||
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
|
||||
calculé automatiquement (« Scale factor » du `.ods`, automatisé)
|
||||
- **Estimation T** empirique `T = plancher + (base − plancher) ÷ (1 + k·E2)`, calibrable
|
||||
avec tes résultats T
|
||||
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
|
||||
automatique permanente** (option, désactivée par défaut) qui calibre **chaque ester
|
||||
avec les labs de sa période** (labs valerate → doses valerate, labs enanthate → doses
|
||||
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 ;
|
||||
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health)
|
||||
- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T)
|
||||
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health) ;
|
||||
**les rappels suivent la Posologie** (v1.4.0) : un traitement injecté tous les
|
||||
7 jours ne sonne que le jour d'injection, pas tous les jours ; **une dose déjà
|
||||
loguée dans la journée saute la notification** (v1.10.0 — le rappel suivant
|
||||
repart au créneau suivant)
|
||||
- **Seuils d'alerte configurables** (v1.4.2) : limites hautes/basses E2 (pg/mL)
|
||||
et T (ng/mL) dans Paramètres → carte d'avertissement sur l'accueil +
|
||||
**notification toutes les 15 min même app fermée** (WorkManager, anti-spam,
|
||||
canal dédié) — évaluées sur le taux **estimé**, opt-in
|
||||
- **Sauvegarde JSON complète** : traitements + doses + analyses + réglages T
|
||||
**+ paramètres (langue, auto-calibration, seuils d'alerte)** (v1.4.2)
|
||||
- **Sauvegarde automatique quotidienne (v1.7.0, opt-in)** : en PLUS de
|
||||
l'export manuel — chaque jour, un backup JSON complet (même format,
|
||||
importable tel quel) est écrit dans le **dossier que tu choisis** (ex.
|
||||
dossier Owncloud synchronisé). Aucune permission de stockage (SAF, dossier
|
||||
choisi une fois, permission persistante révocable), rétention configurable
|
||||
(1–30 copies, défaut 7 — fichier horodaté par run, jamais d'écrasement ;
|
||||
exports manuels et fichiers étrangers **jamais touchés**), statut du
|
||||
dernier run dans Paramètres, premier backup immédiat à l'activation
|
||||
- **Nouveautés à chaque mise à jour** : dialog de changelog automatique
|
||||
(fermé = ne réapparaît pas avant la prochaine version)
|
||||
- **Temps sous THS** affiché en haut de la page Doses (depuis la 1re prise) ;
|
||||
**prochaine dose en jours** sur l'accueil quand elle est à plus de 24 h (v1.4.1)
|
||||
- **Logs de diagnostic** exportables (Paramètres) — utile pour le support
|
||||
- **É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 + paramètres, v1.4.2)
|
||||
- **FR + EN** (langue par app, indépendante du système)
|
||||
- UI Jetpack Compose récente (BOM 2026.08, Material You) ; 100 % local, aucun compte
|
||||
|
||||
## Démarrage rapide (build depuis les sources)
|
||||
|
||||
Prérequis : JDK 17+ (Java 21 OK), Android SDK (API 34). Le wrapper télécharge Gradle 8.9.
|
||||
Prérequis : JDK 17+ (Java 21 OK), Android SDK (la plateforme 37 sera auto-téléchargée
|
||||
par AGP si les licences sont signées). Le wrapper télécharge Gradle 9.7.1.
|
||||
|
||||
```bash
|
||||
git clone <repo> && cd HormoneTrack
|
||||
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
|
||||
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
|
||||
./gradlew testDebugUnitTest # 24 tests (moteur PK, profils, backup)
|
||||
./gradlew testDebugUnitTest # 284 tests (250 sans les données locales)
|
||||
./gradlew connectedDebugAndroidTest # 14 tests UI (émulateur/appareil requis)
|
||||
./gradlew lint # lint vert obligatoire avant release
|
||||
```
|
||||
|
||||
Installation sur un téléphone : mode développeur + Débogage USB, puis Android Studio
|
||||
(**Run ▶️**) ou `adb install -r app/build/outputs/apk/debug/app-debug.apk`.
|
||||
Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md).
|
||||
|
||||
## Git
|
||||
|
||||
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.10.0`) et **deux APK par release** (depuis v1.2.5)
|
||||
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
|
||||
`-release.apk` (**recommandé**, optimisé R8, 2,7 Mo) et `-debug.apk` (20 Mo) :
|
||||
|
||||
```bash
|
||||
git tag # lister les releases
|
||||
git log --oneline # historique par couches (toolchain / moteur / UI / docs)
|
||||
git push -u origin main --tags
|
||||
```
|
||||
|
||||
**Portage web** : depuis v1.4.10, la version navigateur vit dans son propre
|
||||
dépôt `Siphonight/HormoneTrack-web` (mêmes instances) — versions **alignées**
|
||||
(web v1.4.10 = portage de l'Android v1.4.10), releases sync (checklist
|
||||
§16 étape 4.bis), changelogs séparés.
|
||||
|
||||
Chaque commit de release passe `./gradlew testDebugUnitTest` (vert obligatoire) et est
|
||||
taggué annoté. Voir [docs/DEVELOPPEMENT.md §16](docs/DEVELOPPEMENT.md).
|
||||
|
||||
**Signature release** : l'APK release est signé avec la clé debug tant qu'aucun
|
||||
`keystore.properties` n'existe à la racine ; dès sa création via
|
||||
`scripts/make-release-keystore.sh` (fichiers gitignorés), `assembleRelease`
|
||||
signe avec ce keystore — migration téléphone documentée dans
|
||||
[docs/DEVELOPPEMENT.md §16.quater](docs/DEVELOPPEMENT.md).
|
||||
|
||||
## Les modèles en bref
|
||||
|
||||
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée
|
||||
(pg/mL par mg) issue des tables horaires d'Estrannaise / Transfem Science (8001 h) ;
|
||||
les contributions se superposent. Pics de référence :
|
||||
(pg/mL par mg) ; les contributions se superposent. **Trois modèles superposables** :
|
||||
|
||||
| Profil | Modèle | Pic (pg/mL/mg) | Tmax |
|
||||
|----------|------------------|----------------|--------|
|
||||
| EV | Estrannaise | 61,1 | ~45 h |
|
||||
| EU | Estrannaise | 3,4 | ~55 h (plateau long) |
|
||||
| EEn | Estrannaise | 31,4 | ~152 h |
|
||||
| EV | Transfem Science | 59,0 | ~51 h |
|
||||
| EU | Transfem Science | 10,1 | ~198 h |
|
||||
| EEn | Transfem Science | 32,0 | ~156 h |
|
||||
- **Estrannaise** = tables horaires du `.ods` (8001 h), EV/EU/EEn
|
||||
- **Transfem Science** = méta-analyse à 3 compartiments (V3C, forme close,
|
||||
[article](https://transfemscience.org/articles/injectable-e2-meta-analysis/)),
|
||||
7 esters (EV, EU, EEn, EB, EC, EC suspension, PEP)
|
||||
- **WHSAH** (v1.4.6) = fit « license-free » du [WHSAH Collective via
|
||||
Mona](https://github.com/mona-hrt/mona) — même famille mathématique mais
|
||||
paramètres indépendants avec biodisponibilité explicite F < 1 : montée
|
||||
plus rapide à J+1 (EEn ~70 pg/mL à J+1 pour 5 mg vs ~22 chez TFS) et
|
||||
décroissance plus longue (t½ EEn 7,3 j vs 4,5 j). 6 esters (sans PEP)
|
||||
|
||||
La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste le modèle
|
||||
à ton corps, exactement comme la colonne « Scale factor » de la feuille d'origine.
|
||||
Pics de référence (pg/mL par mg) :
|
||||
|
||||
| Profil | Modèle | Pic (pg/mL/mg) | Tmax | t½ term. |
|
||||
|----------|------------------|----------------|--------|----------|
|
||||
| EV | Estrannaise | 61,1 | ~45 h | — |
|
||||
| EU | Estrannaise | 3,4 | ~55 h (plateau long) | — |
|
||||
| EEn | Estrannaise | 31,4 | ~152 h | — |
|
||||
| EV | Transfem Science | 59,0 | ~51 h | 3,0 j |
|
||||
| EU | Transfem Science | 10,1 | ~198 h | — |
|
||||
| EEn | Transfem Science | 32,0 | ~156 h | 4,5 j |
|
||||
| EB | Transfem Science | 194,2 | ~16 h | 1,2 j |
|
||||
| EC (huile) | Transfem Science | 31,1 | ~103 h | 6,7 j |
|
||||
| EC (susp.) | Transfem Science | 48,2 | ~29 h | 5,1 j |
|
||||
| PEP | Transfem Science | 1,03 (dose ~6,5×) | ~18 j | 28,4 j |
|
||||
| EV | WHSAH | 73,5 | ~41 h | 3,1 j |
|
||||
| EEn | WHSAH | 37,6 | ~120 h | 7,3 j |
|
||||
| EB | WHSAH | 260,1 | ~12 h | 1,3 j |
|
||||
| EC (huile) | WHSAH | 25,0 | ~81 h | 7,9 j |
|
||||
| EC (susp.) | WHSAH | 53,5 | ~16 h | 7,1 j |
|
||||
| EU | WHSAH | 4,9 | ~67 h | 31,7 j |
|
||||
|
||||
**Calibration (v1.4.8)** : le facteur d'échelle s'applique par ESTER et par
|
||||
PÉRIODE d'injection (médiane des ratios lab ÷ prédiction, comme la colonne
|
||||
« Scale factor » de la feuille d'origine) — et **par MODÈLE** : chaque courbe
|
||||
affichée (Estrannaise / Transfem Science / WHSAH) est calibrée avec la
|
||||
prédiction de SON modèle → toutes collent à tes labs, quelle que soit leur
|
||||
forme. L'auto-calibration est optionnelle (désactivée par défaut).
|
||||
|
||||
## 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.)
|
||||
- **L'auto-backup quotidien (v1.7.0) n'écrit QUE dans le dossier que tu as
|
||||
toi-même choisi** (sélecteur système SAF, permission révocable) — rien
|
||||
ne part ailleurs, aucun serveur
|
||||
- `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
|
||||
|
||||
```
|
||||
HormoneTrack/
|
||||
├── README.md ← ce fichier
|
||||
├── README.md ← ce fichier (README.en.md = version anglaise)
|
||||
├── docs/
|
||||
│ ├── GUIDE_INSTALLATION.md guide utilisateur (téléphone + montre)
|
||||
│ ├── DEVELOPPEMENT.md doc de dev complète (architecture, maths, bugs, tests)
|
||||
│ ├── CHANGELOG.md journal détaillé des versions
|
||||
│ └── MONTRE-GADGETBRIDGE.md montre Huawei GT 3 : options + limites
|
||||
├── scripts/
|
||||
│ ├── gitea-release.py publie le corps d'une release (CHANGELOG + APK)
|
||||
│ ├── publish-release.py publie les 2 APK d'un tag (vérification par téléchargement)
|
||||
│ ├── seed-emulator.py injecte un backup JSON dans la DB d'un émulateur (cf §16.ter)
|
||||
│ └── make-release-keystore.sh génère le keystore de signature release (v1.9.8, §16.quater)
|
||||
├── local-test-data/ ← gitignoré : backups réels pour les tests
|
||||
│ de régression (JAMAIS dans le dépôt, cf §8.bis)
|
||||
├── keystore.properties ← gitignoré : signature release (optionnel, §16.quater)
|
||||
├── build.gradle.kts config Gradle racine (AGP/Kotlin/KSP épinglés)
|
||||
├── settings.gradle.kts
|
||||
├── gradle.properties
|
||||
├── gradle/wrapper/ wrapper Gradle 8.9 (jar + properties)
|
||||
├── gradle/wrapper/ wrapper Gradle 9.7.1 (jar + properties)
|
||||
├── gradlew / gradlew.bat
|
||||
└── app/
|
||||
├── build.gradle.kts dépendances (Compose, Room, DataStore, Gson…)
|
||||
@ -97,27 +263,34 @@ HormoneTrack/
|
||||
│ ├── assets/pk_profiles.json ← tables horaires (Estrannaise/TFS)
|
||||
│ ├── java/com/hormonetrack/
|
||||
│ │ ├── data/ (Room : models, DAOs, repository, backup)
|
||||
│ │ ├── pk/ (moteur pharmacocinétique + profils)
|
||||
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot)
|
||||
│ │ ├── pk/ (moteur PK + profils : Estrannaise/TFS/WHSAH, alertes, export)
|
||||
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot, worker alertes)
|
||||
│ │ ├── settings/ (DataStore : TConfig, langue)
|
||||
│ │ ├── ui/ (Compose : screens, components, theme)
|
||||
│ │ │ └── screens/settings/ ← cartes des Paramètres (découpage v1.9.8)
|
||||
│ │ ├── HormoneTrackApp.kt
|
||||
│ │ └── MainActivity.kt
|
||||
│ └── res/ (strings FR/EN, thème, icônes)
|
||||
└── test/java/com/hormonetrack/ ← tests unitaires JVM
|
||||
├── pk/ (moteur + profils)
|
||||
└── data/backup/ (round-trip Gson)
|
||||
├── test/java/com/hormonetrack/ ← tests unitaires JVM
|
||||
│ ├── pk/ (moteur, profils ESE/TFS/WHSAH, calibration
|
||||
│ │ par modèle, rappels, régressions data-driven)
|
||||
│ ├── data/backup/ (round-trip Gson + paramètres)
|
||||
│ ├── ui/ (fenêtre graphique, garde de source)
|
||||
│ ├── util/ (noms de fichiers d'export, durées)
|
||||
│ └── reminder/ + settings/ (RRULE agenda, changelog)
|
||||
└── androidTest/java/com/hormonetrack/ ← tests UI Compose (v1.9.8)
|
||||
└── uitest/ (navigation, dialog changelog, Paramètres)
|
||||
```
|
||||
|
||||
## Feuille de route
|
||||
|
||||
- [x] v1 : courbes E2/T, log doses, labs + calibration, rappels, backup JSON, FR/EN
|
||||
- [ ] Tests UI Compose + compilation release signée
|
||||
- [x] Tests UI Compose (13) + infrastructure de compilation release signée (v1.9.8)
|
||||
- [ ] Verrou biométrique, widget, export CSV
|
||||
- [ ] Phase 2 montre : watchface personnalisée et/ou mini-app Lite Wearable (voir [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md))
|
||||
|
||||
## Licence
|
||||
|
||||
À définir avant le premier push public (suggestion : GPL-3.0, cohérent avec l'écosystème
|
||||
Gadgetbridge). Les modèles PK appartiennent à leurs autrices respectives
|
||||
**GPL-3.0** — voir [LICENSE](LICENSE). Cohérent avec l'écosystème Gadgetbridge.
|
||||
Les modèles PK appartiennent à leurs autrices respectives
|
||||
([Estrannaise](https://estrannaise.github.io/), [Transfem Science](https://transfemscience.org)).
|
||||
|
||||
@ -1,20 +1,40 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
// ── Signature RELEASE (v1.9.8) ──────────────────────────────────────────────
|
||||
// keystore.properties (RACINE du dépôt, GITIGNORÉ) décrit le vrai keystore :
|
||||
// storeFile=release.keystore (chemin relatif à la racine du dépôt)
|
||||
// storePassword=…
|
||||
// keyAlias=…
|
||||
// keyPassword=…
|
||||
// Tant que ce fichier est ABSENT, la build release reste signée avec la clé
|
||||
// DEBUG (comportement historique : installation par-dessus l'APK debug sans
|
||||
// perte de données, cf §16.quater). Dès qu'il existe (créé par
|
||||
// scripts/make-release-keystore.sh), la build release l'utilise — ⚠️ signature
|
||||
// différente ⇒ migration obligatoire : export JSON → désinstallation →
|
||||
// réinstallation → réimport (documenté §16.quater).
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
|
||||
android {
|
||||
namespace = "com.hormonetrack"
|
||||
compileSdk = 34
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.hormonetrack"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 2
|
||||
versionName = "1.1.0"
|
||||
targetSdk = 36
|
||||
// ⚠️ MÉTADONNÉES DE VERSION — bump obligatoire à CHAQUE release
|
||||
// (checklist §16 étape 1 de docs/DEVELOPPEMENT.md), committé AVANT le
|
||||
// tag : v1.3.0→v1.3.2 contenaient tous versionCode 14 / "1.3.0"
|
||||
// (bump jamais commité) → BuildConfig.VERSION_NAME était faux dans
|
||||
// les APK publiés (Paramètres + titre du dialog « Nouveautés »).
|
||||
versionCode = 50
|
||||
versionName = "1.13.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@ -24,11 +44,34 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Optimisations R8 + shrink des ressources (le cœur du gain debug→release)
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
// Signature : VRAI keystore si keystore.properties existe (racine du
|
||||
// dépôt, gitignoré — créé par scripts/make-release-keystore.sh) ;
|
||||
// sinon CLÉ DEBUG volontairement : l'APK release reste installable
|
||||
// PAR-DESSUS l'APK debug déjà installé (même signature), sans
|
||||
// désinstallation ni perte de données, tout en bénéficiant de R8.
|
||||
// ⚠️ Passer au vrai keystore CHANGE la signature : migration
|
||||
// backup→désinstall→réimport requise sur le téléphone (§16.quater).
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
val props = Properties().apply {
|
||||
keystorePropertiesFile.inputStream().use { load(it) }
|
||||
}
|
||||
signingConfigs.create("release") {
|
||||
storeFile = rootProject.file(props.getProperty("storeFile"))
|
||||
storePassword = props.getProperty("storePassword")
|
||||
keyAlias = props.getProperty("keyAlias")
|
||||
keyPassword = props.getProperty("keyPassword")
|
||||
}
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,12 +80,11 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
// BuildConfig.VERSION_NAME : écran Paramètres + dialog de changelog
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
@ -50,17 +92,37 @@ android {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
// Lint (v1.3.4) : les checks Compose 1.12+ (NonObservableLocale,
|
||||
// LocalContextGetResourceValueCall) sont rétrogradés en WARNING via
|
||||
// app/lint.xml (meilleure compatibilité que la DSL AGP) — cas THÉORIQUES de staleness de
|
||||
// configuration présents dans tout le code v1, jamais à l'origine des
|
||||
// crashs remontés. Les vrais problèmes de format de strings
|
||||
// (StringFormatMatches — aurait attrapé les bugs #47/#48, épinglés §14)
|
||||
// restent des ERREURS bloquantes. Re-traiter ces warnings lors d'une
|
||||
// refonte i18n (cf §20).
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Compose BOM
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
|
||||
// BOM Compose : épingle toutes les versions des artefacts androidx.compose.*
|
||||
// (material3 >= 1.4 = Material 3 Expressive)
|
||||
val composeBom = platform("androidx.compose:compose-bom:2026.08.00")
|
||||
implementation(composeBom)
|
||||
|
||||
// Core
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
|
||||
implementation("androidx.activity:activity-compose:1.9.0")
|
||||
implementation("androidx.core:core-ktx:1.19.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
|
||||
// Compose UI
|
||||
implementation("androidx.compose.ui:ui")
|
||||
@ -70,32 +132,44 @@ dependencies {
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||
implementation("androidx.navigation:navigation-compose:2.10.0")
|
||||
|
||||
// AppCompat (per-app language on API < 33)
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
// AppCompat (langue par app sur API < 33)
|
||||
implementation("androidx.appcompat:appcompat:1.8.0")
|
||||
|
||||
// Room
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
implementation("androidx.room:room-runtime:2.8.4")
|
||||
implementation("androidx.room:room-ktx:2.8.4")
|
||||
ksp("androidx.room:room-compiler:2.8.4")
|
||||
|
||||
// DataStore (preferences)
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
// DataStore (préférences)
|
||||
implementation("androidx.datastore:datastore-preferences:1.2.1")
|
||||
|
||||
// Work Manager (for periodic tasks)
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.1")
|
||||
// WorkManager (UTILISÉ v1.4.2 : worker périodique des seuils d'alerte, cf §9.bis)
|
||||
implementation("androidx.work:work-runtime-ktx:2.11.2")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
|
||||
// Gson for export
|
||||
implementation("com.google.code.gson:gson:2.11.0")
|
||||
// Gson (backup JSON + parsing de l'asset de profils)
|
||||
implementation("com.google.code.gson:gson:2.14.0")
|
||||
|
||||
// Debug
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
|
||||
// Unit tests (JVM)
|
||||
// Tests unitaires JVM
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
|
||||
// ── Tests UI Compose (v1.9.8, androidTest — cf docs/DEVELOPPEMENT.md §8.ter) ──
|
||||
// Exécution : ./gradlew connectedDebugAndroidTest (émulateur/appareil requis).
|
||||
// Le BOM est réappliqué sur la configuration androidTest pour épinner les
|
||||
// mêmes versions que le runtime Compose de l'app.
|
||||
androidTestImplementation(composeBom)
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
|
||||
// JUnit explicite (org.junit.Rule/@Test) — ne pas compter sur le transitif
|
||||
androidTestImplementation("junit:junit:4.13.2")
|
||||
// UiDevice (pressBack pour fermer les dialogs — v1.10.0, LogDoseRequestTest)
|
||||
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
||||
}
|
||||
|
||||
20
app/lint.xml
Normal file
20
app/lint.xml
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Lint (/v1.3.4). Cf commentaires dans app/build.gradle.kts et §19 de la doc de dev.
|
||||
|
||||
Rétrogradation des deux checks Compose 1.12+ : signalements de staleness
|
||||
de configuration (lecture de resources via LocalContext dans les callbacks
|
||||
et coroutines, Locale.getDefault() dans un remember{}) — présents dans tout
|
||||
le code v1, jamais à l'origine des crashs remontés. À re-traiter lors d'une
|
||||
refonte i18n (cf docs/DEVELOPPEMENT.md §20).
|
||||
|
||||
RESTENT EN ERREUR (bloquants, aucun override nécessaire) :
|
||||
- StringFormatMatches / StringFormatCount / StringFormatInvalid :
|
||||
auraient attrapé les bugs #47 (hrt_duration : 3 placeholders vs 2 args
|
||||
passés) et #48 (pattern horaire formaté sur LocalDate) — crashs réels
|
||||
v1.3.1→v1.3.3 retrouvé au téléphone, cf §14.
|
||||
-->
|
||||
<lint>
|
||||
<issue id="NonObservableLocale" severity="warning" />
|
||||
<issue id="LocalContextGetResourceValueCall" severity="warning" />
|
||||
</lint>
|
||||
38
app/proguard-rules.pro
vendored
38
app/proguard-rules.pro
vendored
@ -1,2 +1,38 @@
|
||||
# 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.data.backup.UserSettings { <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.** { *; }
|
||||
|
||||
# --- Moteur PK : « Tracé labs » (v1.6.0, bug #64 — trouvé en émulateur) ---
|
||||
# Symptôme : en RELEASE SEULE, la courbe « Tracé labs » restait vide (ni
|
||||
# dessinée ni légendée) alors que le même calcul fonctionne en debug et
|
||||
# donne 276 points sur les mêmes données (régression web-miroir). Cause
|
||||
# Prouvée au mapping R8 : `LabTrajectoryModel -> R8$$REMOVED$$CLASS$$...` —
|
||||
# R8 full mode a SUPPRIMÉ la classe en inlinant computeLabAnchoredCurve
|
||||
# (et son synthétique $default des paramètres par défaut) dans le call
|
||||
# site du producer Compose ; le résultat n'y était plus correct. FIX :
|
||||
# garder la classe ENTIÈRE — c'est le cœur scientifique de l'app (fonctions
|
||||
# pures, zéro réflexion) : coût = quelques Ko de dex non compactés.
|
||||
# ⚠️ Toute nouvelle fonctionnalité du pk/ qui « ne marche qu'en debug » :
|
||||
# vérifier le mapping (`app/build/outputs/mapping/release/mapping.txt`)
|
||||
# et étendre cette garde si besoin.
|
||||
-keep class com.hormonetrack.pk.LabTrajectoryModel { *; }
|
||||
|
||||
# --- Auto-backup (v1.7.0) : worker instancié par WorkManager via réflexion ---
|
||||
# Les règles consumer de WorkManager gardent normalement les sous-classes de
|
||||
# ListenableWorker, mais le leçon #64 a montré le coût d'une garde absente :
|
||||
# on l'écrit explicitement (coût = quelques entrées de classe).
|
||||
-keep class com.hormonetrack.data.backup.AutoBackupWorker { *; }
|
||||
|
||||
@ -0,0 +1,108 @@
|
||||
package com.hormonetrack.uitest
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hormonetrack.BuildConfig
|
||||
import com.hormonetrack.MainActivity
|
||||
import com.hormonetrack.R
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Test UI du dialog « Nouveautés » (v1.9.8) — épinglé sur les comportements
|
||||
* historiques des fixes v1.3.2/v1.3.3 (docs/DEVELOPPEMENT.md §14) :
|
||||
* - affiché quand la version installée est plus récente que la dernière vue ;
|
||||
* - PAS affiché quand la version courante a déjà été vue ;
|
||||
* - une fois FERMÉ, il ne RÉAPPARAÎT PAS au redémarrage (la version vue est
|
||||
* mémorisée AVANT l'affichage — sinon le même changelog ressortait à
|
||||
* chaque réouverture de l'app, bug v1.3.2).
|
||||
*
|
||||
* ⚠️ Technique : ces tests lancent l'activité MANUELLEMENT (ActivityScenario
|
||||
* + règle Compose vide) et non via createAndroidComposeRule, car l'état
|
||||
* DataStore (changelogSeenVersion) doit être réglé AVANT que
|
||||
* HormoneTrackRoot ne lise la version vue dans son LaunchedEffect de
|
||||
* démarrage. La règle automatique lancerait l'activité trop tôt (avant le
|
||||
* @Before du test) et le dialog du test précédent fausserait l'état.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChangelogDialogTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
private var scenario: ActivityScenario<MainActivity>? = null
|
||||
|
||||
private fun res(id: Int, vararg args: Any): String =
|
||||
UiTestEnv.targetContext.getString(id, *args)
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scenario?.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dialogShownWhenInstalledVersionIsNewerThanLastSeen() {
|
||||
runBlocking {
|
||||
UiTestEnv.app.container.settings.setChangelogSeenVersion("1.0.0")
|
||||
}
|
||||
scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(
|
||||
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||
).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dialogAbsentWhenVersionAlreadySeen() {
|
||||
runBlocking {
|
||||
UiTestEnv.app.container.settings.setChangelogSeenVersion(BuildConfig.VERSION_NAME)
|
||||
}
|
||||
scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(
|
||||
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||
).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dialogDoesNotReappearAfterDismissAndRestart() {
|
||||
runBlocking {
|
||||
UiTestEnv.app.container.settings.setChangelogSeenVersion("1.0.0")
|
||||
}
|
||||
scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(
|
||||
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||
).assertIsDisplayed()
|
||||
// Fermeture par le bouton « OK » (identique FR/EN)
|
||||
composeRule.onNodeWithText(res(R.string.ok)).performClick()
|
||||
composeRule.onNodeWithText(
|
||||
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||
).assertDoesNotExist()
|
||||
// Redémarrage de l'activité : le dialog ne doit PAS revenir — la
|
||||
// version vue a été persistée AVANT l'affichage (fix v1.3.2).
|
||||
scenario!!.recreate()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(
|
||||
res(R.string.changelog_title, BuildConfig.VERSION_NAME)
|
||||
).assertDoesNotExist()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpApp() {
|
||||
// Permission de notifications uniquement : le changelog est réglé
|
||||
// PAR TEST (avant chaque lancement manuel d'activité).
|
||||
UiTestEnv.grantPostNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package com.hormonetrack.uitest
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import com.hormonetrack.MainActivity
|
||||
import com.hormonetrack.R
|
||||
import org.junit.After
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Test UI de la demande « logger une dose » venue d'une NOTIFICATION
|
||||
* (v1.10.0, **fix #69**) : le dialog s'ouvre UNE FOIS au lancement avec les
|
||||
* extras `open_log_dose`/`treatment_id` (même contrat que ReminderReceiver,
|
||||
* cf reminder/ReminderManager.kt), puis — une fois fermé — ne se rouvre
|
||||
* JAMAIS tout seul. Comportement bogué épinglé : chaque retour sur l'onglet
|
||||
* Accueil rouvrait le dialog indéfiniment (le paramètre d'intent vivait au
|
||||
* niveau activité alors que la consommation vivait dans les `remember` de
|
||||
* Home, détruits à chaque changement d'onglet).
|
||||
*
|
||||
* Lancement MANUEL avec intent (règle Compose vide + ActivityScenario) : le
|
||||
* contrat du fix exige de régler l'état AVANT que la composition ne lise
|
||||
* l'intent — et l'overload `createAndroidComposeRule(intent)` n'existe pas
|
||||
* dans la ligne Compose 1.12. La fermeture passe par BACK (onDismissRequest,
|
||||
* indépendant des libellés) ; le titre `add_dose` (« Log a dose ») est
|
||||
* affiché dans les DEUX variantes du dialog (avec ou sans traitements en
|
||||
* base) : le test reste data-indépendant.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LogDoseRequestTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
private var scenario: ActivityScenario<MainActivity>? = null
|
||||
|
||||
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scenario?.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doseDialogOpensOnceAndNeverReopensAfterTabSwitch() {
|
||||
val intent = Intent(UiTestEnv.targetContext, MainActivity::class.java).apply {
|
||||
putExtra("open_log_dose", true)
|
||||
putExtra("treatment_id", 1L)
|
||||
}
|
||||
scenario = ActivityScenario.launch(intent)
|
||||
|
||||
val dialogTitle = res(R.string.add_dose)
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(dialogTitle).assertIsDisplayed()
|
||||
|
||||
// Fermeture du dialog (BACK → onDismissRequest)
|
||||
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(dialogTitle).assertDoesNotExist()
|
||||
|
||||
// Changer d'onglet puis revenir sur l'accueil : le dialog NE doit
|
||||
// PAS se rouvrir (demande consommée une fois pour toutes)
|
||||
composeRule.onNodeWithText(res(R.string.nav_doses)).performClick()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(dialogTitle).assertDoesNotExist()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpApp() = UiTestEnv.prepare()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
package com.hormonetrack.uitest
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hormonetrack.MainActivity
|
||||
import com.hormonetrack.R
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Test UI de NAVIGATION (v1.9.8) : la barre d'onglets mène bien à chaque
|
||||
* écran, et les Paramètres s'ouvrent depuis l'icône de l'accueil.
|
||||
*
|
||||
* Les preuves de navigation utilisent des éléments **inconditionnels**
|
||||
* (indépendants des données Room, contrairement au contenu des listes) :
|
||||
* - Graphiques → le chip de plage « 24 h » (toujours affiché) ;
|
||||
* - Doses / Analyses / Traitements → leur FloatingActionButton « + »
|
||||
* (contentDescription add_dose / add_lab / add_treatment) ;
|
||||
* - Paramètres → le titre de l'écran + les sections toujours présentes.
|
||||
*
|
||||
* Textes résolus via targetContext (cf UiTestEnv) : indépendant de la langue
|
||||
* système et du réglage in-app FR/EN.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NavigationTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
|
||||
|
||||
@Test
|
||||
fun bottomBarShowsAllFiveTabs() {
|
||||
listOf(
|
||||
R.string.nav_home, R.string.nav_chart, R.string.nav_doses,
|
||||
R.string.nav_labs, R.string.nav_treatments
|
||||
).forEach { labelRes ->
|
||||
composeRule.onNodeWithText(res(labelRes)).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeTabShowsAppTitle() {
|
||||
// Retour explicite à l'accueil d'abord : l'état sauvegardé de la
|
||||
// navigation peut restaurer l'onglet du test précédent.
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithText(res(R.string.app_name)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chartTabShowsRangeChips() {
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithText(res(R.string.nav_chart)).performClick()
|
||||
composeRule.onNodeWithText(res(R.string.chart_24h)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(res(R.string.chart_7j)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(res(R.string.chart_30j)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dosesTabShowsLogDoseFab() {
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithText(res(R.string.nav_doses)).performClick()
|
||||
composeRule.onNodeWithContentDescription(res(R.string.add_dose))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun labsTabShowsAddLabFab() {
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithText(res(R.string.nav_labs)).performClick()
|
||||
composeRule.onNodeWithContentDescription(res(R.string.add_lab))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun treatmentsTabShowsAddTreatmentFab() {
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithText(res(R.string.nav_treatments)).performClick()
|
||||
composeRule.onNodeWithContentDescription(res(R.string.add_treatment))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsOpensFromHomeGearIcon() {
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithContentDescription(res(R.string.settings)).performClick()
|
||||
// Titre de l'écran + bouton retour (TopAppBar des Paramètres)
|
||||
composeRule.onNodeWithText(res(R.string.settings)).assertIsDisplayed()
|
||||
composeRule.onNodeWithContentDescription(res(R.string.back))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpApp() = UiTestEnv.prepare()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package com.hormonetrack.uitest
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hormonetrack.MainActivity
|
||||
import com.hormonetrack.R
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Test UI de l'écran Paramètres (v1.9.8) : chaque SECTION est présente après
|
||||
* l'ouverture depuis l'accueil. Les sections profondes (sauvegarde, logs,
|
||||
* à propos) vivent SOUS le pli de l'écran scrollable → `assertExists`
|
||||
* (présence dans la hiérarchie) et non `assertIsDisplayed` (visibilité à
|
||||
* l'écran) : ce test vérifie la STRUCTURE, pas la mise en page.
|
||||
*
|
||||
* Les chips de langue « Français » / « English » sont des LITTÉRAUX dans le
|
||||
* code (chaque langue s'écrit dans sa propre langue — convention UX i18n) :
|
||||
* l'assertion ne dépend d'aucune ressource.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SettingsScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private fun res(id: Int): String = UiTestEnv.targetContext.getString(id)
|
||||
|
||||
private fun openSettings() {
|
||||
composeRule.onNodeWithText(res(R.string.nav_home)).performClick()
|
||||
composeRule.onNodeWithContentDescription(res(R.string.settings)).performClick()
|
||||
composeRule.waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun languageSectionShowsLocalizedChips() {
|
||||
openSettings()
|
||||
composeRule.onNodeWithText(res(R.string.language)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Français").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("English").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allSettingsSectionsArePresent() {
|
||||
openSettings()
|
||||
listOf(
|
||||
R.string.auto_calibrate, // Calibration automatique
|
||||
R.string.t_model_title, // Estimation T
|
||||
R.string.alert_section, // Seuils d'alerte
|
||||
R.string.timezone_section, // Fuseau du graphique
|
||||
R.string.reminders_section, // Rappels / alarmes exactes
|
||||
R.string.backup_section, // Sauvegarde JSON manuelle
|
||||
R.string.autobackup_section, // Auto-backup quotidien
|
||||
R.string.logs_section, // Logs de diagnostic
|
||||
R.string.about_title // À propos / version
|
||||
).forEach { sectionRes ->
|
||||
composeRule.onNodeWithText(res(sectionRes)).assertExists()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aboutSectionShowsInstalledVersion() {
|
||||
openSettings()
|
||||
// « Version : X.Y.Z » — la valeur vient de BuildConfig.VERSION_NAME
|
||||
// (épinglé : les tags v1.3.0→v1.3.2 embarquaient un versionName
|
||||
// jamais bumpé, bug §14 #46 — ce test l'attraperait désormais).
|
||||
val expected = UiTestEnv.targetContext.getString(
|
||||
R.string.settings_version, com.hormonetrack.BuildConfig.VERSION_NAME
|
||||
)
|
||||
composeRule.onNodeWithText(expected).assertExists()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpApp() = UiTestEnv.prepare()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package com.hormonetrack.uitest
|
||||
|
||||
import android.os.Build
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.hormonetrack.BuildConfig
|
||||
import com.hormonetrack.HormoneTrackApp
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* Environnement partagé des tests UI Compose (v1.9.8, cf docs/DEVELOPPEMENT.md
|
||||
* §8.ter). Les tests instrumentés tournent CONTRE l'app réelle installée :
|
||||
* ils doivent donc être **déterministes quel que soit l'état de l'appareil**
|
||||
* (données Room seedées ou non, locale système, version précédemment vue).
|
||||
*
|
||||
* Deux prérequis réglés ici AVANT le lancement de l'activité :
|
||||
* - **permission POST_NOTIFICATIONS** : MainActivity la demande au premier
|
||||
* lancement (API 33+). Sans pré-grant, le dialog SYSTÈME s'afficherait par
|
||||
* dessus l'app et les assertions Compose ne voient que la fenêtre de
|
||||
* l'app → timeout. Le grant shell (`pm grant`) est persistant.
|
||||
* - **dialog « Nouveautés » neutralisé** : HormoneTrackRoot affiche le
|
||||
* changelog au lancement quand la version installée est plus récente que
|
||||
* la dernière vue — un dialog MODAL qui masquerait toute la navigation.
|
||||
* En marquant la version courante comme vue, il ne s'affiche plus. Les
|
||||
* tests dédiés au dialog (ChangelogDialogTest) relancent l'activité
|
||||
* manuellement APRÈS avoir réglé leur propre état.
|
||||
*
|
||||
* Les assertions de texte passent par `targetContext.getString(...)` : la
|
||||
* ressource est résolue dans la MÊME configuration que celle de l'UI (les
|
||||
* per-app locales d'AppCompat s'appliquent à toute l'application), ce qui
|
||||
* rend les tests indépendants de la langue système OU du réglage in-app.
|
||||
*/
|
||||
object UiTestEnv {
|
||||
|
||||
val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
/** L'Application réelle du process instrumenté (même instance que l'UI). */
|
||||
val app: HormoneTrackApp =
|
||||
targetContext.applicationContext as HormoneTrackApp
|
||||
|
||||
/** Exécute une commande shell et ATTEND sa fin (lecture jusqu'à EOF). */
|
||||
fun runShellCommand(command: String) {
|
||||
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||
automation.executeShellCommand(command).use { pfd ->
|
||||
java.io.FileInputStream(pfd.fileDescriptor).use { it.readBytes() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Pré-grante la permission de notifications (idempotent, API 33+). */
|
||||
fun grantPostNotifications() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
runShellCommand("pm grant com.hormonetrack android.permission.POST_NOTIFICATIONS")
|
||||
}
|
||||
}
|
||||
|
||||
/** Marque le changelog de la version courante comme déjà vu. */
|
||||
fun markChangelogSeen() = runBlocking {
|
||||
app.container.settings.setChangelogSeenVersion(BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
/** À appeler dans le @BeforeClass de chaque classe de test UI. */
|
||||
fun prepare() {
|
||||
grantPostNotifications()
|
||||
markChangelogSeen()
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,15 @@
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
|
||||
<!-- Événements d'agenda récurrents (v1.3.0, calendrier local « HormoneTrack »).
|
||||
⚠️ Ces permissions DANGEREUSES doivent être déclarées ICI en plus d'être
|
||||
demandées à l'exécution par l'éditeur de traitement : sans cette
|
||||
déclaration la demande runtime est refusée d'office et CalendarEvents
|
||||
lève SecurityException → la fonctionnalité agenda était inopérante
|
||||
depuis v1.3.0 (cause racine manquante, cf §14 #44 de la doc de dev). -->
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
|
||||
<application
|
||||
android:name=".HormoneTrackApp"
|
||||
android:allowBackup="false"
|
||||
@ -17,7 +26,13 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- launchMode singleTop (v1.10.1) : un tap sur la notification de
|
||||
rappel quand l'app est DÉJÀ ouverte délivre onNewIntent (avec les
|
||||
extras open_log_dose/treatment_id) SANS détruire/recréer
|
||||
l'activité — l'ancien comportement (standard + CLEAR_TOP)
|
||||
recréait tout, et onNewIntent était du code mort. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
1
app/src/main/assets/mcmc_samples.json
Normal file
1
app/src/main/assets/mcmc_samples.json
Normal file
File diff suppressed because one or more lines are too long
@ -4,9 +4,12 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import com.hormonetrack.data.AppDatabase
|
||||
import com.hormonetrack.data.repository.HormoneRepository
|
||||
import com.hormonetrack.pk.PKProfileStore
|
||||
import com.hormonetrack.pk.EstrannaiseModels
|
||||
import com.hormonetrack.reminder.createNotificationChannel
|
||||
import com.hormonetrack.util.AppLog
|
||||
import com.hormonetrack.reminder.AlertNotifier
|
||||
import com.hormonetrack.settings.AppSettings
|
||||
import com.hormonetrack.data.backup.AutoBackupScheduler
|
||||
|
||||
class HormoneTrackApp : Application() {
|
||||
|
||||
@ -16,8 +19,23 @@ class HormoneTrackApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
PKProfileStore.init(this)
|
||||
// v1.9.0 : les tables ODS ne sont plus chargées au démarrage (le
|
||||
// runtime ne lit que des sources analytiques). L'asset MCMC
|
||||
// (nuage d'incertitude ESE) les remplace (≈ 48 Ko).
|
||||
EstrannaiseModels.init(this)
|
||||
createNotificationChannel(this)
|
||||
AppLog.init(this)
|
||||
// v1.4.2 : worker WorkManager PÉRIODIQUE des seuils d'alerte —
|
||||
// évalue le niveau estimé toutes les 15 min, notifie au franchissement
|
||||
// (anti-spam par état persisté). KEEP : replanifier à chaque
|
||||
// démarrage ne change rien ; WorkManager survit aux reboots.
|
||||
AlertNotifier.createChannel(this)
|
||||
AlertNotifier.schedulePeriodic(this)
|
||||
// v1.7.0 : auto-backup journalier (WorkManager périodique 24 h, KEEP —
|
||||
// le worker sort immédiatement si l'option est désactivée ; cf
|
||||
// data/backup/AutoBackupWorker.kt). WorkManager survit aux reboots :
|
||||
// BootReceiver n'a rien à faire.
|
||||
AutoBackupScheduler.schedulePeriodic(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,9 +6,11 @@ import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.hormonetrack.ui.HormoneTrackRoot
|
||||
@ -21,10 +23,28 @@ class MainActivity : AppCompatActivity() {
|
||||
private val notificationPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
/**
|
||||
* Demande « logger une dose » venue d'une NOTIFICATION (extra
|
||||
* `open_log_dose`/`treatment_id`). Détenu par l'ACTIVITÉ — survit aux
|
||||
* changements d'onglets — et consommé UNE FOIS par HomeScreen via
|
||||
* [HormoneTrackRoot] (**fix #69 v1.10.0** : l'ancien code passait le
|
||||
* paramètre d'intent tel quel à la composition ; quitter l'onglet
|
||||
* Accueil détruisait les `remember` locaux, le paramètre lui vivait au
|
||||
* niveau activité → le dialog se rouvrait à CHAQUE retour sur l'accueil).
|
||||
*/
|
||||
private val logDoseRequest = mutableStateOf<Long?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
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 {
|
||||
val lang = container.settings.language.first()
|
||||
val locales = if (lang == "system") {
|
||||
@ -45,15 +65,11 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val openLogDose = intent?.getBooleanExtra("open_log_dose", false) ?: false
|
||||
val treatmentId = intent?.getLongExtra("treatment_id", -1L) ?: -1L
|
||||
logDoseRequest.value = consumeLogDoseExtras(intent)
|
||||
|
||||
setContent {
|
||||
HormoneTrackTheme {
|
||||
HormoneTrackRoot(
|
||||
initialOpenLogDose = openLogDose,
|
||||
initialTreatmentId = treatmentId
|
||||
)
|
||||
HormoneTrackRoot(logDoseRequest = logDoseRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -61,5 +77,22 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
// v1.10.0 : un tap sur la notification quand l'app est DÉJÀ ouverte
|
||||
// ouvre aussi le dialog (avant : setIntent seul, la demande était
|
||||
// silencieusement perdue).
|
||||
consumeLogDoseExtras(intent)?.let { logDoseRequest.value = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit les extras de demande de log puis les RETIRE de l'intent : un
|
||||
* recreate de l'activité (rotation, processus restauré) ne doit JAMAIS
|
||||
* rouvrir le dialog d'une notification traitée il y a longtemps.
|
||||
*/
|
||||
private fun consumeLogDoseExtras(intent: Intent?): Long? {
|
||||
val open = intent?.getBooleanExtra("open_log_dose", false) ?: false
|
||||
val id = intent?.getLongExtra("treatment_id", -1L) ?: -1L
|
||||
intent?.removeExtra("open_log_dose")
|
||||
intent?.removeExtra("treatment_id")
|
||||
return if (open && id > 0) id else null
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.hormonetrack.data.dao.DoseLogDao
|
||||
import com.hormonetrack.data.dao.LabResultDao
|
||||
import com.hormonetrack.data.dao.TreatmentDao
|
||||
@ -11,9 +13,17 @@ import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
|
||||
/**
|
||||
* Base locale Room.
|
||||
*
|
||||
* ⚠️ Toute évolution du schéma = version++ + MIGRATION explicite ci-dessous.
|
||||
* Ne JAMAIS réintroduire fallbackToDestructiveMigration() : l'utilisatrice a des
|
||||
* données réelles sur son téléphone, une migration manquante doit planter bruyamment
|
||||
* plutôt que tout effacer.
|
||||
*/
|
||||
@Database(
|
||||
entities = [Treatment::class, DoseLog::class, LabResult::class],
|
||||
version = 1,
|
||||
version = 3,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
@ -22,6 +32,27 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun labResultDao(): LabResultDao
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* v1 → v2 (2026-09-05) : ajout de la colonne forecastIntervalDays (nullable,
|
||||
* REAL) pour la simulation prévisionnelle par fréquence d'injection.
|
||||
*/
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE treatments ADD COLUMN forecastIntervalDays REAL")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
@ -32,7 +63,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
AppDatabase::class.java,
|
||||
"hormonetrack.db"
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
package com.hormonetrack.data.backup
|
||||
|
||||
import com.hormonetrack.util.ExportFileNames
|
||||
|
||||
/**
|
||||
* RÉTENTION des auto-backups journaliers (v1.7.0) — décision PURE, testée en
|
||||
* JVM ([com.hormonetrack.data.backup.AutoBackupRetentionTest]) : elle ne fait
|
||||
* AUCUN accès disque/SAF, elle ne fait que DÉCIDER quels noms supprimer.
|
||||
* L'IO (énumération et suppression SAF) vit dans
|
||||
* [BackupManager.writeAutoBackup] — même séparation décision/IO que le reste
|
||||
* du moteur.
|
||||
*
|
||||
* PRINCIPE : chaque run écrit un NOUVEAU fichier horodaté
|
||||
* (`hormonetrack-auto-20260913-1830.json`) et supprime les plus vieux
|
||||
* au-delà de « copies conservées » (défaut 7, borné 1..30 dans les
|
||||
* Paramètres). Pourquoi ne pas ÉCRASER un fichier fixe : un backup corrompu
|
||||
* (écriture interrompue, quota Owncloud plein…) écraserait la dernière
|
||||
* bonne copie — avec la rétention, les N dernières copies saines restent
|
||||
* disponibles.
|
||||
*
|
||||
* GARDES DE VIE PRIVÉE / SÉCURITÉ DE SUPPRESSION (les deux critiques ici) :
|
||||
* 1. le dossier cible est choisi par l'utilisatrice (SAF, ex. un dossier
|
||||
* Owncloud) et peut contenir SES propres fichiers → on ne supprime
|
||||
* JAMAIS un nom qui n'est pas un auto-backup reconnaissable ;
|
||||
* 2. même parmi les `hormonetrack-auto-…json`, un nom MALFORMÉ (horodatage
|
||||
* non parsable) est ignoré : jamais supprimé, jamais trié.
|
||||
*/
|
||||
object AutoBackupRetention {
|
||||
|
||||
/**
|
||||
* Décide quels fichiers supprimer pour respecter la rétention.
|
||||
*
|
||||
* @param existingNames tous les noms vus dans le dossier cible (le tri
|
||||
* d'entrée est indifférent — la fonction trie elle-même)
|
||||
* @param keepCount nombre de copies récentes À CONSERVER (borné : tout
|
||||
* `keepCount < 1` vaut 1 — sans garde, `0` supprimerait TOUT, y
|
||||
* compris la toute dernière copie)
|
||||
* @return les noms à SUPPRIMER (sous-ensemble de [existingNames]) —
|
||||
* vide quand la rétention est déjà respectée.
|
||||
*/
|
||||
fun filesToDelete(existingNames: Iterable<String>, keepCount: Int): List<String> {
|
||||
val keep = keepCount.coerceAtLeast(1)
|
||||
// Seuls les auto-backups RECONNAISSABLES participent à la rétention
|
||||
val autos = existingNames
|
||||
.mapNotNull { name -> ExportFileNames.parseAutoBackupTimestamp(name)?.let { name to it } }
|
||||
.sortedByDescending { it.second } // plus récent d'abord
|
||||
return autos.drop(keep).map { it.first }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
package com.hormonetrack.data.backup
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.hormonetrack.util.AppLog
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* SAUVEGARDE AUTOMATIQUE JOURNALIÈRE (v1.7.0) — en PLUS de l'export manuel
|
||||
* (qui reste inchangé dans Paramètres). Opt-in : désactivée par défaut.
|
||||
*
|
||||
* FONCTIONNEMENT (miroir du worker des alertes, AlertNotifier/AlertWorker) :
|
||||
* - **dossier choisi UNE FOIS** : ACTION_OPEN_DOCUMENT_TREE dans Paramètres
|
||||
* → tree URI persisté (takePersistableUriPermission → la permission
|
||||
* survit au reboot, SANS aucune permission de stockage) — l'utilisatrice
|
||||
* choisit typiquement un dossier Owncloud synchronisé, le backup atterrit
|
||||
* donc dans son cloud ;
|
||||
* - **WorkManager périodique 24 h** : survit aux reboots, gère le Doze
|
||||
* (pas d'alarme exacte nécessaire — un backup journalier n'a pas besoin
|
||||
* d'une heure à la minute près) ; planifié au démarrage de l'app
|
||||
* (HormoneTrackApp.onCreate, KEEP) — le worker sort immédiatement si
|
||||
* l'option est désactivée (même pattern qu'AlertWorker sans seuil) ;
|
||||
* - **run immédiat à l'activation** (`runNow`) : l'utilisatrice voit le
|
||||
* fichier apparaître tout de suite — et c'est ce qui rend la validation
|
||||
* émulateur §16.ter triviale ;
|
||||
* - **contenu = le backup COMPLET** (`BackupManager.exportJson`, format v2 :
|
||||
* traitements + doses + labs + réglages T + paramètres) — un auto-backup
|
||||
* est importable EXACTEMENT comme un export manuel ;
|
||||
* - **rétention** : les N copies les plus récentes sont conservées (défaut
|
||||
* 7, borné 1..30) — cf [AutoBackupRetention], les exports manuels ne
|
||||
* sont JAMAIS supprimés ;
|
||||
* - **statut visible** : « dernier : réussi/échec + date » dans Paramètres
|
||||
* (DataStore, alimenté par le worker) + journalisation AppLog complète
|
||||
* (debug à distance §9.bis — le mécanisme qui a résolu #50).
|
||||
*
|
||||
* ⚠️ R8 (leçon #64) : le worker est instancié par WorkManager via réflexion
|
||||
* → classe couverte par un `-keep` explicite dans proguard-rules.pro.
|
||||
*/
|
||||
object AutoBackupScheduler {
|
||||
|
||||
/** Nom du travail PÉRIODIQUE (unique) — 24 h. */
|
||||
private const val PERIODIC_WORK = "auto_backup_daily"
|
||||
/** Nom du travail one-time (run immédiat à l'activation). */
|
||||
private const val ONESHOT_WORK = "auto_backup_now"
|
||||
/** Période : 1 jour (WorkManager lègue au scheduler OS, Doze inclus). */
|
||||
private const val PERIOD_DAYS = 1L
|
||||
|
||||
/**
|
||||
* Planifie le travail PÉRIODIQUE — à appeler UNE FOIS dans
|
||||
* Application.onCreate. KEEP : si déjà planifié, on ne repousse pas le
|
||||
* prochain run (comme AlertNotifier.schedulePeriodic). Le worker lit ses
|
||||
* options dans DataStore à l'exécution : changer le dossier ou le nombre
|
||||
* de copies ne requiert AUCUN re-enqueue.
|
||||
*/
|
||||
fun schedulePeriodic(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
PERIODIC_WORK,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<AutoBackupWorker>(PERIOD_DAYS, TimeUnit.DAYS).build()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run IMMÉDIAT (à l'activation dans Paramètres) : feedback instantané —
|
||||
* le fichier apparaît dans le dossier choisi sans attendre 24 h.
|
||||
* REPLACE : plusieurs taps rapides = un seul run en attente.
|
||||
*/
|
||||
fun runNow(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
ONESHOT_WORK,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
OneTimeWorkRequestBuilder<AutoBackupWorker>().build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker WorkManager de l'auto-backup : no-op si l'option est désactivée ou
|
||||
* sans dossier (planifié inconditionnellement au démarrage, comme AlertWorker
|
||||
* sans seuils) ; sinon export JSON complet + écriture SAF + rétention, et
|
||||
* statut « dernier run » persisté pour l'affichage Paramètres.
|
||||
*/
|
||||
class AutoBackupWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val container = (applicationContext as com.hormonetrack.HormoneTrackApp).container
|
||||
val settings = container.settings
|
||||
|
||||
val enabled = settings.autoBackupEnabled.first()
|
||||
val treeUri = settings.autoBackupTreeUri.first()
|
||||
if (!enabled || treeUri == null) {
|
||||
// Option off (ou dossier jamais choisi) : rien à faire, ce n'est
|
||||
// pas un échec (Result.success = le périodique continue de tourner)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val ok = try {
|
||||
// MÊME contenu que l'export manuel (format v2 avec paramètres) :
|
||||
// un auto-backup est importable tel quel (rétrocompat assurée)
|
||||
// UNE seule collecte des seuils (même motif que l'export manuel)
|
||||
val thresholds = settings.alertThresholds.first()
|
||||
val userSettings = UserSettings(
|
||||
language = settings.language.first(),
|
||||
autoCalibrate = settings.autoCalibrate.first(),
|
||||
alertE2High = thresholds.e2High,
|
||||
alertE2Low = thresholds.e2Low,
|
||||
alertTHigh = thresholds.tHigh,
|
||||
alertTLow = thresholds.tLow
|
||||
)
|
||||
val json = BackupManager.exportJson(container.repository, settings.tConfig.first(), userSettings)
|
||||
val keep = settings.autoBackupKeep.first()
|
||||
BackupManager.writeAutoBackup(applicationContext, treeUri, json, keep)
|
||||
} catch (e: Exception) {
|
||||
AppLog.log("autobackup", "ERREUR worker : ${e.message}")
|
||||
// Statut quand même enregistré (l'UI montrera « échec »)…
|
||||
settings.setAutoBackupLastRun(false, now)
|
||||
// …et WorkManager re-tentera avec son backoff (IO transitoire :
|
||||
// quota cloud, réseau Owncloud absent — un retry a du sens)
|
||||
return Result.retry()
|
||||
}
|
||||
|
||||
settings.setAutoBackupLastRun(ok, now)
|
||||
AppLog.log("autobackup", "run terminé : ok=$ok")
|
||||
// Échec d'écriture (dossier absent, permission révoquée…) : retry
|
||||
// n'a pas de sens tant que l'utilisatrice ne re-choisit pas le
|
||||
// dossier → success (le prochain cycle journalier réessaiera).
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
package com.hormonetrack.data.backup
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
@ -9,30 +11,95 @@ import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.repository.HormoneRepository
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.util.AppLog
|
||||
import com.hormonetrack.util.ExportFileNames
|
||||
|
||||
/**
|
||||
* Paramètres utilisateur embarqués dans le backup (v1.4.2) — la sauvegarde
|
||||
* transporte désormais les RÉGLAGES en plus des données de santé :
|
||||
* - `language` : "system" / "fr" / "en" (null = ne pas changer à l'import) ;
|
||||
* - `autoCalibrate` : option de calibration automatique (null = défaut) ;
|
||||
* - `alertE2High/Low` (pg/mL) et `alertTHigh/Low` (ng/mL) : seuils d'alerte
|
||||
* configurés (null = pas de seuil).
|
||||
*
|
||||
* ⚠️ Champs PLATS volontairement (pas de nested data class) : Gson lit ces
|
||||
* champs par RÉFLEXION → chaque classe embarquée doit être couverte par un
|
||||
* `-keep` explicite dans proguard-rules.pro (sinon l'export/import produit
|
||||
* des objets vides EN RELEASE SEULE — leçon §16.bis smoke-test R8).
|
||||
* `changelog_seen_version` est volontairement EXCLU : pas une donnée de
|
||||
* configuration utile à restaurer (le dialog « Nouveautés » se montre une
|
||||
* fois de plus après une restauration, ce n'est pas gênant).
|
||||
*/
|
||||
data class UserSettings(
|
||||
val language: String? = null,
|
||||
val autoCalibrate: Boolean? = null,
|
||||
val alertE2High: Double? = null,
|
||||
val alertE2Low: Double? = null,
|
||||
val alertTHigh: Double? = null,
|
||||
val alertTLow: Double? = null,
|
||||
// v1.13.0 : cible de creux E2 (opt-in) — champs NULLABLES pour la
|
||||
// rétrocompatibilité : un backup v1/v2 antérieur ne les a pas → Gson
|
||||
// les laisse à null → l'import ne touche pas à la cible locale.
|
||||
val troughTargetLow: Double? = null,
|
||||
val troughTargetHigh: Double? = null
|
||||
)
|
||||
|
||||
data class BackupData(
|
||||
val version: Int = 1,
|
||||
// v2 (v1.4.2) : champ optionnel `settings` ajouté. RÉTROCOMPATIBILITÉ :
|
||||
// les backups v1 (sans settings) restent parsables (Gson → null) et
|
||||
// importables — l'import ne vérifie pas strictement la version.
|
||||
val version: Int = 2,
|
||||
val exportedAt: Long = System.currentTimeMillis(),
|
||||
val treatments: List<Treatment> = emptyList(),
|
||||
val doseLogs: List<DoseLog> = emptyList(),
|
||||
val labResults: List<LabResult> = emptyList(),
|
||||
val tConfig: TConfig = TConfig()
|
||||
val tConfig: TConfig = TConfig(),
|
||||
/** Paramètres utilisateur (null = backup v1 sans réglages). */
|
||||
val settings: UserSettings? = null
|
||||
)
|
||||
|
||||
object BackupManager {
|
||||
|
||||
suspend fun exportJson(repo: HormoneRepository, tConfig: TConfig): String {
|
||||
suspend fun exportJson(
|
||||
repo: HormoneRepository,
|
||||
tConfig: TConfig,
|
||||
/** Réglages utilisateur à embarquer (v1.4.2) ; null = backup minimal. */
|
||||
settings: UserSettings? = null
|
||||
): String {
|
||||
val data = BackupData(
|
||||
treatments = repo.allTreatmentsOnce(),
|
||||
doseLogs = repo.allDoseLogsOnce(),
|
||||
labResults = repo.allLabResultsOnce(),
|
||||
tConfig = tConfig
|
||||
tConfig = tConfig,
|
||||
settings = settings
|
||||
)
|
||||
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,
|
||||
/** Paramètres utilisateur (v1.4.2) ; null = backup v1 sans réglages. */
|
||||
val settings: UserSettings?
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 et les
|
||||
* paramètres utilisateur du backup (à restaurer dans DataStore par
|
||||
* l'appelant — cf SettingsScreen : langue appliquée sur le thread Main
|
||||
* car elle recrée l'activité).
|
||||
*/
|
||||
suspend fun importJson(
|
||||
repo: HormoneRepository,
|
||||
json: String
|
||||
@ -40,6 +107,11 @@ object BackupManager {
|
||||
val type = object : TypeToken<BackupData>() {}.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.doseLogs.forEach { repo.insertDoseLog(it) }
|
||||
data.labResults.forEach { repo.insertLabResult(it) }
|
||||
@ -47,7 +119,9 @@ object BackupManager {
|
||||
return ImportResult(
|
||||
data.treatments.size,
|
||||
data.doseLogs.size,
|
||||
data.labResults.size
|
||||
data.labResults.size,
|
||||
data.tConfig,
|
||||
data.settings
|
||||
)
|
||||
}
|
||||
|
||||
@ -68,4 +142,98 @@ object BackupManager {
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// ── Auto-backup journalier (v1.7.0) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Écrit UN auto-backup dans le DOSSIER SAF persisté ([treeUriString] = tree
|
||||
* URI choisi par l'utilisatrice via ACTION_OPEN_DOCUMENT_TREE, ex. un
|
||||
* dossier Owncloud synchronisé), puis applique la rétention
|
||||
* ([AutoBackupRetention] : garde les N plus récents, ne supprime QUE les
|
||||
* fichiers `hormonetrack-auto-*.json` — jamais les exports manuels ni les
|
||||
* fichiers personnels du dossier).
|
||||
*
|
||||
* Étapes :
|
||||
* 1. tree URI → document URI du dossier (DocumentsContract, SANS la lib
|
||||
* androidx.documentfile : pas de dépendance ajoutée) ;
|
||||
* 2. `createDocument` d'un nouveau fichier horodaté
|
||||
* ([ExportFileNames.autoBackupFileName]) — jamais d'écrasement :
|
||||
* la rétention garde les N dernières copies saines ;
|
||||
* 3. écriture du JSON via [writeBackup] TEL QUEL (leçon §14 #45 : même
|
||||
* IO éprouvée que l'export manuel — openOutputStream "wt") ;
|
||||
* 4. purge des vieux auto-backups (échec NON bloquant : un refus de
|
||||
* suppression ne doit pas faire échouer le backup écrit).
|
||||
*
|
||||
* @return true si le fichier a été écrit (la purge peut avoir échoué sans
|
||||
* invalider le succès).
|
||||
*/
|
||||
suspend fun writeAutoBackup(
|
||||
context: Context,
|
||||
treeUriString: String,
|
||||
json: String,
|
||||
keepCount: Int
|
||||
): Boolean {
|
||||
return try {
|
||||
val resolver = context.contentResolver
|
||||
val treeUri = Uri.parse(treeUriString)
|
||||
val dirUri = DocumentsContract.buildDocumentUriUsingTree(
|
||||
treeUri, DocumentsContract.getTreeDocumentId(treeUri)
|
||||
)
|
||||
val fileUri = DocumentsContract.createDocument(
|
||||
resolver, dirUri, "application/json", ExportFileNames.autoBackupFileName()
|
||||
) ?: return false
|
||||
val written = writeBackup(context, fileUri, json)
|
||||
if (written) {
|
||||
// Purge best-effort : le backup est écrit, l'échec de purge ne
|
||||
// l'invalide pas (l'utilisatrice le verra dans les AppLog)
|
||||
try {
|
||||
purgeOldAutoBackups(resolver, treeUri, keepCount)
|
||||
} catch (e: Exception) {
|
||||
AppLog.log("autobackup", "purge impossible : ${e.message}")
|
||||
}
|
||||
}
|
||||
written
|
||||
} catch (e: Exception) {
|
||||
AppLog.log("autobackup", "ERREUR écriture : ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Énumère le dossier cible (SAF children) et supprime les auto-backups au-delà
|
||||
* de [keepCount] — décision dans [AutoBackupRetention] (pur, testé) : ne
|
||||
* touche JAMAIS aux exports manuels ni aux fichiers étrangers du dossier.
|
||||
*/
|
||||
private fun purgeOldAutoBackups(
|
||||
resolver: ContentResolver,
|
||||
treeUri: Uri,
|
||||
keepCount: Int
|
||||
) {
|
||||
val dirId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, dirId)
|
||||
// Une seule requête : (nom affiché, id document) — le delete SAF exige
|
||||
// l'URI de DOCUMENT, pas le nom.
|
||||
val byName = LinkedHashMap<String, String>()
|
||||
resolver.query(
|
||||
childrenUri,
|
||||
arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID
|
||||
),
|
||||
null, null, null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val name = cursor.getString(0) ?: continue
|
||||
byName[name] = cursor.getString(1)
|
||||
}
|
||||
}
|
||||
AutoBackupRetention.filesToDelete(byName.keys, keepCount).forEach { name ->
|
||||
byName[name]?.let { docId ->
|
||||
DocumentsContract.deleteDocument(
|
||||
resolver, DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
|
||||
)
|
||||
AppLog.log("autobackup", "rétention : $name supprimé")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,21 +9,17 @@ interface DoseLogDao {
|
||||
@Query("SELECT * FROM dose_logs ORDER BY timestamp DESC")
|
||||
fun getAll(): Flow<List<DoseLog>>
|
||||
|
||||
@Query("SELECT * FROM dose_logs WHERE treatmentId = :treatmentId ORDER BY timestamp DESC")
|
||||
fun getByTreatment(treatmentId: Long): Flow<List<DoseLog>>
|
||||
|
||||
@Query("SELECT * FROM dose_logs WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
|
||||
fun getByTimeRange(start: Long, end: Long): Flow<List<DoseLog>>
|
||||
|
||||
@Query("SELECT * FROM dose_logs WHERE treatmentId = :treatmentId AND timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
|
||||
fun getByTreatmentAndTimeRange(treatmentId: Long, start: Long, end: Long): Flow<List<DoseLog>>
|
||||
|
||||
@Query("SELECT * FROM dose_logs ORDER BY timestamp DESC LIMIT 1")
|
||||
fun getLatest(): Flow<DoseLog?>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@Query("SELECT * FROM dose_logs ORDER BY timestamp ASC")
|
||||
suspend fun getAllOnce(): List<DoseLog>
|
||||
|
||||
/** Efface TOUT l'historique (import en mode écrasement). */
|
||||
@Query("DELETE FROM dose_logs")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Insert
|
||||
suspend fun insert(log: DoseLog): Long
|
||||
|
||||
|
||||
@ -9,15 +9,15 @@ interface LabResultDao {
|
||||
@Query("SELECT * FROM lab_results ORDER BY timestamp DESC")
|
||||
fun getAll(): Flow<List<LabResult>>
|
||||
|
||||
@Query("SELECT * FROM lab_results WHERE marker = :marker ORDER BY timestamp DESC")
|
||||
fun getByMarker(marker: String): Flow<List<LabResult>>
|
||||
|
||||
@Query("SELECT * FROM lab_results WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
|
||||
fun getByTimeRange(start: Long, end: Long): Flow<List<LabResult>>
|
||||
|
||||
|
||||
|
||||
@Query("SELECT * FROM lab_results ORDER BY timestamp ASC")
|
||||
suspend fun getAllOnce(): List<LabResult>
|
||||
|
||||
/** Efface toutes les analyses (import en mode écrasement). */
|
||||
@Query("DELETE FROM lab_results")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Insert
|
||||
suspend fun insert(result: LabResult): Long
|
||||
|
||||
|
||||
@ -15,12 +15,14 @@ interface TreatmentDao {
|
||||
@Query("SELECT * FROM treatments WHERE id = :id")
|
||||
suspend fun getById(id: Long): Treatment?
|
||||
|
||||
@Query("SELECT * FROM treatments WHERE isActive = 1 ORDER BY createdAt DESC")
|
||||
suspend fun getActiveOnce(): List<Treatment>
|
||||
|
||||
@Query("SELECT * FROM treatments ORDER BY createdAt DESC")
|
||||
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
|
||||
suspend fun insert(treatment: Treatment): Long
|
||||
|
||||
@ -30,6 +32,4 @@ interface TreatmentDao {
|
||||
@Delete
|
||||
suspend fun delete(treatment: Treatment)
|
||||
|
||||
@Query("UPDATE treatments SET isActive = :active WHERE id = :id")
|
||||
suspend fun setActive(id: Long, active: Boolean)
|
||||
}
|
||||
|
||||
@ -18,7 +18,8 @@ data class PKPreset(
|
||||
object PKPresets {
|
||||
|
||||
val all: List<PKPreset> = listOf(
|
||||
// --- Injections: Estrannaise models (profiles from the ODS) ---
|
||||
// --- Injections : modèle Estrannaise ANALYTIQUE (v1.9.0 — la
|
||||
// source est estrannaise.js, plus de tables ODS au runtime) ---
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_ev_ese,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
@ -46,6 +47,39 @@ object PKPresets {
|
||||
absorptionHours = 152f, eliminationHalfLifeHours = 150f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
|
||||
),
|
||||
// --- v1.9.4 : le modèle ESE est ANALYTIQUE (estrannaise.js, cf
|
||||
// pk/EstrannaiseModels.kt) et couvre 6 esters injectables —
|
||||
// presets ajoutés pour EC (huile), EB et EUCS (undécylate
|
||||
// suspension cristalline, exclusif à ce fit). Champs Bateman =
|
||||
// t½ terminales des fits (informatifs, pas utilisés au calcul —
|
||||
// le dispatch passe par la forme close).
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_ec_ese,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.EC,
|
||||
pkModel = PKModels.ESTRANNAISE,
|
||||
absorptionHours = 7f, eliminationHalfLifeHours = 202f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
|
||||
),
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_eb_ese,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.EB,
|
||||
pkModel = PKModels.ESTRANNAISE,
|
||||
absorptionHours = 1f, eliminationHalfLifeHours = 4f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 1.0
|
||||
),
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_eucs_ese,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_SUBCUT,
|
||||
esterType = Esters.EUCS,
|
||||
pkModel = PKModels.ESTRANNAISE,
|
||||
absorptionHours = 22f, eliminationHalfLifeHours = 756f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
|
||||
),
|
||||
// --- Injections: Transfem Science models ---
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_ev_tfs,
|
||||
@ -53,7 +87,7 @@ object PKPresets {
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.EV,
|
||||
pkModel = PKModels.TRANSFEM_SCIENCE,
|
||||
absorptionHours = 51f, eliminationHalfLifeHours = 100f, bioavailabilityFraction = 1f,
|
||||
absorptionHours = 50f, eliminationHalfLifeHours = 72f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 4.0
|
||||
),
|
||||
PKPreset(
|
||||
@ -62,7 +96,7 @@ object PKPresets {
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.EU,
|
||||
pkModel = PKModels.TRANSFEM_SCIENCE,
|
||||
absorptionHours = 198f, eliminationHalfLifeHours = 400f, bioavailabilityFraction = 1f,
|
||||
absorptionHours = 204f, eliminationHalfLifeHours = 528f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
|
||||
),
|
||||
PKPreset(
|
||||
@ -71,9 +105,106 @@ object PKPresets {
|
||||
route = AdministrationRoute.INJECTION_SUBCUT,
|
||||
esterType = Esters.EEN,
|
||||
pkModel = PKModels.TRANSFEM_SCIENCE,
|
||||
absorptionHours = 156f, eliminationHalfLifeHours = 150f, bioavailabilityFraction = 1f,
|
||||
absorptionHours = 156f, eliminationHalfLifeHours = 110f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
|
||||
),
|
||||
// --- Nouveaux esters de la méta-analyse TFS (v1.4.0, modèle V3C) :
|
||||
// EB / EC (huile) / EC suspension / PEP — PAS de tables Estrannaise
|
||||
// pour ces clés : les presets sont créés en modèle TFS uniquement.
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_eb_tfs,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.EB,
|
||||
pkModel = PKModels.TRANSFEM_SCIENCE,
|
||||
absorptionHours = 16f, eliminationHalfLifeHours = 29f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 1.0
|
||||
),
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_ec_tfs,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.EC,
|
||||
pkModel = PKModels.TRANSFEM_SCIENCE,
|
||||
absorptionHours = 103f, eliminationHalfLifeHours = 161f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
|
||||
),
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_ecs_tfs,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.ECS,
|
||||
pkModel = PKModels.TRANSFEM_SCIENCE,
|
||||
absorptionHours = 30f, eliminationHalfLifeHours = 122f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
|
||||
),
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_pep_tfs,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.PEP,
|
||||
pkModel = PKModels.TRANSFEM_SCIENCE,
|
||||
absorptionHours = 432f, eliminationHalfLifeHours = 682f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 32.5
|
||||
),
|
||||
// --- Nouveaux esters : modèle WHSAH (v1.4.6, fit license-free de
|
||||
// Mona — cf pk/WhsahModels.kt) : 6 esters, PEP non couvert. Les
|
||||
// champs Bateman reflètent les t½ WHSAH (pas utilisés au calcul,
|
||||
// affichés dans l'éditeur).
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_ev_whs,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.EV,
|
||||
pkModel = PKModels.WHSAH,
|
||||
absorptionHours = 41f, eliminationHalfLifeHours = 75f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 4.0
|
||||
),
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_eu_whs,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.EU,
|
||||
pkModel = PKModels.WHSAH,
|
||||
absorptionHours = 80f, eliminationHalfLifeHours = 722f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
|
||||
),
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_een_whs,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_SUBCUT,
|
||||
esterType = Esters.EEN,
|
||||
pkModel = PKModels.WHSAH,
|
||||
absorptionHours = 120f, eliminationHalfLifeHours = 176f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
|
||||
),
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_eb_whs,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.EB,
|
||||
pkModel = PKModels.WHSAH,
|
||||
absorptionHours = 13f, eliminationHalfLifeHours = 25f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 1.0
|
||||
),
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_ec_whs,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.EC,
|
||||
pkModel = PKModels.WHSAH,
|
||||
absorptionHours = 91f, eliminationHalfLifeHours = 161f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
|
||||
),
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_ecs_whs,
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
esterType = Esters.ECS,
|
||||
pkModel = PKModels.WHSAH,
|
||||
absorptionHours = 60f, eliminationHalfLifeHours = 110f, bioavailabilityFraction = 1f,
|
||||
defaultDoseUnit = "mg", defaultDoseAmount = 5.0
|
||||
),
|
||||
// --- Transdermal / oral (Bateman fallback) ---
|
||||
PKPreset(
|
||||
nameRes = R.string.preset_e2_gel,
|
||||
|
||||
@ -8,11 +8,63 @@ object Esters {
|
||||
const val EV = "EV"
|
||||
const val EU = "EU"
|
||||
const val EEN = "EEN"
|
||||
// Esters supplémentaires couverts par la méta-analyse Transfem Science
|
||||
// (v1.4.0, modèle V3C — cf pk/TransfemScienceModels.kt). Ces clés n'ont
|
||||
// PAS de tables Estrannaise (ODS) : un traitement ESE + EB/EC/ECS/PEP
|
||||
// produirait 0 pg/mL (les presets les créent en modèle TFS uniquement).
|
||||
const val EB = "EB" // benzoate
|
||||
const val EC = "EC" // cypionate, solution huileuse
|
||||
const val ECS = "ECS" // cypionate, suspension aqueuse (microcristaux)
|
||||
const val PEP = "PEP" // polyestradiol phosphate
|
||||
// v1.9.0 : undécylate en SUSPENSION CRISTALLINE — couvert par le modèle
|
||||
// Estrannaise ANALYTIQUE (estrannaise.js « EUn casubq ») ; ni TFS ni les
|
||||
// tables ODS ne le couvrent.
|
||||
const val EUCS = "EUCS"
|
||||
|
||||
/** Esters injectables proposables par ester (override par dose). */
|
||||
val INJECTABLE: List<String> = listOf(EV, EU, EEN, EB, EC, ECS, PEP, EUCS)
|
||||
|
||||
/**
|
||||
* Esters proposables dans l'override par dose pour un MODÈLE PK donné
|
||||
* (v1.4.6, cf DoseDialog) : la liste suit la couverture du modèle —
|
||||
* TFS (V3C méta-analyse) → les 7 esters ; WHSAH (fit Mona) → les 6
|
||||
* (PEP non couvert par ce fit) ; ESE (Estrannaise ANALYTIQUE v1.9.0)
|
||||
* → les 6 injectables du fit estrannaise.js (dont EUCS, exclusif) ;
|
||||
* les tables ODS historiques n'en couvraient que 3 (EV/EU/EEn).
|
||||
*/
|
||||
fun choicesForModel(pkModel: String): List<String> = when (pkModel) {
|
||||
// ⚠️ TFS = liste EXPLICITE (fix v1.8.2-étendu) : INJECTABLE a gagné
|
||||
// EUCS (couvert par l'Estrannaise analytique) mais le V3C TFS n'a PAS
|
||||
// de paramètres pour EUCS → le dispatcher donnerait 0. Chaque liste
|
||||
// reflète la couverture RÉELLE de son modèle.
|
||||
PKModels.TRANSFEM_SCIENCE -> listOf(EV, EU, EEN, EB, EC, ECS, PEP)
|
||||
PKModels.WHSAH -> listOf(EV, EU, EEN, EB, EC, ECS)
|
||||
PKModels.ESTRANNAISE -> listOf(EV, EU, EEN, EC, EB, EUCS)
|
||||
else -> listOf(EV, EU, EEN)
|
||||
}
|
||||
}
|
||||
|
||||
object PKModels {
|
||||
const val ESTRANNAISE = "ESE"
|
||||
const val TRANSFEM_SCIENCE = "TFS"
|
||||
// v1.4.6 : 3ᵉ modèle PK — le fit « license-free » du WHSAH Collective
|
||||
// (source : app Mona, cf pk/WhsahModels.kt). Champ texte → aucune
|
||||
// migration Room ni changement de backup nécessaires.
|
||||
const val WHSAH = "WHS"
|
||||
}
|
||||
|
||||
/**
|
||||
* Ressource de LABEL d'un modèle PK (v1.4.6/1.4.7, fix #59) — UNIQUE source
|
||||
* de vérité pour les affichages : chips de `TreatmentsScreen`, dropdown de
|
||||
* `TreatmentEditorScreen`, chip du graphique. ⚠️ L'ancien ternaire à 2
|
||||
* branches affichait « Estrannaise » pour WHSAH (WHSAH tombait dans le
|
||||
* `else`), remonté par l'utilisatrice — TOUTE nouvelle utilisation doit
|
||||
* passer par ce helper, pas par un ternaire (bug #59).
|
||||
*/
|
||||
fun modelLabelRes(pkModel: String): Int = when (pkModel) {
|
||||
PKModels.TRANSFEM_SCIENCE -> com.hormonetrack.R.string.model_tfs
|
||||
PKModels.WHSAH -> com.hormonetrack.R.string.model_whs
|
||||
else -> com.hormonetrack.R.string.model_ese
|
||||
}
|
||||
|
||||
enum class TreatmentType {
|
||||
@ -42,7 +94,8 @@ data class Treatment(
|
||||
val isActive: Boolean = true,
|
||||
val notes: String? = null,
|
||||
|
||||
// PK: lookup-table model for injections (EV/EU/EEn from Estrannaise / Transfem Science)
|
||||
// PK: analytical models for injections — Estrannaise (v1.9.0, 6 esters),
|
||||
// Transfem Science (V3C, 7), WHSAH (6); see Esters.choicesForModel().
|
||||
val esterType: String = Esters.NONE,
|
||||
val pkModel: String = PKModels.ESTRANNAISE,
|
||||
|
||||
@ -51,18 +104,31 @@ data class Treatment(
|
||||
val eliminationHalfLifeHours: Float = 24f,
|
||||
val bioavailabilityFraction: Float = 1.0f,
|
||||
|
||||
// Calibration: ratio lab_value / model_prediction (like the ODS "Scale factor")
|
||||
// Calibration : ratio lab_value / model_prediction (comme le « Scale factor » du .ods)
|
||||
val scaleFactor: Double = 1.0,
|
||||
|
||||
// Simulation prévisionnelle : si renseigné (en jours), l'app génère des doses
|
||||
// à venir à partir de la dernière injection réelle (section « Fréquence »
|
||||
// de l'éditeur). null = pas de prévision.
|
||||
val forecastIntervalDays: Double? = null,
|
||||
|
||||
// Reminder
|
||||
val reminderHour: Int? = null,
|
||||
val reminderMinute: Int? = null,
|
||||
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 isInjection: Boolean
|
||||
get() = route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT
|
||||
// (redondant avec PharmacokineticEngine.isInjectionRoute — volontaire :
|
||||
// data.model ne doit PAS dépendre de pk (cycle de couches). Si la
|
||||
// définition d'« injectable » change, changer les DEUX endroits.)
|
||||
|
||||
val usesProfileModel: Boolean
|
||||
get() = isInjection && esterType != Esters.NONE
|
||||
|
||||
@ -18,15 +18,6 @@ class HormoneRepository(
|
||||
val allDoseLogs: Flow<List<DoseLog>> = doseLogDao.getAll()
|
||||
val allLabResults: Flow<List<LabResult>> = labResultDao.getAll()
|
||||
|
||||
fun getDosesByTimeRange(start: Long, end: Long): Flow<List<DoseLog>> =
|
||||
doseLogDao.getByTimeRange(start, end)
|
||||
|
||||
fun getDosesByTreatmentAndTimeRange(treatmentId: Long, start: Long, end: Long): Flow<List<DoseLog>> =
|
||||
doseLogDao.getByTreatmentAndTimeRange(treatmentId, start, end)
|
||||
|
||||
fun getLabsByMarker(marker: String): Flow<List<LabResult>> =
|
||||
labResultDao.getByMarker(marker)
|
||||
|
||||
suspend fun getTreatmentById(id: Long): Treatment? = treatmentDao.getById(id)
|
||||
|
||||
suspend fun allTreatmentsOnce(): List<Treatment> = treatmentDao.getAllOnce()
|
||||
@ -44,4 +35,9 @@ class HormoneRepository(
|
||||
suspend fun insertLabResult(result: LabResult): Long = labResultDao.insert(result)
|
||||
suspend fun updateLabResult(result: LabResult) = labResultDao.update(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()
|
||||
}
|
||||
|
||||
127
app/src/main/java/com/hormonetrack/pk/Alerts.kt
Normal file
127
app/src/main/java/com/hormonetrack/pk/Alerts.kt
Normal file
@ -0,0 +1,127 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
/**
|
||||
* Seuils d'alerte configurables (v1.4.2) : limites HAUTE et BASSE définies
|
||||
* par l'utilisatrice pour l'estradiol (pg/mL) et la testostérone (ng/mL),
|
||||
* évaluées contre le TAUX ESTIMÉ ACTUEL (dernier point de courbe, i.e. le
|
||||
* niveau « ≈ X pg/mL » affiché sur l'accueil — pas une mesure de labo).
|
||||
*
|
||||
* Philosophie :
|
||||
* - **opt-in** : aucun seuil par défaut (l'app n'invente pas de normes
|
||||
* médicales — tout champ laissé vide désactive l'alerte correspondante) ;
|
||||
* - l'objet est PUR (aucune dépendance Android) → testable en JVM
|
||||
* (`AlertsTest`) et réutilisable plus loin (widget, notification…).
|
||||
* - Les courbes restent des ESTIMATIONS pharmacocinétiques : l'avertissement
|
||||
* est indicatif, libellé en ce sens dans l'UI (cf strings `alert_*`).
|
||||
*
|
||||
* Validation de cohérence : si haut ET bas sont définis, il faut haut > bas
|
||||
* (sinon l'évaluation serait ambiguë) — vérifié à la saisie dans
|
||||
* SettingsScreen avec feedback, et défensivement ici (`isCoherent`).
|
||||
*/
|
||||
object Alerts {
|
||||
|
||||
/**
|
||||
* Seuils de l'utilisatrice (DataStore). null = pas de limite pour cette
|
||||
* valeur → jamais d'alerte dessus.
|
||||
*/
|
||||
data class Thresholds(
|
||||
/** Limite HAUTE E2 en pg/mL (null = pas d'alerte haute). */
|
||||
val e2High: Double? = null,
|
||||
/** Limite BASSE E2 en pg/mL (null = pas d'alerte basse). */
|
||||
val e2Low: Double? = null,
|
||||
/** Limite HAUTE T en ng/mL (null = pas d'alerte haute). */
|
||||
val tHigh: Double? = null,
|
||||
/** Limite BASSE T en ng/mL (null = pas d'alerte basse). */
|
||||
val tLow: Double? = null
|
||||
) {
|
||||
/** Cohérence : une limite haute doit être strictement au-dessus de la basse. */
|
||||
fun isCoherent(): Boolean =
|
||||
(e2High == null || e2Low == null || e2High > e2Low) &&
|
||||
(tHigh == null || tLow == null || tHigh > tLow)
|
||||
}
|
||||
|
||||
/** Type de dépassement. */
|
||||
enum class Level { HIGH, LOW }
|
||||
|
||||
/** Une alerte déclenchée : valeur estimée vs la limite franchie. */
|
||||
data class Alert(
|
||||
val marker: String, // "E2" ou "T"
|
||||
val level: Level,
|
||||
val value: Double, // taux estimé actuel (même unité que la limite)
|
||||
val limit: Double, // limite franchie
|
||||
val unit: String // "pg/mL" (E2) ou "ng/mL" (T)
|
||||
)
|
||||
|
||||
/**
|
||||
* Évalue UNE valeur contre ses limites.
|
||||
*
|
||||
* - `value > high` → HIGH ; `value < low` → LOW (strict : la valeur
|
||||
* exactement à la limite ne déclenche rien — éviter les alertes
|
||||
* « clignotantes » sur la précision d'affichage) ;
|
||||
* - limite null → jamais d'alerte sur ce côté ;
|
||||
* - un seul verdict par appel : HIGH prime LOW si la configuration était
|
||||
* incohérente (haut < bas) — défensif, la saisie interdit ce cas.
|
||||
*/
|
||||
fun evaluate(value: Double, low: Double?, high: Double?): Pair<Level, Double>? = when {
|
||||
high != null && value > high -> Level.HIGH to high
|
||||
low != null && value < low -> Level.LOW to low
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Évalue le niveau actuel (E2 pg/mL + T ng/mL) contre tous les seuils.
|
||||
* @return la liste des alertes déclenchées (vide = tout va bien / rien
|
||||
* de configuré). Ordre stable : E2 d'abord, puis T.
|
||||
*/
|
||||
fun evaluateAll(currentE2: Double, currentT: Double, t: Thresholds): List<Alert> {
|
||||
val out = mutableListOf<Alert>()
|
||||
evaluate(currentE2, t.e2Low, t.e2High)?.let { (level, limit) ->
|
||||
out += Alert("E2", level, currentE2, limit, "pg/mL")
|
||||
}
|
||||
evaluate(currentT, t.tLow, t.tHigh)?.let { (level, limit) ->
|
||||
out += Alert("T", level, currentT, limit, "ng/mL")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── État de notification (v1.4.2) — anti-spam des vérifications périodiques ──
|
||||
|
||||
/**
|
||||
* Sérialise l'état des alertes déjà NOTIFIÉES pour la persistance
|
||||
* (DataStore) : `"E2:HIGH;T:LOW"`. Chaîne vide = rien de notifié.
|
||||
* Pur → testable en JVM (l'encodage est stocké par l'OS entre les
|
||||
* vérifications du worker, cf reminder/AlertNotifier.kt).
|
||||
*/
|
||||
fun encodeState(alerts: List<Alert>): String =
|
||||
alerts.joinToString(";") { "${it.marker}:${it.level}" }
|
||||
|
||||
/** Décodage de l'état persisté (format [encodeState]) → map marqueur → niveau. */
|
||||
fun parseState(encoded: String?): Map<String, Level> =
|
||||
encoded.orEmpty().split(";")
|
||||
.filter { it.contains(":") }
|
||||
.mapNotNull { part ->
|
||||
val parts = part.split(":", limit = 2)
|
||||
val level = Level.entries.firstOrNull { it.name == parts[1] }
|
||||
if (parts.size == 2 && level != null) parts[0] to level else null
|
||||
}
|
||||
.toMap()
|
||||
|
||||
/**
|
||||
* Décide si une NOTIFICATION doit être envoyée pour cette vérification
|
||||
* (anti-spam du worker périodique, appelé toutes les 15 min) :
|
||||
*
|
||||
* - current vide → **false** (pas de notif de « retour à la normale » ;
|
||||
* l'appelant efface l'état persisté pour permettre la re-notification
|
||||
* au PROCHAIN franchissement) ;
|
||||
* - current identique au dernier état notifié → **false** (l'écart
|
||||
* continue, pas de re-notif toutes les 15 min) ;
|
||||
* - nouveau franchissement OU changement de niveau (H↔L) → **true**.
|
||||
*
|
||||
* @param current état évalué à CETTE vérification (map marqueur → niveau).
|
||||
* @param lastNotified état persisté de la dernière notification (null = jamais).
|
||||
*/
|
||||
fun shouldNotify(current: Map<String, Level>, lastNotified: Map<String, Level>?): Boolean {
|
||||
if (current.isEmpty()) return false
|
||||
return current != lastNotified
|
||||
}
|
||||
}
|
||||
163
app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt
Normal file
163
app/src/main/java/com/hormonetrack/pk/EstrannaiseCloud.kt
Normal file
@ -0,0 +1,163 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
|
||||
/**
|
||||
* NUAGE D'INCERTITUDE (v1.9.0) — feature **exclusive au modèle ESE**.
|
||||
*
|
||||
* SOURCE : Estrannaise publie le **posterior MCMC** de ses paramètres
|
||||
* (313 échantillons `(d, k1, k2, k3)` par ester injectable — cf
|
||||
* [EstrannaiseModels]). Le graphique de simulation peut dessiner un
|
||||
* **nuage diffus** : M courbes (une par échantillon échelonné) qui montrent
|
||||
* la plage d'imprécision possible du modèle — exactement l'affichage du
|
||||
* site estrannaise. TFS/WHSAH ne publient pas de posterior → pas de nuage
|
||||
* pour eux (choix confirmé : le nuage reste sur ESE).
|
||||
*
|
||||
* QUELLES DOSES LE NUAGE COUVRE-T-IL (v1.9.2) : **toutes les doses E2 à
|
||||
* profil injectable dont l'ESTER EFFECTIF (override compris) est couvert
|
||||
* par le fit Estrannaise** — c'est exactement l'ensemble des doses que la
|
||||
* courbe ESE trace (`computeCurve(modelOverride = "ESE")`). Le `pkModel`
|
||||
* STOCKÉ du traitement n'importe pas : quand le chip Nuage est actif avec
|
||||
* ESE affiché, la courbe ESE redessine toutes ces doses avec le modèle
|
||||
* Estrannaise, et le nuage les redessine avec les paramètres MCMC de
|
||||
* LEUR ester. (Fix v1.9.2 : l'ancien filtre `pkModel stocké == ESE`
|
||||
* laissait le nuage vide dès qu'aucun traitement n'était stocké en ESE —
|
||||
* remontée : « le nuage ne s'active que autour du tracé émulé, pas autour
|
||||
* du modèle Estrannaise ». L'exclusivité ESE reste portée par le CHIP,
|
||||
* pas par le filtre des doses.)
|
||||
*
|
||||
* Les contributions Bateman (oral/gel — sans ester échantillonnable) ne
|
||||
* sont PAS dans le nuage : le posterior MCMC ne s'applique qu'aux esters
|
||||
* (limite documentée — le nuage montre l'incertitude du modèle
|
||||
* Estrannaise, pas le niveau total).
|
||||
*
|
||||
* DÉTERMINISME : les M échantillons sont **échelonnés** (stride sur les
|
||||
* 313, pas d'aléatoire) — le nuage est identique à chaque affichage et
|
||||
* testable en JVM (c'est aussi la reproducibilité du site, qui tire au
|
||||
* hasard à chaque trace, lui).
|
||||
*
|
||||
* PUR (aucune dépendance Android) → testable en JVM (EstrannaiseCloudTest).
|
||||
*/
|
||||
object EstrannaiseCloud {
|
||||
|
||||
/**
|
||||
* Nombre de courbes du nuage : assez pour une plage visuellement
|
||||
* « diffuse » (le site en trace des centaines, mais 32 échantillons
|
||||
* échelonnés couvrent déjà l'étendue du posterior à un coût ×32 sur le
|
||||
* calcul — le pas de la grille reste 1 h).
|
||||
*/
|
||||
const val DEFAULT_NB_CURVES = 32
|
||||
|
||||
/**
|
||||
* Calcule le nuage d'incertitude.
|
||||
*
|
||||
* @param treatments TOUS les traitements (actifs ET inactifs, §6.bis)
|
||||
* @param doseLogs toutes les doses réelles
|
||||
* @param startMs/endMs fenêtre demandée (celle du graphique)
|
||||
* @param stepMs pas de la grille (stepForRange)
|
||||
* @param nbCurves nombre de courbes du nuage (défaut [DEFAULT_NB_CURVES])
|
||||
* @return M listes de points (M = [nbCurves]) — **vide** si rien n'est
|
||||
* calculable : aucune dose E2 à profil injectable dont l'ester
|
||||
* effectif est couvert par le fit Estrannaise (v1.9.2 :
|
||||
* indépendamment du pkModel stocké), aucune dose, ou MCMC non chargé.
|
||||
*/
|
||||
fun compute(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
startMs: Long,
|
||||
endMs: Long,
|
||||
stepMs: Long,
|
||||
nbCurves: Int = DEFAULT_NB_CURVES,
|
||||
/**
|
||||
* Calibration à appliquer au nuage (v1.9.1 — fix « le nuage ne
|
||||
* s'active que autour du tracé émulé, pas autour du modèle
|
||||
* Estrannaise ») : la courbe ESE affichée est CALIBRÉE
|
||||
* (scalePerEster des labs) mais le nuage était tracé BRUT → les deux
|
||||
* flottaient à des échelles différentes. Passer le MÊME
|
||||
* `scalePerEster` que la courbe ESE (autoByModel["ESE"]?.esterScales
|
||||
* côté ChartScreen) pour que le nuage ENTOURE la courbe.
|
||||
*/
|
||||
scalePerEster: Map<String, Double>? = null
|
||||
): List<List<LevelPoint>> {
|
||||
if (startMs >= endMs || stepMs <= 0 || nbCurves < 2) return emptyList()
|
||||
|
||||
// ── Doses tracées par la courbe ESE (v1.9.2) ─────────────────────────
|
||||
// Toutes les doses E2 à profil injectable dont l'ESTER EFFECTIF
|
||||
// (override compris) est couvert par le fit Estrannaise — peu importe
|
||||
// le pkModel STOCKÉ du traitement : quand ESE est affiché,
|
||||
// computeCurve redessine toutes ces doses avec modelOverride = "ESE"
|
||||
// et le nuage doit les suivre (fix v1.9.2 : l'ancien filtre
|
||||
// `pkModel stocké == ESE` laissait le nuage vide dès qu'aucun
|
||||
// traitement n'était stocké en ESE — remontée : « le nuage ne
|
||||
// s'active que autour du tracé émulé, pas autour du modèle
|
||||
// Estrannaise ». L'exclusivité ESE reste portée par le CHIP, pas
|
||||
// par le filtre des doses). Les doses des traitements inactifs sont
|
||||
// incluses (§6.bis : l'inactif reste simulé, la courbe ESE les
|
||||
// trace aussi). La couche Bateman (oral/gel sans ester
|
||||
// échantillonnable) est naturellement exclue.
|
||||
val carrierDoses = ArrayList<Pair<DoseLog, Treatment>>()
|
||||
val samplesByEster = HashMap<String, List<EstrannaiseModels.Param>>()
|
||||
for (dose in doseLogs) {
|
||||
if (dose.timestamp > endMs) continue
|
||||
val tr = treatments.firstOrNull { it.id == dose.treatmentId } ?: continue
|
||||
if (tr.type != TreatmentType.ESTRADIOL || !tr.usesProfileModel) continue
|
||||
val ester = PharmacokineticEngine.doseEster(tr, dose)
|
||||
if (!EstrannaiseModels.hasModel(ester)) continue
|
||||
if (!samplesByEster.containsKey(ester)) {
|
||||
// Posterior MCMC de cet ester (chargé au démarrage) — vide si
|
||||
// l'asset n'est pas chargé → pas de nuage (sans posterior,
|
||||
// pas de plage d'imprécision à dessiner)
|
||||
val samples = EstrannaiseModels.mcmcSamples(ester)
|
||||
if (samples.isEmpty()) return emptyList()
|
||||
samplesByEster[ester] = samples
|
||||
}
|
||||
carrierDoses.add(dose to tr)
|
||||
}
|
||||
if (carrierDoses.isEmpty()) return emptyList()
|
||||
|
||||
// ── Échantillons échelonnés du posterior (déterministes) ─────────────
|
||||
// Un échantillon m est appliqué À TOUS les esters du nuage (même
|
||||
// index) : le nuage explore le posterior ester par ester, index par
|
||||
// index — reproductible.
|
||||
val maxSamples = samplesByEster.values.minOf { it.size }
|
||||
val stride = (maxSamples.toDouble() / nbCurves.coerceAtMost(maxSamples)).toInt()
|
||||
.coerceAtLeast(1)
|
||||
|
||||
// Grille : de max(start, 1ʳᵉ dose du porteur) à endMs — M=0 avant la
|
||||
// 1ʳᵉ dose (rien à faire varier)
|
||||
val firstDoseMs = carrierDoses.minOf { (dose, _) -> dose.timestamp }
|
||||
val t0 = maxOf(startMs, firstDoseMs)
|
||||
if (t0 > endMs) return emptyList()
|
||||
|
||||
// ── M courbes ─────────────────────────────────────────────────────────
|
||||
val cloud = mutableListOf<List<LevelPoint>>()
|
||||
for (m in 0 until nbCurves) {
|
||||
val sampleIdx = (m * stride).coerceAtMost(maxSamples - 1)
|
||||
val paramsByEster = samplesByEster.mapValues { (_, samples) ->
|
||||
samples[sampleIdx.coerceAtMost(samples.size - 1)]
|
||||
}
|
||||
val curve = mutableListOf<LevelPoint>()
|
||||
var t = t0
|
||||
while (t <= endMs) {
|
||||
var e2 = 0.0
|
||||
for ((dose, tr) in carrierDoses) {
|
||||
if (dose.timestamp > t) continue
|
||||
val dtH = (t - dose.timestamp) / 3_600_000.0
|
||||
val ester = PharmacokineticEngine.doseEster(tr, dose)
|
||||
val params = paramsByEster.getValue(ester)
|
||||
// Même calibration que la courbe ESE : le nuage ENTOURE
|
||||
// la courbe calibrée (fix v1.9.1)
|
||||
val scale = scalePerEster?.get(ester) ?: tr.scaleFactor
|
||||
e2 += EstrannaiseModels.sampleWithParams(ester, dtH, params) * dose.doseAmount * scale
|
||||
}
|
||||
curve.add(LevelPoint(t, e2, 0.0))
|
||||
t += stepMs
|
||||
}
|
||||
if (curve.size >= 2) cloud.add(curve)
|
||||
}
|
||||
return cloud
|
||||
}
|
||||
}
|
||||
188
app/src/main/java/com/hormonetrack/pk/EstrannaiseModels.kt
Normal file
188
app/src/main/java/com/hormonetrack/pk/EstrannaiseModels.kt
Normal file
@ -0,0 +1,188 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.JsonParser
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.ln
|
||||
|
||||
/**
|
||||
* Modèle **Estrannaise analytique** (v1.9.0) — remplace définitivement les
|
||||
* tables horaires ODS.
|
||||
*
|
||||
* SOURCE ORIGINELLE : le dépôt https://github.com/WHSAH/estrannaise.js/
|
||||
* (site estrannaise) — le modèle 3 compartiments avec les paramètres
|
||||
* d'ajustement **publiés par Estrannaise** eux-mêmes :
|
||||
*
|
||||
* ```
|
||||
* dB(t)/dt = −k1·B(t)
|
||||
* dEE(t)/dt = k1·B(t) − k2·EE(t)
|
||||
* dE2(t)/dt = k2·EE(t) − k3·E2(t)
|
||||
* ```
|
||||
*
|
||||
* forme close pour une dose `dose` (mg) à t = 0 (t en JOURS) :
|
||||
*
|
||||
* ```
|
||||
* C(t) = dose · d · k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3))
|
||||
* − e^(−k2·t)/((k1−k2)(k2−k3))
|
||||
* + e^(−k3·t)/((k1−k3)(k2−k3)) ]
|
||||
* ```
|
||||
*
|
||||
* **FIDÉLITÉ (épinglée par EstrannaiseModelsTest)** : cette forme close
|
||||
* reproduit les anciennes tables ODS **à l'identique** (RMS 0,00 sur 0→200 j
|
||||
* pour EV/EU/EEn, écart de pic ≤ 0,1 %) — vérifié avant le débranchement :
|
||||
* l'ODS n'était que l'échantillonnage horaire de ces formules.
|
||||
*
|
||||
* **NUAGE D'INCERTITUDE (v1.9.0)** : Estrannaise publie le **posterior
|
||||
* MCMC** de ses paramètres (313 échantillons `(d, k1, k2, k3)` par ester,
|
||||
* embarqués dans l'asset `mcmc_samples.json` — cf §15). Le graphique peut
|
||||
* dessiner un **nuage diffus** de courbes (une par échantillon) qui montre
|
||||
* la plage d'imprécision possible du modèle — feature exclusive au modèle
|
||||
* ESE (TFS/WHSAH ne publient pas de posterior).
|
||||
*
|
||||
* POURQUOI CE CHANGEMENT (décision v1.9.0) : « tous les modèles ont
|
||||
* maintenant leurs sources originelles — on abandonne complètement les
|
||||
* liens avec le fichier ODS, qui lui-même était une extrapolation de ces
|
||||
* mêmes sources ». Consequences : plus aucune lecture des tables au
|
||||
* runtime (l'asset `pk_profiles.json` reste uniquement pour les tests de
|
||||
* fidélité), t½ terminale **analytique** (ln2/min(k1,k2,k3)) au lieu de
|
||||
* l'estimation par pente de table.
|
||||
*
|
||||
* COUVERTURE : 6 esters injectables (EV, EU, EEn, EC, EB, EUCS) — le repo
|
||||
* source expose aussi 2 patchs (`patch tw`/`patch ow`) et un cycle
|
||||
* menstruel : hors scope v1.9.0 (documenté §7.12/§19).
|
||||
*
|
||||
* PUR sauf [init]/[initWithJson] (lecture de l'asset MCMC) — la formule
|
||||
* elle-même est testable en JVM.
|
||||
*/
|
||||
object EstrannaiseModels {
|
||||
|
||||
/** Paramètres d'un ester : d (facteur), k1/k2/k3 (vitesses, j⁻¹). */
|
||||
data class Param(val d: Double, val k1: Double, val k2: Double, val k3: Double) {
|
||||
/** t½ terminale ANALYTIQUE : la phase la plus lente domine la queue. */
|
||||
val terminalHalfLifeDays: Double
|
||||
get() = ln(2.0) / minOf(k1, k2, k3)
|
||||
}
|
||||
|
||||
/**
|
||||
* Paramètres d'ajustement publiés par Estrannaise (src/modeldata.js,
|
||||
* `PKParameters`). Clés = clés esters de l'app. ⚠️ Mapping depuis les
|
||||
* clés du repo : « EV im »→EV, « EUn im »→EU, « EEn im »→EEn,
|
||||
* « EC im »→EC, « EB im »→EB, « EUn casubq »→EUCS (nouveau code ester
|
||||
* v1.9.0 : undécylate en suspension cristalline).
|
||||
*/
|
||||
private val PK_PARAMETERS: Map<String, Param> = mapOf(
|
||||
"EV" to Param(478.0, 0.236, 4.85, 1.24),
|
||||
"EU" to Param(471.5, 0.01729, 6.528, 2.285),
|
||||
"EEN" to Param(191.4, 0.119, 0.601, 0.402),
|
||||
"EC" to Param(246.0, 0.0825, 3.57, 0.669),
|
||||
"EB" to Param(1893.1, 0.67, 61.5, 4.34),
|
||||
"EUCS" to Param(16.15, 0.046, 0.022, 0.101),
|
||||
)
|
||||
|
||||
fun hasModel(ester: String): Boolean = PK_PARAMETERS.containsKey(ester.uppercase())
|
||||
|
||||
/** Les paramètres de l'ester, ou null (lookup insensible à la casse). */
|
||||
fun model(ester: String): Param? = PK_PARAMETERS[ester.uppercase()]
|
||||
|
||||
/**
|
||||
* Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg,
|
||||
* forme close exacte (cf KDoc de l'objet). dtHours ≤ 0 → 0.
|
||||
* ⚠️ Le passage t heures → t jours est fait ici (le modèle travaille en jours).
|
||||
*/
|
||||
fun sample(ester: String, dtHours: Double): Double {
|
||||
val m = model(ester) ?: return 0.0
|
||||
if (dtHours <= 0.0) return 0.0
|
||||
return curveDays(dtHours / 24.0, 1.0, m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Courbe avec un jeu de paramètres DONNÉ (échantillon MCMC) — utilisé par
|
||||
* le nuage d'incertitude : chaque courbe du nuage superpose les doses du
|
||||
* porteur ESE avec UN échantillon du posterior. Normalisé par mg comme
|
||||
* [sample].
|
||||
*/
|
||||
fun sampleWithParams(ester: String, dtHours: Double, p: Param): Double {
|
||||
if (dtHours <= 0.0) return 0.0
|
||||
return curveDays(dtHours / 24.0, 1.0, p)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forme close 3C (t en JOURS) — cas général + les 3 cas dégénérés
|
||||
* (taux égaux → dénominateurs nuls : solution limite, portée du JS
|
||||
* source de estrannaise.js).
|
||||
*/
|
||||
private fun curveDays(t: Double, dose: Double, m: Param): Double {
|
||||
if (t < 0) return 0.0
|
||||
var ret = 0.0
|
||||
val (d, k1, k2, k3) = m
|
||||
if (dose > 0 && d > 0) {
|
||||
when {
|
||||
k1 == k2 && k2 == k3 ->
|
||||
ret += dose * d * k1 * k1 * t * t * exp(-k1 * t) / 2
|
||||
k1 == k2 && k2 != k3 ->
|
||||
ret += dose * d * k1 * k1 * (exp(-k3 * t) - exp(-k1 * t) * (1 + (k1 - k3) * t)) /
|
||||
(k1 - k3) / (k1 - k3)
|
||||
k1 != k2 && k1 == k3 ->
|
||||
ret += dose * d * k1 * k2 * (exp(-k2 * t) - exp(-k1 * t) * (1 + (k1 - k2) * t)) /
|
||||
(k1 - k2) / (k1 - k2)
|
||||
k1 != k2 && k2 == k3 ->
|
||||
ret += dose * d * k1 * k2 * (exp(-k1 * t) - exp(-k2 * t) * (1 - (k1 - k2) * t)) /
|
||||
(k1 - k2) / (k1 - k2)
|
||||
else ->
|
||||
ret += dose * d * k1 * k2 * (
|
||||
exp(-k1 * t) / (k1 - k2) / (k1 - k3) -
|
||||
exp(-k2 * t) / (k1 - k2) / (k2 - k3) +
|
||||
exp(-k3 * t) / (k1 - k3) / (k2 - k3)
|
||||
)
|
||||
}
|
||||
}
|
||||
return if (ret.isNaN()) 0.0 else ret
|
||||
}
|
||||
|
||||
// ── Posterior MCMC (nuage d'incertitude) ────────────────────────────────
|
||||
|
||||
@Volatile
|
||||
private var mcmc: Map<String, List<Param>>? = null
|
||||
|
||||
/**
|
||||
* Charge le posterior MCMC depuis l'asset `mcmc_samples.json`
|
||||
* (313 échantillons `(d, k1, k2, k3)` par ester injectable — cf §15).
|
||||
* Appelé au démarrage ([com.hormonetrack.HormoneTrackApp]) — le nuage
|
||||
* n'en a besoin qu'au premier affichage, mais le chargement est court
|
||||
* (≈ 48 Ko) et évite un état « chip actif sans données ».
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
if (mcmc != null) return
|
||||
synchronized(this) {
|
||||
if (mcmc != null) return
|
||||
val json = context.assets.open("mcmc_samples.json").bufferedReader().use { it.readText() }
|
||||
initWithJson(json)
|
||||
}
|
||||
}
|
||||
|
||||
/** Point d'entrée testable (JVM) — même pattern que PKProfileStore.initWithJson. */
|
||||
fun initWithJson(json: String) {
|
||||
synchronized(this) {
|
||||
val root = JsonParser.parseString(json).asJsonObject
|
||||
val parsed = LinkedHashMap<String, List<Param>>()
|
||||
for ((ester, value) in root.entrySet()) {
|
||||
val arr = value.asJsonArray
|
||||
val samples = ArrayList<Param>(arr.size())
|
||||
for (s in arr) {
|
||||
val e = s.asJsonArray
|
||||
samples.add(Param(e[0].asDouble, e[1].asDouble, e[2].asDouble, e[3].asDouble))
|
||||
}
|
||||
parsed[ester.uppercase()] = samples
|
||||
}
|
||||
mcmc = parsed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Les échantillons MCMC d'un ester (vide si l'asset n'est pas chargé ou
|
||||
* ester inconnu) — le nuage d'incertitude en tire M jeux de paramètres
|
||||
* (échelonnés, déterministes : pas d'aléatoire à chaque frame).
|
||||
*/
|
||||
fun mcmcSamples(ester: String): List<Param> =
|
||||
mcmc?.get(ester.uppercase()) ?: emptyList()
|
||||
}
|
||||
413
app/src/main/java/com/hormonetrack/pk/LabTiming.kt
Normal file
413
app/src/main/java/com/hormonetrack/pk/LabTiming.kt
Normal file
@ -0,0 +1,413 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* RECOMMANDATION DE PROCHAINE PRISE DE SANG (v1.8.0) — déduite des courbes,
|
||||
* affichée dans la page Analyses (« Prochaine analyse recommandée »).
|
||||
*
|
||||
* PRINCIPE PHARMACOCINÉTIQUE (choix validé avec l'utilisatrice) :
|
||||
* 1. **Le creux (trough) juste avant l'injection suivante** est le moment le
|
||||
* plus informatif et reproductible pour un ester injectable : le pic
|
||||
* varie énormément d'un cycle à l'autre, le creux reflète le niveau de
|
||||
* fond réel et l'accumulation ;
|
||||
* 2. un creux n'est interprétable que si le régime est **stabilisé** — règle
|
||||
* classique des **5 demi-vies terminales** (≈ 97 % de l'équilibre). Après
|
||||
* un changement d'ester/dose/rythme, les prises intermédiaires reflètent
|
||||
* une transition, pas le régime ;
|
||||
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0 ; **fix
|
||||
* #68 v1.10.0** : la stabilisation se juge sur le CREUX lui-même —
|
||||
* l'instant où la prise de sang serait faite — pas sur le créneau
|
||||
* d'injection qui le suit).
|
||||
*
|
||||
* CALCUL (tout est déduit de ce que l'app connaît déjà) :
|
||||
* - **traitement porteur** : ESTRADIOL + injectable + ACTIF + Posologie
|
||||
* renseignée (`forecastIntervalDays > 0`) — sans Posologie, pas de creux
|
||||
* prévisible → `null` (la page Analyses affiche alors une invite à la
|
||||
* renseigner) ;
|
||||
* - **t½ terminale** de l'ester : ANALYTIQUE pour les trois modèles
|
||||
* (TFS/WHSAH : `terminalHalfLifeDays` des fits ; ESE : idem depuis
|
||||
* v1.9.0 — le modèle Estrannaise est devenu analytique, cf
|
||||
* [EstrannaiseModels]) ;
|
||||
* - **début du régime courant** = 1ʳᵉ dose du traitement actif — PROXY
|
||||
* assumé et documenté : l'app ne trace pas l'historique des éditions de
|
||||
* Posologie/dose, la première dose du traitement est le meilleur
|
||||
* estimateur du début du régime (même sémantique que le « temps sous THS ») ;
|
||||
* - **creux** = niveau PRÉ-INJECTION du créneau (dernier point de la courbe
|
||||
* E2 prévisionnelle juste avant l'injection — principe 1 ; **fix #68
|
||||
* v1.10.0** : l'ancien minimum de fenêtre entière tombait juste après
|
||||
* l'injection PRÉCÉDENTE pour les esters à montée lente comme l'EEn, dont
|
||||
* le pic ~J+5 avoisine l'intervalle de 7 j). La FORME suffit : calcul
|
||||
* SANS calibration (le facteur d'échelle est multiplicatif, il ne
|
||||
* déplace pas le minimum) ;
|
||||
* - **filtres** : creux ≥ maintenant + [MIN_HORIZON_HOURS] (pas de
|
||||
* recommandation quasi-immédiate), creux > dernière prise de sang
|
||||
* existante (ne jamais recommander un creux déjà mesuré), créneau ≥
|
||||
* stabilisation.
|
||||
*
|
||||
* LIMITES ASSUMÉES (documentées, cf doc §7.11) :
|
||||
* - le creux est une ESTIMATION du modèle (pas une physiologie mesurée) —
|
||||
* la carte porte un disclaimer ;
|
||||
* - pas de Posologie, pas de recommandation (l'invite invite à la
|
||||
* renseigner) ;
|
||||
* - horizon borné à [MAX_FORECAST_INTERVALS] créneaux : si la stabilisation
|
||||
* dépasse (ester ultra-long, ex. EU t½ 31 j → ~155 j), `null` (carte
|
||||
* cachée plutôt qu'une recommandation douteuse).
|
||||
*
|
||||
* PUR (aucune dépendance Android) → testable en JVM (LabTimingTest) ;
|
||||
* portage web : `js/pk/lab-timing.js` (miroir fidèle).
|
||||
*/
|
||||
object LabTiming {
|
||||
|
||||
/** Règle de stabilisation : 5 demi-vies terminales (≈ 97 % de l'équilibre). */
|
||||
const val STABILIZATION_HALF_LIVES = 5
|
||||
|
||||
/**
|
||||
* Horizon de créneaux prévisionnels explorés : assez pour couvrir la
|
||||
* stabilisation d'un ester raisonnable (ex. EEn TFS : 5 × 4,5 j ≈ 23 j →
|
||||
* 4 intervalles de 7 j), borné — au-delà (ester ultra-long), `null`.
|
||||
*/
|
||||
const val MAX_FORECAST_INTERVALS = 12
|
||||
|
||||
/** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */
|
||||
const val MIN_HORIZON_HOURS = 6L
|
||||
|
||||
/**
|
||||
* Position d'une valeur par rapport à la CIBLE DE CREUX personnelle
|
||||
* (v1.13.0, opt-in). ⚠️ DISTINCT des seuils d'alerte (pk/Alerts.kt,
|
||||
* §9.bis) : les alertes surveillent le niveau estimé EN CONTINU et
|
||||
* notifient ; la cible de creux est une RÉFÉRENCE DE LAB — « quand je
|
||||
* pique au creux recommandé, je veux être dans [low, high] » — évaluée
|
||||
* en lecture seule sur la carte, JAMAIS de notification.
|
||||
*/
|
||||
enum class TroughTargetStatus { IN_TARGET, BELOW, ABOVE }
|
||||
|
||||
/**
|
||||
* Statut de [predictedE2] par rapport à la cible [low, high].
|
||||
* `null` si pas de prédiction OU cible incomplète/désactivée.
|
||||
* PUR → testable en JVM ; miroir web `troughTargetStatus`
|
||||
* (js/pk/lab-timing.js).
|
||||
*/
|
||||
fun troughTargetStatus(
|
||||
predictedE2: Double?,
|
||||
low: Double?,
|
||||
high: Double?
|
||||
): TroughTargetStatus? {
|
||||
val value = predictedE2 ?: return null
|
||||
val lo = low ?: return null
|
||||
val hi = high ?: return null
|
||||
return when {
|
||||
value < lo -> TroughTargetStatus.BELOW
|
||||
value > hi -> TroughTargetStatus.ABOVE
|
||||
else -> TroughTargetStatus.IN_TARGET
|
||||
}
|
||||
}
|
||||
|
||||
/** Résultat : tout ce que l'UI affiche sur la carte de recommandation. */
|
||||
data class LabRecommendation(
|
||||
/** Date/heure du CREUX exact (minimum prévisionnel entre 2 injections). */
|
||||
val troughMs: Long,
|
||||
/** Créneau d'INJECTION associé (juste après le creux). */
|
||||
val injectionMs: Long,
|
||||
/** Ester du traitement porteur (ex. "EEN") — pour le texte. */
|
||||
val ester: String,
|
||||
/** t½ terminale utilisée (jours) — pour le texte « ~5 demi-vies ». */
|
||||
val terminalHalfLifeDays: Double,
|
||||
/** Début du régime courant (1ʳᵉ dose du traitement actif). */
|
||||
val regimeStartMs: Long,
|
||||
/** Date de stabilisation = régimeStart + 5 × t½. */
|
||||
val stabilizedAtMs: Long,
|
||||
/**
|
||||
* true = le régime était DÉJÀ stabilisé à l'instant du calcul
|
||||
* (texte « stabilisé depuis le … ») ; false = la stabilisation
|
||||
* arrive entre maintenant et le creux recommandé (texte « c'est le
|
||||
* premier creux fiable »).
|
||||
*/
|
||||
val wasAlreadyStabilized: Boolean,
|
||||
/**
|
||||
* E2 ATTENDUE à ce creux (v1.13.0) : valeur brute du modèle ×
|
||||
* facteur d'échelle du ester actif — fourni par l'appelant quand
|
||||
* la calibration est disponible (auto-calibration ON). `null` =
|
||||
* pas de calibration fiable → la carte n'affiche PAS de valeur
|
||||
* (honnêteté : une valeur brute non calibrée serait trompeuse).
|
||||
*/
|
||||
val predictedE2: Double? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Calcule la prochaine prise de sang recommandée, ou `null` si rien n'est
|
||||
* calculable honnêtement (pas de traitement E2 injectable actif à
|
||||
* Posologie, pas de doses pour cadrer la grille, stabilisation hors
|
||||
* horizon, pas de creux exploitable). L'appelant CACHE la carte sur null
|
||||
* (et peut afficher l'invite « renseigne une Posologie » quand un
|
||||
* injectable actif en est dépourvu).
|
||||
*/
|
||||
fun nextBloodDrawRecommendation(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
labs: List<LabResult>,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
/**
|
||||
* Facteurs d'échelle PAR ESTER (v1.13.0) — issus de l'auto-calibration
|
||||
* quand elle est activée (sinon `null`) : permet de retourner la
|
||||
* valeur E2 ATTENDUE au creux ([LabRecommendation.predictedE2]).
|
||||
* Absent → `predictedE2 = null` (carte sans valeur).
|
||||
*/
|
||||
scalePerEster: Map<String, Double>? = null
|
||||
): LabRecommendation? {
|
||||
// ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ─────
|
||||
val carrier = treatments.firstOrNull {
|
||||
it.type == TreatmentType.ESTRADIOL &&
|
||||
it.isActive &&
|
||||
it.usesProfileModel &&
|
||||
(it.forecastIntervalDays ?: 0.0) > 0.0
|
||||
} ?: return null
|
||||
|
||||
// ── 2) t½ terminale de l'ester (analytique ou lue dans la table) ────
|
||||
val tHalfDays = when (carrier.pkModel) {
|
||||
PKModels.TRANSFEM_SCIENCE ->
|
||||
TransfemScienceModels.model(carrier.esterType)?.terminalHalfLifeDays
|
||||
PKModels.WHSAH ->
|
||||
WhsahModels.model(carrier.esterType)?.terminalHalfLifeDays
|
||||
// v1.9.0 : ESE analytique (forme close estrannaise.js) — la t½
|
||||
// n'est plus estimée par pente de table
|
||||
PKModels.ESTRANNAISE ->
|
||||
EstrannaiseModels.model(carrier.esterType)?.terminalHalfLifeDays
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
// ── 3) Début du régime courant + date de stabilisation ──────────────
|
||||
val myDoses = doseLogs.filter { it.treatmentId == carrier.id }
|
||||
if (myDoses.isEmpty()) return null
|
||||
val lastDose = myDoses.maxByOrNull { it.timestamp }!!
|
||||
val activeEster = PharmacokineticEngine.doseEster(carrier, lastDose)
|
||||
// v1.13.0 : régime POOLÉ (cf [pooledRegimeDoses]) — le re-parenting
|
||||
// d'historique entre traitements (ex. « 6d - old » créé a posteriori,
|
||||
// remontée v1.12.0) ne doit pas faire croire à un nouveau régime.
|
||||
val regimeStart = regimeStartMs(carrier, pooledRegimeDoses(carrier, doseLogs, treatments))
|
||||
val stabilizedAtMs = regimeStart +
|
||||
(STABILIZATION_HALF_LIVES * tHalfDays * 24.0 * 3_600_000.0).toLong()
|
||||
|
||||
// ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ──
|
||||
val intervalDays = carrier.forecastIntervalDays!!
|
||||
val intervalsNeeded = maxOf(
|
||||
3L,
|
||||
ceil(STABILIZATION_HALF_LIVES * tHalfDays / intervalDays).toLong() + 1L
|
||||
).coerceAtMost(MAX_FORECAST_INTERVALS.toLong())
|
||||
val horizonMs = nowMs + (intervalsNeeded * intervalDays * 24.0 * 3_600_000.0).toLong()
|
||||
val slots = PharmacokineticEngine.generateForecastDoses(
|
||||
carrier, doseLogs, horizonMs, nowMs
|
||||
)
|
||||
if (slots.isEmpty()) return null
|
||||
|
||||
// ── 5) Courbe E2 fine (FORME brute — sans calibration, le facteur
|
||||
// multiplicatif ne déplace pas le minimum) ─────────────────────
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
treatments, doseLogs + slots, nowMs, slots.last().timestamp,
|
||||
stepMs = 3_600_000L, tConfig = TConfig()
|
||||
)
|
||||
|
||||
// ── 6) Premier creux STABILISÉ jamais mesuré ─────────────────────────
|
||||
val lastLabMs = labs.maxOfOrNull { it.timestamp } ?: 0L
|
||||
val minTroughMs = nowMs + MIN_HORIZON_HOURS * 3_600_000L
|
||||
var windowStart = nowMs
|
||||
for (slot in slots) {
|
||||
// ⚠️ FIX #68 (v1.10.0) : le creux = niveau PRÉ-INJECTION du
|
||||
// créneau (dernier point de courbe avant l'injection — principe 1,
|
||||
// « le moment le plus comparable »). L'ancien MINIMUM DE LA
|
||||
// FENÊTRE ENTIÈRE tombait au DÉBUT de la fenêtre pour les esters
|
||||
// à montée lente (EEn : pic ~J+5 ≈ intervalle 7 j → le minimum
|
||||
// local est le creux d'absorption, quelques heures après
|
||||
// l'injection PRÉCÉDENTE) : la date proposée précédait la
|
||||
// stabilisation affichée sur la MÊME carte, et le texte
|
||||
// « juste avant ton injection du … » était faux de plusieurs
|
||||
// jours (remontée v1.9.8, épinglé par RegressionUserCase6Test).
|
||||
// Pour les esters à t½ courte (EV), le point pré-injection EST
|
||||
// le minimum de fenêtre : comportement inchangé.
|
||||
val trough = curve.lastOrNull {
|
||||
it.timestamp > windowStart && it.timestamp < slot.timestamp
|
||||
}
|
||||
if (trough != null &&
|
||||
trough.timestamp >= minTroughMs &&
|
||||
trough.timestamp > lastLabMs &&
|
||||
// ON SAUTE au premier creux stabilisé (décision v1.8.0) :
|
||||
// le creux pré-injection ≈ instant du créneau, le garde
|
||||
// exprime la stabilisation sur l'instant réellement proposé
|
||||
// pour la prise de sang (trough < slot de ~1 pas de courbe).
|
||||
trough.timestamp >= stabilizedAtMs
|
||||
) {
|
||||
return LabRecommendation(
|
||||
troughMs = trough.timestamp,
|
||||
injectionMs = slot.timestamp,
|
||||
ester = activeEster,
|
||||
terminalHalfLifeDays = tHalfDays,
|
||||
regimeStartMs = regimeStart,
|
||||
stabilizedAtMs = stabilizedAtMs,
|
||||
wasAlreadyStabilized = stabilizedAtMs <= nowMs,
|
||||
// v1.13.0 : E2 attendue au creux = valeur brute du
|
||||
// modèle × facteur du ester actif (lookup insensible à
|
||||
// la casse — les clés de scalePerEster suivent les
|
||||
// esters stockés). null si pas de calibration.
|
||||
predictedE2 = scalePerEster
|
||||
?.entries
|
||||
?.firstOrNull { it.key.equals(activeEster, ignoreCase = true) }
|
||||
?.value
|
||||
?.let { scale -> trough.e2 * scale }
|
||||
)
|
||||
}
|
||||
windowStart = slot.timestamp
|
||||
}
|
||||
return null // stabilisation hors horizon (ester ultra-long) → carte cachée
|
||||
}
|
||||
|
||||
/** Tolérance de comparaison des doses (mg) — évite les faux changements d'arrondi. */
|
||||
private const val DOSE_EPS = 1e-6
|
||||
|
||||
/**
|
||||
* SÉQUENCE DE RÉGIME POOLÉE (v1.13.0) : toutes les doses de TOUS les
|
||||
* traitements partageant l'**ester effectif** du porteur ET son **mg
|
||||
* exact** (± [DOSE_EPS]), triées par timestamp.
|
||||
*
|
||||
* **Pourquoi** (remontée v1.12.0) : le re-parenting d'historique — créer
|
||||
* un traitement « … - old » inactif et y déplacer des doses (ex. séparer
|
||||
* une ère « 6 j » d'une ère « 7 j ») — créait un TROU synthétique dans
|
||||
* l'historique du porteur : le régime redémarrait à la date du
|
||||
* re-parenting et la stabilisation (5 × t½) repartait de plusieurs
|
||||
* semaines, alors que pharmacologiquement rien n'avait changé (même
|
||||
* ester, même dose).
|
||||
*
|
||||
* Garde-fous (épinglés par tests) :
|
||||
* - **ester ≠ → pas de mutualisation** (transition EV → EEn : deux
|
||||
* régimes distincts — régression n°3) ;
|
||||
* - **dose ≠ → exclue du pool** (une dose « 8 mg » au milieu d'un
|
||||
* régime « 5 mg » casse naturellement la séquence via le trou qui
|
||||
* suit — conservateur, ~1 semaine du côté prudent).
|
||||
*
|
||||
* PUR → testable en JVM (DosesExtras? non — LabTimingTest +
|
||||
* RegressionUserCase7Test) ; miroir web `pooledRegimeDoses`
|
||||
* (js/pk/lab-timing.js).
|
||||
*/
|
||||
internal fun pooledRegimeDoses(
|
||||
carrier: Treatment,
|
||||
allDoseLogs: List<DoseLog>,
|
||||
allTreatments: List<Treatment>
|
||||
): List<DoseLog> {
|
||||
val last = allDoseLogs
|
||||
.filter { it.treatmentId == carrier.id }
|
||||
.maxByOrNull { it.timestamp } ?: return emptyList()
|
||||
val activeEster = PharmacokineticEngine.doseEster(carrier, last)
|
||||
val amount = last.doseAmount
|
||||
val ownerById = allTreatments.associateBy { it.id }
|
||||
return allDoseLogs
|
||||
.filter { d ->
|
||||
// Ester EFFECTIF via le traitement PROPRIÉTAIRE de la dose
|
||||
// (override par dose compris) — jamais via le porteur
|
||||
val owner = ownerById[d.treatmentId] ?: return@filter false
|
||||
PharmacokineticEngine.doseEster(owner, d) == activeEster &&
|
||||
Math.abs(d.doseAmount - amount) < DOSE_EPS
|
||||
}
|
||||
.sortedBy { it.timestamp }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolérance de l'écart inter-doses du régime (v1.9.7) : ± 24 h autour de
|
||||
* l'intervalle de Posologie — « je m'injecte le même jour de la semaine,
|
||||
* à l'heure près ». Un log à 12:30 au lieu de 12:00 ne doit pas
|
||||
* repousser la stabilisation de 5 × t½ (remontée v1.9.7) ; un vrai
|
||||
* changement de créneau (2 j au lieu de 7 j) reste hors fenêtre.
|
||||
*/
|
||||
private val GAP_TOLERANCE_MS = 24L * 3_600_000L
|
||||
|
||||
/**
|
||||
* DÉBUT DU RÉGIME COURANT (v1.8.1 — **correction du proxy v1.8.0**).
|
||||
*
|
||||
* **Critique remontée** (v1.8.0) : « je suis passée sous un autre ester,
|
||||
* j'ai changé de dosage ET de posologie, et l'app me disait "stabilisée
|
||||
* depuis février" » — l'ancien proxy (1ʳᵉ dose du traitement) ne voyait
|
||||
* PAS les changements récents de dose ni d'intervalle.
|
||||
*
|
||||
* **Critique remontée** (v1.9.7) : « la suggestion de prochaine prise de
|
||||
* sang change tout le temps à chaque injection si l'injection n'est pas
|
||||
* faite pile à la même heure » — la comparaison EXACTE de v1.8.1 (écart
|
||||
* identique au ms près) cassait le régime pour un log à 12:30 au lieu de
|
||||
* 12:00, repoussant la stabilisation de 5 × t½ à CHAQUE injection : le
|
||||
* creux recommandé fuyait vers l'avant sans jamais se fixer.
|
||||
*
|
||||
* **RÈGLE** : le régime courant = la **séquence terminale de doses**
|
||||
* (v1.13.0 : **poolée** entre traitements — cf [pooledRegimeDoses] — le
|
||||
* re-parenting d'historique ne doit plus redémarrer la stabilisation)
|
||||
* où (a) l'**ester effectif** et la **dose (mg)** sont identiques à
|
||||
* celles de la dose la plus récente, ET (b) chaque **écart entre doses
|
||||
* consécutives reste dans l'INTERVALLE DE POSOLOGIE ± 24 h**
|
||||
* ([GAP_TOLERANCE_MS] — « je m'injecte le même jour, à l'heure près »).
|
||||
* On remonte depuis la dose la plus récente tant que ces conditions
|
||||
* tiennent ; le régime commence à la dose la plus ancienne de la
|
||||
* séquence.
|
||||
*
|
||||
* Pourquoi chaque composante :
|
||||
* - **ester effectif** (override par dose compris) : un switch EV → EEn
|
||||
* réinitialise la stabilisation ;
|
||||
* - **doseAmount** : 3 mg → 5 mg change le niveau d'équilibre ;
|
||||
* - **écart ≈ intervalle de Posologie ± 24 h** : le trough n'est
|
||||
* comparable que sur un intervalle régulier (pharmacocinétique), mais
|
||||
* l'heure de log RÉELLE d'une humaine varie — un écart 7 j ± 23 h EST
|
||||
* le même régime ; 7 j + 25 h (ou un vrai passage à 2 j = 48 h, hors
|
||||
* fenêtre) casse le régime. La fenêtre se réfère à l'INTERVALLE
|
||||
* THÉORIQUE (Posologie), pas à l'écart précédent : des logs à 6,8 j
|
||||
* puis 7,2 j ne se déstabilisent plus en cascade.
|
||||
*
|
||||
* @param carrier traitement porteur (injectable E2 actif à Posologie)
|
||||
* @param myDoses doses du porteur (ordre indifférent)
|
||||
* @return timestamp de la 1ʳᵉ dose du régime courant
|
||||
*/
|
||||
internal fun regimeStartMs(carrier: Treatment, myDoses: List<DoseLog>): Long {
|
||||
val sorted = myDoses.sortedBy { it.timestamp }
|
||||
var idx = sorted.size - 1
|
||||
var regimeStart = sorted[idx].timestamp
|
||||
var current = sorted[idx]
|
||||
// Fenêtre d'écart = INTERVALLE DE POSOLOGIE ± tolérance (v1.9.7 —
|
||||
// remplace la comparaison exacte v1.8.1 qui cassait le régime à
|
||||
// chaque variation d'heure de log). La Posologie est un prérequis de
|
||||
// la carte : absente → on s'arrête à la dernière dose (dégradé).
|
||||
val intervalDays = carrier.forecastIntervalDays ?: return regimeStart
|
||||
val intervalMs = (intervalDays * 24.0 * 3_600_000.0).toLong()
|
||||
val gapMin = intervalMs - GAP_TOLERANCE_MS
|
||||
val gapMax = intervalMs + GAP_TOLERANCE_MS
|
||||
while (idx > 0) {
|
||||
val prev = sorted[idx - 1]
|
||||
val gap = current.timestamp - prev.timestamp
|
||||
val sameMarker = PharmacokineticEngine.doseEster(carrier, current) == PharmacokineticEngine.doseEster(carrier, prev) &&
|
||||
Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS
|
||||
// Remonter seulement si même (ester, dose) ET écart dans la
|
||||
// fenêtre (gap > 0 : un doublon de log n'est jamais un régime)
|
||||
val sameGap = gap > 0 && gap in gapMin..gapMax
|
||||
if (!sameMarker || !sameGap) break
|
||||
regimeStart = prev.timestamp
|
||||
current = prev
|
||||
idx--
|
||||
}
|
||||
return regimeStart
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite d'AFFICHAGE (v1.8.0) : existe-t-il un traitement E2 injectable
|
||||
* ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la
|
||||
* renseigner pour recevoir des recommandations (demande v1.8.0 : « il
|
||||
* faut préciser quelque part sur la page des analyses qu'en l'absence
|
||||
* de posologie, en ajouter une permet de recevoir des recommandations »).
|
||||
* La suggestion ne s'affiche QUE si aucune recommandation n'est calculable
|
||||
* (elle ne remplace pas une vraie reco).
|
||||
*/
|
||||
fun shouldSuggestPosology(treatments: List<Treatment>): Boolean =
|
||||
treatments.any {
|
||||
it.type == TreatmentType.ESTRADIOL &&
|
||||
it.isActive &&
|
||||
it.usesProfileModel &&
|
||||
(it.forecastIntervalDays ?: 0.0) <= 0.0
|
||||
}
|
||||
}
|
||||
294
app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt
Normal file
294
app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt
Normal file
@ -0,0 +1,294 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.ln
|
||||
|
||||
/**
|
||||
* « Tracé labs » (v1.5.0) — courbe hybride ancrée sur les résultats de sang.
|
||||
*
|
||||
* DEMANDE (décision v1.5.0, plan validé avec l'utilisatrice après balancement
|
||||
* variantes A/B — cf doc §7.10) : une courbe « biologiquement plausible MAIS
|
||||
* passant par tous les points de lab ». Formule :
|
||||
*
|
||||
* courbe(t) = M(t) × ρ(t)
|
||||
*
|
||||
* où :
|
||||
* - **M(t)** = niveau du MODÈLE PK brut (superposition des contributions,
|
||||
* scaleFactor forcé à 1, modèle stocké ou `modelOverride`) — il porte TOUTE
|
||||
* la physiologie entre les labs (Tmax, queues, accumulation) ;
|
||||
* - **ρ(t) = lab_k / M(t_k)** à chaque lab — l'amplitude du modèle est
|
||||
* recalée sur la mesure réelle ; ENTRE deux labs consécutifs, ρ
|
||||
* interpole LOG-LINÉAIREMENT (morphing d'amplitude multiplicativement
|
||||
* continu : un passage d'un facteur entre 2 labs ne fait pas de saut
|
||||
* vertical) ;
|
||||
* - courbe(t_k) = M(t_k) × (lab_k / M(t_k)) = **lab_k exactement** :
|
||||
* le passage par chaque point est GARANTI par construction.
|
||||
*
|
||||
* GARDE ANTI-#61 (labIsSignificant) : les labs traités par timestamp
|
||||
* CROISSANT qui ne « gouvernent » plus la prédiction (< 15 % du max déjà
|
||||
* observé) ne sont PAS des ancrages — la dose de test de janvier + des
|
||||
* labs tardifs (#61) ne créent pas de zigzag. À la différence des
|
||||
* pipelines de calibration (#60), un lab rejeté n'est PAS utilisé du tout.
|
||||
*
|
||||
* PORTÉE ET LIMITES (essentielles — cf doc §7.10 / décision plan v1.5.0) :
|
||||
* - **E2 uniquement** (les labs T : 1) sont trop rares, 2) leurs unités
|
||||
* variées ont causé le bug #26 — hors scope) ;
|
||||
* - **fenêtre = [1er lab significatif ; dernier lab]** par défaut — rien
|
||||
* AVANT (M=0 sans dose) ni APRÈS (aucune extrapolation) ;
|
||||
* - **PROLONGATION (v1.6.0, opt-in `extendBeyondLastLab = true`)** : la
|
||||
* courbe peut se PROLONGER au-delà du dernier lab — cf détail ci-dessous ;
|
||||
* - < 2 labs significatifs → courbe vide (un lab seul ne définit pas
|
||||
* d'intervalle) — et donc jamais de prolongation non plus ;
|
||||
* - indépendante de la calibration : cette courbe EST SA PROPRE
|
||||
* calibration continue (ratio brut, scaleFactor des traitements ignoré
|
||||
* ET scalePerEster ignoré — l'inverse serait une double correction,
|
||||
* épinglé par test) ;
|
||||
* - **display-end only** : utilisée UNIQUEMENT par le graphique (chip,
|
||||
* off par défaut). Jamais branchée sur `levelAt` — l'accueil, les
|
||||
* seuils d'alerte et les rappels continuent d'utiliser le modèle
|
||||
* calibré (invariant v1.4.2 §9.bis). La partie PROLONGÉE ne fait pas
|
||||
* exception : elle est purement visuelle (série "LABX" du chart).
|
||||
* - courbes T non modélisées ici : la série n'a pas de tStyle (le chart
|
||||
* ne dessine pas de T pour la série — cf CurveChart).
|
||||
*
|
||||
* PUR (aucune dépendance Android) → testable en JVM
|
||||
* (LabTrajectoryModelTest — l'ancrage exact, la garde #61 exactement,
|
||||
* les bornes de fenêtre, la prolongation).
|
||||
*
|
||||
* ── PROLONGATION AU-DELÀ DU DERNIER LAB (v1.6.0) ──────────────────────────
|
||||
*
|
||||
* DEMANDE (v1.6.0) : « permettre au tracé labs de s'étendre au-delà du
|
||||
* dernier lab, en simulant à partir des labs précédents, du dosage et du
|
||||
* type d'ester injecté, et de l'évolution classique de l'ester ».
|
||||
*
|
||||
* Le constat v1.5.0 : la fenêtre s'arrêtait AU dernier lab — entre ce lab
|
||||
* et « maintenant » (voire au-delà, avec la prévision), la courbe n'existait
|
||||
* pas, alors que c'est précisément la période la plus récente qu'on veut
|
||||
* comparer aux courbes des modèles.
|
||||
*
|
||||
* PRINCIPE : après le dernier ancrage, ρ cesse d'interpoler et reste CONSTANT :
|
||||
*
|
||||
* pour t > t_last_lab : courbe(t) = M(t) × ρ_last
|
||||
*
|
||||
* Justification, composant par composant (la demande, point par point) :
|
||||
* - **« l'évolution classique de l'ester »** = M(t) : la forme du modèle PK
|
||||
* (montée, Tmax, queues, accumulation) porte toute la dynamique ;
|
||||
* - **« le dosage et le type d'ester injecté »** = M(t) inclut AUTOMATIQUEMENT
|
||||
* les doses loguées après le dernier lab : une nouvelle injection (même à
|
||||
* un autre ester) fait repartir la courbe en pic, à l'amplitude calibrée ;
|
||||
* - **« les données de labs précédentes »** = ρ_last : le rapport
|
||||
* corps-réel ÷ modèle observé au dernier lab est supposé rester valable.
|
||||
*
|
||||
* POURQUOI ρ CONSTANT et pas une extrapolation de la PENTE de ρ : la pente
|
||||
* entre deux labs ne mesure pas une tendance du corps, elle morphing deux
|
||||
* corrections d'amplitude — l'extrapoler divergerait sans aucune base
|
||||
* physiologique (un ratio de calibration qui monte de 5 %/jour finirait par
|
||||
* des facteurs ×10 inventés). La calibration observée au dernier lab est le
|
||||
* meilleur estimateur disponible pour la suite ; c'est exactement ce que
|
||||
* fait la calibration classique (le facteur stocké s'applique aux doses
|
||||
* futures — §7.6).
|
||||
*
|
||||
* BORNE DE PROLONGATION (horizon) : la courbe prolongée s'arrête quand le
|
||||
* MODÈLE lui-même s'éteint — `extensionHorizonEndMs` = dernière dose E2 +
|
||||
* [PharmacokineticEngine.cutoffHours] de son traitement (10 t½ terminales pour V3C/WHS, fin de
|
||||
* table pour ODS, 30 t½ Bateman). Au-delà, M(t) = 0 : dessiner plus serait
|
||||
* une ligne à zéro purement inventée. La prolongation est en plus clippée
|
||||
* par la fenêtre DEMANDÉE (celle du graphique).
|
||||
*
|
||||
* LIMITES ASSUMÉES (documentées, pas de bug — cf §7.10.bis) :
|
||||
* - changement d'ESTER après le dernier lab : ρ_last a été mesuré sous
|
||||
* l'ancien ester ; l'amplitude du nouveau reste calibrée par ce ρ —
|
||||
* même limite que la calibration classique (fallback du facteur stocké,
|
||||
* pas de lab de la nouvelle période) ;
|
||||
* - les doses PRÉVISIONNELLES ne participent PAS : la prolongation ne
|
||||
* consomme que les doses RÉELLES (le futur projeté reste la série
|
||||
* « Prévision », qui superpose déjà les modèles calibrés).
|
||||
*/
|
||||
object LabTrajectoryModel {
|
||||
|
||||
/**
|
||||
* Résultat : les points (E2 ancrée, t = 0 — pas de courbe T pour cette
|
||||
* série) + le nombre de labs réellement utilisés comme ancrages
|
||||
* (diagnostic dans la doc ; 0 → courbe vide).
|
||||
*
|
||||
* [lastAnchorMs] (v1.6.0) : timestamp du DERNIER lab significatif (le
|
||||
* dernier point « réel » de la courbe). `null` si < 2 ancres (courbe
|
||||
* vide). L'écran Graphiques l'utilise pour SPLITTER la série en deux
|
||||
* traits distincts :
|
||||
* - "LAB" = points ≤ lastAnchorMs (ancré — passe par les labs) ;
|
||||
* - "LABX" = points > lastAnchorMs (prolongé — ρ constant, estimation).
|
||||
* La distinction visuelle préserve l'honnêteté de l'affichage : la partie
|
||||
* inventée ne peut pas se confondre avec la partie mesurée.
|
||||
*/
|
||||
data class AnchoredCurve(
|
||||
val points: List<LevelPoint>,
|
||||
val anchoredLabs: Int,
|
||||
val lastAnchorMs: Long? = null
|
||||
)
|
||||
|
||||
// Les ancrages sont des couples (timestamp du lab, ratio lab ÷ prédiction)
|
||||
// — traités en timeline triée croissante par ratioAt().
|
||||
|
||||
/**
|
||||
* Construit la courbe ancrée sur les labs.
|
||||
*
|
||||
* @param treatments TOUS les traitements (actifs ET inactifs — §6.bis :
|
||||
* l'inactivation ne retire pas l'historique de la
|
||||
* prédiction M(t))
|
||||
* @param doseLogs toutes les doses (RÉELLES uniquement — les doses
|
||||
* prévisionnelles ne participent jamais au tracé labs ;
|
||||
* les doses loguées APRÈS le dernier lab sont au
|
||||
* contraire INCLUSES : elles portent la forme de la
|
||||
* prolongation, cf KDoc de l'objet)
|
||||
* @param e2Labs labs E2 uniquement (les marqueurs autres sont
|
||||
* ignorés par `marker == "E2"`) — unité pg/mL
|
||||
* @param startMs/endMs fenêtre demandée (celle du graphique) — la courbe
|
||||
* est CLIPPÉE à l'intersection avec [lab 1er ; lab
|
||||
* dernier], ou avec [lab 1er ; horizon de prolongation]
|
||||
* quand [extendBeyondLastLab] est actif
|
||||
* @param stepMs pas d'échantillonnage (stepForRange, v1.2.9)
|
||||
* @param modelOverride modèle pour la prédiction M(t) (défaut = modèle
|
||||
* stocké, même sémantique que l'auto-calibration
|
||||
* Home v1.4.8)
|
||||
* @param extendBeyondLastLab PROLONGATION (v1.6.0, opt-in) : au-delà du
|
||||
* dernier lab significatif, ρ reste CONSTANT
|
||||
* (= ρ du dernier lab) et la courbe continue
|
||||
* `M(t) × ρ_last` jusqu'à
|
||||
* [extensionHorizonEndMs] ∩ fenêtre demandée.
|
||||
* false (défaut) = comportement v1.5.0 exact :
|
||||
* la fenêtre s'arrête au dernier lab.
|
||||
*/
|
||||
fun computeLabAnchoredCurve(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
e2Labs: List<LabResult>,
|
||||
startMs: Long,
|
||||
endMs: Long,
|
||||
stepMs: Long = 3_600_000L,
|
||||
modelOverride: String? = null,
|
||||
extendBeyondLastLab: Boolean = false
|
||||
): AnchoredCurve {
|
||||
if (startMs >= endMs) return AnchoredCurve(emptyList(), 0)
|
||||
|
||||
val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL }
|
||||
if (estrogenTreatments.isEmpty()) return AnchoredCurve(emptyList(), 0)
|
||||
|
||||
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le
|
||||
// facteur — combiner avec le facteur stocké = double correction).
|
||||
// Contexte moteur pré-calculé UNE fois (v1.9.5, audit) : la boucle
|
||||
// de grille appelle e2At par point (720 pts) — sans contexte
|
||||
// partagé, chaque appel reconstruisait les maps internes.
|
||||
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
||||
val e2Ctx = PharmacokineticEngine.prepareE2Context(unscaled, doseLogs)
|
||||
|
||||
// ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ───────────
|
||||
// Un couple (t_lab, ratio) par lab significatif, trié croissant.
|
||||
val anchors = mutableListOf<Pair<Long, Double>>()
|
||||
var maxPredictionSoFar = 0.0
|
||||
for (lab in e2Labs.filter { it.marker.equals("E2", ignoreCase = true) }.sortedBy { it.timestamp }) {
|
||||
val predicted = PharmacokineticEngine.e2At(ctx = e2Ctx, tMs = lab.timestamp, modelOverride = modelOverride)
|
||||
if (!PharmacokineticEngine.labIsSignificant(predicted, maxPredictionSoFar)) continue
|
||||
anchors.add(lab.timestamp to (lab.value / predicted))
|
||||
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
|
||||
}
|
||||
if (anchors.size < 2) return AnchoredCurve(emptyList(), anchors.size)
|
||||
|
||||
val firstLabMs = anchors.first().first
|
||||
val lastLabMs = anchors.last().first
|
||||
|
||||
// ── 2) Fenêtre effective ─────────────────────────────────────────────
|
||||
// v1.5.0 : [1er lab ; dernier lab] ∩ fenêtre demandée.
|
||||
// v1.6.0 (extendBeyondLastLab) : la borne droite devient le min de la
|
||||
// fenêtre demandée et de l'HORIZON DE PROLONGATION (dernière dose E2 +
|
||||
// cutoff de son traitement). Au-delà du dernier lab, ratioAt() retourne
|
||||
// déjà ρ_last (garde défensive aux bornes) : la boucle de calcul est
|
||||
// INCHANGÉE — élargir la fenêtre EST la prolongation, et la continuité
|
||||
// au point de suture est garantie par construction (ρ(t_last) = ρ_last
|
||||
// exactement, dérivable à gauche comme à droite puisque ρ est constant
|
||||
// après).
|
||||
val t0 = maxOf(startMs, firstLabMs)
|
||||
val t1 = if (extendBeyondLastLab) {
|
||||
minOf(endMs, extensionHorizonEndMs(estrogenTreatments, doseLogs, lastLabMs) ?: lastLabMs)
|
||||
} else {
|
||||
minOf(endMs, lastLabMs)
|
||||
}
|
||||
if (t1 <= t0) return AnchoredCurve(emptyList(), anchors.size)
|
||||
|
||||
// ── 3) Courbe = M(t) × ρ(t) sur la grille ─────────────────────────────
|
||||
val points = mutableListOf<LevelPoint>()
|
||||
var t = t0
|
||||
while (t <= t1) {
|
||||
val m = PharmacokineticEngine.e2At(e2Ctx, t, modelOverride)
|
||||
val rho = ratioAt(anchors, t)
|
||||
points.add(LevelPoint(t, m * rho, 0.0)) // t=0 : pas de courbe T pour cette série
|
||||
t += stepMs
|
||||
}
|
||||
return AnchoredCurve(points, anchors.size, lastLabMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* BORNE DE PROLONGATION (v1.6.0) : timestamp jusqu'où la partie extrapolée
|
||||
* a un sens. Défini par le MODÈLE, pas par un cap arbitraire :
|
||||
*
|
||||
* horizon = (timestamp de la dernière dose E2) + cutoffHours(son traitement)
|
||||
*
|
||||
* où cutoffHours = 10 t½ terminales (V3C/WHS), fin de table (ODS) ou
|
||||
* 30 t½ (Bateman) — cf [PharmacokineticEngine.cutoffHours]. Après cette
|
||||
* borne, M(t) = 0 : la courbe prolongée serait une ligne à zéro inventée.
|
||||
*
|
||||
* @return le timestamp de fin de prolongation, ou `null` quand il n'y a
|
||||
* RIEN à prolonger : aucune dose E2, ou modèle déjà éteint au dernier
|
||||
* lab (le caller retombe alors sur la fenêtre v1.5.0).
|
||||
*/
|
||||
internal fun extensionHorizonEndMs(
|
||||
estrogenTreatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
lastLabMs: Long
|
||||
): Long? {
|
||||
val e2Doses = doseLogs.filter { d -> estrogenTreatments.any { it.id == d.treatmentId } }
|
||||
val lastDose = e2Doses.maxByOrNull { it.timestamp } ?: return null
|
||||
val tr = estrogenTreatments.first { it.id == lastDose.treatmentId }
|
||||
val cutoffMs = (PharmacokineticEngine.cutoffHours(tr) * 3_600_000.0).toLong()
|
||||
val horizonEnd = lastDose.timestamp + cutoffMs
|
||||
return if (horizonEnd > lastLabMs) horizonEnd else null
|
||||
}
|
||||
|
||||
/**
|
||||
* ρ(t) : interpolation LOG-LINÉAIRE entre ancrages consécutifs
|
||||
* (`anchors` trié croissant). Pour t ∈ [t_k ; t_(k+1)] :
|
||||
*
|
||||
* ρ(t) = exp( lnρ_k + (t − t_k)/(t_(k+1) − t_k) · (lnρ_(k+1) − lnρ_k) )
|
||||
*
|
||||
* Garanties : ρ(t_k) = ρ_k exactement (passage par le lab), continuité
|
||||
* multiplicative (pas de saut), monotone entre deux références.
|
||||
* Doublons de timestamp (2 labs au même instant) : le 2ᵉ ratio gagne
|
||||
* (garde division par zéro).
|
||||
*
|
||||
* HORS bornes : avant le 1ᵉʳ / après le DERNIER ancre → ρ de borne.
|
||||
* v1.6.0 : la branche « après le dernier » (return ρ_last) est devenue un
|
||||
* COMPORTEMENT, pas une simple garde — c'est elle qui fonde la
|
||||
* prolongation (ρ constant au-delà du dernier lab, cf KDoc de l'objet) ;
|
||||
* en v1.5.0 la fenêtre du caller n'y entrait jamais.
|
||||
*/
|
||||
internal fun ratioAt(anchors: List<Pair<Long, Double>>, tMs: Long): Double {
|
||||
if (tMs <= anchors.first().first) return anchors.first().second
|
||||
if (tMs >= anchors.last().first) return anchors.last().second
|
||||
|
||||
for (i in 0 until anchors.size - 1) {
|
||||
val (t0, r0) = anchors[i]
|
||||
val (t1, r1) = anchors[i + 1]
|
||||
if (tMs in t0..t1) {
|
||||
val span = t1 - t0
|
||||
if (span <= 0L) return r1 // doublon de timestamp : 2ᵉ gagne
|
||||
val f = (tMs - t0).toDouble() / span.toDouble()
|
||||
return exp(ln(r0) + f * (ln(r1) - ln(r0)))
|
||||
}
|
||||
}
|
||||
// inatteignable si anchors croissant et bornes cohérentes — garde
|
||||
return anchors.last().second
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,22 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.JsonParser
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import kotlin.math.ln
|
||||
|
||||
/**
|
||||
* ⚠️ v1.9.0 : ce lecteur de tables ODS n'est PLUS UTILISÉ AU RUNTIME —
|
||||
* le modèle Estrannaise est devenu ANALYTIQUE (pk/EstrannaiseModels.kt,
|
||||
* fidélité aux tables épinglée RMS 0) et tous les dispatchs moteur lisent
|
||||
* des sources originelles. Il est conservé UNIQUEMENT pour :
|
||||
* 1. le **test de fidélité** (EstrannaiseModelsTest : forme close vs
|
||||
* tables, RMS 0 — garde anti-régression du débranchement) ;
|
||||
* 2. le jeu de données historique (`pk_profiles.json`, déplacé en
|
||||
* `src/test/assets/` — l'APK ne l'embarque plus, −550 Ko).
|
||||
*
|
||||
* L'ancienne [init] (lecture de l'asset au démarrage) a été retirée :
|
||||
* [initWithJson] suffit pour les tests.
|
||||
*/
|
||||
object PKProfileStore {
|
||||
|
||||
@Volatile
|
||||
@ -10,15 +24,6 @@ object PKProfileStore {
|
||||
|
||||
private lateinit var profiles: Map<String, FloatArray>
|
||||
|
||||
fun init(context: Context) {
|
||||
if (initialized) return
|
||||
synchronized(this) {
|
||||
if (initialized) return
|
||||
val json = context.assets.open("pk_profiles.json").bufferedReader().use { it.readText() }
|
||||
initWithJson(json)
|
||||
}
|
||||
}
|
||||
|
||||
/** Testable entry point: parse the profiles JSON directly (JVM unit tests). */
|
||||
fun initWithJson(json: String) {
|
||||
synchronized(this) {
|
||||
@ -36,7 +41,7 @@ object PKProfileStore {
|
||||
}
|
||||
|
||||
fun profileKey(ester: String, model: String): String =
|
||||
"${ester}_${if (model == com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE) "tfs" else "ese"}"
|
||||
"${ester}_${if (model == PKModels.TRANSFEM_SCIENCE) "tfs" else "ese"}"
|
||||
|
||||
/**
|
||||
* Case-insensitive lookup. The ODS asset keys use the biological casing
|
||||
@ -56,6 +61,62 @@ object PKProfileStore {
|
||||
fun profileLength(ester: String, model: String): Int =
|
||||
lookup(profileKey(ester, model))?.size ?: 0
|
||||
|
||||
/**
|
||||
* Demi-vie TERMINALE (JOURS) estimée depuis la table horaire (v1.8.0) :
|
||||
* même logique que l'extrapolation de [sample] (dernier point ≥ 1 % du
|
||||
* pic, pente = décroissance moyenne sur les 48 h précédentes, jamais
|
||||
* avant le pic) — t½ = ln2 ÷ |pente|.
|
||||
*
|
||||
* Pourquoi ce helper : la recommandation de prochaine prise de sang
|
||||
* ([LabTiming]) a besoin des 5 × t½ terminales de l'ester pour juger de
|
||||
* la STABILISATION du régime — et le modèle Estrannaise (tables ODS)
|
||||
* n'a pas de forme close : sa t½ se LIT dans la table. Les modèles TFS/
|
||||
* WHSAH, eux, exposent leur t½ analytiquement (`terminalHalfLifeDays`).
|
||||
*
|
||||
* @return t½ en jours (≥ 0), ou `null` si la table ne permet pas
|
||||
* d'estimer une décroissance terminale exploitable (ester inconnu,
|
||||
* pic nul, pas de queue décroissante).
|
||||
*/
|
||||
fun terminalHalfLifeDays(ester: String, model: String): Double? {
|
||||
if (!initialized) return null
|
||||
val suffix = when (model) {
|
||||
PKModels.TRANSFEM_SCIENCE -> "tfs"
|
||||
PKModels.ESTRANNAISE -> "ese"
|
||||
else -> return 0.0
|
||||
}
|
||||
val arr = lookup("${ester}_$suffix") ?: return null
|
||||
val (j, rate) = terminalDecayParameters(arr) ?: return null
|
||||
if (rate >= 0.0) return null
|
||||
return ln(2.0) / -rate / 24.0 // pente /h → t½ en jours
|
||||
}
|
||||
|
||||
/**
|
||||
* Paramètres de DÉCROISSANCE TERMINALE d'une table (partagés par
|
||||
* [sample] — extrapolation au-delà de la fin — et [terminalHalfLifeDays])
|
||||
* : `(index du dernier point ≥ 1 % du pic, pente log-linéaire /h)` sur
|
||||
* les 48 h précédentes, jamais avant le pic. `null` si la table n'a ni
|
||||
* pic ni queue exploitable.
|
||||
*/
|
||||
private fun terminalDecayParameters(arr: FloatArray): Pair<Int, Double>? {
|
||||
var peakIdx = 0
|
||||
var peakV = 0f
|
||||
for (idx in arr.indices) {
|
||||
if (arr[idx] > peakV) {
|
||||
peakV = arr[idx]
|
||||
peakIdx = idx
|
||||
}
|
||||
}
|
||||
if (peakV <= 0f) return null
|
||||
var j = arr.size - 1
|
||||
while (j > 0 && arr[j] < peakV * 0.01f) j--
|
||||
if (j <= 0) return null
|
||||
val window = 48
|
||||
val j0 = maxOf(peakIdx, j - window)
|
||||
if (j <= j0) return null
|
||||
val rate = kotlin.math.ln(arr[j].toDouble() / arr[j0].toDouble().coerceAtLeast(1e-12)) / (j - j0)
|
||||
return j to rate
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized response (pg/mL per mg injected) at dtHours after a 1 mg injection,
|
||||
* linearly interpolated between hourly points; beyond the table, extrapolated
|
||||
@ -64,8 +125,8 @@ object PKProfileStore {
|
||||
fun sample(ester: String, model: String, dtHours: Double): Double {
|
||||
if (!initialized || dtHours <= 0.0) return 0.0
|
||||
val suffix = when (model) {
|
||||
com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE -> "tfs"
|
||||
com.hormonetrack.data.model.PKModels.ESTRANNAISE -> "ese"
|
||||
PKModels.TRANSFEM_SCIENCE -> "tfs"
|
||||
PKModels.ESTRANNAISE -> "ese"
|
||||
else -> return 0.0
|
||||
}
|
||||
val arr = lookup("${ester}_$suffix") ?: return 0.0
|
||||
@ -77,23 +138,7 @@ object PKProfileStore {
|
||||
// display floor long before the true value vanishes. Extrapolate from the
|
||||
// last point still >= 1% of the peak, using the average hourly decay of
|
||||
// the previous 48 h (never sampling before the peak).
|
||||
var peakIdx = 0
|
||||
var peakV = 0f
|
||||
for (idx in arr.indices) {
|
||||
if (arr[idx] > peakV) {
|
||||
peakV = arr[idx]
|
||||
peakIdx = idx
|
||||
}
|
||||
}
|
||||
if (peakV <= 0f) return 0.0
|
||||
var j = lastIdx
|
||||
while (j > 0 && arr[j] < peakV * 0.01f) j--
|
||||
if (j <= 0) return 0.0
|
||||
val window = 48
|
||||
val j0 = maxOf(peakIdx, j - window)
|
||||
val rate = if (j > j0) {
|
||||
kotlin.math.ln(arr[j].toDouble() / arr[j0].toDouble().coerceAtLeast(1e-12)) / (j - j0)
|
||||
} else 0.0
|
||||
val (j, rate) = terminalDecayParameters(arr) ?: return 0.0
|
||||
return arr[j].toDouble() * kotlin.math.exp(rate * (dtHours - j))
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.Esters
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import kotlin.math.abs
|
||||
@ -67,7 +68,13 @@ object PharmacokineticEngine {
|
||||
treatment: Treatment,
|
||||
dose: DoseLog,
|
||||
queryTimeMs: Long,
|
||||
bateman: BatemanParams? = null
|
||||
bateman: BatemanParams? = null,
|
||||
/**
|
||||
* Force un modèle PK (ESE/TFS) pour ce calcul, indépendamment du pkModel du
|
||||
* traitement — utilisé par le graphique pour dessiner les deux modèles côte à
|
||||
* côte. Ne concerne que les traitements par profil (injections EV/EU/EEn).
|
||||
*/
|
||||
modelOverride: String? = null
|
||||
): Double {
|
||||
val dtH = (queryTimeMs - dose.timestamp) / 3_600_000.0
|
||||
if (dtH <= 0.0) return 0.0
|
||||
@ -78,7 +85,26 @@ object PharmacokineticEngine {
|
||||
if (treatment.usesProfileModel) {
|
||||
val ester = doseEster(treatment, dose)
|
||||
if (ester != Esters.NONE) {
|
||||
return PKProfileStore.sample(ester, treatment.pkModel, dtH) * mg
|
||||
val model = modelOverride ?: treatment.pkModel
|
||||
// v1.4.0 : le modèle Transfem Science utilise les paramètres
|
||||
// V3C (3 compartiments) de la méta-analyse TFS, évalués en
|
||||
// forme close.
|
||||
// v1.4.6 : le modèle WHSAH (fit license-free de Mona) est la
|
||||
// 3ᵉ voie de dispatch, au même niveau que TFS.
|
||||
// v1.9.0 : l'Estrannaise devient ANALYTIQUE (forme close de
|
||||
// estrannaise.js — fidélité aux tables ODS épinglée RMS 0) :
|
||||
// les 4 voies de dispatch sont toutes des sources originelles,
|
||||
// PLUS AUCUNE lecture des tables ODS au runtime (décision
|
||||
// v1.9.0 : l'ODS n'était que l'échantillonnage de ces sources).
|
||||
return when {
|
||||
model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester) ->
|
||||
TransfemScienceModels.sample(ester, dtH) * mg
|
||||
model == PKModels.WHSAH && WhsahModels.hasModel(ester) ->
|
||||
WhsahModels.sample(ester, dtH) * mg
|
||||
model == PKModels.ESTRANNAISE && EstrannaiseModels.hasModel(ester) ->
|
||||
EstrannaiseModels.sample(ester, dtH) * mg
|
||||
else -> 0.0 // ester sans modèle (documenté : ni TFS ni WHSAH ni ESE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,9 +120,40 @@ object PharmacokineticEngine {
|
||||
return max(0.0, c)
|
||||
}
|
||||
|
||||
private fun cutoffHours(treatment: Treatment): Double {
|
||||
/**
|
||||
* Coupure temporelle (HEURES) au-delà de laquelle une dose de ce traitement
|
||||
* ne contribue plus à la prédiction :
|
||||
* - profil avec modèle V3C/WHS → 10 demi-vies TERMINALES du fit
|
||||
* (ex. EEn TFS ≈ 45 j, PEP ≈ 284 j) ;
|
||||
* - profil Estrannaise → 10 demi-vies TERMINALES analytiques (v1.9.0 ;
|
||||
* l'ancienne coupure « longueur de table ODS 8001 h » a disparu avec
|
||||
* les tables du runtime) ;
|
||||
* - Bateman (gel/patch/oral) → 30 demi-vies d'élimination.
|
||||
*
|
||||
* Public (v1.6.0) : réutilisé par [LabTrajectoryModel.extensionHorizonEndMs]
|
||||
* pour borner la PROLONGATION du tracé labs — la partie extrapolée s'éteint
|
||||
* exactement quand le modèle lui-même s'éteint (même sémantique de coupure,
|
||||
* une seule source de vérité).
|
||||
*/
|
||||
fun cutoffHours(treatment: Treatment): Double {
|
||||
val profileH = if (treatment.usesProfileModel) {
|
||||
PKProfileStore.profileLength(treatment.esterType, treatment.pkModel).toDouble()
|
||||
val model = treatment.pkModel
|
||||
when {
|
||||
// v1.4.6 : modèle WHSAH → coupure à 10 demi-vies terminales
|
||||
// du fit (ex. EEn t½ 7,3 j → 73 j), comme pour le V3C TFS.
|
||||
model == PKModels.WHSAH && WhsahModels.hasModel(treatment.esterType) ->
|
||||
WhsahModels.model(treatment.esterType)!!
|
||||
.terminalHalfLifeDays * 24.0 * 10.0
|
||||
model == PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType) ->
|
||||
TransfemScienceModels.model(treatment.esterType)!!
|
||||
.terminalHalfLifeDays * 24.0 * 10.0
|
||||
// v1.9.0 : ESE analytique → t½ terminale ANALYTIQUE (comme
|
||||
// TFS/WHS) au lieu de la longueur de table ODS
|
||||
model == PKModels.ESTRANNAISE && EstrannaiseModels.hasModel(treatment.esterType) ->
|
||||
EstrannaiseModels.model(treatment.esterType)!!
|
||||
.terminalHalfLifeDays * 24.0 * 10.0
|
||||
else -> 0.0
|
||||
}
|
||||
} else 0.0
|
||||
val batemanH = if (treatment.usesProfileModel) 0.0
|
||||
else 30.0 * treatment.eliminationHalfLifeHours.toDouble()
|
||||
@ -107,27 +164,81 @@ object PharmacokineticEngine {
|
||||
// Aggregated levels
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fun e2At(treatments: List<Treatment>, doseLogs: List<DoseLog>, tMs: Long): Double {
|
||||
/**
|
||||
* Contexte PRÉ-CALCULÉ d'une série d'appels [e2At] (v1.9.5, audit) :
|
||||
* cutoff par traitement + Bateman paresseux + doses pré-groupées. Le
|
||||
* chemin chaud du TRACÉ LABS appelle e2At par point de grille (720 pts)
|
||||
* et par lab — reconstruire ce contexte à CHAQUE appel diluait
|
||||
* l'optimisation v1.8.2 (720 × N traitements reconstructions de maps).
|
||||
* Préparer UNE fois puis appeler [e2At] surcharge contexte — résultat
|
||||
* bit-identique (mêmes boucles, même ordre de sommation flottante).
|
||||
*/
|
||||
class E2Context internal constructor(
|
||||
internal val treatments: List<Treatment>,
|
||||
internal val dosesByTreatment: Map<Long, List<DoseLog>>,
|
||||
internal val cutoffByTreatment: HashMap<Long, Double>,
|
||||
internal val batemanCache: HashMap<Long, BatemanParams>,
|
||||
)
|
||||
|
||||
/** Prépare un [E2Context] pour une série d'appels e2At (même entrées). */
|
||||
fun prepareE2Context(treatments: List<Treatment>, doseLogs: List<DoseLog>): E2Context {
|
||||
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
|
||||
val cutoffByTreatment = HashMap<Long, Double>()
|
||||
val batemanCache = HashMap<Long, BatemanParams>()
|
||||
var total = 0.0
|
||||
for (treatment in treatments) {
|
||||
if (treatment.type != TreatmentType.ESTRADIOL) continue
|
||||
if (!batemanCache.containsKey(treatment.id)) {
|
||||
cutoffByTreatment[treatment.id] = cutoffHours(treatment)
|
||||
if (!treatment.usesProfileModel) {
|
||||
batemanCache[treatment.id] = batemanParams(treatment)
|
||||
}
|
||||
for (dose in doseLogs) {
|
||||
if (dose.treatmentId != treatment.id || dose.timestamp > tMs) continue
|
||||
}
|
||||
return E2Context(treatments, dosesByTreatment, cutoffByTreatment, batemanCache)
|
||||
}
|
||||
|
||||
/** e2At sur contexte pré-calculé (voir [E2Context]) — la forme WORKHORSE. */
|
||||
fun e2At(
|
||||
ctx: E2Context,
|
||||
tMs: Long,
|
||||
modelOverride: String? = null,
|
||||
scalePerEster: Map<String, Double>? = null
|
||||
): Double {
|
||||
var total = 0.0
|
||||
for (treatment in ctx.treatments) {
|
||||
if (treatment.type != TreatmentType.ESTRADIOL) continue
|
||||
for (dose in ctx.dosesByTreatment[treatment.id].orEmpty()) {
|
||||
if (dose.timestamp > tMs) continue
|
||||
val dtH = (tMs - dose.timestamp) / 3_600_000.0
|
||||
if (dtH > cutoffHours(treatment)) continue
|
||||
if (dtH > ctx.cutoffByTreatment.getValue(treatment.id)) continue
|
||||
val c = concentrationOfDose(
|
||||
treatment, dose, tMs, batemanCache[treatment.id]
|
||||
treatment, dose, tMs, ctx.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
|
||||
}
|
||||
|
||||
/** e2At à appel unique : prépare le contexte puis délègue. */
|
||||
fun e2At(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
tMs: Long,
|
||||
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 {
|
||||
return e2At(prepareE2Context(treatments, doseLogs), tMs, modelOverride, scalePerEster)
|
||||
}
|
||||
|
||||
fun testosteroneAt(e2Level: Double, config: TConfig): Double {
|
||||
if (e2Level <= 0.0) return config.base
|
||||
return config.floor + (config.base - config.floor) / (1.0 + config.k * e2Level)
|
||||
@ -136,33 +247,71 @@ object PharmacokineticEngine {
|
||||
/**
|
||||
* 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).
|
||||
* 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 {
|
||||
val u = unit.lowercase().replace(" ", "")
|
||||
return when {
|
||||
u.contains("dl") -> value / 100.0
|
||||
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
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Niveau combiné à un instant donné. [scalePerEster] (v1.4.2, optionnel)
|
||||
* applique la calibration par période d'ester EXACTEMENT comme
|
||||
* [computeCurve] — la notification d'alerte (AlertNotifier) et la carte
|
||||
* « niveau actuel » de l'accueil partagent ainsi le même calcul.
|
||||
* null = scaleFactor stocké des traitements (comportement historique).
|
||||
*/
|
||||
fun levelAt(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
tMs: Long,
|
||||
tConfig: TConfig
|
||||
tConfig: TConfig,
|
||||
tKPerEster: Map<String, Double>? = null,
|
||||
scalePerEster: Map<String, Double>? = null
|
||||
): LevelPoint {
|
||||
val e2 = e2At(treatments, doseLogs, tMs)
|
||||
return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig))
|
||||
val e2 = e2At(treatments, doseLogs, tMs, scalePerEster = scalePerEster)
|
||||
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(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
tConfig: TConfig,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig)
|
||||
tKPerEster: Map<String, Double>? = null,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
scalePerEster: Map<String, Double>? = null
|
||||
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster, scalePerEster)
|
||||
|
||||
fun computeCurve(
|
||||
treatments: List<Treatment>,
|
||||
@ -170,7 +319,17 @@ object PharmacokineticEngine {
|
||||
startMs: Long,
|
||||
endMs: Long,
|
||||
stepMs: Long = HOUR_MS,
|
||||
tConfig: TConfig
|
||||
tConfig: TConfig,
|
||||
/** Force un modèle PK (ESE/TFS) pour les traitements par profil. */
|
||||
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> {
|
||||
if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList()
|
||||
|
||||
@ -183,33 +342,350 @@ object PharmacokineticEngine {
|
||||
val searchStart = maxOf(startMs, earliestDose)
|
||||
|
||||
val batemanCache = HashMap<Long, BatemanParams>()
|
||||
relevantTreatments.forEach { batemanCache[it.id] = batemanParams(it) }
|
||||
relevantTreatments.forEach {
|
||||
// Bateman paresseux (v1.8.2) : inutile pour les traitements à profil
|
||||
if (!it.usesProfileModel) batemanCache[it.id] = batemanParams(it)
|
||||
}
|
||||
// v1.8.2 : cutoffs pré-calculés (déterministes par traitement) et
|
||||
// doses pré-groupées — finis les scans complets de doseLogs à chaque
|
||||
// point × traitement (l'ordre de sommation est préservé, cf e2At)
|
||||
val cutoffByTreatment = relevantTreatments.associate { it.id to cutoffHours(it) }
|
||||
val dosesByTreatment = doseLogs.groupBy { it.treatmentId }
|
||||
|
||||
// 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>()
|
||||
var t = searchStart
|
||||
while (t <= endMs) {
|
||||
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) {
|
||||
val cutoff = cutoffHours(treatment)
|
||||
val cutoff = cutoffByTreatment.getValue(treatment.id)
|
||||
val p = batemanCache[treatment.id]
|
||||
for (dose in doseLogs) {
|
||||
if (dose.treatmentId != treatment.id || dose.timestamp > t) continue
|
||||
for (dose in dosesByTreatment[treatment.id].orEmpty()) {
|
||||
if (dose.timestamp > t) continue
|
||||
val dtH = (t - dose.timestamp) / 3_600_000.0
|
||||
if (dtH > cutoff) continue
|
||||
val c = concentrationOfDose(treatment, dose, t, p)
|
||||
if (c > 0.0) e2 += c * treatment.scaleFactor
|
||||
val c = concentrationOfDose(treatment, dose, t, p, modelOverride)
|
||||
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
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère les doses PRÉVISIONNELLES d'un traitement, à partir de sa « fréquence »
|
||||
* (forecastIntervalDays en jours) et de la dernière dose réellement enregistrée.
|
||||
*
|
||||
* Règles :
|
||||
* - intervalle null ou ≤ 0 → aucune prévision ;
|
||||
* - la première dose projetée suit exactement l'intervalle après la dernière dose
|
||||
* réelle (pas d'alignement sur un rythme passé moyen — le rythme reste sous le
|
||||
* contrôle de l'utilisatrice) ;
|
||||
* - la dose projetée reprend la dose standard du traitement et l'ester de la
|
||||
* dernière injection réelle (override compris) ;
|
||||
* - toutes les doses projetées sont STRICTEMENT postérieures à nowMs et jusqu'à toMs.
|
||||
*
|
||||
* Les doses retournées ne sont jamais persistées : elles alimentent uniquement
|
||||
* computeCurve pour dessiner la partie « prévision » du graphique.
|
||||
*/
|
||||
fun generateForecastDoses(
|
||||
treatment: Treatment,
|
||||
allDoseLogs: List<DoseLog>,
|
||||
toMs: Long,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): List<DoseLog> {
|
||||
val intervalDays = treatment.forecastIntervalDays ?: return emptyList()
|
||||
if (intervalDays <= 0.0) return emptyList()
|
||||
// ⚠️ FIX #70 (v1.12.0, demande utilisatrice) : un traitement INACTIF ne
|
||||
// projette PLUS de créneaux — l'archivage arrête les prédictions
|
||||
// d'injections futures (graphique Prévision, extension de fenêtre,
|
||||
// horizon LabTiming). Cohérent avec les rappels (nextReminderFireFor
|
||||
// garde déjà !isActive). ⚠️ §6.bis inchangé : l'INACTIF reste simulé
|
||||
// pour son HISTORIQUE (computeCurve ne passe pas ici) — seul le
|
||||
// FUTUR s'arrête.
|
||||
if (!treatment.isActive) return emptyList()
|
||||
val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong()
|
||||
if (intervalMs <= 0L) return emptyList()
|
||||
|
||||
val last = allDoseLogs
|
||||
.filter { it.treatmentId == treatment.id && it.timestamp <= nowMs }
|
||||
.maxByOrNull { it.timestamp } ?: return emptyList()
|
||||
|
||||
val forecast = mutableListOf<DoseLog>()
|
||||
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) {
|
||||
forecast.add(
|
||||
DoseLog(
|
||||
treatmentId = treatment.id,
|
||||
timestamp = t,
|
||||
doseAmount = treatment.doseAmount,
|
||||
esterType = doseEster(treatment, last)
|
||||
)
|
||||
)
|
||||
t += intervalMs
|
||||
}
|
||||
return forecast
|
||||
}
|
||||
|
||||
/**
|
||||
* Calibration PAR PÉRIODE D'ESTER (v1.2.1).
|
||||
*
|
||||
* Principe : chaque lab est attribué à la période d'injection dans laquelle il
|
||||
* tombe = la dernière dose E2 antérieure ou égale au lab (une prise de sang
|
||||
* 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>,
|
||||
modelOverride: String? = null
|
||||
): 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,
|
||||
// avec le MODÈLE demandé (fix #60 : chaque courbe est calibrée avec
|
||||
// SA propre prédiction — v1.4.7 calculait tout depuis le modèle
|
||||
// stocké du traitement, ce qui produisait des facteurs absurdes dès
|
||||
// qu'un traitement basculait de modèle, ex. ×2,21 sur WHSAH)
|
||||
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
||||
// Contexte pré-calculé UNE fois pour toute la série de labs (v1.9.5)
|
||||
val ctx = prepareE2Context(unscaled, doseLogs)
|
||||
val ratiosByEster = HashMap<String, MutableList<Double>>()
|
||||
var maxPredictionSoFar = 0.0
|
||||
// ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant —
|
||||
// les labs hors de la fenêtre d'action du modèle (prédiction
|
||||
// résiduelle) produisaient des ratios aberrants dont la MÉDIANE
|
||||
// tombait à ×2,21
|
||||
for (lab in e2Labs.sortedBy { it.timestamp }) {
|
||||
val predicted = e2At(ctx, lab.timestamp, modelOverride = modelOverride)
|
||||
if (!labIsSignificant(predicted, maxPredictionSoFar)) 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)
|
||||
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
|
||||
}
|
||||
return ratiosByEster.mapValues { (_, ratios) ->
|
||||
medianRounded(ratios, 2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
modelOverride: String? = 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>>()
|
||||
var maxE2SoFar = 0.0
|
||||
// ⚠️ fix #61 : labs par timestamp CROISSANT + garde labIsSignificant
|
||||
// (l'E2 prédite doit rester ≥ 15 % du max observé — cf labIsSignificant)
|
||||
for (lab in tLabs.sortedBy { it.timestamp }) {
|
||||
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
|
||||
|
||||
// v1.4.8 : l'E2 de référence suit le MODÈLE demandé (fix #60)
|
||||
val e2 = e2At(treatments, doseLogs, lab.timestamp, scalePerEster = scalePerEster,
|
||||
modelOverride = modelOverride)
|
||||
if (!labIsSignificant(e2, maxE2SoFar)) 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)
|
||||
}
|
||||
if (e2 > maxE2SoFar) maxE2SoFar = e2
|
||||
}
|
||||
return ksByEster.mapValues { (_, ks) -> medianRounded(ks, 3) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
val treatments: List<Treatment>,
|
||||
val tConfig: TConfig,
|
||||
/** Facteur d'échelle par ester ("EV"/"EU"/"EEN"), recalculé depuis les labs. */
|
||||
val esterScales: Map<String, Double>,
|
||||
/** Nombre d'esters calibrés avec au moins un lab exploitable. */
|
||||
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(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
labs: List<LabResult>,
|
||||
tConfig: TConfig,
|
||||
/**
|
||||
* Modèle PK pour les PRÉDICTIONS de calibration (v1.4.8, fix #60) :
|
||||
* null = modèle stocké de chaque traitement (Home). Le graphique
|
||||
* appelle une fois PAR MODÈLE affiché (ESE/TFS/WHS) — chaque courbe
|
||||
* reçoit ainsi SES échelles et SON k.
|
||||
*/
|
||||
modelOverride: String? = null
|
||||
): AutoCalibrated {
|
||||
val e2Labs = labs.filter { it.marker.equals("E2", true) }
|
||||
val tLabs = labs.filter { it.marker.equals("T", true) }
|
||||
|
||||
val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs,
|
||||
modelOverride = modelOverride)
|
||||
val tKPerEster = computeTKPerEster(
|
||||
treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales,
|
||||
modelOverride = modelOverride
|
||||
)
|
||||
return AutoCalibrated(
|
||||
treatments = treatments,
|
||||
tConfig = tConfig,
|
||||
esterScales = esterScales,
|
||||
calibratedEsters = esterScales.size,
|
||||
tRecalibrated = tKPerEster.isNotEmpty(),
|
||||
tKPerEster = tKPerEster
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Calibration from lab results
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Garde de SIGNIFICATIVITÉ d'un lab (v1.4.9, fix #61) : le lab ne
|
||||
* calibre que si la prédiction du modèle à son instant reste ≥ 15 %
|
||||
* du maximum de prédiction déjà observé — c'est-à-dire tant que
|
||||
* l'injection « gouverne » encore le taux. Au-delà, le lab reflète
|
||||
* AUTRE CHOSE (temps mort, début de traitement, autre ester) et son
|
||||
* ratio lab ÷ prédiction devient aberrant (ex. lab 373 pg/mL contre
|
||||
* une prédiction résiduelle de 23 pg/mL → ratio 15,9). Remontée
|
||||
* v1.4.8 : une dose EEn de test logguée en janvier, des labs de
|
||||
* janvier-mars à 15–76 j → ratios aberrants dont la MÉDIANE tombait
|
||||
* à ×2,21 — dans les DEUX pipelines (manuel et auto, même défaut).
|
||||
*
|
||||
* Pourquoi une garde RELATIVE (et pas temporelle en jours) : la
|
||||
* fenêtre d'action dépend du profil (l'injection gouverne ~2 j pour
|
||||
* le benzoate, des mois pour l'undécylate) — une fenêtre fixe
|
||||
* casserait autant les labs réels que les tests. La PRÉDICTION,
|
||||
* elle, contient déjà toute la physiologie du modèle.
|
||||
*/
|
||||
const val LAB_MIN_PREDICTION_FRACTION = 0.15
|
||||
|
||||
/**
|
||||
* Le lab est SIGNIFICATIF pour la calibration : prédiction > 0,5 pg/mL
|
||||
* (pas du bruit) ET ≥ [LAB_MIN_PREDICTION_FRACTION] du maximum de
|
||||
* prédiction déjà observé. [maxSoFar] = max des prédictions calculées
|
||||
* jusqu'ici (les labs doivent être traités par timestamp CROISSANT) ;
|
||||
* 0 = premier lab → seule la garde > 0,5 s'applique.
|
||||
*/
|
||||
|
||||
/**
|
||||
* MÉDIANE arrondie d'une liste de ratios (helper partagé par les 4
|
||||
* pipelines de calibration — était dupliqué 4×). Liste triée en place.
|
||||
* @param decimals nombre de décimales après arrondi (via troncature ×10^n)
|
||||
*/
|
||||
private fun medianRounded(values: MutableList<Double>, decimals: Int): Double {
|
||||
values.sort()
|
||||
val median = if (values.size % 2 == 1) values[values.size / 2]
|
||||
else (values[values.size / 2 - 1] + values[values.size / 2]) / 2.0
|
||||
val factor = Math.pow(10.0, decimals.toDouble())
|
||||
// ⚠️ ARRONDI (v1.8.2), pas troncature : l'ancien motif
|
||||
// `(median * factor).toInt() / factor` tronquait au dernier bit
|
||||
// flottant — 0.8999999999999999 × 100 → toInt() = 89 → « 0,89 »
|
||||
// (le modèle ESE analytique a déplacé ce bit, révélant le piège ;
|
||||
// le bug #61 historique du même genre : 6,5 → 6 avec
|
||||
// kotlin.math.round — là, Math.round est l'arrondi au plus proche).
|
||||
return Math.round(median * factor) / factor
|
||||
}
|
||||
|
||||
fun labIsSignificant(predicted: Double, maxSoFar: Double): Boolean =
|
||||
predicted > 0.5 &&
|
||||
(maxSoFar <= 0.0 || predicted >= LAB_MIN_PREDICTION_FRACTION * maxSoFar)
|
||||
|
||||
fun computeScaleFactor(
|
||||
treatment: Treatment,
|
||||
allDoseLogs: List<DoseLog>,
|
||||
@ -221,7 +697,12 @@ object PharmacokineticEngine {
|
||||
|
||||
val p = batemanParams(treatment)
|
||||
val ratios = mutableListOf<Double>()
|
||||
for (lab in e2Labs) {
|
||||
var maxPredictionSoFar = 0.0
|
||||
// ⚠️ fix #61 : labs par timestamp CROISSANT — le max de prédiction se
|
||||
// construit au fil de l'eau et la garde labIsSignificant exclut les
|
||||
// labs qui n'atteignent plus 15 % du max (l'injection ne gouverne
|
||||
// plus : le lab reflète autre chose — ratios aberrants → ×2,21)
|
||||
for (lab in e2Labs.sortedBy { it.timestamp }) {
|
||||
var predicted = 0.0
|
||||
for (dose in myDoses) {
|
||||
if (dose.timestamp > lab.timestamp) continue
|
||||
@ -229,18 +710,13 @@ object PharmacokineticEngine {
|
||||
if (dtH > cutoffHours(treatment)) continue
|
||||
predicted += concentrationOfDose(treatment, dose, lab.timestamp, p)
|
||||
}
|
||||
if (predicted > 0.5) {
|
||||
if (labIsSignificant(predicted, maxPredictionSoFar)) {
|
||||
ratios.add(lab.value / predicted)
|
||||
}
|
||||
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted
|
||||
}
|
||||
if (ratios.isEmpty()) return null
|
||||
ratios.sort()
|
||||
val median = if (ratios.size % 2 == 1) {
|
||||
ratios[ratios.size / 2]
|
||||
} else {
|
||||
(ratios[ratios.size / 2 - 1] + ratios[ratios.size / 2]) / 2.0
|
||||
}
|
||||
return (median * 100).toInt() / 100.0
|
||||
return medianRounded(ratios, 2)
|
||||
}
|
||||
|
||||
fun computeTConfigCalibration(
|
||||
@ -249,8 +725,10 @@ object PharmacokineticEngine {
|
||||
doseLogs: List<DoseLog>,
|
||||
current: TConfig
|
||||
): TConfig? {
|
||||
// Le premier filtre « brut > floor » est soussumé par celui sur la
|
||||
// valeur CONVERTIE (toute conversion est ≤ la valeur brute) : un seul
|
||||
// filtre après conversion suffit — résultat strictement identique.
|
||||
val usable = tLabs
|
||||
.filter { it.value > current.floor + 0.02 }
|
||||
.map { it.copy(value = convertTToNgMl(it.value, it.unit)) }
|
||||
.filter { it.value > current.floor + 0.02 }
|
||||
if (usable.isEmpty()) return null
|
||||
@ -263,34 +741,168 @@ object PharmacokineticEngine {
|
||||
if (k > 1e-4 && k < 10.0) ks.add(k)
|
||||
}
|
||||
if (ks.isEmpty()) return null
|
||||
ks.sort()
|
||||
val median = if (ks.size % 2 == 1) {
|
||||
ks[ks.size / 2]
|
||||
} else {
|
||||
(ks[ks.size / 2 - 1] + ks[ks.size / 2]) / 2.0
|
||||
}
|
||||
return current.copy(k = (median * 1000).toInt() / 1000.0)
|
||||
return current.copy(k = medianRounded(ks, 3))
|
||||
}
|
||||
|
||||
fun nextReminderFireMs(treatments: List<Treatment>, nowMs: Long = System.currentTimeMillis()): Long? {
|
||||
var next: Long? = null
|
||||
/**
|
||||
* Une dose de CE traitement est-elle déjà loguée LE JOUR de [atMs]
|
||||
* (jour calendaire LOCAL — fuseau paramétrable pour la testabilité) ?
|
||||
*
|
||||
* v1.10.0 (demande utilisatrice) : si la dose prévue du jour est
|
||||
* saisie AVANT l'heure de rappel, la notification ne doit PAS sonner
|
||||
* pour une prise déjà faite — le rappel suivant repart au créneau
|
||||
* SUIVANT de la grille (le receiver re-programme dans tous les cas).
|
||||
* S'applique identiquement au fallback quotidien (oral sans Posologie :
|
||||
* dose loguée aujourd'hui → pas de notif aujourd'hui, demain re-part).
|
||||
*
|
||||
* PUR (aucune dépendance Android) → testable en JVM (ReminderLoggedDayTest).
|
||||
*/
|
||||
fun hasDoseLoggedOnDay(
|
||||
treatmentId: Long,
|
||||
doseLogs: List<DoseLog>,
|
||||
atMs: Long,
|
||||
zoneId: java.time.ZoneId = java.time.ZoneId.systemDefault()
|
||||
): Boolean {
|
||||
val day = java.time.Instant.ofEpochMilli(atMs).atZone(zoneId).toLocalDate()
|
||||
return doseLogs.any {
|
||||
it.treatmentId == treatmentId &&
|
||||
java.time.Instant.ofEpochMilli(it.timestamp).atZone(zoneId).toLocalDate() == day
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prochain déclenchement de rappel pour UN traitement (v1.4.0).
|
||||
*
|
||||
* ⚠️ FIX du bug « rappels quotidiens » (#52) : si le traitement a une
|
||||
* **Posologie** (forecastIntervalDays) ET un historique de doses, le
|
||||
* rappel suit la GRILLE des prises (dernière dose réelle + k × intervalle,
|
||||
* créneaux passés sautés — même sémantique que [generateForecastDoses])
|
||||
* et sonne le jour du créneau à l'heure de rappel choisie : une injection
|
||||
* tous les samedis à 18 h → rappel uniquement le samedi à 18 h.
|
||||
* Sinon (gel/oral, ou aucune dose pour cadrer la grille) → comportement
|
||||
* QUOTIDIEN historique (prochaine occurrence HH:mm).
|
||||
*/
|
||||
fun nextReminderFireFor(treatment: Treatment, doseLogs: List<DoseLog>, nowMs: Long): Long? {
|
||||
if (!treatment.reminderEnabled || !treatment.isActive) return null
|
||||
val h = treatment.reminderHour ?: return null
|
||||
val m = treatment.reminderMinute ?: return null
|
||||
|
||||
val intervalDays = treatment.forecastIntervalDays
|
||||
val myDoses = doseLogs.filter { it.treatmentId == treatment.id && it.timestamp <= nowMs }
|
||||
if (intervalDays != null && intervalDays > 0.0 && myDoses.isNotEmpty()) {
|
||||
val intervalMs = (intervalDays * 24.0 * HOUR_MS).toLong()
|
||||
if (intervalMs > 0L) {
|
||||
var slot = myDoses.maxByOrNull { it.timestamp }!!.timestamp
|
||||
// Le premier créneau STRICTEMENT futur (un créneau passé = oubli :
|
||||
// on avance au rythme configuré, cf generateForecastDoses).
|
||||
while (slot <= nowMs) slot += intervalMs
|
||||
val cal = java.util.Calendar.getInstance()
|
||||
var fireMs = 0L
|
||||
var candidate = slot
|
||||
// L'alarme sonne le jour du créneau, à l'heure de rappel choisie.
|
||||
while (true) {
|
||||
cal.timeInMillis = candidate
|
||||
cal.set(java.util.Calendar.HOUR_OF_DAY, h)
|
||||
cal.set(java.util.Calendar.MINUTE, m)
|
||||
cal.set(java.util.Calendar.SECOND, 0)
|
||||
cal.set(java.util.Calendar.MILLISECOND, 0)
|
||||
fireMs = cal.timeInMillis
|
||||
if (fireMs > nowMs) return fireMs
|
||||
// L'heure de rappel du jour du créneau est déjà passée → créneau suivant
|
||||
candidate += intervalMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback QUOTIDIEN (comportement historique) : prochaine occurrence HH:mm
|
||||
val cal = java.util.Calendar.getInstance()
|
||||
cal.timeInMillis = nowMs
|
||||
cal.set(java.util.Calendar.HOUR_OF_DAY, h)
|
||||
cal.set(java.util.Calendar.MINUTE, m)
|
||||
cal.set(java.util.Calendar.SECOND, 0)
|
||||
cal.set(java.util.Calendar.MILLISECOND, 0)
|
||||
if (cal.timeInMillis <= nowMs) cal.add(java.util.Calendar.DAY_OF_MONTH, 1)
|
||||
return cal.timeInMillis
|
||||
}
|
||||
|
||||
/**
|
||||
* Prochain rappel parmi tous les traitements (min). [doseLogs] active la
|
||||
* planification par Posologie (cf [nextReminderFireFor]) ; vide = fallback
|
||||
* quotidien pur (compatibilité avec les appels historiques).
|
||||
*/
|
||||
fun nextReminderFireMs(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog> = emptyList(),
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): Long? {
|
||||
var next: Long? = null
|
||||
for (tr in treatments) {
|
||||
if (!tr.reminderEnabled) continue
|
||||
val h = tr.reminderHour ?: continue
|
||||
val m = tr.reminderMinute ?: continue
|
||||
cal.timeInMillis = nowMs
|
||||
cal.set(java.util.Calendar.HOUR_OF_DAY, h)
|
||||
cal.set(java.util.Calendar.MINUTE, m)
|
||||
cal.set(java.util.Calendar.SECOND, 0)
|
||||
cal.set(java.util.Calendar.MILLISECOND, 0)
|
||||
if (cal.timeInMillis <= nowMs) cal.add(java.util.Calendar.DAY_OF_MONTH, 1)
|
||||
val fire = cal.timeInMillis
|
||||
if (next == null || fire < next) next = fire
|
||||
val fire = nextReminderFireFor(tr, doseLogs, nowMs)
|
||||
if (fire != null && (next == null || fire < next)) next = fire
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
fun isInjectionRoute(route: AdministrationRoute): Boolean =
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
103
app/src/main/java/com/hormonetrack/pk/TransfemScienceModels.kt
Normal file
103
app/src/main/java/com/hormonetrack/pk/TransfemScienceModels.kt
Normal file
@ -0,0 +1,103 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
/**
|
||||
* Modèles pharmacocinétiques « Transfem Science » — méta-analyse des esters
|
||||
* d'estradiol injectables (transfemscience.org/articles/injectable-e2-meta-analysis/).
|
||||
*
|
||||
* SOURCE DES PARAMÈTRES (v1.4.0) : les coefficients du modèle à 3 compartiments
|
||||
* publiés par le simulateur officiel de Transfem Science
|
||||
* (https://transfemscience.org/misc/injectable-e2-simulator/ — les valeurs sont
|
||||
* des DONNÉES SCIENTIFIQUES issues des études agrégées, Tableaux 9–10 de
|
||||
* l'article). Avant v1.4.0, le modèle TFS de l'app utilisait des tables
|
||||
* horaires dérivées du tableur de l'utilisatrice (ODS) ; désormais la courbe
|
||||
* TFS est calculée EXACTEMENT comme le simulateur :
|
||||
*
|
||||
* Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3))
|
||||
* + e^(−k3·t)/((k1−k3)(k2−k3))
|
||||
* + e^(−k2·t)·(k3−k1)/((k1−k2)(k1−k3)(k2−k3)) ]
|
||||
*
|
||||
* avec t en JOURS, Cp en pg/mL pour la dose de calibration fitDose (5 mg).
|
||||
* La courbe est NORMALISÉE PAR MG : `sample()` retourne Cp(t) / fitDose —
|
||||
* le moteur PK multiplie déjà par la dose réelle (dose_mg), exactement comme
|
||||
* avec les tables ODS. Sanity checks épinglés par `TransfemScienceModelsTest`
|
||||
* (Cmax/Tmax/t½ de l'article reproduits à ~1 %) :
|
||||
*
|
||||
* EV : Cmax 295 pg/mL @ 2,1 j, t½ 3,0 j | EEn : 160 @ 6,5 j, t½ 4,6 j
|
||||
* EB : 971 @ 0,65 j, t½ 1,2 j | EC (huile) : 155 @ 4,3 j, t½ 6,7 j
|
||||
* EC susp. : 241 @ 1,2 j, t½ 5,1 j | PEP : 34 pg/mL @ 32,5 mg @ 18 j, t½ 28,4 j
|
||||
*
|
||||
* Remarques de l'article conservées dans la doc de dev (§7) : l'undécylate
|
||||
* (EU) a des données d'étude limitées (ajustement moins fiable) ; PEP a une
|
||||
* biodisponibilité réduite (~15–20 % — d'où des doses ~6,5× plus élevées).
|
||||
*/
|
||||
object TransfemScienceModels {
|
||||
|
||||
/** Modèle à 3 compartiments (V3C) d'un ester, tel que publié par TFS. */
|
||||
data class V3C(
|
||||
/** Clé ester de l'app ("EV", "EB", …). */
|
||||
val ester: String,
|
||||
/** Constante d'amplitude (pg/mL pour la dose de fit, unités TFS). */
|
||||
val D: Double,
|
||||
/** Constantes de vitesse en j⁻¹ (k1 = absorption dépôt, k2/k3 phases). */
|
||||
val k1: Double,
|
||||
val k2: Double,
|
||||
val k3: Double,
|
||||
/** Dose à laquelle les paramètres ont été ajustés (5 mg dans l'article). */
|
||||
val fitDoseMg: Double = 5.0
|
||||
) {
|
||||
/** Demi-vie TERMINALE (jours) = la phase la plus lente des trois. */
|
||||
val terminalHalfLifeDays: Double
|
||||
get() = kotlin.math.ln(2.0) / minOf(k1, k2, k3)
|
||||
}
|
||||
|
||||
private val MODELS: Map<String, V3C> = mapOf(
|
||||
// estradiol valerate (solution huileuse)
|
||||
"EV" to V3C("EV", D = 2596.05956, k1 = 2.38229125, k2 = 0.23345814, k3 = 1.37642769),
|
||||
// estradiol enanthate (solution huileuse)
|
||||
"EEN" to V3C("EEN", D = 333.874181, k1 = 0.42412968, k2 = 0.43452980, k3 = 0.15291485),
|
||||
// estradiol undecylate — ⚠️ données d'étude limitées (cf article, §Limites)
|
||||
"EU" to V3C("EU", D = 65.9493374, k1 = 0.29634323, k2 = 4799337.57, k3 = 0.03141554),
|
||||
// estradiol benzoate (solution huileuse)
|
||||
"EB" to V3C("EB", D = 1.7050e+08, k1 = 3.22397192, k2 = 0.58870148, k3 = 70721.4018),
|
||||
// estradiol cypionate — SOLUTION HUILEUSE
|
||||
"EC" to V3C("EC", D = 1920.89671, k1 = 0.10321089, k2 = 0.89854779, k3 = 0.89359759),
|
||||
// estradiol cypionate — SUSPENSION AQUEUSE (microcristaux) ; clé dédiée
|
||||
"ECS" to V3C("ECS", D = 1.5669e+08, k1 = 0.13586726, k2 = 2.51772731, k3 = 74768.1493),
|
||||
// polyestradiol phosphate — biodispo réduite, doses ~6,5× (cf article)
|
||||
"PEP" to V3C("PEP", D = 34.46836875, k1 = 0.02456035, k2 = 135643.711, k3 = 0.10582368)
|
||||
)
|
||||
|
||||
fun hasModel(ester: String): Boolean = MODELS.containsKey(ester.uppercase())
|
||||
|
||||
/** Le modèle V3C de l'ester, ou null (lookup insensible à la casse). */
|
||||
fun model(ester: String): V3C? = MODELS[ester.uppercase()]
|
||||
|
||||
/**
|
||||
* Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg,
|
||||
* modèle V3C de la méta-analyse TFS. dtHours ≤ 0 → 0 (rien avant la dose).
|
||||
*/
|
||||
fun sample(ester: String, dtHours: Double): Double {
|
||||
if (dtHours <= 0.0) return 0.0
|
||||
val m = model(ester) ?: return 0.0
|
||||
val t = dtHours / 24.0 // la formule TFS travaille en jours
|
||||
return rawConcentrationDays(m, t) / m.fitDoseMg
|
||||
}
|
||||
|
||||
/**
|
||||
* Cp(t) en pg/mL pour la dose de fit (formule V3C exacte du simulateur).
|
||||
* Le compartiment k2 peut être « ultra-rapide » (EU, PEP : k2 ~10⁵–10⁶ j⁻¹) :
|
||||
* son terme décroît instantanément et n'affecte que t≈0 — double précision
|
||||
* suffisante (vérifié par les tests de pics de l'article).
|
||||
*/
|
||||
private fun rawConcentrationDays(m: V3C, tDays: Double): Double {
|
||||
val D = m.D
|
||||
val k1 = m.k1
|
||||
val k2 = m.k2
|
||||
val k3 = m.k3
|
||||
return D * k1 * k2 * (
|
||||
kotlin.math.exp(-k1 * tDays) / ((k1 - k2) * (k1 - k3)) +
|
||||
kotlin.math.exp(-k3 * tDays) / ((k1 - k3) * (k2 - k3)) +
|
||||
kotlin.math.exp(-k2 * tDays) * (k3 - k1) / ((k1 - k2) * (k1 - k3) * (k2 - k3))
|
||||
)
|
||||
}
|
||||
}
|
||||
102
app/src/main/java/com/hormonetrack/pk/WhsahModels.kt
Normal file
102
app/src/main/java/com/hormonetrack/pk/WhsahModels.kt
Normal file
@ -0,0 +1,102 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
/**
|
||||
* Modèle PK « WHSAH » (v1.4.6) — 3ᵉ modèle au niveau d'Estrannaise et
|
||||
* Transfem Science, superposable sur le graphique.
|
||||
*
|
||||
* SOURCE : le fit « license-free » publié par le WHSAH Collective dans
|
||||
* l'app open-source Mona (projet Flutter — lib/data/model/graph_calculator.dart,
|
||||
* commit « new license-free pk params » ; leurs auteurs déclarent explicitement
|
||||
* les coefficients libres d'usage, « Inferred with love exclusively for Mona »).
|
||||
* Crédit : alix / WHSAH Collective via Mona.
|
||||
*
|
||||
* POURQUOI CE MODÈLE EXISTE (cf diagnostic comparatif du 7 sept. 2026) :
|
||||
* Mona a d'abord utilisé les paramètres Transfem Science (identiques aux
|
||||
* nôtres, commit du 6 mars 2026) PUIS les a remplacés par ce fit indépendant.
|
||||
* Les deux sont des tri-exponentielles en forme close ajustées sur les mêmes
|
||||
* études, avec des conventions différentes :
|
||||
*
|
||||
* - WHSAH paramètre une BIODISPONIBILITÉ explicite F < 1 (0,62–0,76) et
|
||||
* un facteur d'échelle `auc` intégré — les courbes « brutes » sont ~24 %
|
||||
* plus basses que TFS non calibrée (la calibration de l'app absorbe ça) ;
|
||||
* - la MONTÉE est plus rapide (compartiment rapide plus vif) et la t½
|
||||
* terminale plus longue sur certains esters : EV Cmax 367 pg/mL @ 1,7 j
|
||||
* (TFS : 295 @ 2,1 j), EEn Cmax 188 @ 5,0 j (TFS : 160 @ 6,5 j) et
|
||||
* t½ EEn 7,3 j (TFS : 4,5 j) — à J+1 l'EEn WHSAH est ~3× plus haut
|
||||
* (70 vs 22 pg/mL) ;
|
||||
* - PEP NON COUVERT (retiré du jeu WHSAH) → `sample("PEP", …) = 0`.
|
||||
*
|
||||
* FORMULE (telle qu'implémentée dans Mona, normalisée PAR MG pour le moteur
|
||||
* — le multiplicateur de dose est appliqué par `PharmacokineticEngine`) :
|
||||
*
|
||||
* sample = F · auc · k1·k2·k3 · [ e^(−k1·t)/((k1−k2)(k1−k3))
|
||||
* − e^(−k2·t)/((k1−k2)(k2−k3))
|
||||
* + e^(−k3·t)/((k1−k3)(k2−k3)) ]
|
||||
*
|
||||
* avec t en JOURS, résultat en pg/mL pour 1 mg. Sanity checks épinglés par
|
||||
* `WhsahModelsTest` (fidélité au comportement de Mona, ±2 %) :
|
||||
* EV Cmax 367,5 @ 1,69 j · EEn 187,9 @ 4,99 j · t½ EEn 7,34 j.
|
||||
*/
|
||||
object WhsahModels {
|
||||
|
||||
/** Modèle WHSAH d'un ester (fit F/auc/k1/k2/k3). */
|
||||
data class WHS(
|
||||
/** Clé ester de l'app ("EV", "EB", …). */
|
||||
val ester: String,
|
||||
/** Biodisponibilité explicite (< 1 dans le fit WHSAH). */
|
||||
val F: Double,
|
||||
/** Constante d'échelle du fit (pg/mL intégrés). */
|
||||
val auc: Double,
|
||||
/** Constantes de vitesse en j⁻¹ (k1 = absorption, k2/k3 phases). */
|
||||
val k1: Double,
|
||||
val k2: Double,
|
||||
val k3: Double
|
||||
) {
|
||||
/** Demi-vie TERMINALE (jours) = la phase la plus lente des trois. */
|
||||
val terminalHalfLifeDays: Double
|
||||
get() = kotlin.math.ln(2.0) / minOf(k1, k2, k3)
|
||||
}
|
||||
|
||||
private val MODELS: Map<String, WHS> = mapOf(
|
||||
// estradiol enanthate (solution huileuse)
|
||||
"EEN" to WHS("EEN", F = 0.708, auc = 875.4, k1 = 0.09441, k2 = 3.354, k3 = 0.4078),
|
||||
// estradiol valerate (solution huileuse)
|
||||
"EV" to WHS("EV", F = 0.764, auc = 621.3, k1 = 0.2230, k2 = 17.62, k3 = 1.305),
|
||||
// estradiol benzoate (solution huileuse)
|
||||
"EB" to WHS("EB", F = 0.723, auc = 889.9, k1 = 0.5220, k2 = 521.9, k3 = 5.223),
|
||||
// estradiol cypionate — SOLUTION HUILEUSE
|
||||
"EC" to WHS("EC", F = 0.687, auc = 554.5, k1 = 0.0880, k2 = 17.95, k3 = 0.7177),
|
||||
// estradiol cypionate — SUSPENSION AQUEUSE (microcristaux)
|
||||
"ECS" to WHS("ECS", F = 0.687, auc = 852.6, k1 = 0.0973, k2 = 218.67, k3 = 6.624),
|
||||
// estradiol undecylate — données d'étude limitées (comme chez TFS)
|
||||
"EU" to WHS("EU", F = 0.618, auc = 385.8, k1 = 0.02189, k2 = 183.4, k3 = 1.564)
|
||||
)
|
||||
|
||||
fun hasModel(ester: String): Boolean = MODELS.containsKey(ester.uppercase())
|
||||
|
||||
/** Le modèle WHSAH de l'ester, ou null (lookup insensible à la casse). */
|
||||
fun model(ester: String): WHS? = MODELS[ester.uppercase()]
|
||||
|
||||
/**
|
||||
* Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg,
|
||||
* modèle WHSAH. dtHours ≤ 0 → 0 (rien avant la dose).
|
||||
*/
|
||||
fun sample(ester: String, dtHours: Double): Double {
|
||||
if (dtHours <= 0.0) return 0.0
|
||||
val m = model(ester) ?: return 0.0
|
||||
return rawConcentrationDays(m, dtHours / 24.0) // t en jours
|
||||
}
|
||||
|
||||
/**
|
||||
* Cp(t) en pg/mL pour 1 mg (forme Mona exacte — voir KDoc de l'objet).
|
||||
* Les compartiments « ultra-rapides » (k2 jusqu'à ~520 j⁻¹ : EB) décroissent
|
||||
* instantanément et n'affectent que t≈0 — double précision suffisante
|
||||
* (vérifié par les tests de pics).
|
||||
*/
|
||||
private fun rawConcentrationDays(m: WHS, tDays: Double): Double {
|
||||
val part1 = kotlin.math.exp(-m.k1 * tDays) / ((m.k1 - m.k2) * (m.k1 - m.k3))
|
||||
val part2 = kotlin.math.exp(-m.k2 * tDays) / ((m.k1 - m.k2) * (m.k2 - m.k3))
|
||||
val part3 = kotlin.math.exp(-m.k3 * tDays) / ((m.k1 - m.k3) * (m.k2 - m.k3))
|
||||
return m.F * m.auc * m.k1 * m.k2 * m.k3 * (part1 - part2 + part3)
|
||||
}
|
||||
}
|
||||
214
app/src/main/java/com/hormonetrack/reminder/AlertNotifier.kt
Normal file
214
app/src/main/java/com/hormonetrack/reminder/AlertNotifier.kt
Normal file
@ -0,0 +1,214 @@
|
||||
package com.hormonetrack.reminder
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.hormonetrack.MainActivity
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.AppDatabase
|
||||
import com.hormonetrack.pk.Alerts
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.util.AppLog
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Notification d'alerte de seuil (v1.4.2) — complète la carte d'avertissement
|
||||
* de l'accueil : l'évaluation tourne MÊME app fermée via **WorkManager**
|
||||
* (dépendance déclarée depuis v1.0, enfin utilisée).
|
||||
*
|
||||
* Fonctionnement :
|
||||
* - `AlertWorker` (périodique, 15 min — minimum WorkManager) évalue le
|
||||
* niveau estimé actuel (`levelAt`, MÊME calcul que la carte accueil :
|
||||
* calibration par période d'ester si l'option est active) contre les
|
||||
* seuils configurés ;
|
||||
* - **anti-spam** : l'état des alertes déjà notifiées est persisté en
|
||||
* DataStore (`Alerts.encodeState`) → pas de re-notification du même
|
||||
* franchissement ; le retour à la normale ré-arme la prochaine notif
|
||||
* (cf `Alerts.shouldNotify`) ;
|
||||
* - au SAVE des seuils dans Paramètres, un check **one-time immédiat** est
|
||||
* déclenché (feedback quasi instantané sans attendre les 15 min) ;
|
||||
* - canal de notification DÉDIÉ (`hormonetrack_alerts`) : l'utilisatrice
|
||||
* règle les avertissements indépendamment des rappels (montre GT 3).
|
||||
*
|
||||
* ⚠️ WorkManager persiste les travaux entre reboots — BootReceiver n'a
|
||||
* rien à faire pour ce worker.
|
||||
*/
|
||||
object AlertNotifier {
|
||||
|
||||
const val CHANNEL_ID = "hormonetrack_alerts"
|
||||
/** Id du travail périodique (unique) et nom du travail one-time. */
|
||||
private const val PERIODIC_WORK = "alert_threshold_periodic"
|
||||
private const val ONESHOT_WORK = "alert_threshold_oneshot"
|
||||
/** Période de vérification : minimum autorisé par WorkManager. */
|
||||
private const val PERIOD_MINUTES = 15L
|
||||
|
||||
/** Notification id — une seule notification d'alerte à la fois (update). */
|
||||
private const val NOTIFICATION_ID = 4_002
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.alert_channel_name),
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = context.getString(R.string.alert_channel_desc)
|
||||
}
|
||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Planifie le worker PÉRIODIQUE (à appeler une fois dans Application
|
||||
*.onCreate). KEEP : si le travail existe déjà, on ne le replanifie pas.
|
||||
*/
|
||||
fun schedulePeriodic(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
PERIODIC_WORK,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<AlertWorker>(PERIOD_MINUTES, TimeUnit.MINUTES).build()
|
||||
)
|
||||
}
|
||||
|
||||
/** Check IMMÉDIAT (au save des seuils) — remplace le travail one-time en attente. */
|
||||
fun checkNow(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
ONESHOT_WORK,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
OneTimeWorkRequestBuilder<AlertWorker>().build()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Évalue le niveau actuel et notifie si nécessaire. Retourne l'état à
|
||||
* persister (encodé, cf [Alerts.encodeState]) — l'appelant le stocke.
|
||||
* Séparé du worker pour rester lisible ; le worker ne fait que l'IO.
|
||||
*/
|
||||
suspend fun evaluateAndNotify(context: Context): String {
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val container = (context.applicationContext as com.hormonetrack.HormoneTrackApp).container
|
||||
val settings = container.settings
|
||||
|
||||
val thresholds = settings.alertThresholds.first()
|
||||
if (thresholds == Alerts.Thresholds()) {
|
||||
// Aucun seuil configuré → nettoyer l'état et sortir (pas de notif)
|
||||
settings.setAlertNotifiedState(null)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Même calcul que la carte accueil : calibration auto si activée
|
||||
val treatments = db.treatmentDao().getAllOnce()
|
||||
val doseLogs = db.doseLogDao().getAllOnce()
|
||||
val labs = db.labResultDao().getAllOnce()
|
||||
val tConfig = settings.tConfig.first()
|
||||
val autoCalibrate = settings.autoCalibrate.first()
|
||||
val auto = if (autoCalibrate && treatments.isNotEmpty()) {
|
||||
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labs, tConfig)
|
||||
} else null
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val level = PharmacokineticEngine.levelAt(
|
||||
treatments, doseLogs, now,
|
||||
tConfig = auto?.tConfig ?: tConfig,
|
||||
tKPerEster = auto?.tKPerEster,
|
||||
scalePerEster = auto?.esterScales
|
||||
)
|
||||
|
||||
val alerts = Alerts.evaluateAll(level.e2, level.t, thresholds)
|
||||
val lastNotified = Alerts.parseState(settings.alertNotifiedState.first())
|
||||
val current = alerts.associate { it.marker to it.level }
|
||||
|
||||
when {
|
||||
alerts.isEmpty() -> {
|
||||
// Retour à la normale : on efface l'état (prochain écart re-notifiera)
|
||||
if (lastNotified.isNotEmpty()) {
|
||||
settings.setAlertNotifiedState(null)
|
||||
AppLog.log("alerte", "seuils : retour à la normale (E2≈${"%.0f".format(level.e2)}, T≈${"%.2f".format(level.t)})")
|
||||
}
|
||||
}
|
||||
Alerts.shouldNotify(current, lastNotified) -> {
|
||||
notify(context, alerts)
|
||||
settings.setAlertNotifiedState(Alerts.encodeState(alerts))
|
||||
AppLog.log("alerte", "notification : ${alerts.joinToString { "${it.marker}:${it.level}" }} (E2≈${"%.0f".format(level.e2)}, T≈${"%.2f".format(level.t)})")
|
||||
}
|
||||
else -> {
|
||||
AppLog.log("alerte", "seuils : franchissement inchangé (${current}) — pas de re-notification")
|
||||
}
|
||||
}
|
||||
return Alerts.encodeState(alerts)
|
||||
}
|
||||
|
||||
/** Construit et poste la notification d'alerte (canal dédié). */
|
||||
private fun notify(context: Context, alerts: List<Alerts.Alert>) {
|
||||
// POST_NOTIFICATIONS runtime (API 33+) : sans permission, ne pas
|
||||
// poster (l'app ne crashe pas — NotificationManager ignore silencieusement
|
||||
// sauf SecurityException selon les builds ; garde défensive).
|
||||
if (android.os.Build.VERSION.SDK_INT >= 33 &&
|
||||
ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
AppLog.log("alerte", "notification impossible : POST_NOTIFICATIONS refusée")
|
||||
return
|
||||
}
|
||||
val title = context.getString(R.string.alert_notif_title)
|
||||
val text = alerts.joinToString("\n") { a ->
|
||||
val valueStr = if (a.value >= 10) "%.0f".format(a.value) else "%.2f".format(a.value)
|
||||
val limitStr = if (a.limit >= 10) "%.0f".format(a.limit) else "%.2f".format(a.limit)
|
||||
if (a.level == Alerts.Level.HIGH) {
|
||||
context.getString(R.string.alert_high, a.marker, valueStr, a.unit, limitStr)
|
||||
} else {
|
||||
context.getString(R.string.alert_low, a.marker, valueStr, a.unit, limitStr)
|
||||
}
|
||||
}
|
||||
val openIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val openPi = PendingIntent.getActivity(
|
||||
context, NOTIFICATION_ID, openIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||
.setContentIntent(openPi)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker WorkManager : appelle [AlertNotifier.evaluateAndNotify] en coroutine
|
||||
* (CoroutineWorker → dispatchers WorkManager, IO-friendly). Toute exception
|
||||
* est journalisée puis renvoyée en échec RETRYABLE (WorkManager re-tentera).
|
||||
*/
|
||||
class AlertWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result = try {
|
||||
AlertNotifier.evaluateAndNotify(applicationContext)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
AppLog.log("alerte", "ERREUR worker : ${e.message}")
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
194
app/src/main/java/com/hormonetrack/reminder/CalendarEvents.kt
Normal file
194
app/src/main/java/com/hormonetrack/reminder/CalendarEvents.kt
Normal file
@ -0,0 +1,194 @@
|
||||
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 com.hormonetrack.ui.components.formatDose
|
||||
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).
|
||||
*
|
||||
* ⚠️ BUG fixé (v1.3.5, #50) : un URI avec CALLER_IS_SYNCADAPTER DOIT aussi
|
||||
* embarquer ACCOUNT_NAME + ACCOUNT_TYPE — sinon le CalendarProvider lève
|
||||
* `IllegalArgumentException: Sync adapters must specify an account and
|
||||
* account type` sur la requête ou l'insertion. Vu dans les AppLog exportés
|
||||
* du Pixel 9 /e/OS v1.3.4 : la permission agenda était accordée (fix
|
||||
* manifest v1.3.3 ✓) et ensureCalendar plantait juste après.
|
||||
*/
|
||||
fun ensureCalendar(context: Context): Long {
|
||||
val calUri = CalendarContract.Calendars.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()
|
||||
|
||||
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).
|
||||
*
|
||||
* ⚠️ #50 bis (v1.3.5) : CALLER_IS_SYNCADAPTER exige AUSSI les paramètres
|
||||
* ACCOUNT_NAME/ACCOUNT_TYPE ici — sans eux le provider lève
|
||||
* IllegalArgumentException (avalée par le catch), et l'événement restait
|
||||
* dans le provider après « switch OFF » + save. Validé émulateur : la
|
||||
* suppression échouait silencieusement avant ce fix.
|
||||
*/
|
||||
fun deleteEvent(context: Context, eventId: Long) {
|
||||
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
|
||||
.buildUpon()
|
||||
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.appendQueryParameter(
|
||||
CalendarContract.Calendars.ACCOUNT_NAME, CALENDAR_NAME
|
||||
)
|
||||
.appendQueryParameter(
|
||||
CalendarContract.Calendars.ACCOUNT_TYPE,
|
||||
CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
)
|
||||
.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) } ?: "?"
|
||||
|
||||
// formatDose est la fonction PUBLIQUE de ui/components/DoseDialog.kt
|
||||
// (dédupliquée v1.8.2 — la même règle d'affichage des doses partout)
|
||||
}
|
||||
@ -31,6 +31,10 @@ class DoseActionReceiver : BroadcastReceiver() {
|
||||
val treatment = db.treatmentDao().getById(treatmentId) ?: return@launch
|
||||
val amount = if (dose > 0.0) dose else treatment.doseAmount
|
||||
db.doseLogDao().insert(DoseLog(treatmentId = treatmentId, doseAmount = amount))
|
||||
// ⚠️ v1.4.0 (fix #52) : la dose loguée AVANCE la grille —
|
||||
// reprogrammer immédiatement le rappel au créneau suivant
|
||||
// (Posologie si configurée, sinon demain à la même heure).
|
||||
AlarmScheduler(context).scheduleFor(treatment, db.doseLogDao().getAllOnce())
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
@ -39,7 +43,14 @@ class DoseActionReceiver : BroadcastReceiver() {
|
||||
ReminderContract.ACTION_SNOOZE -> {
|
||||
val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: ""
|
||||
val dose = intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0)
|
||||
AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose)
|
||||
// v1.10.1 : le snooze FORWARDE l'instant planifié d'origine —
|
||||
// le garde du feu différé évalue le jour du CRÉNEAU, pas
|
||||
// celui du snooze (sinon un snooze à cheval sur minuit
|
||||
// évaluerait le mauvais jour).
|
||||
val plannedAt = if (intent.hasExtra(ReminderContract.EXTRA_PLANNED_AT)) {
|
||||
intent.getLongExtra(ReminderContract.EXTRA_PLANNED_AT, 0L)
|
||||
} else null
|
||||
AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose, plannedAt = plannedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,8 +12,14 @@ import androidx.core.app.NotificationCompat
|
||||
import com.hormonetrack.MainActivity
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.AppDatabase
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import java.util.Calendar
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.util.AppLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object ReminderContract {
|
||||
const val CHANNEL_ID = "hormonetrack_reminders"
|
||||
@ -24,6 +30,17 @@ object ReminderContract {
|
||||
const val EXTRA_TREATMENT_NAME = "treatment_name"
|
||||
const val EXTRA_DOSE_AMOUNT = "dose_amount"
|
||||
|
||||
/**
|
||||
* Instant PLANIFIÉ de l'alarme (v1.10.1) — embarqué par
|
||||
* AlarmScheduler.scheduleFor au moment du scheduling et relu par
|
||||
* [ReminderReceiver]. ⚠️ Nécessaire car une alarme INEXACTE (setWindow
|
||||
* sans SCHEDULE_EXACT_ALARM) ou retardée (Doze) peut fire après la
|
||||
* frontière de jour : le garde « créneau déjà honoré » doit évaluer le
|
||||
* jour PLANIFIÉ, pas le jour réel du feu. Absent (vieux PendingIntents)
|
||||
* → le receiver retombe sur l'heure courante.
|
||||
*/
|
||||
const val EXTRA_PLANNED_AT = "planned_at"
|
||||
|
||||
fun reminderIntent(context: Context, treatmentId: Long): Intent =
|
||||
Intent(context, ReminderReceiver::class.java).apply {
|
||||
action = ACTION_REMINDER
|
||||
@ -47,10 +64,16 @@ class AlarmScheduler(private val context: Context) {
|
||||
|
||||
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
private fun pendingBroadcast(treatmentId: Long, name: String?, dose: Double?): PendingIntent {
|
||||
private fun pendingBroadcast(
|
||||
treatmentId: Long,
|
||||
name: String?,
|
||||
dose: Double?,
|
||||
plannedAt: Long? = null
|
||||
): PendingIntent {
|
||||
val intent = ReminderContract.reminderIntent(context, treatmentId)
|
||||
name?.let { intent.putExtra(ReminderContract.EXTRA_TREATMENT_NAME, it) }
|
||||
dose?.let { intent.putExtra(ReminderContract.EXTRA_DOSE_AMOUNT, it) }
|
||||
plannedAt?.let { intent.putExtra(ReminderContract.EXTRA_PLANNED_AT, it) }
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
treatmentId.toInt(),
|
||||
@ -59,32 +82,42 @@ class AlarmScheduler(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun nextOccurrence(hour: Int, minute: Int): Long {
|
||||
val cal = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, hour)
|
||||
set(Calendar.MINUTE, minute)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
if (cal.timeInMillis <= System.currentTimeMillis()) {
|
||||
cal.add(Calendar.DAY_OF_MONTH, 1)
|
||||
}
|
||||
return cal.timeInMillis
|
||||
}
|
||||
|
||||
fun canScheduleExact(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
|
||||
|
||||
fun scheduleDaily(treatment: Treatment): Boolean {
|
||||
val hour = treatment.reminderHour ?: return false
|
||||
val minute = treatment.reminderMinute ?: return false
|
||||
val triggerAt = nextOccurrence(hour, minute)
|
||||
val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount)
|
||||
return scheduleAt(triggerAt, pi)
|
||||
/**
|
||||
* Programme la PROCHAINE alarme du traitement (v1.4.0, fix #52) :
|
||||
* la date vient du MOTEUR (`nextReminderFireFor`) — unique source de
|
||||
* vérité — donc suit la Posologie quand elle existe (rappel le jour du
|
||||
* créneau uniquement), sinon quotidien. One-shot : l'alarme suivante est
|
||||
* reprogrammée par ReminderReceiver (après notif) et DoseActionReceiver
|
||||
* (après « Pris »).
|
||||
*/
|
||||
fun scheduleFor(treatment: Treatment, doseLogs: List<DoseLog>): Boolean {
|
||||
val fireAt = PharmacokineticEngine.nextReminderFireFor(
|
||||
treatment, doseLogs, System.currentTimeMillis()
|
||||
) ?: return false
|
||||
// L'instant planifié voyage dans l'intent : le garde « créneau déjà
|
||||
// honoré » (v1.10.1) évalue le jour PLANIFIÉ, pas celui du feu réel
|
||||
// (une alarme inexacte peut glisser après minuit — cf
|
||||
// ReminderContract.EXTRA_PLANNED_AT).
|
||||
val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount, fireAt)
|
||||
return scheduleAt(fireAt, pi)
|
||||
}
|
||||
|
||||
fun scheduleSnooze(treatmentId: Long, name: String, dose: Double, delayMs: Long = 60 * 60_000L): Boolean {
|
||||
val pi = pendingBroadcast(treatmentId, name, dose)
|
||||
/**
|
||||
* Alarme « Reporter 1 h ». [plannedAt] : l'instant PLANIFIÉ du créneau
|
||||
* d'origine — forwardé tel quel pour que le garde du feu différé
|
||||
* évalue toujours le bon jour (et non le jour du snooze).
|
||||
*/
|
||||
fun scheduleSnooze(
|
||||
treatmentId: Long,
|
||||
name: String,
|
||||
dose: Double,
|
||||
delayMs: Long = 60 * 60_000L,
|
||||
plannedAt: Long? = null
|
||||
): Boolean {
|
||||
val pi = pendingBroadcast(treatmentId, name, dose, plannedAt)
|
||||
return scheduleAt(System.currentTimeMillis() + delayMs, pi)
|
||||
}
|
||||
|
||||
@ -112,10 +145,15 @@ class AlarmScheduler(private val context: Context) {
|
||||
alarmManager.cancel(pi)
|
||||
}
|
||||
|
||||
fun rescheduleAll(treatments: List<Treatment>) {
|
||||
/**
|
||||
* Reprogramme/annule tous les rappels. [doseLogs] alimente la grille
|
||||
* Posologie (fix #52) ; vide = fallback quotidien (compat. appelants).
|
||||
*/
|
||||
fun rescheduleAll(treatments: List<Treatment>, doseLogs: List<DoseLog> = emptyList()) {
|
||||
treatments.forEach { tr ->
|
||||
if (tr.reminderEnabled) {
|
||||
scheduleDaily(tr)
|
||||
// Un traitement inactif ne rappelle plus : son alarme est annulée
|
||||
if (tr.reminderEnabled && tr.isActive) {
|
||||
scheduleFor(tr, doseLogs)
|
||||
} else {
|
||||
cancel(tr.id)
|
||||
}
|
||||
@ -181,8 +219,11 @@ class ReminderReceiver : BroadcastReceiver() {
|
||||
} else {
|
||||
context.getString(R.string.reminder_title_plain)
|
||||
}
|
||||
// ⚠️ reminder_text_with_dose attend un %s : passer le Double brut
|
||||
// déclenche un warning lint StringFormatMatches (« suspicious type ») —
|
||||
// formater explicitement en dose humaine (ex. « 4,0 »).
|
||||
val text = if (dose != null && dose > 0.0) {
|
||||
context.getString(R.string.reminder_text_with_dose, dose)
|
||||
context.getString(R.string.reminder_text_with_dose, dose.toString())
|
||||
} else {
|
||||
context.getString(R.string.reminder_text)
|
||||
}
|
||||
@ -199,24 +240,69 @@ class ReminderReceiver : BroadcastReceiver() {
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
// ⚠️ Les alarmes sont one-shot (setExactAndAllowWhileIdle) : reprogrammer
|
||||
// ICI le déclenchement SUIVANT (v1.4.0, fix #52) — sinon le rappel suivant
|
||||
// n'existait qu'après un reboot/une édition. Le calcul passe par le moteur
|
||||
// (grille Posologie si elle existe, sinon quotidien).
|
||||
// v1.10.0 (demande utilisatrice) : si la dose du jour est DÉJÀ loguée
|
||||
// (prise saisie avant l'heure de rappel), la notification est SAUTÉE —
|
||||
// elle ne doit pas sonner pour une prise déjà faite — et le rappel
|
||||
// suivant repart au créneau suivant (re-schedule dans tous les cas).
|
||||
// v1.10.1 : le jour évalué est celui du créneau PLANIFIÉ
|
||||
// (EXTRA_PLANNED_AT), pas celui du feu réel — une alarme inexacte
|
||||
// peut glisser après minuit sans changer de créneau.
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(treatmentId.toInt(), notification)
|
||||
val plannedAtMs = if (intent.hasExtra(ReminderContract.EXTRA_PLANNED_AT)) {
|
||||
intent.getLongExtra(ReminderContract.EXTRA_PLANNED_AT, 0L)
|
||||
} else {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
val pending = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val doses = db.doseLogDao().getAllOnce()
|
||||
if (PharmacokineticEngine.hasDoseLoggedOnDay(
|
||||
treatmentId, doses, plannedAtMs
|
||||
)
|
||||
) {
|
||||
AppLog.log(
|
||||
"reminder",
|
||||
"créneau déjà honoré (dose loguée le jour planifié) → notification sautée (treatment=$treatmentId)"
|
||||
)
|
||||
} else {
|
||||
manager.notify(treatmentId.toInt(), notification)
|
||||
}
|
||||
db.treatmentDao().getById(treatmentId)?.let { tr ->
|
||||
AlarmScheduler(context).scheduleFor(tr, doses)
|
||||
}
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
val result = goAsync()
|
||||
Thread {
|
||||
// v1.10.1 : consolidation goAsync + coroutine (pattern ReminderReceiver —
|
||||
// remplace le Thread + double runBlocking de la v1.0, dette §20.bis #2) :
|
||||
// une seule lecture séquentielle des DAO dans un scope IO dédié, finish()
|
||||
// garanti par le finally.
|
||||
val pending = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
val treatments = kotlinx.coroutines.runBlocking {
|
||||
AppDatabase.getInstance(context).treatmentDao().getActiveOnce()
|
||||
}
|
||||
AlarmScheduler(context).rescheduleAll(treatments)
|
||||
val db = AppDatabase.getInstance(context)
|
||||
// TOUS les traitements : les inactifs doivent voir leur alarme
|
||||
// annulée au boot (sinon une alarme périmée continue de sonner) ;
|
||||
// les doses alimentent la grille Posologie (fix #52)
|
||||
val treatments = db.treatmentDao().getAllOnce()
|
||||
val doses = db.doseLogDao().getAllOnce()
|
||||
AlarmScheduler(context).rescheduleAll(treatments, doses)
|
||||
} finally {
|
||||
result.finish()
|
||||
pending.finish()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,16 +3,24 @@ package com.hormonetrack.settings
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.hormonetrack.pk.Alerts
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
/**
|
||||
* Préférences persistées (DataStore) : modèle T, langue, options d'affichage,
|
||||
* seuils d'alerte (v1.4.2).
|
||||
*/
|
||||
class AppSettings(private val context: Context) {
|
||||
|
||||
private object Keys {
|
||||
@ -20,6 +28,66 @@ class AppSettings(private val context: Context) {
|
||||
val T_FLOOR = doublePreferencesKey("t_floor")
|
||||
val T_K = doublePreferencesKey("t_k")
|
||||
val LANGUAGE = stringPreferencesKey("language")
|
||||
|
||||
/** Option : ajuster automatiquement scale factor + modèle T depuis les labs. */
|
||||
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")
|
||||
|
||||
// ── Seuils d'alerte (v1.4.2) : limites E2 (pg/mL) et T (ng/mL). ──
|
||||
// Clé absente = pas de seuil → pas d'alerte sur ce côté (opt-in pur).
|
||||
val ALERT_E2_HIGH = doublePreferencesKey("alert_e2_high")
|
||||
val ALERT_E2_LOW = doublePreferencesKey("alert_e2_low")
|
||||
val ALERT_T_HIGH = doublePreferencesKey("alert_t_high")
|
||||
val ALERT_T_LOW = doublePreferencesKey("alert_t_low")
|
||||
|
||||
/**
|
||||
* État des alertes DÉJÀ NOTIFIÉES (encodage [Alerts.encodeState],
|
||||
* ex. "E2:HIGH;T:LOW") — anti-spam du worker périodique : on ne
|
||||
* re-notify pas le même franchissement toutes les 15 min.
|
||||
*/
|
||||
val ALERT_NOTIFIED_STATE = stringPreferencesKey("alert_notified_state")
|
||||
|
||||
/**
|
||||
* Fuseau de LECTURE du graphique (v1.4.5) : ID java.util.TimeZone
|
||||
* (ex. "Europe/Paris") ; null/absent = fuseau du téléphone (auto).
|
||||
*/
|
||||
val CHART_TIMEZONE = stringPreferencesKey("chart_timezone")
|
||||
|
||||
// ── Auto-backup journalier (v1.7.0) ────────────────────────────────
|
||||
/** Activer/désactiver la sauvegarde automatique (défaut : false). */
|
||||
val AUTO_BACKUP_ENABLED = booleanPreferencesKey("auto_backup_enabled")
|
||||
|
||||
/**
|
||||
* Dossier cible SAF (v1.7.0) : tree URI issu d'ACTION_OPEN_DOCUMENT_TREE,
|
||||
* persisté via takePersistableUriPermission (la permission SURVIT au
|
||||
* reboot — le worker peut écrire sans intervention). null = pas de
|
||||
* dossier choisi → l'auto-backup ne peut pas tourner (worker no-op).
|
||||
*/
|
||||
val AUTO_BACKUP_TREE_URI = stringPreferencesKey("auto_backup_tree_uri")
|
||||
|
||||
/**
|
||||
* Nombre de copies d'auto-backup conservées dans le dossier (1..30,
|
||||
* défaut 7) — les plus vieilles sont supprimées après chaque run
|
||||
* (cf AutoBackupRetention ; l'export MANUEL n'est jamais touché).
|
||||
*/
|
||||
val AUTO_BACKUP_KEEP = intPreferencesKey("auto_backup_keep")
|
||||
|
||||
/** Résultat du DERNIER run auto : "ok" / "error" (null = jamais tourné). */
|
||||
val AUTO_BACKUP_LAST_RESULT = stringPreferencesKey("auto_backup_last_result")
|
||||
|
||||
/** Horodatage du dernier run auto tenté (millisecondes, 0/absent = jamais). */
|
||||
val AUTO_BACKUP_LAST_AT = longPreferencesKey("auto_backup_last_at")
|
||||
|
||||
// ── Cible de creux E2 (v1.13.0, opt-in) ────────────────────────────
|
||||
// Référence PERSONNELLE pour la prochaine prise de sang (« je veux
|
||||
// être dans [low, high] au creux recommandé »). DISTINCT des seuils
|
||||
// d'alerte (ALERT_E2_*), qui surveillent le niveau en continu —
|
||||
// les deux bandes sont volontairement indépendantes. Champ absent =
|
||||
// cible désactivée (comportement v1.12).
|
||||
val TROUGH_TARGET_LOW = doublePreferencesKey("trough_target_low")
|
||||
val TROUGH_TARGET_HIGH = doublePreferencesKey("trough_target_high")
|
||||
}
|
||||
|
||||
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
|
||||
@ -34,6 +102,30 @@ class AppSettings(private val context: Context) {
|
||||
prefs[Keys.LANGUAGE] ?: "system"
|
||||
}
|
||||
|
||||
/** Par défaut DÉSACTIVÉ : la calibration reste sous contrôle de l'utilisatrice. */
|
||||
val autoCalibrate: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
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]
|
||||
}
|
||||
|
||||
/**
|
||||
* Seuils d'alerte (v1.4.2) : null = pas de limite pour la valeur
|
||||
* (les champs laissés vides dans Paramètres). Évalués contre le taux
|
||||
* ESTIMÉ actuel sur l'accueil (cf pk/Alerts.kt).
|
||||
*/
|
||||
val alertThresholds: Flow<Alerts.Thresholds> = context.dataStore.data.map { prefs ->
|
||||
Alerts.Thresholds(
|
||||
e2High = prefs[Keys.ALERT_E2_HIGH],
|
||||
e2Low = prefs[Keys.ALERT_E2_LOW],
|
||||
tHigh = prefs[Keys.ALERT_T_HIGH],
|
||||
tLow = prefs[Keys.ALERT_T_LOW]
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setTConfig(config: TConfig) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.T_BASE] = config.base
|
||||
@ -47,4 +139,131 @@ class AppSettings(private val context: Context) {
|
||||
prefs[Keys.LANGUAGE] = code
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAutoCalibrate(enabled: Boolean) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.AUTO_CALIBRATE] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setChangelogSeenVersion(version: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.CHANGELOG_SEEN] = version
|
||||
}
|
||||
}
|
||||
|
||||
/** État des alertes déjà notifiées (cf [Keys.ALERT_NOTIFIED_STATE]). */
|
||||
val alertNotifiedState: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.ALERT_NOTIFIED_STATE]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone
|
||||
* (auto-détecté). Un ID invalide est impossible (validé à la saisie).
|
||||
*/
|
||||
val chartTimezone: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.CHART_TIMEZONE]
|
||||
}
|
||||
|
||||
suspend fun setChartTimezone(timezoneId: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (timezoneId.isNullOrBlank()) prefs.remove(Keys.CHART_TIMEZONE)
|
||||
else prefs[Keys.CHART_TIMEZONE] = timezoneId
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-backup journalier (v1.7.0) ─────────────────────────────────────
|
||||
|
||||
/** Auto-backup activé (défaut DÉSACTIVÉ — opt-in, comme tout ce qui écrit). */
|
||||
val autoBackupEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_ENABLED] ?: false
|
||||
}
|
||||
|
||||
/** Dossier cible SAF persisté (null = pas de dossier → worker no-op). */
|
||||
val autoBackupTreeUri: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_TREE_URI]
|
||||
}
|
||||
|
||||
/** Copies conservées (1..30, défaut 7 — borné au SET, cf setAutoBackupKeep). */
|
||||
val autoBackupKeep: Flow<Int> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_KEEP] ?: 7
|
||||
}
|
||||
|
||||
/** Résultat du dernier run : Pair(résultat, horodatage) ; null = jamais tourné. */
|
||||
val autoBackupLastRun: Flow<Pair<String?, Long?>> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_LAST_RESULT] to prefs[Keys.AUTO_BACKUP_LAST_AT]
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupEnabled(enabled: Boolean) {
|
||||
context.dataStore.edit { prefs -> prefs[Keys.AUTO_BACKUP_ENABLED] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupTreeUri(uri: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (uri.isNullOrBlank()) prefs.remove(Keys.AUTO_BACKUP_TREE_URI)
|
||||
else prefs[Keys.AUTO_BACKUP_TREE_URI] = uri
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies conservées — borné [1 ; 30] (un `0` supprimerait la dernière copie). */
|
||||
suspend fun setAutoBackupKeep(keep: Int) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_KEEP] = keep.coerceIn(1, 30)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste le résultat du dernier run (appelé par [com.hormonetrack.data.backup.AutoBackupWorker])
|
||||
* — affiché dans Paramètres (« dernier : réussi/échec, date »).
|
||||
*/
|
||||
suspend fun setAutoBackupLastRun(ok: Boolean, atMs: Long) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.AUTO_BACKUP_LAST_RESULT] = if (ok) "ok" else "error"
|
||||
prefs[Keys.AUTO_BACKUP_LAST_AT] = atMs
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAlertNotifiedState(encoded: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (encoded.isNullOrEmpty()) prefs.remove(Keys.ALERT_NOTIFIED_STATE)
|
||||
else prefs[Keys.ALERT_NOTIFIED_STATE] = encoded
|
||||
}
|
||||
}
|
||||
|
||||
/** Cible de creux (v1.13.0) : `null` si désactivée (un champ manquant suffit). */
|
||||
val troughTarget: Flow<Pair<Double, Double>?> = context.dataStore.data.map { prefs ->
|
||||
val low = prefs[Keys.TROUGH_TARGET_LOW]
|
||||
val high = prefs[Keys.TROUGH_TARGET_HIGH]
|
||||
if (low != null && high != null) low to high else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste la cible de creux (v1.13.0). `null` EFFACE les deux clés —
|
||||
* « champs vides = cible désactivée ». La cohérence high > low est
|
||||
* validée dans l'UI (TroughTargetCard), comme pour les seuils d'alerte.
|
||||
*/
|
||||
suspend fun setTroughTarget(target: Pair<Double, Double>?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (target == null) {
|
||||
prefs.remove(Keys.TROUGH_TARGET_LOW)
|
||||
prefs.remove(Keys.TROUGH_TARGET_HIGH)
|
||||
} else {
|
||||
prefs[Keys.TROUGH_TARGET_LOW] = target.first
|
||||
prefs[Keys.TROUGH_TARGET_HIGH] = target.second
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste les seuils d'alerte (v1.4.2). Les valeurs null EFFACENT la clé
|
||||
* (DataStore : remove) — « champ vide = pas d'alerte ».
|
||||
*/
|
||||
suspend fun setAlertThresholds(t: Alerts.Thresholds) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (t.e2High != null) prefs[Keys.ALERT_E2_HIGH] = t.e2High else prefs.remove(Keys.ALERT_E2_HIGH)
|
||||
if (t.e2Low != null) prefs[Keys.ALERT_E2_LOW] = t.e2Low else prefs.remove(Keys.ALERT_E2_LOW)
|
||||
if (t.tHigh != null) prefs[Keys.ALERT_T_HIGH] = t.tHigh else prefs.remove(Keys.ALERT_T_HIGH)
|
||||
if (t.tLow != null) prefs[Keys.ALERT_T_LOW] = t.tLow else prefs.remove(Keys.ALERT_T_LOW)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
package com.hormonetrack.settings
|
||||
|
||||
/** Précompilées (v1.8.2) : appelées à chaque ouverture du dialog « Nouveautés » */
|
||||
private val VERSION_PATTERN = Regex("^\\d+(\\.\\d+)*$")
|
||||
private val SECTION_PATTERN = Regex("## \\[(\\d+\\.\\d+\\.\\d+)\\][^\\n]*\\n(.*?)(?=\\n## \\[|\\Z)", RegexOption.DOT_MATCHES_ALL)
|
||||
|
||||
/**
|
||||
* 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 (!VERSION_PATTERN.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 = SECTION_PATTERN
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
package com.hormonetrack.ui
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
@ -14,8 +15,13 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
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.res.stringResource
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
@ -40,8 +46,45 @@ val LocalAppContainer = compositionLocalOf<AppContainer> {
|
||||
private data class TabItem(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector)
|
||||
|
||||
@Composable
|
||||
fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
|
||||
fun HormoneTrackRoot(logDoseRequest: androidx.compose.runtime.MutableState<Long?>) {
|
||||
val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp
|
||||
val 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 current = com.hormonetrack.BuildConfig.VERSION_NAME
|
||||
val seen = app.container.settings.changelogSeenVersion.first()
|
||||
com.hormonetrack.util.AppLog.log("changelog", "démarrage : app=$current, dernière vue=$seen")
|
||||
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, current
|
||||
)
|
||||
// ⚠️ FIX v1.3.2 : MÉMORISER LA VERSION VUE AVANT D'AFFICHER — sinon,
|
||||
// si l'app est fermée pendant que le dialog est ouvert (ou que
|
||||
// DataStore n'a pas eu le temps de flusher), le même changelog
|
||||
// ressort à chaque réouverture.
|
||||
app.container.settings.setChangelogSeenVersion(current)
|
||||
if (text.isNotBlank()) {
|
||||
changelogText = text
|
||||
com.hormonetrack.util.AppLog.log("changelog", "nouveautés affichées (${text.length} car.)")
|
||||
}
|
||||
}
|
||||
changelogText?.let { md ->
|
||||
com.hormonetrack.ui.components.ChangelogDialog(
|
||||
changelogMarkdown = md,
|
||||
version = com.hormonetrack.BuildConfig.VERSION_NAME,
|
||||
onDismiss = { changelogText = null }
|
||||
)
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalAppContainer provides app.container) {
|
||||
val navController = rememberNavController()
|
||||
val tabs = listOf(
|
||||
@ -55,6 +98,10 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
|
||||
val currentRoute = backStack?.destination?.route
|
||||
|
||||
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 = {
|
||||
if (currentRoute in tabs.map { it.route }) {
|
||||
NavigationBar {
|
||||
@ -85,8 +132,22 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
|
||||
) {
|
||||
composable("home") {
|
||||
HomeScreen(
|
||||
openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else null,
|
||||
// Demande de notification : état détenu par l'activité
|
||||
// (survit aux onglets), consommé UNE fois par HomeScreen
|
||||
// (fix #69 — dialog fantôme au retour sur l'accueil)
|
||||
openLogDoseForTreatmentId = logDoseRequest.value,
|
||||
onLogDoseConsumed = { logDoseRequest.value = null },
|
||||
onOpenSettings = { navController.navigate("settings") },
|
||||
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") }
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -3,6 +3,7 @@ package com.hormonetrack.ui.components
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
@ -13,22 +14,146 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.pk.LevelPoint
|
||||
import com.hormonetrack.ui.theme.ChartE2
|
||||
import com.hormonetrack.ui.theme.ChartT
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.ui.theme.LabDot
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* Style d'une courbe dessinée sur le graphique.
|
||||
* @param color couleur du tracé
|
||||
* @param dashed true = trait pointillé (utilisé pour distinguer TFS d'Estrannaise, ou T)
|
||||
*/
|
||||
data class CurveStyle(val color: Color, val dashed: Boolean = false)
|
||||
|
||||
/**
|
||||
* Une série = un jeu de points (LevelPoint avec e2 ET t) + les styles de tracé
|
||||
* associés. Le graphique superpose autant de séries qu'on veut (ex. : les deux
|
||||
* modèles Estrannaise et Transfem Science côte à côte).
|
||||
*/
|
||||
data class ChartSeries(
|
||||
val points: List<LevelPoint>,
|
||||
val e2Style: CurveStyle,
|
||||
/** Style de la courbe T de cette série ; null = pas de courbe T pour cette série. */
|
||||
val tStyle: CurveStyle? = null,
|
||||
/**
|
||||
* v1.5.0 : cette série participe-t-elle à la détection des pics/creux ?
|
||||
* Défaut true (toutes les séries modèle) ; « Tracé labs » est FALSE —
|
||||
* ses extrema reflèteraient l'interp ρ entre labs, pas la physiologie du
|
||||
* modèle, et `labIsSignificant` a déjà filtré les points aberrants.
|
||||
*/
|
||||
val showExtrema: Boolean = true,
|
||||
/**
|
||||
* v1.9.0 : NUAGE D'INCERTITUDE du modèle ESE (échantillons MCMC — cf
|
||||
* pk/EstrannaiseCloud) : chaque sous-liste est une polyligne dessinée en
|
||||
* alpha faible SOUS la courbe principale de la série. null = pas de nuage.
|
||||
* Hors labels/extrema par construction (c'est de l'incertitude, pas des
|
||||
* courbes nommées).
|
||||
*/
|
||||
val cloud: List<List<LevelPoint>>? = null
|
||||
)
|
||||
|
||||
data class ChartOptions(
|
||||
val showT: 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()
|
||||
)
|
||||
|
||||
/**
|
||||
* Instants des LABELS X (v1.4.5, fix #55) : alignés sur les frontières LOCALES
|
||||
* de [zone] (minuit local pour les pas en jours, heures rondes locales pour
|
||||
* les pas horaires) et NON sur des multiples UTC — l'ancien alignement
|
||||
* plaçait le label « 13/09 » à 02:00 du matin FR, ce qui décalait la lecture
|
||||
* visuelle des événements (remontée : « la prévision simule mon injection le
|
||||
* 13 au lieu du 12 »). [zone] = fuseau de LECTURE du graphique : celui du
|
||||
* téléphone par défaut, ou celui choisi dans Paramètres (v1.4.5). Pur
|
||||
* (Calendar + TimeZone) → testable en JVM.
|
||||
*
|
||||
* @return la liste croissante des instants de tick dans [t0 ; t1].
|
||||
*/
|
||||
fun xLabelTicks(
|
||||
t0: Long,
|
||||
t1: Long,
|
||||
stepHours: Long,
|
||||
zone: java.util.TimeZone = java.util.TimeZone.getDefault()
|
||||
): List<Long> {
|
||||
if (t1 <= t0 || stepHours <= 0) return emptyList()
|
||||
val cal = Calendar.getInstance(zone)
|
||||
cal.timeInMillis = t0
|
||||
if (stepHours >= 24) {
|
||||
// pas en JOURS : frontières = MINUIT LOCAL (dans [zone])
|
||||
cal.set(Calendar.HOUR_OF_DAY, 0)
|
||||
cal.set(Calendar.MINUTE, 0)
|
||||
cal.set(Calendar.SECOND, 0)
|
||||
cal.set(Calendar.MILLISECOND, 0)
|
||||
val days = (stepHours / 24L).toInt().coerceAtLeast(1)
|
||||
while (cal.timeInMillis <= t0) cal.add(Calendar.DAY_OF_MONTH, days)
|
||||
} else {
|
||||
// pas horaire : heures rondes LOCALES (dans [zone])
|
||||
cal.set(Calendar.MINUTE, 0)
|
||||
cal.set(Calendar.SECOND, 0)
|
||||
cal.set(Calendar.MILLISECOND, 0)
|
||||
while (cal.timeInMillis <= t0) cal.add(Calendar.HOUR_OF_DAY, 1)
|
||||
}
|
||||
val out = mutableListOf<Long>()
|
||||
var t = cal.timeInMillis
|
||||
while (t <= t1) {
|
||||
out.add(t)
|
||||
// On avance via le Calendar (et non un +stepMs fixe) : une addition
|
||||
// en millisecondes dérive d'1 h aux transitions DST, alors que le
|
||||
// pas « 24 h » doit tomber sur minuit LOCAL tous les jours — y
|
||||
// compris le jour du changement d'heure.
|
||||
if (stepHours >= 24) {
|
||||
cal.add(Calendar.DAY_OF_MONTH, (stepHours / 24L).toInt().coerceAtLeast(1))
|
||||
} else {
|
||||
cal.add(Calendar.HOUR_OF_DAY, stepHours.toInt())
|
||||
}
|
||||
t = cal.timeInMillis
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un delta de drag en heures de pan, en CUMULANT le résiduel
|
||||
* fractionnaire (v1.4.10, fix #62).
|
||||
*
|
||||
* ⚠️ BUG #62 : l'ancien code tronquait CHAQUE delta incrémental en heures
|
||||
* entières (`(pan.x / largeur * plage).toLong()`) — à la plage 24 h, le
|
||||
* delta d'un doigt réel (~30 px ≈ 0,67 h) était tronqué à 0 → le pan ne
|
||||
* bougeait JAMAIS sur la vue 24 h (alors qu'il marchait sur 7 j+ : le même
|
||||
* delta ≈ 4,7 h). Le fix : le résiduel fractionnaire (la part < 1 h) est
|
||||
* conservé d'un événement de mouvement au suivant — le pan se déclenche dès
|
||||
* que le CUMUL franchit une heure entière.
|
||||
*
|
||||
* Pur → testable en JVM (`ChartZoomTest`).
|
||||
*
|
||||
* @param panPx delta horizontal du geste (px, signé).
|
||||
* @param chartWidthPx largeur du canvas en px.
|
||||
* @param rangeHours plage temporelle courante (24 h / 7 j / 30 j…).
|
||||
* @param residualHours résiduel fractionnaire précédent (à ré-injecter).
|
||||
* @return Pair(heures entières à appliquer au pan, nouveau résiduel).
|
||||
*/
|
||||
fun panDeltaHours(
|
||||
panPx: Float,
|
||||
chartWidthPx: Int,
|
||||
rangeHours: Long,
|
||||
residualHours: Double
|
||||
): Pair<Long, Double> {
|
||||
if (chartWidthPx <= 0 || rangeHours <= 0) return 0L to residualHours
|
||||
val total = panPx / chartWidthPx * rangeHours + residualHours
|
||||
val whole = total.toLong() // troncature vers ZÉRO (gère les deux signes)
|
||||
return whole to (total - whole)
|
||||
}
|
||||
|
||||
/** Arrondit une valeur max à une borne « propre » (1/2/2.5/5/10 × 10ⁿ) pour l'axe. */
|
||||
fun niceCeil(value: Double): Double {
|
||||
if (value <= 0) return 100.0
|
||||
val exp = Math.pow(10.0, Math.floor(Math.log10(value)))
|
||||
@ -43,43 +168,219 @@ fun niceCeil(value: Double): Double {
|
||||
return nice * exp
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles de MODÈLES PK par défaut (v1.4.7, fix #58) : au chargement du
|
||||
* graphique, seuls les modèles RÉELLEMENT utilisés par les traitements (en
|
||||
* cours ou passés — `isActive` n'est pas un filtre de simulation, cf §6.bis)
|
||||
* sont affichés ; les autres restent disponibles (chip off, activables au
|
||||
* tap). Ex. EV historique sous TFS + EEn actuel sous WHSAH → TFS ✓ WHSAH ✓
|
||||
* Estrannaise ✗.
|
||||
*
|
||||
* @param pkModelsUsed ensemble des `pkModel` des traitements de l'utilisatrice.
|
||||
* @return map modèle → toggle initial. GARDE-FOU : si AUCUN modèle profil
|
||||
* n'est utilisé (traitements gel/oral uniquement), TFS est forcé à ON —
|
||||
* les traitements Bateman sont tracés IDENTIQUEMENT dans chaque série
|
||||
* (cf §7.3b) : sans garde-fou, le graphique serait vide.
|
||||
*/
|
||||
fun defaultModelToggles(pkModelsUsed: Set<String>): Map<String, Boolean> {
|
||||
val toggles = mapOf(
|
||||
PKModels.ESTRANNAISE to pkModelsUsed.contains(PKModels.ESTRANNAISE),
|
||||
PKModels.TRANSFEM_SCIENCE to pkModelsUsed.contains(PKModels.TRANSFEM_SCIENCE),
|
||||
PKModels.WHSAH to pkModelsUsed.contains(PKModels.WHSAH)
|
||||
)
|
||||
// Garde-fou : aucune courbe sinon (Bateman tracé dans chaque série)
|
||||
return if (toggles.values.none { it }) toggles + (PKModels.TRANSFEM_SCIENCE to true) else toggles
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizon de PRÉVISION en heures (v1.4.1) : quantité de doses projetées
|
||||
* générées au-delà de « maintenant » quand le chip Prévision est actif.
|
||||
* 12 × la plus grande Posologie, borné [30 j ; 365 j] — « scroller à
|
||||
* l'infini » s'arrête à 1 an (compromis calcul : 365 j / 6 j ≈ 61 doses
|
||||
* projetées superposées, coût O(points × doses) maîtrisé).
|
||||
* @return null si aucun traitement n'a de Posologie (chip désactivé).
|
||||
*/
|
||||
fun forecastHorizonHours(maxIntervalDays: Double?): Long? {
|
||||
if (maxIntervalDays == null || maxIntervalDays <= 0.0) return null
|
||||
return (maxIntervalDays * 24.0 * 12.0).toLong().coerceIn(24L * 30, 24L * 365)
|
||||
}
|
||||
|
||||
/**
|
||||
* EXTENSION de fenêtre à droite quand le chip Prévision est activé
|
||||
* (v1.4.4, fix #54) : le toggle doit avoir un effet IMMÉDIAT et visible.
|
||||
*
|
||||
* ⚠️ Historique : en v1.4.1, activer la prévision ne déplaçait plus la
|
||||
* fenêtre (le saut « tout à droite » de v1.4.0 avait été rejeté) MAIS la
|
||||
* projection ne devenait visible qu'en tirant vers la gauche — car
|
||||
* `endMs = now` faisait que `generateForecastDoses(..., toMs = now, ...)`
|
||||
* ne générait AUCUNE dose : le chip semblait inerte (« le toggle ne fait
|
||||
* plus rien », remontée v1.4.3). La solution : étendre la fenêtre à droite
|
||||
* SANS déplacer son début (l'historique visible reste à sa place, seule la
|
||||
* largeur augmente) jusqu'à couvrir au moins la prochaine dose projetée,
|
||||
* sinon d'un tiers de la plage actuelle.
|
||||
*
|
||||
* @param rangeHours plage courante (24 h / 7 j / 30 j…)
|
||||
* @param horizonHours horizon de prévision ([forecastHorizonHours], ≤ 0 = pas de prévision)
|
||||
* @param firstSlotGapHours écart « maintenant → premier créneau projeté »
|
||||
* (null = aucun traitement avec Posologie)
|
||||
* @return heures à ajouter à `endMs` (0 = rien).
|
||||
*/
|
||||
fun forecastExtensionHours(
|
||||
rangeHours: Long,
|
||||
horizonHours: Long,
|
||||
firstSlotGapHours: Long?
|
||||
): Long {
|
||||
if (horizonHours <= 0L) return 0L
|
||||
// Montrer AU MOINS la prochaine dose (+12 h de contexte), sinon 1/3 de
|
||||
// la plage actuelle — jamais au-delà de l'horizon.
|
||||
val toFirstSlot = (firstSlotGapHours ?: 0L) + 12L
|
||||
return maxOf(rangeHours / 3, toFirstSlot).coerceIn(0L, horizonHours)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bornes du panoramique (v1.4.1) : la fenêtre peut reculer dans le PASSÉ
|
||||
* jusqu'à [0 ; maxPastHours] et — quand la prévision est active — AVANCER
|
||||
* dans le FUTUR jusqu'à [-futureHorizonHours ; …] (panHours négatif =
|
||||
* fenêtre décalée vers le futur). Unique point de clamp partagé par le
|
||||
* drag et le zoom pour éviter les divergences.
|
||||
*/
|
||||
fun clampPanHours(panHours: Long, futureHorizonHours: Long, maxPastHours: Long): Long =
|
||||
panHours.coerceIn(-futureHorizonHours.coerceAtLeast(0L), maxPastHours.coerceAtLeast(0L))
|
||||
|
||||
/**
|
||||
* Point le plus PROCHE de `hours` heures AVANT le dernier point de la série
|
||||
* (v1.4.3, fix #53) : utilisé par le delta « vs il y a 6 h » de la carte
|
||||
* d'accueil.
|
||||
*
|
||||
* ⚠️ BUG corrigé ici : l'ancien code faisait `curve.firstOrNull { écart >= 6 h }`
|
||||
* sur une fenêtre de 24 h → le PREMIER point de la liste (il y a ~24 h !)
|
||||
* satisfaisait la condition immédiatement, et le delta affiché « vs 6 h »
|
||||
* comparait en réalité à il y a 24 h (remontée : « −11 pg/mL » le lendemain
|
||||
* d'une injection EEn, cf régression #4). Cette fonction prend le point dont
|
||||
* l'écart au dernier est le plus PETIT au-dessus de `hours` (par excès —
|
||||
* avec une grille régulière, c'est le point à ~6 h exactement).
|
||||
*
|
||||
* @return null si aucun point n'est au-delà de `hours` (courbe trop courte).
|
||||
*/
|
||||
fun pointHoursBefore(points: List<LevelPoint>, hours: Double): LevelPoint? {
|
||||
val last = points.lastOrNull() ?: return null
|
||||
var best: LevelPoint? = null
|
||||
var bestGap = Double.MAX_VALUE
|
||||
for (p in points) {
|
||||
val gap = (last.timestamp - p.timestamp) / 3_600_000.0
|
||||
if (gap < hours) continue // seulement AVANT le point de référence
|
||||
if (gap < bestGap) { bestGap = gap; best = p }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Graphique Canvas pur, multi-séries :
|
||||
* - axe gauche = E2 (pg/mL), axe droit = T (ng/mL) ;
|
||||
* - toutes les séries partagent les mêmes échelles (comparaison directe des modèles) ;
|
||||
* - les labs E2 sont des points orange ronds, les labs T des carrés orange
|
||||
* (converties en ng/mL via [PharmacokineticEngine.convertTToNgMl] — les labs
|
||||
* peuvent être saisis en ng/dL ou nmol/L) ;
|
||||
* - ligne verticale « maintenant » ;
|
||||
* - [forecastDoses] (v1.4.5) : les doses PRÉVISIONNELLES sont marquées par une
|
||||
* ligne verticale pointillée + un triangle ▼ en haut — sinon elles sont
|
||||
* invisibles et la projection paraît décalée d'un jour (bug #55).
|
||||
*/
|
||||
@Composable
|
||||
fun CurveChart(
|
||||
points: List<LevelPoint>,
|
||||
series: List<ChartSeries>,
|
||||
e2Labs: List<LabResult>,
|
||||
tLabs: List<LabResult>,
|
||||
options: ChartOptions,
|
||||
modifier: Modifier
|
||||
modifier: Modifier,
|
||||
/** Doses PRÉVISIONNELLES (jamais persistées) à marquer sur le graphique. */
|
||||
forecastDoses: List<DoseLog> = emptyList(),
|
||||
/** Doses RÉELLES enregistrées : marqueurs discrets (triangles bas) pour
|
||||
* la lisibilité des prises passées (v1.4.5). */
|
||||
realDoses: List<DoseLog> = emptyList(),
|
||||
/** Fuseau de LECTURE du graphique (téléphone par défaut, choix Paramètres). */
|
||||
zone: java.util.TimeZone = java.util.TimeZone.getDefault()
|
||||
) {
|
||||
val gridColor = MaterialTheme.colorScheme.outlineVariant
|
||||
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val nowLineColor = MaterialTheme.colorScheme.tertiary
|
||||
// Couleurs des marqueurs (lues hors Canvas : MaterialTheme n'est pas
|
||||
// appelable dans le DrawScope)
|
||||
val forecastMarkerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f)
|
||||
val realDoseMarkerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
|
||||
|
||||
// v1.9.5 (audit) : extrema mémoïsés PAR SÉRIE (index aligné sur
|
||||
// `series`) — detectExtrema scanne tous les points (O(n)) et ne change
|
||||
// que si les séries/options changent, pas à chaque pan/zoom (le Canvas
|
||||
// se redessine à chaque geste).
|
||||
val extremaBySeries = remember(series, options) {
|
||||
series.map { s ->
|
||||
Pair(
|
||||
if (s.showExtrema) {
|
||||
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
|
||||
} else emptyList(),
|
||||
if (s.showExtrema) {
|
||||
s.tStyle?.let { PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02) }
|
||||
} else null
|
||||
)
|
||||
}
|
||||
}
|
||||
// v1.9.5 (audit) : formatters mémoïsés (SimpleDateFormat n'est pas
|
||||
// thread-safe — thread UI seul ici — et l'allouer par frame était du
|
||||
// gaspillage, même standard que le fix des marqueurs v1.8.2).
|
||||
val markerTimeFmt = remember(zone) {
|
||||
SimpleDateFormat("HH:mm", Locale.getDefault()).apply { timeZone = zone }
|
||||
}
|
||||
val xAxisHourFmt = remember(zone) {
|
||||
SimpleDateFormat("HH'h'", Locale.getDefault()).apply { timeZone = zone }
|
||||
}
|
||||
val xAxisDayFmt = remember(zone) {
|
||||
SimpleDateFormat("dd/MM", Locale.getDefault()).apply { timeZone = zone }
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier) {
|
||||
if (points.size < 2) return@Canvas
|
||||
val allPoints = series.flatMap { it.points }
|
||||
if (allPoints.size < 2) return@Canvas
|
||||
val padLeft = 42.dp.toPx()
|
||||
val padRight = 42.dp.toPx()
|
||||
val padTop = 12.dp.toPx()
|
||||
// v1.7.0 : padTop élargi (12 → 26 dp) pour loger les UNITÉS d'axe au
|
||||
// sommet des colonnes de labels (remontée « on n'a jamais ajouté les
|
||||
// unités » — l'axe gauche était des nombres nus depuis la v1.0).
|
||||
val padTop = 26.dp.toPx()
|
||||
val padBottom = 26.dp.toPx()
|
||||
|
||||
val w = size.width - padLeft - padRight
|
||||
val h = size.height - padTop - padBottom
|
||||
if (w <= 0 || h <= 0) return@Canvas
|
||||
|
||||
val t0 = points.first().timestamp
|
||||
val t1 = points.last().timestamp
|
||||
// Fenêtre temporelle commune à toutes les séries (min/max)
|
||||
val t0 = allPoints.minOf { it.timestamp }
|
||||
val t1 = allPoints.maxOf { it.timestamp }
|
||||
if (t1 <= t0) return@Canvas
|
||||
|
||||
val convertedTLabs = tLabs.map { lab ->
|
||||
lab.copy(value = PharmacokineticEngine.convertTToNgMl(lab.value, lab.unit))
|
||||
}
|
||||
|
||||
val e2DataMax = maxOf(
|
||||
points.maxOf { it.e2 },
|
||||
allPoints.maxOf { it.e2 },
|
||||
if (options.showLabs) e2Labs.maxOfOrNull { it.value } ?: 0.0 else 0.0
|
||||
)
|
||||
val e2Max = niceCeil(maxOf(e2DataMax, 50.0))
|
||||
val tDataMax = maxOf(
|
||||
points.maxOf { it.t },
|
||||
if (options.showLabs) {
|
||||
tLabs.maxOfOrNull { com.hormonetrack.pk.PharmacokineticEngine.convertTToNgMl(it.value, it.unit) } ?: 0.0
|
||||
} else 0.0
|
||||
allPoints.maxOf { it.t },
|
||||
if (options.showLabs) convertedTLabs.maxOfOrNull { it.value } ?: 0.0 else 0.0
|
||||
)
|
||||
val tMax = niceCeil(tDataMax * 1.1)
|
||||
|
||||
@ -89,33 +390,146 @@ fun CurveChart(
|
||||
|
||||
drawGrid(gridColor, padLeft, padTop, w, h, rows = 4)
|
||||
drawLeftYLabels(labelColor, e2Max, padLeft, padTop, h)
|
||||
if (options.showT) drawRightYLabels(labelColor, tMax, padLeft + w, padTop, h)
|
||||
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w)
|
||||
|
||||
val path = Path()
|
||||
points.forEachIndexed { i, p ->
|
||||
val x = xOf(p.timestamp)
|
||||
val y = yE2(p.e2)
|
||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
// Unités des axes (v1.7.0 — remontée : « on n'a jamais ajouté les
|
||||
// unités des mesures dans les axes ») : pg/mL pour l'E2 (axe gauche),
|
||||
// ng/mL pour la T (axe droit) — dessinées au SOMMET de chaque colonne
|
||||
// de labels, au-dessus du premier tick (padTop élargi pour ça).
|
||||
// ⚠️ Ce ne sont PAS des strings i18n : pg/mL et ng/mL sont des unités
|
||||
// universelles, déjà en dur dans les légendes du chart.
|
||||
drawAxisUnit(E2_UNIT, padLeft - 6.dp.toPx(), padTop - 6.dp.toPx(),
|
||||
android.graphics.Paint.Align.RIGHT)
|
||||
if (options.showT && series.any { it.tStyle != null }) {
|
||||
drawRightYLabels(labelColor, tMax, padLeft + w, padTop, h)
|
||||
drawAxisUnit(T_UNIT, padLeft + w + 6.dp.toPx(), padTop - 6.dp.toPx(),
|
||||
android.graphics.Paint.Align.LEFT)
|
||||
}
|
||||
drawPath(path, ChartE2, style = Stroke(width = 2.5.dp.toPx()))
|
||||
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w, zone, xAxisHourFmt, xAxisDayFmt)
|
||||
|
||||
if (options.showT) {
|
||||
val tPath = Path()
|
||||
points.forEachIndexed { i, p ->
|
||||
val x = xOf(p.timestamp)
|
||||
val y = yT(p.t)
|
||||
if (i == 0) tPath.moveTo(x, y) else tPath.lineTo(x, y)
|
||||
}
|
||||
drawPath(
|
||||
tPath, ChartT,
|
||||
style = Stroke(
|
||||
width = 2.dp.toPx(),
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 10f))
|
||||
// Doses PRÉVISIONNELLES (v1.4.5) : ligne verticale pointillée + petit
|
||||
// triangle ▼ en haut de la zone — rend l'instant de la dose simulée
|
||||
// VISIBLE (sinon la projection paraît décalée d'un jour, bug #55).
|
||||
if (forecastDoses.isNotEmpty()) {
|
||||
val markerColor = forecastMarkerColor
|
||||
val paint = textPaint(markerColor, 10f, android.graphics.Paint.Align.CENTER)
|
||||
// SimpleDateFormat créé UNE fois (hors boucle) : le Canvas se
|
||||
// redessine à chaque pan/zoom, une allocation par dose × frame
|
||||
// était du gaspillage (la classe n'est de toute façon pas
|
||||
// thread-safe — ici on reste dans le DrawScope UI)
|
||||
val hourFmt = markerTimeFmt
|
||||
for (dose in forecastDoses) {
|
||||
if (dose.timestamp !in t0..t1) continue
|
||||
val x = xOf(dose.timestamp)
|
||||
drawLine(
|
||||
markerColor,
|
||||
Offset(x, padTop + 14.dp.toPx()),
|
||||
Offset(x, padTop + h),
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 6f))
|
||||
)
|
||||
val r = 4.dp.toPx()
|
||||
val path = Path().apply {
|
||||
moveTo(x, padTop + 14.dp.toPx())
|
||||
lineTo(x - r, padTop + 6.dp.toPx())
|
||||
lineTo(x + r, padTop + 6.dp.toPx())
|
||||
close()
|
||||
}
|
||||
drawPath(path, markerColor)
|
||||
// heure de la dose simulée DANS le fuseau de lecture
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
hourFmt.format(Date(dose.timestamp)), x, padTop + 3.dp.toPx(), paint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// NUAGE D'INCERTITUDE (v1.9.0, exclusif ESE) : dessiné EN PREMIER
|
||||
// (sous toutes les courbes nommées), chaque sous-courbe du posterior
|
||||
// MCMC en alpha faible. Décisions d'affichage :
|
||||
// - le nuage ne participe PAS aux échelles (allPoints ne l'inclut
|
||||
// pas) : c'est une plage d'imprécision, pas des données à cadrer —
|
||||
// il peut sortir du cadre en haut/bas sans casser la lecture ;
|
||||
// - ni aux labels ni aux extrema (pas des courbes nommées).
|
||||
val cloudPaths = mutableListOf<Pair<Color, Path>>()
|
||||
for (s in series) {
|
||||
val cloud = s.cloud ?: continue
|
||||
for (sub in cloud) {
|
||||
if (sub.size < 2) continue
|
||||
val path = Path()
|
||||
sub.forEachIndexed { i, p ->
|
||||
val x = xOf(p.timestamp)
|
||||
val y = yE2(p.e2)
|
||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
}
|
||||
cloudPaths.add(s.e2Style.color.copy(alpha = 0.15f) to path)
|
||||
}
|
||||
}
|
||||
for ((color, path) in cloudPaths) {
|
||||
drawPath(
|
||||
path, color,
|
||||
style = Stroke(width = 1.5.dp.toPx())
|
||||
)
|
||||
}
|
||||
|
||||
// Une courbe E2 + une courbe T par série
|
||||
for ((sIdx, s) in series.withIndex()) {
|
||||
if (s.points.size < 2) continue
|
||||
val e2Path = Path()
|
||||
s.points.forEachIndexed { i, p ->
|
||||
val x = xOf(p.timestamp)
|
||||
val y = yE2(p.e2)
|
||||
if (i == 0) e2Path.moveTo(x, y) else e2Path.lineTo(x, y)
|
||||
}
|
||||
drawPath(
|
||||
e2Path, s.e2Style.color,
|
||||
style = Stroke(
|
||||
width = 2.5.dp.toPx(),
|
||||
pathEffect = if (s.e2Style.dashed) {
|
||||
PathEffect.dashPathEffect(floatArrayOf(12f, 10f))
|
||||
} else null
|
||||
)
|
||||
)
|
||||
if (options.showT) {
|
||||
s.tStyle?.let { tStyle ->
|
||||
val tPath = Path()
|
||||
s.points.forEachIndexed { i, p ->
|
||||
val x = xOf(p.timestamp)
|
||||
val y = yT(p.t)
|
||||
if (i == 0) tPath.moveTo(x, y) else tPath.lineTo(x, y)
|
||||
}
|
||||
drawPath(
|
||||
tPath, tStyle.color,
|
||||
style = Stroke(
|
||||
width = 2.dp.toPx(),
|
||||
pathEffect = if (tStyle.dashed) {
|
||||
PathEffect.dashPathEffect(floatArrayOf(12f, 10f))
|
||||
} else null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée
|
||||
// (v1.2.6 : le triangle est accompagné de la valeur estimée du pic/creux).
|
||||
// v1.5.0 : une série peut être exclue (Tracé labs → showExtrema=false).
|
||||
// v1.9.5 (audit) : les extrema sont mémoïsés (extremaByModel, hors
|
||||
// Canvas, recalculés seulement quand séries/options changent) — le
|
||||
// scan local était refait PAR FRAME, x2 séries.
|
||||
if (options.showExtrema && s.showExtrema) {
|
||||
extremaBySeries[sIdx]?.let { (e2Extrema, tExtrema) ->
|
||||
e2Extrema.forEach { ex ->
|
||||
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
|
||||
}
|
||||
if (options.showT) {
|
||||
s.tStyle?.let { tStyle ->
|
||||
tExtrema.orEmpty().forEach { ex ->
|
||||
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Labs (points/carrés oranges)
|
||||
if (options.showLabs) {
|
||||
e2Labs.forEach { lab ->
|
||||
if (lab.timestamp in t0..t1) {
|
||||
@ -124,21 +538,40 @@ fun CurveChart(
|
||||
drawDotLabel(lab.value, Offset(c.x, c.y - 10.dp.toPx()))
|
||||
}
|
||||
}
|
||||
tLabs.forEach { lab ->
|
||||
convertedTLabs.forEach { lab ->
|
||||
if (lab.timestamp in t0..t1) {
|
||||
// labs may be in ng/dL or nmol/L: normalize to the model's ng/mL
|
||||
val tVal = com.hormonetrack.pk.PharmacokineticEngine.convertTToNgMl(lab.value, lab.unit)
|
||||
val c = Offset(xOf(lab.timestamp), yT(tVal))
|
||||
val c = Offset(xOf(lab.timestamp), yT(lab.value))
|
||||
drawRect(
|
||||
LabDot,
|
||||
topLeft = Offset(c.x - 4.dp.toPx(), c.y - 4.dp.toPx()),
|
||||
size = Size(8.dp.toPx(), 8.dp.toPx())
|
||||
)
|
||||
drawDotLabel(tVal, Offset(c.x, c.y - 10.dp.toPx()))
|
||||
drawDotLabel(lab.value, Offset(c.x, c.y - 10.dp.toPx()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Doses RÉELLES (v1.4.5) : petits triangles ▲ discrets au bas de la
|
||||
// zone — un « peigne » des prises passées, lisible sans surcharger
|
||||
// (pas de ligne verticale : les injections sont fréquentes).
|
||||
if (realDoses.isNotEmpty()) {
|
||||
val realColor = realDoseMarkerColor
|
||||
for (dose in realDoses) {
|
||||
if (dose.timestamp !in t0..t1) continue
|
||||
val x = xOf(dose.timestamp)
|
||||
val r = 3.5.dp.toPx()
|
||||
val base = padTop + h
|
||||
val path = Path().apply {
|
||||
moveTo(x, base - 8.dp.toPx())
|
||||
lineTo(x - r, base)
|
||||
lineTo(x + r, base)
|
||||
close()
|
||||
}
|
||||
drawPath(path, realColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Repère « maintenant »
|
||||
if (options.nowMs in t0..t1) {
|
||||
drawLine(
|
||||
nowLineColor,
|
||||
@ -150,6 +583,59 @@ fun CurveChart(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: 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 (Paint mis
|
||||
// en cache — v1.9.5 : un Paint par extremum × frame était du gaspillage)
|
||||
val paint = textPaint(color, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
formatValue(extremum.value), cx, valueY, paint
|
||||
)
|
||||
}
|
||||
|
||||
/** Unité de l'axe E2 (gauche) — universelle, pas une string i18n (cf drawAxisUnit). */
|
||||
private const val E2_UNIT = "pg/mL"
|
||||
|
||||
/** Unité de l'axe T (droite) — idem. */
|
||||
private const val T_UNIT = "ng/mL"
|
||||
|
||||
/**
|
||||
* Libellé d'UNITÉ d'axe (v1.7.0) : petit texte gris au sommet d'une colonne
|
||||
* de labels Y, aligné comme les nombres de sa colonne ([align] RIGHT pour
|
||||
* l'axe gauche, LEFT pour l'axe droit).
|
||||
*/
|
||||
private fun DrawScope.drawAxisUnit(
|
||||
text: String,
|
||||
x: Float,
|
||||
baseline: Float,
|
||||
align: android.graphics.Paint.Align
|
||||
) {
|
||||
drawContext.canvas.nativeCanvas.drawText(text, x, baseline, labelPaint(align))
|
||||
}
|
||||
|
||||
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
|
||||
@ -157,21 +643,42 @@ private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: F
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint {
|
||||
val c = Color(0xFF6B7280)
|
||||
return android.graphics.Paint().apply {
|
||||
color = android.graphics.Color.argb(
|
||||
(c.alpha * 255).toInt(),
|
||||
(c.red * 255).toInt(),
|
||||
(c.green * 255).toInt(),
|
||||
(c.blue * 255).toInt()
|
||||
)
|
||||
textSize = 10.dp.toPx()
|
||||
textAlign = align
|
||||
isAntiAlias = true
|
||||
/**
|
||||
* Cache de Paints TEXTE du graphique (v1.9.5, audit) : un Paint par
|
||||
* (couleur, taille, align, gras) au lieu d'une allocation PAR EXTREMUM /
|
||||
* label / frame — le Canvas se redessine à chaque pan/zoom/tick. Tout le
|
||||
* dessin Compose est séquentiel sur le thread UI → un cache d'objet suffit.
|
||||
* Les clés sont des value classes (Color) → pas de fuite.
|
||||
*/
|
||||
private val textPaintCache = HashMap<String, android.graphics.Paint>()
|
||||
|
||||
private fun DrawScope.textPaint(
|
||||
color: Color,
|
||||
textSizeDp: Float,
|
||||
align: android.graphics.Paint.Align,
|
||||
fakeBold: Boolean = false
|
||||
): android.graphics.Paint {
|
||||
val key = "$color|$textSizeDp|$align|$fakeBold"
|
||||
return textPaintCache.getOrPut(key) {
|
||||
android.graphics.Paint().apply {
|
||||
this.color = android.graphics.Color.argb(
|
||||
(color.alpha * 255).toInt(),
|
||||
(color.red * 255).toInt(),
|
||||
(color.green * 255).toInt(),
|
||||
(color.blue * 255).toInt()
|
||||
)
|
||||
textSize = textSizeDp.dp.toPx()
|
||||
textAlign = align
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = fakeBold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Paint partagé pour les labels texte (couleur gris neutre fixe, 10 dp). */
|
||||
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint =
|
||||
textPaint(Color(0xFF6B7280), 10f, align)
|
||||
|
||||
private fun DrawScope.drawLeftYLabels(color: Color, e2Max: Double, padLeft: Float, padTop: Float, h: Float) {
|
||||
val paint = labelPaint(android.graphics.Paint.Align.RIGHT)
|
||||
for (i in 0..4) {
|
||||
@ -192,35 +699,44 @@ private fun DrawScope.drawRightYLabels(color: Color, tMax: Double, xRight: Float
|
||||
|
||||
private fun DrawScope.drawXLabels(
|
||||
color: Color, t0: Long, t1: Long,
|
||||
padLeft: Float, yBottom: Float, w: Float
|
||||
padLeft: Float, yBottom: Float, w: Float,
|
||||
zone: java.util.TimeZone = java.util.TimeZone.getDefault(),
|
||||
// v1.9.5 (audit) : formatters mémoïsés du composable (plus de
|
||||
// SimpleDateFormat alloué par frame)
|
||||
hourFmt: SimpleDateFormat = SimpleDateFormat("HH'h'", Locale.getDefault()),
|
||||
dayFmt: SimpleDateFormat = SimpleDateFormat("dd/MM", Locale.getDefault())
|
||||
) {
|
||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER)
|
||||
val spanH = (t1 - t0) / 3_600_000.0
|
||||
// Pas des labels X adaptatif — le zoom (v1.2.9) descend jusqu'à quelques heures
|
||||
val stepHours = when {
|
||||
spanH <= 8 -> 1
|
||||
spanH <= 18 -> 3
|
||||
spanH <= 25 -> 6
|
||||
spanH <= 25 * 7 -> 24
|
||||
else -> 24 * 5
|
||||
}
|
||||
val fmt = if (spanH <= 25) SimpleDateFormat("HH'h'", Locale.getDefault())
|
||||
else SimpleDateFormat("dd/MM", Locale.getDefault())
|
||||
var tick = ceil(t0 / (stepHours * 3_600_000.0)).toLong() * stepHours * 3_600_000L
|
||||
while (tick <= t1) {
|
||||
// v1.4.5 : le label est formaté DANS le fuseau de lecture — sinon le
|
||||
// tick « minuit local » serait étiqueté avec l'heure système.
|
||||
// v1.9.5 : formatters mémoïsés passés par le composable (plus d'alloc/frame).
|
||||
val fmt = if (spanH <= 25) hourFmt else dayFmt
|
||||
// v1.4.5 (fix #55) : les ticks sont alignés sur les frontières LOCALES
|
||||
// (minuit local pour les pas en jours) — l'ancien alignement sur des
|
||||
// multiples UTC plaçait le label « 13/09 » à 02:00 du matin FR.
|
||||
// v1.9.5 (audit) : la ZONE de lecture est passée à xLabelTicks — l'oubli
|
||||
// alignait les ticks sur le fuseau de l'appareil pendant que les labels
|
||||
// sont formatés dans le fuseau choisi (heures décalées dès que les deux
|
||||
// divergent, contredisant le fix #55).
|
||||
for (tick in xLabelTicks(t0, t1, stepHours.toLong(), zone)) {
|
||||
val x = padLeft + w * ((tick - t0).toFloat() / (t1 - t0).toFloat())
|
||||
drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint)
|
||||
tick += stepHours * 3_600_000L
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawDotLabel(v: Double, c: Offset) {
|
||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply {
|
||||
color = android.graphics.Color.rgb(
|
||||
(LabDot.red * 255).toInt(),
|
||||
(LabDot.green * 255).toInt(),
|
||||
(LabDot.blue * 255).toInt()
|
||||
)
|
||||
textSize = 9.dp.toPx()
|
||||
isFakeBoldText = true
|
||||
}
|
||||
// Paint en cache (v1.9.5 : une allocation par lab × frame → une par
|
||||
// couleur, réutilisée entre frames)
|
||||
val paint = textPaint(LabDot, 9f, android.graphics.Paint.Align.CENTER, fakeBold = true)
|
||||
drawContext.canvas.nativeCanvas.drawText(formatValue(v), c.x, c.y, paint)
|
||||
}
|
||||
|
||||
|
||||
@ -91,7 +91,14 @@ fun DateTimeField(
|
||||
TextButton(onClick = { showTime = false }) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
) {
|
||||
TimePicker(state = state)
|
||||
// Le contenu d'AlertDialog est aligné à gauche : on centre l'horloge
|
||||
// explicitement (bug d'affichage signalé — horloge partant vers la gauche).
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
TimePicker(state = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.Esters
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
|
||||
/**
|
||||
@ -139,7 +140,12 @@ fun DoseDialog(
|
||||
text = { Text(stringResource(R.string.ester_default, selected.esterType)) },
|
||||
onClick = { esterOverride = null; esterExpanded = false }
|
||||
)
|
||||
listOf(Esters.EV, Esters.EU, Esters.EEN).forEach { e ->
|
||||
// v1.4.0/v1.4.6 : la liste d'esters suit le MODÈLE
|
||||
// du traitement (helper pur Esters.choicesForModel,
|
||||
// testé) — TFS = 7 esters (V3C), WHSAH = 6 (PEP non
|
||||
// couvert), Estrannaise (ODS) = EV/EU/EEn seulement.
|
||||
val choices = Esters.choicesForModel(selected.pkModel)
|
||||
choices.forEach { e ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(esterLabel(e)) },
|
||||
onClick = { esterOverride = e; esterExpanded = false }
|
||||
@ -175,6 +181,10 @@ private fun esterLabel(ester: String): String = when (ester) {
|
||||
Esters.EV -> "EV — valerate"
|
||||
Esters.EU -> "EU — undecylate"
|
||||
Esters.EEN -> "EEn — enanthate"
|
||||
Esters.EB -> "EB — benzoate"
|
||||
Esters.EC -> "EC — cypionate (oil)"
|
||||
Esters.ECS -> "EC — cypionate (suspension)"
|
||||
Esters.PEP -> "PEP — polyestradiol phosphate"
|
||||
else -> ester
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ package com.hormonetrack.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@ -9,6 +10,7 @@ import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@ -23,66 +25,175 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
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)
|
||||
@Composable
|
||||
fun LabDialog(
|
||||
existing: LabResult? = null,
|
||||
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 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 valueText by remember { mutableStateOf("") }
|
||||
var unit by remember { mutableStateOf("pg/mL") }
|
||||
var valueText by remember(existing) {
|
||||
mutableStateOf(existing?.let { formatLabValue(it.value) } ?: "")
|
||||
}
|
||||
var unit by remember(existing) { mutableStateOf(existing?.unit ?: "pg/mL") }
|
||||
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(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val v = valueText.replace(',', '.').toDoubleOrNull() ?: return@TextButton
|
||||
onConfirm(marker.trim().uppercase(), v, unit, msFromLocalDateTime(dateTime), notes)
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
title = { Text(stringResource(R.string.add_lab)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ExposedDropdownMenuBox(expanded = markerExpanded, onExpandedChange = { markerExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = marker,
|
||||
onValueChange = { marker = it },
|
||||
label = { Text(stringResource(R.string.lab_marker)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = markerExpanded) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(expanded = markerExpanded, onDismissRequest = { markerExpanded = false }) {
|
||||
markerSuggestions.forEach { s ->
|
||||
DropdownMenuItem(text = { Text(s) }, onClick = { marker = s; markerExpanded = false })
|
||||
}
|
||||
TextButton(
|
||||
enabled = if (isEdit) {
|
||||
valueText.replace(',', '.').toDoubleOrNull() != null
|
||||
} 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
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = valueText,
|
||||
onValueChange = { valueText = it },
|
||||
label = { Text(stringResource(R.string.lab_value)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenuBox(expanded = unitExpanded, onExpandedChange = { unitExpanded = it }) {
|
||||
) { Text(stringResource(R.string.save)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
title = {
|
||||
Text(stringResource(if (isEdit) R.string.edit_lab else R.string.add_lab))
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (isEdit) {
|
||||
// Édition : un seul marqueur (l'entrée existante)
|
||||
ExposedDropdownMenuBox(expanded = markerExpanded, onExpandedChange = { markerExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = marker,
|
||||
onValueChange = { marker = it },
|
||||
label = { Text(stringResource(R.string.lab_marker)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = markerExpanded) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(expanded = markerExpanded, onDismissRequest = { markerExpanded = false }) {
|
||||
markerSuggestions.forEach { s ->
|
||||
DropdownMenuItem(text = { Text(s) }, onClick = { marker = s; markerExpanded = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = unit,
|
||||
onValueChange = { unit = it },
|
||||
label = { Text(stringResource(R.string.lab_unit)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitExpanded) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
value = valueText,
|
||||
onValueChange = { valueText = it },
|
||||
label = { Text(stringResource(R.string.lab_value)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(expanded = unitExpanded, onDismissRequest = { unitExpanded = false }) {
|
||||
unitSuggestions.forEach { s ->
|
||||
DropdownMenuItem(text = { Text(s) }, onClick = { unit = s; unitExpanded = false })
|
||||
ExposedDropdownMenuBox(expanded = unitExpanded, onExpandedChange = { unitExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = unit,
|
||||
onValueChange = { unit = it },
|
||||
label = { Text(stringResource(R.string.lab_unit)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitExpanded) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(expanded = unitExpanded, onDismissRequest = { unitExpanded = false }) {
|
||||
unitSuggestions.forEach { s ->
|
||||
DropdownMenuItem(text = { Text(s) }, onClick = { unit = s; unitExpanded = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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('.')
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
package com.hormonetrack.ui.screens
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@ -8,65 +12,430 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.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.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.pk.LevelPoint
|
||||
import com.hormonetrack.pk.EstrannaiseCloud
|
||||
import com.hormonetrack.pk.LabTrajectoryModel
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.components.ChartOptions
|
||||
import com.hormonetrack.ui.components.ChartSeries
|
||||
import com.hormonetrack.ui.components.CurveChart
|
||||
import com.hormonetrack.ui.components.CurveStyle
|
||||
import com.hormonetrack.ui.theme.ChartE2
|
||||
import com.hormonetrack.ui.theme.ChartLabTrajectory
|
||||
import com.hormonetrack.ui.theme.ChartT
|
||||
import com.hormonetrack.ui.theme.ChartTWhs
|
||||
import com.hormonetrack.ui.theme.ChartWhs
|
||||
import com.hormonetrack.ui.theme.LabDot
|
||||
import com.hormonetrack.ui.theme.TealTertiary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.hormonetrack.ui.components.clampPanHours
|
||||
import com.hormonetrack.ui.components.defaultModelToggles
|
||||
import com.hormonetrack.ui.components.forecastExtensionHours
|
||||
import com.hormonetrack.ui.components.forecastHorizonHours
|
||||
import com.hormonetrack.ui.components.panDeltaHours
|
||||
import com.hormonetrack.ui.components.stepForRange
|
||||
|
||||
private const val HOUR_MS = 3_600_000L
|
||||
// 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.
|
||||
*
|
||||
* Fonctions :
|
||||
* - plages 6 h → 300 j (zoom v1.2.9, presets 24 h / 7 j / 30 j) ;
|
||||
* - **panoramique** : glisser horizontalement déplace la fenêtre dans le passé
|
||||
* (le futur avec la Prévision active) ;
|
||||
* - **les modèles PK** (Estrannaise / Transfem Science / WHSAH) peuvent être
|
||||
* affichés simultanément (override de modèle dans le moteur), toggles
|
||||
* indépendants ; + Tracé labs (v1.5.0) et sa prolongation (v1.6.0) ;
|
||||
* - **prévision** : prolonge la courbe avec des doses projetées à partir de la
|
||||
* « fréquence » configurée sur chaque traitement ;
|
||||
* - **auto-calibration** (option des Paramètres) : facteurs d'échelle et modèle T
|
||||
* recalculés à la volée depuis les labs avant tracé.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChartScreen() {
|
||||
val container = LocalAppContainer.current
|
||||
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 labResults by repo.allLabResults.collectAsState(initial = emptyList())
|
||||
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
||||
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
||||
// Fuseau de LECTURE du graphique (v1.4.5) : choisi dans Paramètres ou
|
||||
// fuseau du téléphone (auto). Sert à l'alignement minuit des labels X et
|
||||
// à l'heure affichée des marqueurs de doses.
|
||||
val chartTimezoneId by container.settings.chartTimezone.collectAsState(initial = null as String?)
|
||||
val chartZone = remember(chartTimezoneId) {
|
||||
chartTimezoneId?.let { id ->
|
||||
runCatching { java.util.TimeZone.getTimeZone(id) }.getOrNull()
|
||||
} ?: java.util.TimeZone.getDefault()
|
||||
}
|
||||
|
||||
// Zoom temporel (v1.2.9) : plage bornée 6 h → 300 j, pinch + boutons
|
||||
var rangeHours by remember { mutableLongStateOf(24L) }
|
||||
var panHours by remember { mutableLongStateOf(0L) }
|
||||
var showT by remember { mutableStateOf(true) }
|
||||
var showLabs by remember { mutableStateOf(true) }
|
||||
|
||||
val curve by produceState<List<LevelPoint>>(emptyList(), treatments, doseLogs, tConfig, rangeHours) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val end = System.currentTimeMillis()
|
||||
value = PharmacokineticEngine.computeCurve(
|
||||
treatments, doseLogs,
|
||||
startMs = end - rangeHours * 3_600_000L,
|
||||
endMs = end,
|
||||
tConfig = tConfig
|
||||
var showEse by remember { mutableStateOf(true) }
|
||||
var showTfs by remember { mutableStateOf(true) }
|
||||
// v1.4.6 : 3ᵉ modèle PK superposable (WHSAH — fit license-free de Mona)
|
||||
var showWhs by remember { mutableStateOf(false) }
|
||||
// v1.5.0 : courbe « Tracé labs » (hybride ancrée sur les labs, cf
|
||||
// pk/LabTrajectoryModel.kt) — OFF par défaut : opt-in, la courbe
|
||||
// revendique « passer par tes labs » et doit rester une référence de
|
||||
// comparaison, pas un affichage par défiant
|
||||
var showLabTrack by remember { mutableStateOf(false) }
|
||||
// v1.9.0 : NUAGE D'INCERTITUDE du modèle ESE (échantillons MCMC de
|
||||
// estrannaise.js — cf pk/EstrannaiseCloud) — OFF par défaut, opt-in,
|
||||
// activable/désactivable à volonté ; ACTIVABLE SEULEMENT quand le
|
||||
// modèle ESE est affiché (le nuage est exclusif à ce modèle — les
|
||||
// fits TFS/WHSAH ne publient pas de posterior). Se désactive
|
||||
// automatiquement si ESE est éteint.
|
||||
var showCloud by remember { mutableStateOf(false) }
|
||||
// v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du
|
||||
// dernier lab constant × modèle, bornée par l'extinction du modèle —
|
||||
// cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la
|
||||
// partie prolongée est une ESTIMATION non ancrée, elle doit être
|
||||
// demandée explicitement ; le chip n'est actif que si Tracé labs est ON.
|
||||
var showLabTrackExtend by remember { mutableStateOf(false) }
|
||||
// v1.4.7 (fix #58) : au CHARGEMENT des traitements, les toggles sont
|
||||
// alignés sur les modèles réellement UTILISÉS par des traitements à
|
||||
// PROFIL PK (injection + ester, `usesProfileModel`) — en cours ou passés
|
||||
// (cf §6.bis : EV inactif reste simulé) ; les autres modèles restent
|
||||
// activables au tap. ⚠️ FILTRER par usesProfileModel : sinon un
|
||||
// traitement oral/anti-androgène (pkModel stocké = ESE par défaut, sans
|
||||
// courbe profil) activerait Estrannaise à tort (vu sur l'émulateur avec
|
||||
// le CPA seedé). Une seule initialisation.
|
||||
var modelTogglesInitialized by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(treatments) {
|
||||
if (!modelTogglesInitialized && treatments.isNotEmpty()) {
|
||||
val toggles = defaultModelToggles(
|
||||
treatments.filter { it.usesProfileModel }.map { it.pkModel }.toSet()
|
||||
)
|
||||
showEse = toggles.getValue(PKModels.ESTRANNAISE)
|
||||
showTfs = toggles.getValue(PKModels.TRANSFEM_SCIENCE)
|
||||
showWhs = toggles.getValue(PKModels.WHSAH)
|
||||
modelTogglesInitialized = true
|
||||
}
|
||||
}
|
||||
var showForecast by remember { mutableStateOf(false) }
|
||||
var showExtrema by remember { mutableStateOf(false) }
|
||||
var chartWidthPx by remember { mutableIntStateOf(0) }
|
||||
|
||||
// « Maintenant » rafraîchi chaque minute pour le repère vertical
|
||||
var tick by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(60_000)
|
||||
tick++
|
||||
}
|
||||
}
|
||||
|
||||
val (a, b) = if (curve.size >= 2) {
|
||||
curve.first().timestamp to curve.last().timestamp
|
||||
// Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T,
|
||||
// recalculés à la volée depuis les labs. null = option désactivée.
|
||||
// ── Calibration PAR MODÈLE (v1.4.8, fix #60) ──────────────────────────────
|
||||
// Demande : « la calibration doit être active sur tous les modèles si elle
|
||||
// est activée, mais les facteurs ne doivent pas être identiques entre les
|
||||
// profils PK ». AVANT : un SEUL jeu de facteurs calculé depuis le modèle
|
||||
// STOCKÉ des traitements puis partagé par toutes les courbes → en
|
||||
// basculant un traitement (ex. EEn TFS → WHSAH), le facteur devenait
|
||||
// absurde (×2,21, remontée). DÉSORMAIS : une AutoCalibrated PAR modèle
|
||||
// (ESE/TFS/WHS), chacune calibrée avec la prédiction DE SON modèle →
|
||||
// chaque courbe affichée colle à ses labs. Home continue d'utiliser le
|
||||
// modèle stocké (calcul sans override).
|
||||
val autoByModel by produceState<Map<String, PharmacokineticEngine.AutoCalibrated>>(
|
||||
emptyMap(), treatments, doseLogs, labResults, tConfig, autoCalibrate
|
||||
) {
|
||||
value = if (!autoCalibrate) {
|
||||
emptyMap()
|
||||
} else {
|
||||
withContext(Dispatchers.Default) {
|
||||
mapOf(
|
||||
PKModels.ESTRANNAISE to PharmacokineticEngine.autoCalibrated(
|
||||
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.ESTRANNAISE
|
||||
),
|
||||
PKModels.TRANSFEM_SCIENCE to PharmacokineticEngine.autoCalibrated(
|
||||
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.TRANSFEM_SCIENCE
|
||||
),
|
||||
PKModels.WHSAH to PharmacokineticEngine.autoCalibrated(
|
||||
treatments, doseLogs, labResults, tConfig, modelOverride = PKModels.WHSAH
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Horizon de prévision (v1.4.1) : 12 × la plus grande Posologie, borné
|
||||
// [30 j ; 365 j] (cf forecastHorizonHours dans CurveChart.kt).
|
||||
// ⚠️ FIX #70 (v1.12.0) : SEULS les traitements ACTIFS comptent — la
|
||||
// Posologie d'un traitement archivé ne doit ni activer le chip
|
||||
// Prévision ni étendre l'horizon (le moteur ne projette plus ses
|
||||
// créneaux, cf generateForecastDoses).
|
||||
val maxIntervalDays = treatments
|
||||
.filter { it.isActive }
|
||||
.mapNotNull { it.forecastIntervalDays }
|
||||
.maxOrNull()
|
||||
val forecastHorizonHours = forecastHorizonHours(maxIntervalDays)
|
||||
|
||||
// ── Fenêtre temporelle (v1.4.1, extension v1.4.4, stabilisation v1.4.5) ──
|
||||
// ⚠️ BOUCLE DE RECOMPOSITION (fix #56) : `nowMs` doit être MÉMOÏSÉ sur le
|
||||
// tick minute — relu frais à chaque recomposition, il faisait dériver
|
||||
// endMs → les keys du producer (endMs) changeaient à chaque frame →
|
||||
// re-calcul perpétuel → main thread saturé (« Skipped 52 frames ») → les
|
||||
// taps sur les chips étaient perdus. Les vals dérivées de nowMs sont
|
||||
// donc stables entre recompositions et ne bougent qu'au tick (60 s).
|
||||
val nowMs = remember(tick) { System.currentTimeMillis() }
|
||||
val earliestDoseMs = doseLogs.minOfOrNull { it.timestamp } ?: nowMs
|
||||
val maxPanHours = (((nowMs - earliestDoseMs) / HOUR_MS) + rangeHours / HOUR_MS).coerceAtLeast(0L)
|
||||
val futurePanHorizon = if (showForecast) (forecastHorizonHours ?: 0L) else 0L
|
||||
// Gap jusqu'au 1ᵉʳ créneau projeté : MÉMOÏSÉ (le calcul de créneaux dans
|
||||
// le corps du composable tournerait à chaque recomposition — cf #56).
|
||||
val firstSlotGapH = remember(treatments, doseLogs, forecastHorizonHours) {
|
||||
if (forecastHorizonHours == null) null
|
||||
else {
|
||||
val horizonMs = forecastHorizonHours * HOUR_MS
|
||||
val at = System.currentTimeMillis()
|
||||
treatments.flatMap { tr ->
|
||||
PharmacokineticEngine.generateForecastDoses(tr, doseLogs, at + horizonMs, at)
|
||||
}.minOfOrNull { it.timestamp }?.let { (it - at) / HOUR_MS }
|
||||
}
|
||||
}
|
||||
val forecastExtensionH = if (showForecast && forecastHorizonHours != null) {
|
||||
forecastExtensionHours(
|
||||
rangeHours, forecastHorizonHours, firstSlotGapH
|
||||
)
|
||||
} else 0L
|
||||
val endMs = nowMs - panHours * HOUR_MS + forecastExtensionH * HOUR_MS
|
||||
val startMs = endMs - rangeHours * HOUR_MS
|
||||
|
||||
// ⚠️ FIX #54 (v1.4.4) — captures FIGÉES dans pointerInput(Unit) : la
|
||||
// closure du gestionnaire de gestes est créée UNE SEULE FOIS ; les vals
|
||||
// calculées ci-dessus y étaient capturées à leur valeur de la PREMIÈRE
|
||||
// composition (showForecast = false au lancement → futurePanHorizon = 0)
|
||||
// → le clamp `coerceIn(0, maxPan)` INTERDISAIT le pan vers le futur pour
|
||||
// toujours : le chip activé + le drag vers la gauche ne généraient JAMAIS
|
||||
// la projection (« le toggle ne fait rien »). rememberUpdatedState expose
|
||||
// ces valeurs comme des états lus À JOUR par la closure (les MutableState
|
||||
// — panHours, rangeHours, showForecast — n'ont pas ce problème).
|
||||
val gestureFutureHorizon by rememberUpdatedState(futurePanHorizon)
|
||||
val gestureMaxPan by rememberUpdatedState(maxPanHours)
|
||||
val gestureExtension by rememberUpdatedState(forecastExtensionH)
|
||||
// Résiduel fractionnaire du pan (fix #62) : conserve la part < 1 h des
|
||||
// deltas pour que le pan fonctionne sur la vue 24 h (les deltas de doigt
|
||||
// y sont inférieurs à une heure entière)
|
||||
var panResidualHours by remember { mutableStateOf(0.0) }
|
||||
|
||||
// Doses PRÉVISIONNELLES (v1.4.5) : les mêmes que la courbe utilise —
|
||||
// exposées pour être MARQUÉES sur le graphique (lignes pointillées aux
|
||||
// créneaux projetés, fix #55 : sans marqueur, la projection paraissait
|
||||
// décalée d'un jour).
|
||||
// ⚠️ v1.9.5 (audit) : ce producer et le producer `curves` calculent
|
||||
// chacun leurs doses prévisionnelles — VOLONTAIRE. Un partage d'état
|
||||
// (curves lisant ce producer) créerait une lecture STALE d'un cycle de
|
||||
// recomposition (leçon #65 : un producer doit redériver de ses vraies
|
||||
// entrées) pour un calcul léger (O(traitements), pas par point de grille).
|
||||
val forecastDoses by produceState(
|
||||
emptyList<com.hormonetrack.data.model.DoseLog>(),
|
||||
treatments, doseLogs, showForecast, endMs, forecastHorizonHours
|
||||
) {
|
||||
withContext(Dispatchers.Default) {
|
||||
value = if (showForecast && forecastHorizonHours != null) {
|
||||
treatments.flatMap {
|
||||
PharmacokineticEngine.generateForecastDoses(
|
||||
it, doseLogs, endMs, System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
} else emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Une courbe rendue par le producer : clé de modèle ("ESE"/"TFS"/"WHS"/
|
||||
* "LAB"/"LABX"), ses points, et — v1.9.0 — son NUAGE D'INCERTITUDE
|
||||
* (uniquement pour "ESE" quand le chip Nuage est actif).
|
||||
*/
|
||||
data class ChartCurve(
|
||||
val model: String,
|
||||
val points: List<LevelPoint>,
|
||||
val cloud: List<List<LevelPoint>> = emptyList()
|
||||
)
|
||||
|
||||
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS") —
|
||||
// v1.5.0 : la 4ᵉ série possible est "LAB" (Tracé labs, indépendante) ;
|
||||
// v1.6.0 : la 5ᵉ est "LABX" (partie PROLONGÉE du tracé labs, ρ constant)
|
||||
val curves by produceState(
|
||||
emptyList<ChartCurve>(),
|
||||
autoByModel, doseLogs, startMs, endMs, showEse, showTfs, showWhs, showForecast,
|
||||
showLabTrack, showLabTrackExtend, showCloud,
|
||||
// Clé = la liste ENTIÈRE des labs (pas .size, fix #66) : éditer la
|
||||
// VALEUR d'une analyse doit recalculer les courbes
|
||||
labResults
|
||||
) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val list = mutableListOf<ChartCurve>()
|
||||
// ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────
|
||||
// Totalement indépendante des toggles de modèles (elle utilise le
|
||||
// modèle STOCKÉ des traitements — sémantique Home, cf doc §7.10)
|
||||
// et de la calibration (elle EST sa propre calibration continue —
|
||||
// scalePerEster ignoré, épinglé par LabTrajectoryModelTest).
|
||||
// showLabTrack + série visible UNIQUEMENT si ≥ 2 labs qui ancrent.
|
||||
//
|
||||
// ── v1.6.0 : split ancré / prolongé ──────────────────────────────
|
||||
// Avec « Prolonger », la série est SPLITTÉE au dernier lab
|
||||
// significatif (lastAnchorMs) : "LAB" = partie ancrée (passe par
|
||||
// les labs), "LABX" = partie prolongée (M(t) × ρ_last — une
|
||||
// estimation). Deux traits distincts (LABX en atténué) préservent
|
||||
// l'honnêteté de l'affichage : la partie inventée ne peut pas se
|
||||
// confondre avec la partie mesurée. ⚠️ Deux clés DISTINCTES et
|
||||
// non vides seulement — la légende (plus bas) doit connaître
|
||||
// "LABX" AVANT son else (leçon #63).
|
||||
if (showLabTrack) {
|
||||
val step = stepForRange(rangeHours)
|
||||
val labCurve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
treatments, doseLogs, labResults, startMs, endMs, step,
|
||||
extendBeyondLastLab = showLabTrackExtend
|
||||
)
|
||||
val lastAnchor = labCurve.lastAnchorMs
|
||||
if (showLabTrackExtend && lastAnchor != null) {
|
||||
val anchored = labCurve.points.filter { it.timestamp <= lastAnchor }
|
||||
val extended = labCurve.points.filter { it.timestamp > lastAnchor }
|
||||
list.add(ChartCurve("LAB", anchored))
|
||||
if (extended.isNotEmpty()) list.add(ChartCurve("LABX", extended))
|
||||
} else {
|
||||
list.add(ChartCurve("LAB", labCurve.points))
|
||||
}
|
||||
}
|
||||
if (showEse || showTfs || showWhs) {
|
||||
// Calibration PAR MODÈLE (fix #60) : chaque courbe reçoit les
|
||||
// échelles/k calculés avec SA prédiction. Hors auto-calibration
|
||||
// → scaleFactor stocké des traitements (comportement historique).
|
||||
val effTreatments = autoByModel[PKModels.TRANSFEM_SCIENCE]?.treatments ?: treatments
|
||||
val effTConfig = autoByModel[PKModels.TRANSFEM_SCIENCE]?.tConfig ?: tConfig
|
||||
// Doses réelles + doses prévisionnelles éventuelles
|
||||
val forecastDoses = if (showForecast) {
|
||||
effTreatments.flatMap {
|
||||
PharmacokineticEngine.generateForecastDoses(it, doseLogs, endMs, nowMs)
|
||||
}
|
||||
} else emptyList()
|
||||
val allDoses = doseLogs + forecastDoses
|
||||
// Pas d'échantillonnage adaptatif au zoom (v1.2.9)
|
||||
val step = stepForRange(rangeHours)
|
||||
// v1.8.2 : les 3 blocs copiés (ESE/TFS/WHS) sont factorisés —
|
||||
// le modèle est la seule variable (même calcul, même calibration
|
||||
// PAR MODÈLE via autoByModel[model], fix #60)
|
||||
for ((model, enabled) in listOf(PKModels.ESTRANNAISE to showEse, PKModels.TRANSFEM_SCIENCE to showTfs, PKModels.WHSAH to showWhs)) {
|
||||
if (!enabled) continue
|
||||
val autoM = autoByModel[model]
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
effTreatments, allDoses, startMs, endMs,
|
||||
stepMs = step,
|
||||
tConfig = autoM?.tConfig ?: effTConfig, modelOverride = model,
|
||||
scalePerEster = autoM?.esterScales,
|
||||
tKPerEster = autoM?.tKPerEster
|
||||
)
|
||||
// v1.9.0 : le nuage d'incertitude est attaché à la série
|
||||
// ESE (exclusif à ce modèle — les fits TFS/WHSAH ne
|
||||
// publient pas de posterior MCMC)
|
||||
val cloud = if (model == PKModels.ESTRANNAISE && showCloud) {
|
||||
// MÊME calibration que la courbe ESE (fix v1.9.1 :
|
||||
// nuage brut ≠ échelle de la courbe calibrée)
|
||||
EstrannaiseCloud.compute(
|
||||
treatments, doseLogs, startMs, endMs, step,
|
||||
scalePerEster = autoM?.esterScales
|
||||
)
|
||||
} else emptyList()
|
||||
list.add(ChartCurve(model, curve, cloud))
|
||||
}
|
||||
}
|
||||
value = list
|
||||
}
|
||||
}
|
||||
|
||||
val allPoints = curves.flatMap { it.points }
|
||||
val (a, b) = if (allPoints.size >= 2) {
|
||||
allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp }
|
||||
} else 0L to 0L
|
||||
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 ;
|
||||
// T Estrannaise = rose plein, T TFS = rose pointillé.
|
||||
val series = curves.map { (model, points, cloud) ->
|
||||
when (model) {
|
||||
PKModels.ESTRANNAISE -> ChartSeries(points, CurveStyle(ChartE2), CurveStyle(ChartT), cloud = cloud)
|
||||
// v1.4.6 : WHSAH = E2 vert plein, T violet pointillé
|
||||
PKModels.WHSAH -> ChartSeries(points, CurveStyle(ChartWhs), CurveStyle(ChartTWhs, dashed = true))
|
||||
// v1.5.0 : « Tracé labs » = E2 rose foncé pointillé, PAS de courbe T
|
||||
// (la série n'a qu'une valeur E2 — tStyle null → rien de dessiné)
|
||||
// et hors pics/creux (labIsSignificant a déjà filtré, §7.10)
|
||||
"LAB" -> ChartSeries(points, CurveStyle(ChartLabTrajectory, dashed = true), null,
|
||||
showExtrema = false)
|
||||
// v1.6.0 : partie PROLONGÉE du tracé labs — même couleur ATTÉNUÉE
|
||||
// (alpha 0,55) pour distinguer visuellement l'estimation non
|
||||
// ancrée de la partie qui passe par les labs. Même exclusions
|
||||
// (pas de T, pas d'extrema — les extrema de l'interp ρ constant
|
||||
// ne sont que ceux du modèle brut).
|
||||
"LABX" -> ChartSeries(
|
||||
points,
|
||||
CurveStyle(ChartLabTrajectory.copy(alpha = 0.55f), dashed = true),
|
||||
null,
|
||||
showExtrema = false
|
||||
)
|
||||
else -> ChartSeries(points, CurveStyle(TealTertiary), CurveStyle(ChartT, dashed = true))
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ PAS de verticalScroll ici (fix #62) : le scroll parent AVALE le
|
||||
// drag horizontal du graphique (pan + pinch, cf §11) — le pan était
|
||||
// totalement mort avec lui (vu en émulateur v1.4.9). Le contenu tient
|
||||
// sur les écrans standards (chips en 3 rangées compactes + carte 320 dp,
|
||||
// vérifié par dump §16.ter) ; si un futur ajout repousse la carte sous
|
||||
// le pli : compacter les rangées plutôt que re-rajouter un scroll.
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@ -78,6 +447,7 @@ fun ChartScreen() {
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Plage temporelle
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = rangeHours == 24L,
|
||||
@ -94,10 +464,28 @@ fun ChartScreen() {
|
||||
onClick = { rangeHours = 24L * 30 },
|
||||
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))
|
||||
|
||||
// Affichage : T, labs, prévision
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = showT,
|
||||
@ -109,13 +497,140 @@ fun ChartScreen() {
|
||||
onClick = { showLabs = !showLabs },
|
||||
label = { Text(stringResource(R.string.show_labs)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = showForecast,
|
||||
onClick = {
|
||||
showForecast = !showForecast
|
||||
// v1.4.1 : désactiver la prévision pendant qu'on est dans le
|
||||
// futur → revenir à maintenant (sinon fenêtre vide au-delà
|
||||
// de « maintenant » sans doses projetées).
|
||||
if (!showForecast) panHours = maxOf(panHours, 0L)
|
||||
},
|
||||
enabled = forecastHorizonHours != null,
|
||||
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))
|
||||
|
||||
// Modèles PK superposables (3ᵉ = WHSAH, v1.4.6) + v1.5.0 : le chip
|
||||
// « Tracé labs » vit ICI (4ᵉ chip de la rangée). ⚠️ SCROLL HORIZONTAL
|
||||
// CONFINÉ à la rangée : 4 chips débordent sur écrans étroits (constat
|
||||
// émulateur v1.5.0 — le 4ᵉ chip était hors fenêtre, absent du dump
|
||||
// uiautomator). AUCUN conflit avec le pan du graphique : le scroll
|
||||
// portre sur CETTE rangée uniquement, le pan/gesture reste sur le
|
||||
// Card du chart (le fix #62 portait sur un scroll VERTICAL parent ;
|
||||
// ici le libellé est différent et localisé).
|
||||
Row(
|
||||
Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = showEse,
|
||||
onClick = {
|
||||
showEse = !showEse
|
||||
// v1.9.0 : le nuage est exclusif à ESE — éteindre ESE
|
||||
// éteint le nuage (sinon il flotterait sans sa courbe)
|
||||
if (!showEse) showCloud = false
|
||||
},
|
||||
label = { Text(stringResource(R.string.model_ese)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = showTfs,
|
||||
onClick = { showTfs = !showTfs },
|
||||
label = { Text(stringResource(R.string.model_tfs)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = showWhs,
|
||||
onClick = { showWhs = !showWhs },
|
||||
label = { Text(stringResource(R.string.model_whs)) }
|
||||
)
|
||||
// v1.5.0 : courbe hybride ancrée sur les labs (off par défaut)
|
||||
FilterChip(
|
||||
selected = showLabTrack,
|
||||
onClick = { showLabTrack = !showLabTrack },
|
||||
label = { Text(stringResource(R.string.show_lab_track)) }
|
||||
)
|
||||
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab
|
||||
// (off par défaut, sans effet — donc désactivé — si Tracé labs
|
||||
// est off : la prolongation n'existe que comme extension de CETTE
|
||||
// courbe, pas comme série autonome)
|
||||
FilterChip(
|
||||
selected = showLabTrackExtend,
|
||||
onClick = { showLabTrackExtend = !showLabTrackExtend },
|
||||
enabled = showLabTrack,
|
||||
label = { Text(stringResource(R.string.show_lab_track_extend)) }
|
||||
)
|
||||
// v1.9.0 : NUAGE D'INCERTITUDE (exclusif ESE) — off par défaut,
|
||||
// activable à volonté mais SEULEMENT si ESE est affiché (même
|
||||
// pattern que Prolonger/Tracé labs) ; éteindre ESE coupe le
|
||||
// nuage automatiquement (sinon le nuage flotterait sans sa courbe)
|
||||
FilterChip(
|
||||
selected = showCloud,
|
||||
onClick = { showCloud = !showCloud },
|
||||
enabled = showEse,
|
||||
label = { Text(stringResource(R.string.show_cloud)) }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Card(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { chartWidthPx = it.width }
|
||||
.pointerInput(Unit) {
|
||||
// Pan (1 doigt) + pinch-zoom (2 doigts, v1.2.9) — un seul
|
||||
// handler pour éviter les conflits de consommation
|
||||
detectTransformGestures { centroid, pan, zoom, _ ->
|
||||
if (chartWidthPx > 0) {
|
||||
// Pan horizontal : glisser à DROITE = remonter le
|
||||
// temps (panHours croît) ; glisser à GAUCHE =
|
||||
// avancer dans le FUTUR (panHours décroît, borné
|
||||
// par l'horizon de prévision — v1.4.1)
|
||||
// v1.4.10 (fix #62) : cumul fractionnaire du pan
|
||||
// (l'ancienne troncature par delta tuait le pan
|
||||
// sur la vue 24 h)
|
||||
val (hoursDragged, newResidual) = panDeltaHours(
|
||||
pan.x, chartWidthPx, rangeHours, panResidualHours
|
||||
)
|
||||
panResidualHours = newResidual
|
||||
if (hoursDragged != 0L) {
|
||||
panHours = clampPanHours(
|
||||
panHours + hoursDragged, gestureFutureHorizon, gestureMaxPan
|
||||
)
|
||||
}
|
||||
// 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) {
|
||||
// L'ancre de droite inclut l'extension de
|
||||
// prévision (v1.4.4) : cohérent avec endMs
|
||||
val endAnchor = nowMs - panHours * HOUR_MS +
|
||||
gestureExtension * 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 - newEnd) / HOUR_MS).toLong()
|
||||
panHours = clampPanHours(
|
||||
newPan, gestureFutureHorizon, gestureMaxPan
|
||||
)
|
||||
rangeHours = newRange
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
if (curve.isEmpty()) {
|
||||
if (series.isEmpty() || allPoints.size < 2) {
|
||||
Text(
|
||||
stringResource(R.string.no_data),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
@ -123,34 +638,158 @@ fun ChartScreen() {
|
||||
)
|
||||
} else {
|
||||
CurveChart(
|
||||
points = curve,
|
||||
series = series,
|
||||
e2Labs = e2Labs,
|
||||
tLabs = tLabs,
|
||||
options = ChartOptions(showT = showT, showLabs = showLabs),
|
||||
options = ChartOptions(
|
||||
showT = showT,
|
||||
showLabs = showLabs,
|
||||
showExtrema = showExtrema,
|
||||
nowMs = nowMs
|
||||
),
|
||||
forecastDoses = if (showForecast) forecastDoses else emptyList(),
|
||||
realDoses = doseLogs,
|
||||
zone = chartZone,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(320.dp)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.legend_e2),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (showT) {
|
||||
Text(
|
||||
stringResource(R.string.legend_t),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
|
||||
// Retour au présent dès qu'on a voyagé (passé OU futur —
|
||||
// v1.4.1 : le futur est atteint en tirant vers la gauche
|
||||
// avec la prévision active)
|
||||
if (panHours != 0L) {
|
||||
TextButton(onClick = { panHours = 0L }) {
|
||||
Text(stringResource(R.string.back_to_present))
|
||||
}
|
||||
}
|
||||
|
||||
// Légende dynamique — ⚠️ v1.5.0 : la clé "LAB" (Tracé labs)
|
||||
// est SKIPPÉE ici : elle a sa propre légende dédiée plus
|
||||
// bas, sinon le `else` (TFS) imprimerait la légende TFS
|
||||
// une 2ᵉ fois (bug v1.5.0-trouvé-en-émulateur #63).
|
||||
curves.forEach { (model, _) ->
|
||||
when (model) {
|
||||
PKModels.ESTRANNAISE -> {
|
||||
Text(
|
||||
stringResource(R.string.legend_ese_e2),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ChartE2
|
||||
)
|
||||
if (showT) {
|
||||
Text(
|
||||
stringResource(R.string.legend_ese_t),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ChartT
|
||||
)
|
||||
}
|
||||
}
|
||||
// v1.4.6 : légende WHSAH (vert / violet pointillé)
|
||||
PKModels.WHSAH -> {
|
||||
Text(
|
||||
stringResource(R.string.legend_whs_e2),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ChartWhs
|
||||
)
|
||||
if (showT) {
|
||||
Text(
|
||||
stringResource(R.string.legend_whs_t),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ChartTWhs
|
||||
)
|
||||
}
|
||||
}
|
||||
"LAB" -> { /* légende dédiée, cf plus bas */ }
|
||||
// v1.6.0 : idem — la partie prolongée a sa légende
|
||||
// dédiée plus bas. ⚠️ branche EXPLICITE requise :
|
||||
// sinon le `else` (= TFS) imprimerait la légende
|
||||
// TFS une 2ᵉ fois (leçon #63, v1.5.0)
|
||||
"LABX" -> { /* légende dédiée, cf plus bas */ }
|
||||
else -> {
|
||||
Text(
|
||||
stringResource(R.string.legend_tfs_e2),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TealTertiary
|
||||
)
|
||||
if (showT) {
|
||||
Text(
|
||||
stringResource(R.string.legend_tfs_t),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ChartT
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showLabs) {
|
||||
Text(
|
||||
stringResource(R.string.legend_labs),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = com.hormonetrack.ui.theme.LabDot
|
||||
color = LabDot
|
||||
)
|
||||
}
|
||||
if (showForecast && forecastDoses.isNotEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.legend_forecast_dose),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
if (showExtrema) {
|
||||
Text(
|
||||
stringResource(R.string.legend_extrema),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
// v1.5.0 : légende « Tracé labs » — seulement si la série
|
||||
// a ≥ 2 points (≥ 2 labs significatifs, la grille est vide sinon)
|
||||
if (showLabTrack && curves.any { it.model == "LAB" && it.points.size >= 2 }) {
|
||||
Text(
|
||||
stringResource(R.string.legend_lab_track),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ChartLabTrajectory
|
||||
)
|
||||
}
|
||||
// v1.9.0 : légende du nuage d'incertitude (ESE) — visible
|
||||
// tant que le nuage est affiché (série ESE avec cloud non vide)
|
||||
if (showCloud && showEse && curves.any { it.model == PKModels.ESTRANNAISE && it.cloud.isNotEmpty() }) {
|
||||
Text(
|
||||
stringResource(R.string.legend_cloud),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ChartE2.copy(alpha = 0.55f)
|
||||
)
|
||||
}
|
||||
// v1.6.0 : légende de la partie PROLONGÉE + AVERTISSEMENT —
|
||||
// seulement si la série "LABX" existe (chip Prolonger actif
|
||||
// ET horizon de prolongation atteint dans la fenêtre visible)
|
||||
val labExtendVisible = showLabTrack && showLabTrackExtend &&
|
||||
curves.any { it.model == "LABX" && it.points.size >= 2 }
|
||||
if (labExtendVisible) {
|
||||
Text(
|
||||
stringResource(R.string.legend_lab_track_extend),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = ChartLabTrajectory.copy(alpha = 0.55f)
|
||||
)
|
||||
// Avertissement (demande v1.6.0) : la partie prolongée
|
||||
// est une EXTRAPOLATION sans garantie — elle repose sur
|
||||
// des mesures de labs elles-mêmes faillibles. Affiché
|
||||
// TANT QUE la prolongation est visible (sous la
|
||||
// légende), pour qu'elle ne puisse pas être lue comme
|
||||
// une prédiction fiable de l'avenir.
|
||||
Text(
|
||||
stringResource(R.string.lab_track_extend_warning),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.drag_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,26 +2,34 @@ package com.hormonetrack.ui.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Colorize
|
||||
import androidx.compose.material.icons.filled.Healing
|
||||
import androidx.compose.material.icons.filled.Medication
|
||||
import androidx.compose.material.icons.filled.Science
|
||||
import androidx.compose.material.icons.filled.Vaccines
|
||||
import androidx.compose.material.icons.filled.WaterDrop
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@ -35,13 +43,19 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.components.DoseDialog
|
||||
import com.hormonetrack.ui.theme.LabDot
|
||||
import com.hormonetrack.util.HrtDuration
|
||||
import com.hormonetrack.ui.components.formatDose
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -50,6 +64,54 @@ import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Icône de FORME DE PRISE (v1.12.0, demande utilisatrice : « des icônes pour
|
||||
* bien séparer les différentes formes de prises des traitements »). Mapping :
|
||||
* ORAL = pilule (Medication) ; IM/SC = seringue (Vaccines — le glyphe est
|
||||
* partagé, la TEinte les distingue côté UI : IM = couleur courante, SC =
|
||||
* tertiary) ; GEL = goutte (WaterDrop) ; PATCH = sparadrap (Healing) ;
|
||||
* OTHER/inconnu = compte-gouttes (Colorize). PUR → testable en JVM
|
||||
* (DosesExtrasTest) ; miroir web `routeGlyph` (js/data/models.js).
|
||||
*/
|
||||
fun routeIcon(route: AdministrationRoute?): ImageVector = when (route) {
|
||||
AdministrationRoute.ORAL -> Icons.Filled.Medication
|
||||
AdministrationRoute.INJECTION_IM -> Icons.Filled.Vaccines
|
||||
AdministrationRoute.INJECTION_SUBCUT -> Icons.Filled.Vaccines
|
||||
AdministrationRoute.TRANSDERMAL_GEL -> Icons.Filled.WaterDrop
|
||||
AdministrationRoute.TRANSDERMAL_PATCH -> Icons.Filled.Healing
|
||||
else -> Icons.Filled.Colorize
|
||||
}
|
||||
|
||||
/**
|
||||
* Marqueurs d'ANALYSE DE SANG pour UNE dose (v1.12.0, demande utilisatrice :
|
||||
* « une petite indication … dans le cas où un test sanguin a été effectué
|
||||
* avant ou après une prise — peut-être un petit encart sur le côté »).
|
||||
*
|
||||
* Une analyse compte si elle tombe le MÊME JOUR calendaire LOCAL que la
|
||||
* dose : au-delà, le lien clinique (labs au creux/pic autour d'une
|
||||
* injection) devient ténu et chaque dose finirait marquée.
|
||||
*
|
||||
* @return Pair(AVANT la dose, APRÈS la dose) — une analyse exactement À
|
||||
* l'heure de la dose compte comme APRÈS (convention arbitraire documentée).
|
||||
* PUR → testable en JVM (DosesExtrasTest) ; miroir web `labMarkersForDose`
|
||||
* (js/util/format.js).
|
||||
*/
|
||||
fun labMarkersForDose(
|
||||
labs: List<LabResult>,
|
||||
doseMs: Long,
|
||||
zone: ZoneId = ZoneId.systemDefault()
|
||||
): Pair<Boolean, Boolean> {
|
||||
val day = Instant.ofEpochMilli(doseMs).atZone(zone).toLocalDate()
|
||||
var before = false
|
||||
var after = false
|
||||
for (lab in labs) {
|
||||
if (Instant.ofEpochMilli(lab.timestamp).atZone(zone).toLocalDate() == day) {
|
||||
if (lab.timestamp < doseMs) before = true else after = true
|
||||
}
|
||||
}
|
||||
return before to after
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DosesScreen() {
|
||||
@ -57,6 +119,8 @@ fun DosesScreen() {
|
||||
val repo = container.repository
|
||||
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
|
||||
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
|
||||
// v1.12.0 : les labs alimentent les marqueurs « prise de sang avant/après »
|
||||
val labResults by repo.allLabResults.collectAsState(initial = emptyList())
|
||||
val treatmentMap = remember(treatments) { treatments.associateBy { it.id } }
|
||||
|
||||
var showLogDialog by remember { mutableStateOf(false) }
|
||||
@ -69,9 +133,26 @@ fun DosesScreen() {
|
||||
}.toSortedMap(compareByDescending { it })
|
||||
}
|
||||
|
||||
/**
|
||||
* Intervalle (en jours) entre chaque dose et la dose précédente du MÊME traitement
|
||||
* (colonne « Interval (d) » du .ods). Clé = id de la dose.
|
||||
*/
|
||||
val intervalsByDoseId = remember(doseLogs) {
|
||||
val byTreatment = doseLogs.sortedBy { it.timestamp }.groupBy { it.treatmentId }
|
||||
buildMap<Long, Double> {
|
||||
byTreatment.values.forEach { logs ->
|
||||
logs.zipWithNext { prev, cur ->
|
||||
put(cur.id, (cur.timestamp - prev.timestamp) / 86_400_000.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val timeFmt = remember { DateTimeFormatter.ofPattern("HH:mm") }
|
||||
|
||||
Scaffold(
|
||||
// Insets à zéro : le TopAppBar interne consomme déjà la barre de statut
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { showLogDialog = true }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_dose))
|
||||
@ -82,6 +163,41 @@ fun DosesScreen() {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.nav_doses)) }
|
||||
)
|
||||
|
||||
// En-tête « temps sous THS » : de la 1re prise enregistrée à maintenant
|
||||
val earliest = remember(doseLogs) { doseLogs.minOfOrNull { it.timestamp } }
|
||||
earliest?.let { first ->
|
||||
Card(
|
||||
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer)
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
val dateStr = DateTimeFormatter.ofPattern("d MMMM yyyy")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
.format(Instant.ofEpochMilli(first))
|
||||
Text(
|
||||
stringResource(R.string.hrt_since, dateStr),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
val totalDays = HrtDuration.daysSince(first, System.currentTimeMillis())
|
||||
if (totalDays > 0) {
|
||||
val (months, days) = HrtDuration.monthsAndDays(totalDays)
|
||||
// ⚠️ hrt_duration a TROIS placeholders (%1$d mois, %2$d jours,
|
||||
// %3$d total) : passer aussi totalDays, sinon
|
||||
// MissingFormatArgumentException = crash de l'écran Doses
|
||||
// dès que totalDays > 0 (donc à chaque affichage avec des
|
||||
// données réelles). Testé au bug réel v1.3.3 (cf §14 #47).
|
||||
Text(
|
||||
if (months > 0) stringResource(R.string.hrt_duration, months, days, totalDays)
|
||||
else stringResource(R.string.hrt_duration_days_only, days),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (doseLogs.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.no_data),
|
||||
@ -103,12 +219,39 @@ fun DosesScreen() {
|
||||
val time = timeFmt.format(Instant.ofEpochMilli(log.timestamp).atZone(zone))
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { toEdit = log },
|
||||
// v1.12.0 : icône de FORME DE PRISE à gauche —
|
||||
// oral / injection IM / injection SC / gel /
|
||||
// patch / autre se repèrent d'un coup d'œil.
|
||||
leadingContent = {
|
||||
Icon(
|
||||
routeIcon(tr?.route),
|
||||
contentDescription = tr?.let {
|
||||
stringResource(routeLabelRes(it.route))
|
||||
},
|
||||
tint = if (tr?.route == AdministrationRoute.INJECTION_SUBCUT) {
|
||||
// IM/SC partagent le glyphe seringue :
|
||||
// la teinte tertiary distingue la SC
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
}
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text("${tr?.name ?: "?"} · ${formatDose(log.doseAmount)} ${tr?.doseUnit ?: ""}")
|
||||
},
|
||||
supportingContent = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(time)
|
||||
// Intervalle depuis la dose précédente du même traitement
|
||||
intervalsByDoseId[log.id]?.let {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.interval_value,
|
||||
String.format(java.util.Locale.getDefault(), "%.1f", it)
|
||||
)
|
||||
)
|
||||
}
|
||||
log.esterType?.takeIf { it.isNotEmpty() && it != "NONE" }?.let {
|
||||
AssistChip(onClick = {}, label = { Text(it) })
|
||||
}
|
||||
@ -118,8 +261,16 @@ fun DosesScreen() {
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = { toDelete = log }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// v1.12.0 : encart latéral « prise de sang
|
||||
// le même jour » — ↑ = lab AVANT la dose,
|
||||
// ↓ = lab APRÈS (cf labMarkersForDose)
|
||||
val (labBefore, labAfter) = labMarkersForDose(labResults, log.timestamp, zone)
|
||||
if (labBefore) LabSideMarker(up = true)
|
||||
if (labAfter) LabSideMarker(up = false)
|
||||
IconButton(onClick = { toDelete = log }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -132,7 +283,10 @@ fun DosesScreen() {
|
||||
|
||||
if (showLogDialog || toEdit != null) {
|
||||
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,
|
||||
existing = toEdit,
|
||||
onDismiss = {
|
||||
@ -171,11 +325,37 @@ fun DosesScreen() {
|
||||
CoroutineScope(Dispatchers.IO).launch { repo.deleteDoseLog(log) }
|
||||
}) { Text(stringResource(R.string.delete)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) } },
|
||||
title = { Text(stringResource(R.string.delete)) },
|
||||
text = { Text(stringResource(R.string.confirm_delete)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encart latéral « prise de sang le même jour que cette dose » (v1.12.0,
|
||||
* demande utilisatrice) : icône laboratoire + flèche — **↑** = analyse
|
||||
* effectuée AVANT la dose, **↓** = APRÈS. La description sémantique porte
|
||||
* la phrase complète (accessibilité TalkBack) ; le glyphe seul est décoratif
|
||||
* (contentDescription = null sur l'icône, description portée par le Row).
|
||||
*/
|
||||
@Composable
|
||||
private fun LabSideMarker(up: Boolean) {
|
||||
val description = stringResource(if (up) R.string.lab_before_dose else R.string.lab_after_dose)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.semantics { contentDescription = description }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Science,
|
||||
contentDescription = null,
|
||||
tint = LabDot,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
if (up) "↑" else "↓",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LabDot
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,18 +43,24 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.pk.LevelPoint
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.pk.LabTiming
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.components.ChartOptions
|
||||
import com.hormonetrack.ui.components.ChartSeries
|
||||
import com.hormonetrack.ui.components.CurveChart
|
||||
import com.hormonetrack.ui.components.CurveStyle
|
||||
import com.hormonetrack.ui.components.DoseDialog
|
||||
import com.hormonetrack.ui.components.formatDose
|
||||
import com.hormonetrack.ui.theme.TransPink
|
||||
import com.hormonetrack.ui.theme.TransSky
|
||||
import com.hormonetrack.ui.theme.ChartE2
|
||||
import com.hormonetrack.ui.theme.ChartT
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
@ -66,29 +72,58 @@ import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
private val HOUR_MS = 3_600_000L
|
||||
private const val HOUR_MS = 3_600_000L
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
openLogDoseForTreatmentId: Long?,
|
||||
onLogDoseConsumed: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenCharts: () -> Unit,
|
||||
onOpenTreatment: (Long) -> Unit
|
||||
) {
|
||||
val container = LocalAppContainer.current
|
||||
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 labResults by repo.allLabResults.collectAsState(initial = emptyList())
|
||||
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
||||
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
||||
|
||||
// Auto-calibration (v1.2.1) : facteurs d'échelle PAR PÉRIODE D'ESTER + modèle T,
|
||||
// recalculés à la volée depuis les labs. null = option désactivée (échelles
|
||||
// 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) {
|
||||
null
|
||||
} else {
|
||||
withContext(Dispatchers.Default) {
|
||||
PharmacokineticEngine.autoCalibrated(allTreatments, doseLogs, labResults, tConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var showLogDialog by remember { mutableStateOf(false) }
|
||||
var preselectId by remember { mutableStateOf<Long?>(null) }
|
||||
|
||||
// Demande de notification : ouvrir le dialog UNE SEULE FOIS puis
|
||||
// CONSOMMER la demande au niveau activité (fix #69 — l'ancien code
|
||||
// relançait le dialog à chaque RÉ-ENTRÉE en composition de Home :
|
||||
// quitter l'onglet détruit les remember locaux, le paramètre d'intent
|
||||
// lui survivait → retour sur l'accueil = dialog fantôme à chaque fois).
|
||||
LaunchedEffect(openLogDoseForTreatmentId) {
|
||||
openLogDoseForTreatmentId?.takeIf { it > 0 }?.let {
|
||||
showLogDialog = true
|
||||
preselectId = it
|
||||
onLogDoseConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@ -100,14 +135,23 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val curve by produceState<List<LevelPoint>>(emptyList(), treatments, doseLogs, tConfig, tick) {
|
||||
// v1.8.2 : allTreatments et tConfig ajoutés aux clés — éditer un
|
||||
// scaleFactor ou le modèle T rafraîchit la courbe immédiatement
|
||||
// (l'ancien trio de clés ratait ces changements jusqu'au tick de 60 s)
|
||||
val curve by produceState<List<LevelPoint>>(emptyList(), allTreatments, tConfig, effectiveAuto, doseLogs, tick) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val end = System.currentTimeMillis()
|
||||
value = PharmacokineticEngine.computeCurve(
|
||||
treatments, doseLogs,
|
||||
effectiveAuto?.treatments ?: allTreatments,
|
||||
doseLogs,
|
||||
startMs = end - 24 * HOUR_MS,
|
||||
endMs = end,
|
||||
tConfig = tConfig
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -140,7 +184,154 @@ fun HomeScreen(
|
||||
|
||||
NowLevelCard(curve)
|
||||
|
||||
NextDoseCard(treatments)
|
||||
// Seuils d'alerte (v1.4.2) : évaluation du TAUX ESTIMÉ ACTUEL
|
||||
// (dernier point de la courbe = même valeur que la carte
|
||||
// « niveau actuel ») contre les limites configurées dans
|
||||
// Paramètres (pk/Alerts.kt — opt-in, champ vide = pas d'alerte).
|
||||
val alertThresholds by container.settings.alertThresholds.collectAsState(
|
||||
initial = com.hormonetrack.pk.Alerts.Thresholds()
|
||||
)
|
||||
curve.lastOrNull()?.let { now ->
|
||||
val alerts = com.hormonetrack.pk.Alerts.evaluateAll(now.e2, now.t, alertThresholds)
|
||||
if (alerts.isNotEmpty()) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.alert_card_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
alerts.forEach { a ->
|
||||
val formatted = formatDose(a.value)
|
||||
val limitStr = formatDose(a.limit)
|
||||
Text(
|
||||
if (a.level == com.hormonetrack.pk.Alerts.Level.HIGH) {
|
||||
stringResource(R.string.alert_high, a.marker, formatted, a.unit, limitStr)
|
||||
} else {
|
||||
stringResource(R.string.alert_low, a.marker, formatted, a.unit, limitStr)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.alert_disclaimer),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NextDoseCard(activeTreatments, doseLogs)
|
||||
|
||||
// ── Recommandation de prochaine prise de sang (v1.8.1) ───────────
|
||||
// Même calcul que la page Analyses (pk/LabTiming) — carte COMPACTE
|
||||
// sur l'accueil : creux daté + créneau associé + disclaimer court.
|
||||
// Cachée si non calculable (pas d'injectable actif à Posologie) ;
|
||||
// l'invite « renseigne une Posologie » reste sur la page Analyses
|
||||
// (l'accueil ne fait pas doublon d'explication).
|
||||
val labRecommendation by produceState(
|
||||
initialValue = null as LabTiming.LabRecommendation?,
|
||||
allTreatments, doseLogs, labResults, tick, effectiveAuto
|
||||
) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
// v1.13.0 : les facteurs d'échelle de l'auto-calibration
|
||||
// alimentent la prédiction « ≈ X pg/mL au creux » (null
|
||||
// si l'option est OFF → carte sans valeur, inchangée)
|
||||
LabTiming.nextBloodDrawRecommendation(
|
||||
allTreatments, doseLogs, labResults, System.currentTimeMillis(),
|
||||
effectiveAuto?.esterScales
|
||||
)
|
||||
}
|
||||
}
|
||||
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
|
||||
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
|
||||
labRecommendation?.let { rec ->
|
||||
val fmtDate = remember {
|
||||
DateTimeFormatter.ofPattern("EEE d MMM HH:mm", Locale.getDefault())
|
||||
.withZone(ZoneId.systemDefault())
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.labrec_section),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.labrec_when, fmtDate.format(Instant.ofEpochMilli(rec.troughMs))),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si
|
||||
// la calibration est disponible) + statut face à la
|
||||
// cible de creux personnelle (lecture seule — jamais
|
||||
// de notification, distinct des seuils d'alerte)
|
||||
rec.predictedE2?.let { predicted ->
|
||||
val targetStatus = LabTiming.troughTargetStatus(
|
||||
predicted, troughTarget?.first, troughTarget?.second
|
||||
)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.labrec_predicted,
|
||||
String.format(java.util.Locale.getDefault(), "%.0f", predicted)
|
||||
) + when (targetStatus) {
|
||||
LabTiming.TroughTargetStatus.IN_TARGET ->
|
||||
" — " + stringResource(R.string.labrec_in_target)
|
||||
LabTiming.TroughTargetStatus.BELOW,
|
||||
LabTiming.TroughTargetStatus.ABOVE ->
|
||||
" — " + stringResource(R.string.labrec_out_of_target)
|
||||
null -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = when (targetStatus) {
|
||||
LabTiming.TroughTargetStatus.IN_TARGET ->
|
||||
MaterialTheme.colorScheme.primary
|
||||
LabTiming.TroughTargetStatus.BELOW,
|
||||
LabTiming.TroughTargetStatus.ABOVE ->
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
null -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.labrec_before_injection,
|
||||
rec.ester,
|
||||
fmtDate.format(Instant.ofEpochMilli(rec.injectionMs))
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
if (!rec.wasAlreadyStabilized) {
|
||||
// Mention SEULEMENT si le régime n'était pas encore
|
||||
// stabilisé (info utile — sinon le texte est identique
|
||||
// à chaque ouverture)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.labrec_not_stabilized,
|
||||
rec.ester,
|
||||
fmtDate.format(Instant.ofEpochMilli(rec.stabilizedAtMs))
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@ -156,7 +347,7 @@ fun HomeScreen(
|
||||
Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
treatments.forEach { tr ->
|
||||
activeTreatments.forEach { tr ->
|
||||
AssistChip(
|
||||
onClick = {
|
||||
preselectId = tr.id
|
||||
@ -177,7 +368,10 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Card(
|
||||
onClick = onOpenCharts,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.home_chart_title),
|
||||
@ -185,7 +379,14 @@ fun HomeScreen(
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
CurveChart(
|
||||
points = curve,
|
||||
series = listOf(
|
||||
// Home affiche le modèle propre à chaque traitement (pas d'override)
|
||||
ChartSeries(
|
||||
curve,
|
||||
CurveStyle(ChartE2),
|
||||
CurveStyle(ChartT)
|
||||
)
|
||||
),
|
||||
e2Labs = labResults.filterInRange(curve, "E2"),
|
||||
tLabs = labResults.filterInRange(curve, "T"),
|
||||
options = ChartOptions(showT = true, showLabs = true),
|
||||
@ -193,6 +394,23 @@ fun HomeScreen(
|
||||
.fillMaxWidth()
|
||||
.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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -207,7 +425,7 @@ fun HomeScreen(
|
||||
|
||||
if (showLogDialog) {
|
||||
DoseDialog(
|
||||
treatments = treatments,
|
||||
treatments = activeTreatments,
|
||||
preselectedTreatmentId = preselectId,
|
||||
existing = null,
|
||||
onDismiss = { showLogDialog = false },
|
||||
@ -239,9 +457,11 @@ private fun List<LabResult>.filterInRange(curve: List<LevelPoint>, marker: Strin
|
||||
@Composable
|
||||
private fun NowLevelCard(curve: List<LevelPoint>) {
|
||||
val now = curve.lastOrNull()
|
||||
val sixHAgo = curve.firstOrNull { p ->
|
||||
(curve.last().timestamp - p.timestamp) >= 6 * HOUR_MS
|
||||
}
|
||||
// v1.4.3 (fix #53) : le point de comparaison « il y a 6 h » est celui le
|
||||
// plus PROCHE de −6 h (l'ancien code prenait le PREMIER point de la
|
||||
// fenêtre 24 h → le delta affiché était en réalité un delta 24 h,
|
||||
// trompeur le lendemain d'une injection lente type EEn — cf régression #4).
|
||||
val sixHAgo = com.hormonetrack.ui.components.pointHoursBefore(curve, hours = 6.0)
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
|
||||
@ -282,40 +502,78 @@ private fun NowLevelCard(curve: List<LevelPoint>) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NextDoseCard(treatments: List<Treatment>) {
|
||||
val next = PharmacokineticEngine.nextReminderFireMs(treatments)
|
||||
if (next == null) return
|
||||
val nextTreatment = treatments.filter { it.reminderEnabled }
|
||||
.minByOrNull { tr ->
|
||||
val h = tr.reminderHour ?: 23
|
||||
val m = tr.reminderMinute ?: 59
|
||||
val candidate = (h * 60L + m) * 60_000L
|
||||
val nowMs = System.currentTimeMillis()
|
||||
val nowOfDay = nowMs % (24 * HOUR_MS)
|
||||
if (candidate >= nowOfDay) candidate else candidate + 24 * HOUR_MS
|
||||
}
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.next_dose),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val deltaMs = next - System.currentTimeMillis()
|
||||
val h = deltaMs / HOUR_MS
|
||||
val m = (deltaMs % HOUR_MS) / 60_000L
|
||||
val timeStr = DateTimeFormatter.ofPattern("HH:mm")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
.format(Instant.ofEpochMilli(next))
|
||||
Text(
|
||||
if (h > 0) {
|
||||
String.format(Locale.getDefault(), "%dh%02d · %s (%s)", h, m, timeStr, nextTreatment?.name ?: "")
|
||||
} else {
|
||||
String.format(Locale.getDefault(), "%d min · %s (%s)", m, timeStr, nextTreatment?.name ?: "")
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
/**
|
||||
* Ligne « prochaine prise » d'un traitement (v1.9.6 — PUR, testable JVM) :
|
||||
* « Xj Yh · ven. 25 10:00 (nom) » au-delà de 24 h (les heures deviennent peu
|
||||
* lisibles, v1.4.1), « XhMM · HH:mm (nom) » en dessous, « N min · HH:mm
|
||||
* (nom) » dans l'heure. Utilisée par chaque ligne de [NextDoseCard].
|
||||
*/
|
||||
internal fun nextDoseLine(
|
||||
nowMs: Long,
|
||||
fireMs: Long,
|
||||
name: String,
|
||||
/** Template localisé du cas ≥ 24 h (string `next_dose_days`, 4 args). */
|
||||
daysTemplate: String,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
locale: Locale = Locale.getDefault()
|
||||
): String {
|
||||
val deltaMs = fireMs - nowMs
|
||||
val h = deltaMs / HOUR_MS
|
||||
val m = (deltaMs % HOUR_MS) / 60_000L
|
||||
return if (h >= 24) {
|
||||
val (days, remH) = com.hormonetrack.util.HrtDuration.daysAndHours(h)
|
||||
val dayTimeStr = DateTimeFormatter.ofPattern("EEE d HH:mm")
|
||||
.withZone(zone)
|
||||
.withLocale(locale)
|
||||
.format(Instant.ofEpochMilli(fireMs))
|
||||
String.format(locale, daysTemplate, days, remH, dayTimeStr, name)
|
||||
} else {
|
||||
val timeStr = DateTimeFormatter.ofPattern("HH:mm")
|
||||
.withZone(zone)
|
||||
.withLocale(locale)
|
||||
.format(Instant.ofEpochMilli(fireMs))
|
||||
if (h > 0) {
|
||||
String.format(locale, "%dh%02d · %s (%s)", h, m, timeStr, name)
|
||||
} else {
|
||||
String.format(locale, "%d min · %s (%s)", m, timeStr, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NextDoseCard(treatments: List<Treatment>, doseLogs: List<DoseLog>) {
|
||||
// v1.4.0 (fix #52) : les dates de rappel passent par le moteur — la carte
|
||||
// suit la grille Posologie (jour du créneau uniquement) comme les alarmes.
|
||||
// v1.9.6 (demande) : la carte liste TOUTES les prochaines prises, UNE
|
||||
// ligne par traitement, TRIÉES par prochaine prise — l'ancienne carte ne
|
||||
// montrait que la plus proche, et une prise quotidienne (CPA) masquait
|
||||
// totalement le rappel de l'injection hebdomadaire (le rappel EEn
|
||||
// « n'arrivait qu'après celui de la CPA »). Le titre passe au pluriel
|
||||
// dès qu'il y a au moins deux lignes.
|
||||
val nowMs = System.currentTimeMillis()
|
||||
val fires = treatments
|
||||
.mapNotNull { tr -> PharmacokineticEngine.nextReminderFireFor(tr, doseLogs, nowMs)?.let { tr to it } }
|
||||
.sortedBy { it.second }
|
||||
if (fires.isEmpty()) return
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
stringResource(if (fires.size == 1) R.string.next_dose else R.string.next_doses),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// Première ligne (la plus proche) mise en avant, les suivantes en
|
||||
// style secondaire — même hiérarchie visuelle que v1.4.x.
|
||||
fires.forEachIndexed { idx, (treatment, next) ->
|
||||
Text(
|
||||
nextDoseLine(
|
||||
nowMs, next, treatment?.name ?: "",
|
||||
stringResource(R.string.next_dose_days)
|
||||
),
|
||||
style = if (idx == 0) MaterialTheme.typography.titleMedium
|
||||
else MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
package com.hormonetrack.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
@ -23,9 +26,12 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -33,33 +39,148 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.pk.LabTiming
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.components.LabDialog
|
||||
import com.hormonetrack.ui.components.formatLabValue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
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 }
|
||||
|
||||
/**
|
||||
* Notes à AFFICHER pour un groupe de labs (v1.7.1 — fix du bug « une seule
|
||||
* note sur deux s'affiche »).
|
||||
*
|
||||
* HISTORIQUE DU BUG : à l'origine, le dialog de création mettait LA MÊME note
|
||||
* aux deux entrées d'une prise E2+T — l'affichage ne prenait donc que la
|
||||
* première non vide (`firstNotNullOfOrNull`), hypothèse documentée « toutes
|
||||
* identiques en pratique ». Depuis l'édition UNITAIRE (tap → sélecteur E2/T),
|
||||
* chaque entrée porte SA note (cas réel : « Analyse faite suite à des maux de
|
||||
* tête. » sur E2 et « DHT : 12ng/dL » sur T) → l'autre note disparaissait de
|
||||
* l'affichage tout en restant bien sauvegardée. Même bug côté web (mirror
|
||||
* `.find()`), corrigé en miroir.
|
||||
*
|
||||
* RÈGLES :
|
||||
* - notes vides/blanches ignorées ;
|
||||
* - ordre du groupe préservé (E2 avant T — ordre de [groupLabsForDisplay]) ;
|
||||
* - note IDENTIQUE sur plusieurs entrées (création via le dialog commun) →
|
||||
* affichée UNE seule fois (pas de doublon) ;
|
||||
* - notes DISTINCTES → une ligne par note, préfixée du marqueur
|
||||
* (« E2 : … » / « T : … ») pour savoir à quelle entrée elle se rapporte ;
|
||||
* - une seule note distincte → affichée brute (comportement v1.0 préservé).
|
||||
*
|
||||
* PUR (aucune dépendance Android) → testé en JVM (LabsGroupingTest).
|
||||
*/
|
||||
fun labNotesForDisplay(group: List<LabResult>): List<String> {
|
||||
val notes = group.mapNotNull { lab ->
|
||||
lab.notes?.takeIf { it.isNotBlank() }
|
||||
?.let { lab.marker.uppercase() to it.trim() }
|
||||
}
|
||||
if (notes.isEmpty()) return emptyList()
|
||||
val distinct = notes.distinctBy { it.second } // même texte = une seule ligne
|
||||
return if (distinct.size == 1) {
|
||||
listOf(distinct.first().second)
|
||||
} else {
|
||||
distinct.map { (marker, note) -> "$marker : $note" }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabsScreen() {
|
||||
val container = LocalAppContainer.current
|
||||
val repo = container.repository
|
||||
// ⚠️ TOUS les traitements + toutes les doses (actifs ET inactifs, §6.bis) :
|
||||
// la recommandation se base sur le traitement E2 injectable ACTIF, mais le
|
||||
// moteur reçoit l'historique complet (l'inactif participe à la courbe).
|
||||
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
|
||||
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
|
||||
val labs by repo.allLabResults.collectAsState(initial = emptyList())
|
||||
|
||||
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) {
|
||||
labs.sortedBy { it.marker.lowercase() }.groupBy { it.marker.uppercase() }
|
||||
}
|
||||
val grouped = remember(labs) { groupLabsForDisplay(labs) }
|
||||
val fmt = remember {
|
||||
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault())
|
||||
}
|
||||
|
||||
// ── Recommandation de prochaine prise de sang (v1.8.0) ───────────────────
|
||||
// Tick minute (même pattern que ChartScreen) : « maintenant » avance, la
|
||||
// fenêtre de recherche de creux se rafraîchit sans interaction.
|
||||
var tick by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(60_000)
|
||||
tick++
|
||||
}
|
||||
}
|
||||
val nowMs = remember(tick) { System.currentTimeMillis() }
|
||||
// Auto-calibration (v1.13.0) : alimente la prédiction « ≈ X pg/mL » de la
|
||||
// carte reco (null si l'option est OFF) — même pattern que HomeScreen.
|
||||
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
||||
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
||||
val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
|
||||
null, treatments, doseLogs, labs, tConfig, autoCalibrate
|
||||
) {
|
||||
value = if (!autoCalibrate) null
|
||||
else withContext(Dispatchers.Default) {
|
||||
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labs, tConfig)
|
||||
}
|
||||
}
|
||||
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
|
||||
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
|
||||
// Calcul HORS UI thread (produceState) — c'est un calcul PK complet
|
||||
// (courbe prévisionnelle), même discipline que Home/Chart.
|
||||
val recommendation by produceState(
|
||||
initialValue = null as LabTiming.LabRecommendation?,
|
||||
treatments, doseLogs, labs, nowMs, effectiveAuto
|
||||
) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
LabTiming.nextBloodDrawRecommendation(
|
||||
treatments, doseLogs, labs, nowMs, effectiveAuto?.esterScales
|
||||
)
|
||||
}
|
||||
}
|
||||
// Invite « renseigne une Posologie » (v1.8.0, demande explicite) : quand
|
||||
// un traitement E2 injectable actif n'a PAS de Posologie, aucune
|
||||
// recommandation n'est calculable — on l'explique pour que le lien
|
||||
// Posologie → recommandation soit découvrable. Ne s'affiche JAMAIS en
|
||||
// même temps qu'une vraie recommandation.
|
||||
val suggestPosology = recommendation == null && LabTiming.shouldSuggestPosology(treatments)
|
||||
|
||||
Scaffold(
|
||||
// Insets à zéro : le TopAppBar interne consomme déjà la barre de statut
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { showLabDialog = true }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_lab))
|
||||
@ -70,41 +191,139 @@ fun LabsScreen() {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.nav_labs)) }
|
||||
)
|
||||
if (labs.isEmpty()) {
|
||||
|
||||
// ── Carte « Prochaine analyse recommandée » (v1.8.0) ─────────────
|
||||
// Affichée seulement si le calcul est honnête (traitement E2
|
||||
// injectable actif à Posologie + creux stable trouvé). Sinon, si
|
||||
// un injectable actif est SANS Posologie : invite à la renseigner
|
||||
// (les deux cartes sont mutuellement exclusives).
|
||||
if (recommendation != null) {
|
||||
val r = recommendation!!
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.labrec_section),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// Creux EXACT (choix v1.8.0 : les deux affichés)
|
||||
Text(
|
||||
stringResource(R.string.labrec_when, fmt.format(Instant.ofEpochMilli(r.troughMs))),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si
|
||||
// la calibration est disponible) + statut cible —
|
||||
// lecture seule, jamais de notification (cf §9.bis)
|
||||
r.predictedE2?.let { predicted ->
|
||||
val targetStatus = LabTiming.troughTargetStatus(
|
||||
predicted, troughTarget?.first, troughTarget?.second
|
||||
)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.labrec_predicted,
|
||||
String.format(java.util.Locale.getDefault(), "%.0f", predicted)
|
||||
) + when (targetStatus) {
|
||||
LabTiming.TroughTargetStatus.IN_TARGET ->
|
||||
" — " + stringResource(R.string.labrec_in_target)
|
||||
LabTiming.TroughTargetStatus.BELOW,
|
||||
LabTiming.TroughTargetStatus.ABOVE ->
|
||||
" — " + stringResource(R.string.labrec_out_of_target)
|
||||
null -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = when (targetStatus) {
|
||||
LabTiming.TroughTargetStatus.IN_TARGET ->
|
||||
MaterialTheme.colorScheme.primary
|
||||
LabTiming.TroughTargetStatus.BELOW,
|
||||
LabTiming.TroughTargetStatus.ABOVE ->
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
null -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.labrec_before_injection,
|
||||
r.ester,
|
||||
fmt.format(Instant.ofEpochMilli(r.injectionMs))
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
if (r.wasAlreadyStabilized) {
|
||||
stringResource(
|
||||
R.string.labrec_stabilized,
|
||||
r.ester,
|
||||
fmt.format(Instant.ofEpochMilli(r.stabilizedAtMs))
|
||||
)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.labrec_not_stabilized,
|
||||
r.ester,
|
||||
fmt.format(Instant.ofEpochMilli(r.stabilizedAtMs))
|
||||
)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.labrec_disclaimer),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (suggestPosology) {
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.labrec_section),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.labrec_needs_posology),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (grouped.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.no_data),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
grouped.forEach { (marker, results) ->
|
||||
item(key = "header_$marker") {
|
||||
Text(
|
||||
marker,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
items(grouped, key = { it.first }) { (ts, group) ->
|
||||
// Affichage côte à côte : « E2 306 pg/mL · T 45 ng/dL »
|
||||
val headline = group.joinToString(" · ") { lab ->
|
||||
"${lab.marker.uppercase()} ${formatLabValue(lab.value)} ${lab.unit}"
|
||||
}
|
||||
items(results, key = { it.id }) { lab ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text("${lab.value} ${lab.unit}")
|
||||
},
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(fmt.format(Instant.ofEpochMilli(lab.timestamp)))
|
||||
lab.notes?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = { toDelete = lab }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
if (group.size > 1) chooserGroup = group else toEdit = group.first()
|
||||
},
|
||||
headlineContent = { Text(headline) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(fmt.format(Instant.ofEpochMilli(ts)))
|
||||
// Notes du groupe (v1.7.1) : TOUTES les notes
|
||||
// distinctes, préfixées du marqueur quand elles
|
||||
// diffèrent — l'ancien code n'affichait que la
|
||||
// première non vide (bug « une note sur deux »)
|
||||
labNotesForDisplay(group).forEach { note ->
|
||||
Text(note, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = { toDeleteGroup = group }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -113,38 +332,88 @@ fun LabsScreen() {
|
||||
|
||||
if (showLabDialog) {
|
||||
LabDialog(
|
||||
existing = null,
|
||||
onDismiss = { showLabDialog = false },
|
||||
onConfirm = { marker, value, unit, ts, notes ->
|
||||
onConfirmCreate = { e2, t, ts, notes ->
|
||||
showLabDialog = false
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
repo.insertLabResult(
|
||||
LabResult(
|
||||
marker = marker,
|
||||
value = value,
|
||||
unit = unit,
|
||||
timestamp = ts,
|
||||
notes = notes.ifBlank { null }
|
||||
e2?.let {
|
||||
repo.insertLabResult(
|
||||
LabResult(marker = "E2", value = it.value, unit = it.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(
|
||||
onDismissRequest = { toDelete = null },
|
||||
onDismissRequest = { toDeleteGroup = null },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
toDelete = null
|
||||
CoroutineScope(Dispatchers.IO).launch { repo.deleteLabResult(lab) }
|
||||
toDeleteGroup = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
group.forEach { repo.deleteLabResult(it) }
|
||||
}
|
||||
}) { Text(stringResource(R.string.delete)) }
|
||||
},
|
||||
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)) },
|
||||
text = { Text(stringResource(R.string.confirm_delete)) }
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.confirm_delete_blood_draw,
|
||||
group.joinToString(" · ") { "${it.marker.uppercase()} ${formatLabValue(it.value)}" }
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,10 @@
|
||||
package com.hormonetrack.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@ -18,21 +12,17 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@ -44,43 +34,153 @@ import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.backup.BackupManager
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.data.backup.UserSettings
|
||||
import com.hormonetrack.pk.Alerts
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.reminder.AlarmScheduler
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.screens.settings.AboutCard
|
||||
import com.hormonetrack.ui.screens.settings.AlertThresholdsCard
|
||||
import com.hormonetrack.ui.screens.settings.AutoBackupCard
|
||||
import com.hormonetrack.ui.screens.settings.AutoCalibrationCard
|
||||
import com.hormonetrack.ui.screens.settings.BackupCard
|
||||
import com.hormonetrack.ui.screens.settings.ChartTimezoneCard
|
||||
import com.hormonetrack.ui.screens.settings.LanguageCard
|
||||
import com.hormonetrack.ui.screens.settings.LogsCard
|
||||
import com.hormonetrack.ui.screens.settings.RemindersCard
|
||||
import com.hormonetrack.ui.screens.settings.TroughTargetCard
|
||||
import com.hormonetrack.ui.screens.settings.TModelCard
|
||||
import com.hormonetrack.util.AppLog
|
||||
import com.hormonetrack.util.ExportFileNames
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Écran PARAMÈTRES (v1.9.8 : découpé en cartes thématiques, cf le
|
||||
* sous-package [com.hormonetrack.ui.screens.settings] — comportement et
|
||||
* rendu IDENTIQUES, chaque carte est un fichier dédié documenté).
|
||||
*
|
||||
* Cet orchestrateur garde ce qui est PARTAGÉ entre cartes :
|
||||
* - la collecte des réglages DataStore (collectAsState) ;
|
||||
* - les 4 launchers SAF (export JSON, export logs, import, dossier
|
||||
* auto-backup) : leurs callbacks écrivent l'état `message` et le dialog
|
||||
* de confirmation d'import, déclarés ICI ;
|
||||
* - le dialog de confirmation d'import (écrasement) ;
|
||||
* - l'ordre d'affichage des cartes.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(onBack: () -> Unit) {
|
||||
val container = LocalAppContainer.current
|
||||
val repo = container.repository
|
||||
val context = LocalContext.current
|
||||
val scheduler = remember { AlarmScheduler(context) }
|
||||
|
||||
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
|
||||
val currentLanguage by container.settings.language.collectAsState(initial = "system")
|
||||
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
||||
// Seuils d'alerte (v1.4.2) : limites E2/T évaluées sur l'accueil
|
||||
val alertThresholds by container.settings.alertThresholds.collectAsState(
|
||||
initial = Alerts.Thresholds()
|
||||
)
|
||||
// Cible de creux (v1.13.0) : référence de lab personnelle — distincte
|
||||
// des seuils d'alerte (cf TroughTargetCard)
|
||||
val troughTarget by container.settings.troughTarget.collectAsState(initial = null)
|
||||
// Fuseau de lecture du graphique (v1.4.5) : null = fuseau du téléphone
|
||||
val chartTimezone by container.settings.chartTimezone.collectAsState(initial = null as String?)
|
||||
|
||||
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
|
||||
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
|
||||
var tKText by remember(tConfig.k) { mutableStateOf(formatDoubles(tConfig.k)) }
|
||||
// ── Auto-backup (v1.7.0) : états collectés ici, logique dans la carte ──
|
||||
val autoBackupEnabled by container.settings.autoBackupEnabled
|
||||
.collectAsState(initial = false)
|
||||
val autoBackupTreeUri by container.settings.autoBackupTreeUri
|
||||
.collectAsState(initial = null as String?)
|
||||
val autoBackupKeep by container.settings.autoBackupKeep.collectAsState(initial = 7)
|
||||
val autoBackupLastRun by container.settings.autoBackupLastRun
|
||||
.collectAsState(initial = null as Pair<String?, Long?>?)
|
||||
|
||||
var showImportConfirm by remember { mutableStateOf(false) }
|
||||
var importJson by remember { mutableStateOf<String?>(null) }
|
||||
var logLineCount by remember { mutableIntStateOf(AppLog.count()) }
|
||||
// Message de feedback affiché en haut de l'écran (succès/échec des exports,
|
||||
// imports, calibration T…) — déclaré AVANT les launchers : le callback de
|
||||
// logExportLauncher l'écrit (un state local doit être déclaré avant le
|
||||
// lambda qui le capture).
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
/** Écrit le feedback depuis le thread principal (les cartes l'appellent). */
|
||||
val showMessage: (String) -> Unit = { message = it }
|
||||
|
||||
val logExportLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("text/plain")
|
||||
) { uri ->
|
||||
// ── Export des LOGS — même PROCESSUS que l'export JSON (cf §14 #45) ──────
|
||||
// Historique : v1.3.1 plantait (écriture inline), le « fix » v1.3.2 a
|
||||
// réécrit inline AUSSI (et laissait des AppLog.log hors try/catch) —
|
||||
// l'app plantait toujours. Leçon §14 #41 : quand un pattern d'IO marche,
|
||||
// le réutiliser TEL QUEL. L'export JSON (BackupManager.writeBackup)
|
||||
// fonctionne sur le téléphone → l'export des logs passe par LA MÊME
|
||||
// fonction (elle prend un String, peu importe son contenu).
|
||||
//
|
||||
// Points de robustesse :
|
||||
// - le résultat SAF arrive sur le thread UI ; l'IO part dans une
|
||||
// coroutine à SCOPE RACINE (CoroutineScope standalone, jamais
|
||||
// rememberCoroutineScope : elle serait annulée si l'écran quittait la
|
||||
// composition pendant l'écriture) ;
|
||||
// - TOUT ce qui peut lever est sous try/catch : une exception non
|
||||
// interceptée dans un scope racine remonte au handler de la thread
|
||||
// = crash de l'app. (AppLog.log est de toute façon fail-safe depuis
|
||||
// v1.3.3, double filet.)
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val ok = try {
|
||||
val logs = AppLog.all()
|
||||
AppLog.log("logs", "export démarré (${logs.length} caractères)")
|
||||
// ⚠️ Code path IDENTIQUE à l'export JSON : BackupManager
|
||||
// .writeBackup (openOutputStream "wt", exceptions capturées).
|
||||
BackupManager.writeBackup(context, uri, logs)
|
||||
} catch (e: Exception) {
|
||||
AppLog.log("logs", "ERREUR export : ${e.message}")
|
||||
false
|
||||
}
|
||||
AppLog.log("logs", "export terminé : ok=$ok")
|
||||
withContext(Dispatchers.Main) {
|
||||
// Feedback visible comme l'export JSON + compteur rafraîchi
|
||||
// (l'export vient d'ajouter des lignes de journal). Écriture
|
||||
// d'état Compose UNIQUEMENT sur le thread principal.
|
||||
logLineCount = AppLog.count()
|
||||
message = context.getString(
|
||||
if (ok) R.string.logs_export_ok else R.string.logs_export_fail
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/json")
|
||||
) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val json = BackupManager.exportJson(repo, tConfig)
|
||||
// v1.4.2 : le backup embarque aussi les PARAMÈTRES utilisateur
|
||||
// (langue, auto-calibration, seuils d'alerte) — collect des
|
||||
// DataStore Flows (first()) avant sérialisation.
|
||||
// UNE seule collecte des seuils (les 4 champs viennent du même
|
||||
// snapshot DataStore — 4 × .first() = 4 collectes redondantes)
|
||||
val thresholds = container.settings.alertThresholds.first()
|
||||
val settings = UserSettings(
|
||||
language = container.settings.language.first(),
|
||||
autoCalibrate = container.settings.autoCalibrate.first(),
|
||||
alertE2High = thresholds.e2High,
|
||||
alertE2Low = thresholds.e2Low,
|
||||
alertTHigh = thresholds.tHigh,
|
||||
alertTLow = thresholds.tLow,
|
||||
// v1.13.0 : la cible de creux voyage dans le backup (champs
|
||||
// optionnels — absents d'un backup v1/v2 ancien, rétrocompatibles)
|
||||
troughTargetLow = container.settings.troughTarget.first()?.first,
|
||||
troughTargetHigh = container.settings.troughTarget.first()?.second
|
||||
)
|
||||
val json = BackupManager.exportJson(repo, tConfig, settings)
|
||||
val ok = BackupManager.writeBackup(context, uri, json)
|
||||
withContext(Dispatchers.Main) {
|
||||
message = context.getString(
|
||||
@ -107,6 +207,46 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sauvegarde automatique (v1.7.0) : dossier SAF persisté ──────────────
|
||||
// L'utilisateur choisit le dossier UNE FOIS (ACTION_OPEN_DOCUMENT_TREE) :
|
||||
// la permission persistable survit au reboot, le worker WorkManager écrit
|
||||
// ensuite sans intervention — AUCUNE permission de stockage déclarée.
|
||||
// pickFolderToEnable distingue les deux entrées du picker (switch vs
|
||||
// « Choisir le dossier » — cf AutoBackupCard).
|
||||
var pickFolderToEnable by remember { mutableStateOf(false) }
|
||||
|
||||
val folderPickerLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocumentTree()
|
||||
) { uri ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
// Persiste la permission : sans takePersistableUriPermission, le tree
|
||||
// URI serait mort au prochain démarrage (worker sans droit d'écrire).
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
val toEnable = pickFolderToEnable
|
||||
pickFolderToEnable = false
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoBackupTreeUri(uri.toString())
|
||||
if (toEnable) {
|
||||
container.settings.setAutoBackupEnabled(true)
|
||||
// Premier backup IMMÉDIAT (feedback instantané + validation
|
||||
// émulateur directe), puis le périodique 24 h prend le relais
|
||||
com.hormonetrack.data.backup.AutoBackupScheduler.runNow(context)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
message = if (toEnable) {
|
||||
context.getString(R.string.autobackup_first_run)
|
||||
} else {
|
||||
context.getString(R.string.saved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Assemblage : l'ordre des cartes fait le rendu de l'écran ───────────
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@ -128,186 +268,56 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// --- Language ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.language), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = currentLanguage == "system",
|
||||
onClick = {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setLanguage("system")
|
||||
withContext(Dispatchers.Main) {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.getEmptyLocaleList()
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.language_system)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = currentLanguage == "fr",
|
||||
onClick = {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setLanguage("fr")
|
||||
withContext(Dispatchers.Main) {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.forLanguageTags("fr")
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text("Français") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = currentLanguage == "en",
|
||||
onClick = {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setLanguage("en")
|
||||
withContext(Dispatchers.Main) {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.forLanguageTags("en")
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text("English") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
LanguageCard(currentLanguage)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Testosterone model ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.t_model_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = tBaseText,
|
||||
onValueChange = { tBaseText = it },
|
||||
label = { Text(stringResource(R.string.t_base)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = tFloorText,
|
||||
onValueChange = { tFloorText = it },
|
||||
label = { Text(stringResource(R.string.t_floor)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = tKText,
|
||||
onValueChange = { tKText = it },
|
||||
label = { Text("k") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
val base = tBaseText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||
val floor = tFloorText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||
val k = tKText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setTConfig(TConfig(base, floor, k))
|
||||
withContext(Dispatchers.Main) {
|
||||
message = context.getString(R.string.saved)
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
OutlinedButton(onClick = {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val labs = repo.allLabResultsOnce().filter { it.marker.equals("T", true) }
|
||||
val trs = repo.allTreatmentsOnce()
|
||||
val doses = repo.allDoseLogsOnce()
|
||||
val calibrated = PharmacokineticEngine.computeTConfigCalibration(
|
||||
labs, trs, doses, tConfig
|
||||
)
|
||||
calibrated?.let {
|
||||
container.settings.setTConfig(it)
|
||||
withContext(Dispatchers.Main) {
|
||||
message = context.getString(R.string.t_calibrated)
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.calibrate_from_labs)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
AutoCalibrationCard(autoCalibrate)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Reminders / exact alarms ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.reminders_section), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
if (!scheduler.canScheduleExact()) {
|
||||
Text(
|
||||
stringResource(R.string.exact_alarm_needed),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Button(onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
context.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
|
||||
}
|
||||
}) { Text(stringResource(R.string.grant_exact_alarm)) }
|
||||
} else {
|
||||
Text(stringResource(R.string.exact_alarm_ok), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
TModelCard(tConfig, showMessage)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Backup ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.backup_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.backup_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
val date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
|
||||
exportLauncher.launch("hormonetrack-backup-$date.json")
|
||||
}) { Text(stringResource(R.string.export_json)) }
|
||||
OutlinedButton(onClick = {
|
||||
importLauncher.launch(arrayOf("application/json"))
|
||||
}) { Text(stringResource(R.string.import_json)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
AlertThresholdsCard(alertThresholds, showMessage)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- About ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.disclaimer),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.models_credit),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
TroughTargetCard(troughTarget, showMessage)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ChartTimezoneCard(chartTimezone, showMessage)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
RemindersCard()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
BackupCard(
|
||||
onExport = { exportLauncher.launch(ExportFileNames.backupFileName()) },
|
||||
onImport = { importLauncher.launch(arrayOf("application/json")) }
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
AutoBackupCard(
|
||||
enabled = autoBackupEnabled,
|
||||
treeUri = autoBackupTreeUri,
|
||||
keep = autoBackupKeep,
|
||||
lastRun = autoBackupLastRun,
|
||||
onPickFolderToEnable = {
|
||||
pickFolderToEnable = true
|
||||
folderPickerLauncher.launch(null)
|
||||
},
|
||||
onChooseFolder = {
|
||||
pickFolderToEnable = false
|
||||
folderPickerLauncher.launch(null)
|
||||
},
|
||||
onMessage = showMessage
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
LogsCard(
|
||||
logLineCount = logLineCount,
|
||||
onExport = {
|
||||
// Nom de fichier via ExportFileNames (cf §14 #48) : extrait
|
||||
// et testé — passer un LocalDate au pattern horaire crashait
|
||||
// l'app au tap (bug v1.3.1→v1.3.3).
|
||||
logExportLauncher.launch(ExportFileNames.diagnosticLogFileName())
|
||||
},
|
||||
onClear = {
|
||||
AppLog.clear()
|
||||
logLineCount = AppLog.count()
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
AboutCard()
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
|
||||
@ -319,11 +329,56 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
showImportConfirm = false
|
||||
val json = importJson ?: return@TextButton
|
||||
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, les PARAMÈTRES utilisateur
|
||||
// (v1.4.2 : langue, auto-calibration, seuils d'alerte)
|
||||
// et les rappels sont reprogrammés
|
||||
val result = try {
|
||||
BackupManager.importJson(repo, json)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
result?.let {
|
||||
container.settings.setTConfig(it.tConfig)
|
||||
// ⚠️ Paramètres du backup (null = backup v1 → ne rien
|
||||
// toucher). La LANGUE est appliquée sur le thread
|
||||
// MAIN : setApplicationLocales recrée l'activité.
|
||||
it.settings?.let { us ->
|
||||
container.settings.setAutoCalibrate(us.autoCalibrate ?: false)
|
||||
container.settings.setAlertThresholds(
|
||||
Alerts.Thresholds(
|
||||
e2High = us.alertE2High, e2Low = us.alertE2Low,
|
||||
tHigh = us.alertTHigh, tLow = us.alertTLow
|
||||
)
|
||||
)
|
||||
// v1.13.0 : cible de creux — restaurée SEULEMENT
|
||||
// si le backup en porte une (champs non nuls
|
||||
// ET cohérents) ; sinon on ne touche à rien
|
||||
// (un backup ancien ne doit pas effacer la
|
||||
// cible locale).
|
||||
if (us.troughTargetLow != null && us.troughTargetHigh != null &&
|
||||
us.troughTargetHigh > us.troughTargetLow
|
||||
) {
|
||||
container.settings.setTroughTarget(
|
||||
us.troughTargetLow to us.troughTargetHigh
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
us.language?.let { lang ->
|
||||
container.settings.setLanguage(lang)
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
if (lang == "system") LocaleListCompat.getEmptyLocaleList()
|
||||
else LocaleListCompat.forLanguageTags(lang)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Doses + traitements : grille Posologie pour les rappels (#52)
|
||||
AlarmScheduler(context).rescheduleAll(
|
||||
repo.allTreatmentsOnce(), repo.allDoseLogsOnce()
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
message = if (result != null) {
|
||||
context.getString(R.string.import_ok, result.treatments, result.doseLogs, result.labResults)
|
||||
@ -342,6 +397,3 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDoubles(d: Double): String =
|
||||
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d)
|
||||
|
||||
@ -34,6 +34,7 @@ import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@ -47,11 +48,16 @@ import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.Esters
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.data.model.modelLabelRes
|
||||
import com.hormonetrack.data.model.PKPresets
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.hormonetrack.reminder.AlarmScheduler
|
||||
import com.hormonetrack.reminder.CalendarEvents
|
||||
import com.hormonetrack.util.AppLog
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.components.formatDose
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -74,6 +80,9 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
var route by remember { mutableStateOf(AdministrationRoute.INJECTION_IM) }
|
||||
var ester by remember { mutableStateOf(Esters.EV) }
|
||||
var model by remember { mutableStateOf(PKModels.ESTRANNAISE) }
|
||||
// v1.4.9 : état de l'auto-calibration — sert au hint de la carte
|
||||
// Calibration (l'auto écrase le facteur manuel à l'AFFICHAGE des courbes)
|
||||
val autoCalibrate by container.settings.autoCalibrate.collectAsState(initial = false)
|
||||
var doseText by remember { mutableStateOf("4") }
|
||||
var unit by remember { mutableStateOf("mg") }
|
||||
var tmaxText by remember { mutableStateOf("46") }
|
||||
@ -83,11 +92,34 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
var reminderEnabled by remember { mutableStateOf(false) }
|
||||
var reminderTime by remember { mutableStateOf(LocalTime.of(12, 0)) }
|
||||
var active by remember { mutableStateOf(true) }
|
||||
|
||||
// Section « Fréquence » : simulation prévisionnelle des doses à venir
|
||||
var forecastEnabled by remember { mutableStateOf(false) }
|
||||
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 ->
|
||||
// ⚠️ FIX v1.3.1 : le résultat arrive ASYNCHRONE — c'est ICI (et pas
|
||||
// dans le onCheckedChange synchrone) que le switch doit s'activer.
|
||||
calendarPermissionGranted = grants.values.all { it }
|
||||
AppLog.log("agenda", "permission agenda : accordée=$calendarPermissionGranted")
|
||||
if (calendarPermissionGranted) calendarEnabled = true
|
||||
}
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
var showPresetMenu by remember { mutableStateOf(false) }
|
||||
var showTimePicker by remember { mutableStateOf(false) }
|
||||
var loading by remember { mutableStateOf(treatmentId > 0) }
|
||||
var loadedCreatedAt by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||
// ⚠️ BUG #51 (v1.3.5) : buildTreatment() ne transmettait PAS
|
||||
// calendarEventId → l'id revenait à null à CHAQUE save : l'événement
|
||||
// d'agenda devenait ORPHELIN (plus jamais supprimable/désactivable par
|
||||
// l'app, il restait dans le provider). L'id existant est chargé ici,
|
||||
// reporté par buildTreatment(), puis recalcité par le save (upsert).
|
||||
var loadedCalendarEventId by remember { mutableStateOf<Long?>(null) }
|
||||
|
||||
LaunchedEffect(treatmentId) {
|
||||
if (treatmentId > 0) {
|
||||
@ -107,6 +139,16 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
reminderTime = LocalTime.of(tr.reminderHour ?: 12, tr.reminderMinute ?: 0)
|
||||
active = tr.isActive
|
||||
loadedCreatedAt = tr.createdAt
|
||||
loadedCalendarEventId = tr.calendarEventId
|
||||
// Fréquence de prévision (si configurée)
|
||||
tr.forecastIntervalDays?.let { fi ->
|
||||
forecastEnabled = true
|
||||
forecastDaysText = formatDose(fi)
|
||||
}
|
||||
if (tr.calendarEventId != null) {
|
||||
calendarEnabled = true
|
||||
calendarPermissionGranted = true // déjà accordée pour l'event existant
|
||||
}
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
@ -119,6 +161,10 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
val bio = bioText.replace(',', '.').toFloatOrNull() ?: 1f
|
||||
val scale = scaleText.replace(',', '.').toDoubleOrNull() ?: 1.0
|
||||
if (name.isBlank() || dose <= 0.0) return null
|
||||
// Prévision : uniquement si activée et intervalle valide (> 0)
|
||||
val forecastInterval = if (forecastEnabled) {
|
||||
forecastDaysText.replace(',', '.').toDoubleOrNull()?.takeIf { it > 0.0 }
|
||||
} else null
|
||||
return Treatment(
|
||||
id = id,
|
||||
name = name.trim(),
|
||||
@ -133,9 +179,13 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
eliminationHalfLifeHours = thalf,
|
||||
bioavailabilityFraction = bio.coerceIn(0.01f, 1f),
|
||||
scaleFactor = scale.coerceAtLeast(0.01),
|
||||
forecastIntervalDays = forecastInterval,
|
||||
reminderEnabled = reminderEnabled,
|
||||
reminderHour = if (reminderEnabled) reminderTime.hour else null,
|
||||
reminderMinute = if (reminderEnabled) reminderTime.minute else null,
|
||||
// ⚠️ #51 : conserver l'id de l'événement d'agenda existant (cf
|
||||
// déclaration de loadedCalendarEventId) — sinon orphelin au save.
|
||||
calendarEventId = loadedCalendarEventId,
|
||||
createdAt = loadedCreatedAt
|
||||
)
|
||||
}
|
||||
@ -222,23 +272,44 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (PharmacokineticEngine.isInjectionRoute(route)) {
|
||||
// v1.9.4 : la liste des esters suit la couverture RÉELLE du
|
||||
// modèle choisi (Esters.choicesForModel — miroir du DoseDialog
|
||||
// et du treatment-editor web) : le modèle ESE ANALYTIQUE offre
|
||||
// ses 6 esters injectables (EV/EU/EEn/EC/EB/EUCS) directement
|
||||
// dans l'éditeur, au lieu des seuls EV/EU/EEn de l'époque des
|
||||
// tables ODS. Chaque ester listé est dispatché par le moteur —
|
||||
// aucun choix possible ne produit 0 pg/mL.
|
||||
DropdownField(
|
||||
label = stringResource(R.string.ester),
|
||||
selectedLabel = ester,
|
||||
options = listOf(Esters.EV, Esters.EU, Esters.EEN).map { it to it },
|
||||
options = Esters.choicesForModel(model).map { it to it },
|
||||
onSelect = { ester = it }
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
DropdownField(
|
||||
label = stringResource(R.string.pk_model),
|
||||
selectedLabel = stringResource(
|
||||
if (model == PKModels.TRANSFEM_SCIENCE) R.string.model_tfs else R.string.model_ese
|
||||
),
|
||||
// v1.4.6 : 3ᵉ modèle PK (WHSAH — fit license-free de Mona,
|
||||
// cf pk/WhsahModels.kt) superposable dans le graphique.
|
||||
// Label par le helper UNIQUE (règle anti-bug #59 :
|
||||
// jamais de when/ternaire de labels en ligne — l'ancien
|
||||
// à 2 branches affichait « Estrannaise » pour WHSAH).
|
||||
selectedLabel = stringResource(modelLabelRes(model)),
|
||||
options = listOf(
|
||||
PKModels.ESTRANNAISE to stringResource(R.string.model_ese),
|
||||
PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs)
|
||||
PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs),
|
||||
PKModels.WHSAH to stringResource(R.string.model_whs)
|
||||
),
|
||||
onSelect = { model = it }
|
||||
onSelect = { model = it
|
||||
// v1.9.4 : l'ester courant peut sortir de la couverture
|
||||
// du nouveau modèle (ex. EUCS + TFS : le V3C n'a pas de
|
||||
// paramètres EUCS → le dispatcher donnerait 0) — on
|
||||
// rebascule sur le premier ester couvert (EV dans les
|
||||
// trois listes). Le recompose reconstruit ensuite la
|
||||
// liste des esters (options = choicesForModel(model)).
|
||||
if (ester !in Esters.choicesForModel(it)) {
|
||||
ester = Esters.choicesForModel(it).first()
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@ -297,6 +368,19 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (autoCalibrate) {
|
||||
// v1.4.9 (demande utilisateur) : quand l'auto-
|
||||
// calibration est ON, le facteur manuel (champ +
|
||||
// bouton) est IGNORÉ à l'affichage des courbes —
|
||||
// l'expliciter pour éviter la confusion « j'ai
|
||||
// calibré mais rien ne change ».
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.calibration_auto_override),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
FilledTonalButton(onClick = {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
@ -319,6 +403,37 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// --- Section « Fréquence » : doses prévisionnelles ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.freq_section), style = MaterialTheme.typography.titleMedium)
|
||||
Switch(checked = forecastEnabled, onCheckedChange = { forecastEnabled = it })
|
||||
}
|
||||
if (forecastEnabled) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = forecastDaysText,
|
||||
onValueChange = { forecastDaysText = it },
|
||||
label = { Text(stringResource(R.string.freq_interval)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.freq_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
@ -335,8 +450,9 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
String.format(Locale.getDefault(), "%02d:%02d", reminderTime.hour, reminderTime.minute)
|
||||
)
|
||||
}
|
||||
val scheduler2 = AlarmScheduler(context)
|
||||
if (!scheduler2.canScheduleExact()) {
|
||||
// Le scheduler est déjà créé au niveau de l'écran
|
||||
// (var `scheduler`) — pas de recréation par recompose.
|
||||
if (!scheduler.canScheduleExact()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.exact_alarm_needed),
|
||||
@ -356,6 +472,69 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
}
|
||||
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) {
|
||||
// ⚠️ L'agenda exige une Posologie (l'événement
|
||||
// se répète selon son intervalle) : sans elle,
|
||||
// on refuse l'activation (feedback rouge)
|
||||
if (!forecastEnabled) {
|
||||
AppLog.log("agenda", "switch ON sans Posologie → refusé")
|
||||
return@Switch
|
||||
}
|
||||
if (calendarPermissionGranted) {
|
||||
calendarEnabled = true
|
||||
AppLog.log("agenda", "switch ON (permission déjà accordée)")
|
||||
} else {
|
||||
// le callback activera le switch si accordée
|
||||
AppLog.log("agenda", "demande de permission agenda")
|
||||
calendarPermissionLauncher.launch(
|
||||
arrayOf(
|
||||
android.Manifest.permission.WRITE_CALENDAR,
|
||||
android.Manifest.permission.READ_CALENDAR
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
calendarEnabled = false
|
||||
AppLog.log("agenda", "switch OFF")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
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(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@ -376,11 +555,52 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
repo.insertTreatment(tr)
|
||||
}
|
||||
val saved = tr.copy(id = savedId)
|
||||
if (saved.reminderEnabled) {
|
||||
scheduler.scheduleDaily(saved)
|
||||
// Rappel actif seulement si le traitement l'est aussi ;
|
||||
// désactiver un traitement annule son alarme. Les doses
|
||||
// alimentent la grille Posologie (fix #52) : le rappel
|
||||
// sonne uniquement les jours de créneau.
|
||||
if (saved.reminderEnabled && saved.isActive) {
|
||||
scheduler.scheduleFor(saved, repo.allDoseLogsOnce())
|
||||
} else {
|
||||
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 {
|
||||
// ⚠️ Permission vérifiée RÉELLEMENT via ContextCompat —
|
||||
// le flag remembered peut être faux après recomposition
|
||||
val hasCalendarPermission = androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.WRITE_CALENDAR
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (calendarEnabled && saved.isActive && saved.forecastIntervalDays != null
|
||||
&& hasCalendarPermission
|
||||
) {
|
||||
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))
|
||||
}
|
||||
AppLog.log("agenda",
|
||||
// Log VÉRIDIQUE (v1.3.5) : « supprimé » était
|
||||
// imprimé aussi quand rien n'a été supprimé
|
||||
// (switch désactivé, posologie absente,
|
||||
// permission absente) → messages distincts.
|
||||
"traitement #${saved.id} : " + when {
|
||||
eventToStore != null ->
|
||||
"événement agenda créé/mis à jour (id=$eventToStore)"
|
||||
saved.calendarEventId != null ->
|
||||
"événement agenda supprimé"
|
||||
else ->
|
||||
"agenda non activé (switch/posologie/permission)"
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
AppLog.log("agenda", "ERREUR événement agenda : ${e.message}")
|
||||
}
|
||||
withContext(Dispatchers.Main) { onDone() }
|
||||
}
|
||||
},
|
||||
@ -418,7 +638,15 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
TextButton(onClick = { showTimePicker = false }) { Text(stringResource(R.string.cancel)) }
|
||||
},
|
||||
title = { Text(stringResource(R.string.reminder_time)) },
|
||||
text = { TimePicker(state = state) }
|
||||
text = {
|
||||
// Horloge centrée dans le dialog (sinon elle part vers la gauche)
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
TimePicker(state = state)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -27,14 +27,31 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.modelLabelRes
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.components.formatDose
|
||||
|
||||
/**
|
||||
* ORDRE D'AFFICHAGE de la page Traitements (v1.11.0, demande utilisatrice) :
|
||||
* les ACTIFS d'abord, les INACTIFS regroupés TOUT EN BAS — l'ancien ordre
|
||||
* brut du DAO mélangeait les deux groupes et le badge discret ne suffisait
|
||||
* plus à les discerner (« peu discernables », remontée).
|
||||
*
|
||||
* Tri STABLE : l'ordre relatif À L'INTÉRIEUR de chaque groupe est conservé
|
||||
* (le tri ne réordonne jamais les actifs entre eux, ni les inactifs entre
|
||||
* eux). PUR (aucune dépendance Android) → testable en JVM
|
||||
* (TreatmentsDisplayTest) ; miroir web `treatmentsForDisplay`
|
||||
* (js/data/models.js, mêmes sémantiques).
|
||||
*/
|
||||
fun treatmentsForDisplay(treatments: List<Treatment>): List<Treatment> =
|
||||
treatments.sortedBy { !it.isActive }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TreatmentsScreen(
|
||||
@ -44,6 +61,11 @@ fun TreatmentsScreen(
|
||||
val container = LocalAppContainer.current
|
||||
val repo = container.repository
|
||||
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
|
||||
// v1.11.0 : actifs d'abord, inactifs regroupés tout en bas
|
||||
// (cf [treatmentsForDisplay]) — mémoïsé sur la liste brute.
|
||||
val displayTreatments = remember(treatments) { treatmentsForDisplay(treatments) }
|
||||
val activeTreatments = displayTreatments.filter { it.isActive }
|
||||
val inactiveTreatments = displayTreatments.filter { !it.isActive }
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
@ -57,22 +79,44 @@ fun TreatmentsScreen(
|
||||
title = { Text(stringResource(R.string.nav_treatments)) }
|
||||
)
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(treatments, key = { it.id }) { tr ->
|
||||
items(activeTreatments, key = { it.id }) { tr ->
|
||||
TreatmentCard(tr, onClick = { onEdit(tr.id) })
|
||||
}
|
||||
// En-tête de section SEULEMENT s'il existe des inactifs —
|
||||
// la séparation visuelle (en-tête + atténuation, cf
|
||||
// TreatmentCard) rend le groupe lisible d'un coup d'œil.
|
||||
if (inactiveTreatments.isNotEmpty()) {
|
||||
item(key = "inactive_section") {
|
||||
Text(
|
||||
stringResource(R.string.inactive_section),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(inactiveTreatments, key = { it.id }) { tr ->
|
||||
TreatmentCard(tr, dimmed = true, onClick = { onEdit(tr.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte d'un traitement. @param dimmed : carte INACTIVE (v1.11.0) → contenu
|
||||
* atténué (alpha réduit) en PLUS du badge : second signal visuel demandé
|
||||
* (« plus distincts des actifs ») — la carte reste lisible et tappable.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TreatmentCard(tr: Treatment, onClick: () -> Unit) {
|
||||
private fun TreatmentCard(tr: Treatment, dimmed: Boolean = false, onClick: () -> Unit) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.alpha(if (dimmed) 0.55f else 1f)
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@ -99,10 +143,12 @@ private fun TreatmentCard(tr: Treatment, onClick: () -> Unit) {
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
// v1.4.7 (fix #59) : label via modelLabelRes —
|
||||
// l'ancien ternaire à 2 branches affichait
|
||||
// « Estrannaise » pour le modèle WHSAH.
|
||||
Text(
|
||||
tr.esterType + " · " + stringResource(
|
||||
if (tr.pkModel == PKModels.TRANSFEM_SCIENCE) R.string.model_tfs
|
||||
else R.string.model_ese
|
||||
modelLabelRes(tr.pkModel)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.BuildConfig
|
||||
import com.hormonetrack.R
|
||||
|
||||
/**
|
||||
* Carte À PROPOS de l'écran Paramètres (v1.9.8 : extraite de
|
||||
* SettingsScreen.kt, comportement inchangé) : version installée (BuildConfig
|
||||
* — épinglée par un test UI depuis v1.9.8, le bug #46 des versions jamais
|
||||
* bumpées ne peut plus revenir silencieusement), lien vers les releases
|
||||
* Gitea, avertissement médical et crédit des modèles PK.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutCard() {
|
||||
val context = LocalContext.current
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.settings_version, BuildConfig.VERSION_NAME),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
TextButton(onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases"))
|
||||
)
|
||||
}) { Text(stringResource(R.string.releases_link)) }
|
||||
Text(
|
||||
stringResource(R.string.disclaimer),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.models_credit),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,144 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.pk.Alerts
|
||||
import com.hormonetrack.reminder.AlertNotifier
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Carte SEUILS D'ALERTE de l'écran Paramètres (v1.4.2 ; v1.9.8 : extraite de
|
||||
* SettingsScreen.kt, comportement inchangé). Limites E2 (pg/mL) / T (ng/mL)
|
||||
* évaluées contre le taux ESTIMÉ courant de l'accueil (pk/Alerts.kt) et par
|
||||
* le worker périodique de 15 min. Opt-in : champ vide = pas d'alerte sur ce
|
||||
* côté ; incohérence haut ≤ bas ou saisie non numérique = message d'erreur
|
||||
* local, rien n'est persisté.
|
||||
*
|
||||
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||
*/
|
||||
@Composable
|
||||
internal fun AlertThresholdsCard(
|
||||
alertThresholds: Alerts.Thresholds,
|
||||
onMessage: (String) -> Unit
|
||||
) {
|
||||
val container = LocalAppContainer.current
|
||||
val context = LocalContext.current
|
||||
|
||||
// Champs réinitialisés à chaque valeur persistée (clé du remember) ;
|
||||
// null (pas de seuil) = champ vide.
|
||||
var e2HighText by remember(alertThresholds.e2High) {
|
||||
mutableStateOf(alertThresholds.e2High?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var e2LowText by remember(alertThresholds.e2Low) {
|
||||
mutableStateOf(alertThresholds.e2Low?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var tHighText by remember(alertThresholds.tHigh) {
|
||||
mutableStateOf(alertThresholds.tHigh?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var tLowText by remember(alertThresholds.tLow) {
|
||||
mutableStateOf(alertThresholds.tLow?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var alertError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.alert_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.alert_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = e2HighText,
|
||||
onValueChange = { e2HighText = it },
|
||||
label = { Text(stringResource(R.string.alert_e2_high)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = e2LowText,
|
||||
onValueChange = { e2LowText = it },
|
||||
label = { Text(stringResource(R.string.alert_e2_low)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = tHighText,
|
||||
onValueChange = { tHighText = it },
|
||||
label = { Text(stringResource(R.string.alert_t_high)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = tLowText,
|
||||
onValueChange = { tLowText = it },
|
||||
label = { Text(stringResource(R.string.alert_t_low)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
alertError?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = {
|
||||
// Parsing tolérant (virgule décimale FR), champ vide = null
|
||||
fun parse(s: String): Double? =
|
||||
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
|
||||
val e2High = parse(e2HighText)
|
||||
val e2Low = parse(e2LowText)
|
||||
val tHigh = parse(tHighText)
|
||||
val tLow = parse(tLowText)
|
||||
// Saisie invalide (texte non numérique) ou incohérence haut ≤ bas
|
||||
val invalidSyntax = listOf(e2HighText, e2LowText, tHighText, tLowText)
|
||||
.any { it.trim().isNotEmpty() && parse(it) == null }
|
||||
val thresholds = Alerts.Thresholds(e2High, e2Low, tHigh, tLow)
|
||||
when {
|
||||
invalidSyntax || !thresholds.isCoherent() -> {
|
||||
alertError = context.getString(R.string.alert_invalid)
|
||||
}
|
||||
else -> {
|
||||
alertError = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAlertThresholds(thresholds)
|
||||
// v1.4.2 : check IMMÉDIAT (worker one-time) —
|
||||
// si le seuil est déjà franchi, la notification
|
||||
// part sans attendre le cycle périodique de
|
||||
// 15 min (état anti-spam réinitialisé au besoin).
|
||||
AlertNotifier.checkNow(context)
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.saved))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,164 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.data.backup.AutoBackupScheduler
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Carte SAUVEGARDE AUTOMATIQUE QUOTIDIENNE de l'écran Paramètres (v1.7.0 ;
|
||||
* v1.9.8 : extraite de SettingsScreen.kt, comportement inchangé).
|
||||
*
|
||||
* Opt-in pur : désactivée par défaut ; nécessite un dossier choisi UNE fois
|
||||
* (SAF tree persisté — ACTION_OPEN_DOCUMENT_TREE, cf orchestrateur) ; un run
|
||||
* immédiat est déclenché à l'activation (feedback + validation directe).
|
||||
* Rétention 1..30 copies (le worker purge les plus vieilles, ne touche
|
||||
* JAMAIS les exports manuels — cf AutoBackupRetention).
|
||||
*
|
||||
* Sémantique des deux entrées du picker (v1.7.0) :
|
||||
* - via le SWITCH quand aucun dossier n'existe → la sélection ACTIVE la
|
||||
* sauvegarde (+ premier run immédiat) : le switch sans dossier ne peut
|
||||
* rien faire, le picker est la suite logique ;
|
||||
* - via « Choisir le dossier » (changement) → on enregistre le dossier
|
||||
* SANS changer l'état activé/désactivé.
|
||||
*
|
||||
* @param onPickFolderToEnable ouvre le picker SAF dont la sélection ACTIVERA
|
||||
* la sauvegarde (branche « switch sans dossier » — launcher externe).
|
||||
* @param onChooseFolder ouvre le picker SAF pour CHANGER de dossier sans
|
||||
* toucher à l'état activé/désactivé.
|
||||
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||
*/
|
||||
@Composable
|
||||
internal fun AutoBackupCard(
|
||||
enabled: Boolean,
|
||||
treeUri: String?,
|
||||
keep: Int,
|
||||
lastRun: Pair<String?, Long?>?,
|
||||
onPickFolderToEnable: () -> Unit,
|
||||
onChooseFolder: () -> Unit,
|
||||
onMessage: (String) -> Unit
|
||||
) {
|
||||
val container = LocalAppContainer.current
|
||||
val context = LocalContext.current
|
||||
|
||||
var keepText by remember(keep) { mutableStateOf(keep.toString()) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.autobackup_section), style = MaterialTheme.typography.titleMedium)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = { wantEnabled ->
|
||||
if (!wantEnabled) {
|
||||
// Désactivation : le périodique reste planifié
|
||||
// mais le worker sort immédiatement (no-op)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoBackupEnabled(false)
|
||||
}
|
||||
} else if (treeUri == null) {
|
||||
// Pas de dossier → le picker EST la suite :
|
||||
// la sélection activera (+ premier run)
|
||||
onPickFolderToEnable()
|
||||
} else {
|
||||
// Dossier existant → activation directe + run
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoBackupEnabled(true)
|
||||
AutoBackupScheduler.runNow(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.autobackup_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
// Dossier choisi : nom lisible (documents "primary:Download" →
|
||||
// "Download") + bouton pour le changer (sans toucher au switch)
|
||||
Text(
|
||||
stringResource(R.string.autobackup_folder) + " : " +
|
||||
(treeUri?.let { folderDisplayName(it) }
|
||||
?: stringResource(R.string.autobackup_no_folder)),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
TextButton(onClick = onChooseFolder) {
|
||||
Text(stringResource(R.string.autobackup_choose_folder))
|
||||
}
|
||||
|
||||
// Dernier run (statut alimenté par le worker) : réussi/échec + date
|
||||
lastRun?.let { (result, atMs) ->
|
||||
if (atMs != null && atMs > 0L) {
|
||||
val date = java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
|
||||
.format(java.util.Date(atMs))
|
||||
Text(
|
||||
text = context.getString(
|
||||
if (result == "ok") R.string.autobackup_last_ok
|
||||
else R.string.autobackup_last_fail,
|
||||
date
|
||||
),
|
||||
color = if (result == "ok") MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// Rétention : copies conservées (1..30, borné au set) — Save
|
||||
// persiste ; le worker relit DataStore à CHAQUE run (aucun
|
||||
// re-enqueue nécessaire, cf AutoBackupScheduler)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = keepText,
|
||||
onValueChange = { keepText = it },
|
||||
label = { Text(stringResource(R.string.autobackup_keep)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Button(onClick = {
|
||||
val parsed = keepText.trim().toIntOrNull() ?: return@Button
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoBackupKeep(parsed) // borné [1;30]
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.saved))
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Carte CALIBRATION AUTOMATIQUE de l'écran Paramètres (v1.9.8 : extraite de
|
||||
* SettingsScreen.kt, comportement inchangé). UNE seule option (fusionnée
|
||||
* v1.2.1) : facteurs d'échelle E2 par période d'ester + calibration du modèle
|
||||
* T, recalculés depuis les labs. Désactivée par défaut — la calibration reste
|
||||
* sous contrôle explicite de l'utilisatrice.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AutoCalibrationCard(autoCalibrate: Boolean) {
|
||||
val container = LocalAppContainer.current
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.auto_calibrate), style = MaterialTheme.typography.titleMedium)
|
||||
Switch(
|
||||
checked = autoCalibrate,
|
||||
onCheckedChange = { enabled ->
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoCalibrate(enabled)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.auto_calibrate_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
|
||||
/**
|
||||
* Carte SAUVEGARDE MANUELLE (JSON) de l'écran Paramètres (v1.9.8 : extraite
|
||||
* de SettingsScreen.kt, comportement inchangé). L'export passe par le picker
|
||||
* SAF système (CreateDocument) — aucun accès disque direct ; l'import
|
||||
* ouvre le backup via OpenDocument puis demande CONFIRMATION avant
|
||||
* écrasement (dialog géré par l'orchestrateur SettingsScreen).
|
||||
*
|
||||
* @param onExport lance le picker SAF d'export (launcher de l'orchestrateur)
|
||||
* @param onImport lance le picker SAF d'import (launcher de l'orchestrateur)
|
||||
*/
|
||||
@Composable
|
||||
internal fun BackupCard(onExport: () -> Unit, onImport: () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.backup_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.backup_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onExport) { Text(stringResource(R.string.export_json)) }
|
||||
OutlinedButton(onClick = onImport) { Text(stringResource(R.string.import_json)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Carte FUSEAU HORAIRE DU GRAPHIQUE de l'écran Paramètres (v1.4.5 ; v1.9.8 :
|
||||
* extraite de SettingsScreen.kt, comportement inchangé). « auto » = fuseau
|
||||
* du téléphone (champ vide) ; sinon un ID java.util.TimeZone (ex.
|
||||
* « Europe/Paris ») — il pilote l'alignement minuit des labels X du
|
||||
* graphique (fix #55/#67 : ticks ET labels dans le MÊME fuseau).
|
||||
*
|
||||
* La validité de l'ID est vérifiée AVANT persistance (un ID inconnu retombe
|
||||
* sur « GMT » côté java.util.TimeZone : on le détecte et on refuse).
|
||||
*
|
||||
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||
*/
|
||||
@Composable
|
||||
internal fun ChartTimezoneCard(chartTimezone: String?, onMessage: (String) -> Unit) {
|
||||
val container = LocalAppContainer.current
|
||||
val context = LocalContext.current
|
||||
|
||||
var tzText by remember(chartTimezone) {
|
||||
mutableStateOf(chartTimezone ?: "")
|
||||
}
|
||||
var tzError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.timezone_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.timezone_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = tzText,
|
||||
onValueChange = { tzText = it },
|
||||
label = { Text(stringResource(R.string.timezone_field)) },
|
||||
placeholder = { Text(stringResource(R.string.timezone_auto)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
tzError?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
val id = tzText.trim()
|
||||
// Un ID inconnu retombe silencieusement sur « GMT » : on
|
||||
// l'accepte seulement si l'utilisateur l'a VRAIMENT saisi.
|
||||
val valid = id.isEmpty() || try {
|
||||
java.util.TimeZone.getTimeZone(id)
|
||||
.let { !it.id.equals("GMT", true) || id.equals("GMT", true) }
|
||||
} catch (_: Exception) { false }
|
||||
if (!valid) {
|
||||
tzError = context.getString(R.string.timezone_invalid)
|
||||
} else {
|
||||
tzError = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setChartTimezone(id.ifBlank { null })
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.saved))
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
OutlinedButton(onClick = {
|
||||
tzText = ""
|
||||
tzError = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setChartTimezone(null)
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.saved))
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.timezone_auto)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Carte LANGUE de l'écran Paramètres (v1.9.8 : extraite de SettingsScreen.kt,
|
||||
* comportement inchangé). La langue est indépendante du système : « system »
|
||||
* suit la configuration du téléphone, « fr »/« en » forcent via les per-app
|
||||
* locales d'AppCompat (recréent l'activité — le réglage survit au redémarrage,
|
||||
* réappliqué par MainActivity.onCreate).
|
||||
*
|
||||
* Les libellés « Français » / « English » sont des LITTÉRAUX volontaires :
|
||||
* chaque langue s'écrit dans sa propre langue (convention i18n).
|
||||
*/
|
||||
@Composable
|
||||
internal fun LanguageCard(currentLanguage: String) {
|
||||
val container = LocalAppContainer.current
|
||||
|
||||
/** Persiste la langue puis l'applique immédiatement (locales per-app). */
|
||||
fun applyLanguage(code: String) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setLanguage(code)
|
||||
withContext(Dispatchers.Main) {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
if (code == "system") LocaleListCompat.getEmptyLocaleList()
|
||||
else LocaleListCompat.forLanguageTags(code)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.language), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = currentLanguage == "system",
|
||||
onClick = { applyLanguage("system") },
|
||||
label = { Text(stringResource(R.string.language_system)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = currentLanguage == "fr",
|
||||
onClick = { applyLanguage("fr") },
|
||||
label = { Text("Français") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = currentLanguage == "en",
|
||||
onClick = { applyLanguage("en") },
|
||||
label = { Text("English") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.util.AppLog
|
||||
|
||||
/**
|
||||
* Carte LOGS DE DIAGNOSTIC de l'écran Paramètres (v1.3.1 ; v1.9.8 : extraite
|
||||
* de SettingsScreen.kt, comportement inchangé). Le buffer AppLog (500
|
||||
* lignes, persisté) a permis le premier diagnostic À DISTANCE de l'historique
|
||||
* du projet (§14 #50) — l'export est le canal de support de l'app.
|
||||
*
|
||||
* @param logLineCount compteur affiché (état de l'orchestrateur : il est
|
||||
* rafraîchi APRÈS l'export, qui ajoute des lignes au journal).
|
||||
* @param onExport exporte les logs via le picker SAF (nom de fichier sûr —
|
||||
* helper ExportFileNames, cf bug §14 #48).
|
||||
* @param onClear efface le buffer et rafraîchit le compteur.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LogsCard(
|
||||
logLineCount: Int,
|
||||
onExport: () -> Unit,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.logs_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.logs_count, logLineCount),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = onExport) { Text(stringResource(R.string.logs_export)) }
|
||||
TextButton(onClick = onClear) { Text(stringResource(R.string.logs_clear)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.reminder.AlarmScheduler
|
||||
|
||||
/**
|
||||
* Carte RAPPELS / ALARMES EXACTES de l'écran Paramètres (v1.9.8 : extraite de
|
||||
* SettingsScreen.kt, comportement inchangé). Les rappels de doses utilisent
|
||||
* des alarmes EXACTES (heure juste) : depuis Android 12 (S), l'autorisation
|
||||
* SCHEDULE_EXACT_ALARM est opt-in par application. Sans elle, les alarmes
|
||||
* deviennent inexactes (fenêtre ± quelques minutes) — la carte invite à
|
||||
* l'accorder via les réglages système quand c'est le cas.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RemindersCard() {
|
||||
val context = LocalContext.current
|
||||
val scheduler = remember { AlarmScheduler(context) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.reminders_section), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
if (!scheduler.canScheduleExact()) {
|
||||
Text(
|
||||
stringResource(R.string.exact_alarm_needed),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Button(onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
context.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
|
||||
}
|
||||
}) { Text(stringResource(R.string.grant_exact_alarm)) }
|
||||
} else {
|
||||
Text(stringResource(R.string.exact_alarm_ok), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import android.net.Uri
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Helpers de formatage/affichage partagés par les cartes de l'écran
|
||||
* Paramètres (v1.9.8 : extraits de SettingsScreen.kt lors du découpage en
|
||||
* cartes — cf docs/DEVELOPPEMENT.md §10). Purs et défensifs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formate un Double pour l'affichage dans un champ texte : les entiers sans
|
||||
* décimales, les autres avec 3 décimales max (Locale.US : point décimal — la
|
||||
* virgule FR est tolérée À LA SAISIE via replace(',', '.') dans les cartes).
|
||||
*/
|
||||
internal fun formatDoubles(d: Double): String =
|
||||
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d)
|
||||
|
||||
/**
|
||||
* Nom lisible d'un dossier SAF tree URI (v1.7.0) : l'id d'un tree document
|
||||
* est de la forme « primary:Download » (stockage principal) ou
|
||||
* « <provider>:<chemin » pour les providers tiers (Owncloud…) — on affiche
|
||||
* la partie APRÈS le ':', sinon l'ID brut. Pur et défensif : un URI non
|
||||
* parsable s'affiche tel quel plutôt que de faire crasher l'écran.
|
||||
*/
|
||||
internal fun folderDisplayName(treeUriString: String): String =
|
||||
try {
|
||||
val id = android.provider.DocumentsContract.getTreeDocumentId(Uri.parse(treeUriString))
|
||||
id.substringAfter(':', id)
|
||||
} catch (_: Exception) {
|
||||
treeUriString
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import com.hormonetrack.pk.TConfig
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Carte ESTIMATION T de l'écran Paramètres (v1.9.8 : extraite de
|
||||
* SettingsScreen.kt, comportement inchangé). Réglages MANUELS du modèle
|
||||
* `T = plancher + (base − plancher) ÷ (1 + k·E2)` ; l'auto-calibration
|
||||
* (carte du dessus) les REMPLACE à l'affichage quand elle est activée.
|
||||
*
|
||||
* « Calibrer depuis les labs » recalcule k par période d'ester depuis les
|
||||
* analyses T enregistrées (PharmacokineticEngine.computeTConfigCalibration).
|
||||
*
|
||||
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||
*/
|
||||
@Composable
|
||||
internal fun TModelCard(tConfig: TConfig, onMessage: (String) -> Unit) {
|
||||
val container = LocalAppContainer.current
|
||||
val repo = container.repository
|
||||
val context = LocalContext.current
|
||||
|
||||
// Champs de saisie réinitialisés à chaque valeur persistée (clé du
|
||||
// remember) — la virgule décimale FR est tolérée à la saisie.
|
||||
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
|
||||
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
|
||||
var tKText by remember(tConfig.k) { mutableStateOf(formatDoubles(tConfig.k)) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.t_model_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = tBaseText,
|
||||
onValueChange = { tBaseText = it },
|
||||
label = { Text(stringResource(R.string.t_base)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = tFloorText,
|
||||
onValueChange = { tFloorText = it },
|
||||
label = { Text(stringResource(R.string.t_floor)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = tKText,
|
||||
onValueChange = { tKText = it },
|
||||
label = { Text("k") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
val base = tBaseText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||
val floor = tFloorText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||
val k = tKText.replace(',', '.').toDoubleOrNull() ?: return@Button
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setTConfig(TConfig(base, floor, k))
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.saved))
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
OutlinedButton(onClick = {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val labs = repo.allLabResultsOnce().filter { it.marker.equals("T", true) }
|
||||
val trs = repo.allTreatmentsOnce()
|
||||
val doses = repo.allDoseLogsOnce()
|
||||
val calibrated = PharmacokineticEngine.computeTConfigCalibration(
|
||||
labs, trs, doses, tConfig
|
||||
)
|
||||
calibrated?.let {
|
||||
container.settings.setTConfig(it)
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.t_calibrated))
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.calibrate_from_labs)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
package com.hormonetrack.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hormonetrack.R
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Carte CIBLE DE CREUX E2 de l'écran Paramètres (v1.13.0, opt-in).
|
||||
*
|
||||
* ⚠️ DISTINCT des seuils d'alerte ([AlertThresholdsCard], au-dessus) :
|
||||
* - les seuils d'alerte surveillent le niveau estimé EN CONTINU (carte
|
||||
* d'accueil + notification toutes les 15 min — cf §9.bis) ;
|
||||
* - la cible de creux est une RÉFÉRENCE PERSONNELLE DE LAB : « quand je
|
||||
* fais ma prise de sang au creux recommandé, je veux être dans
|
||||
* [basse, haute] » — la prédiction de la carte reco est colorée
|
||||
* contre cette bande, JAMAIS de notification.
|
||||
*
|
||||
* Opt-in pur : champ vide = désactivée (les DEUX champs, vides ou remplis).
|
||||
* Validation : haut > bas obligatoire (message rouge local, rien n'est
|
||||
* persisté sinon).
|
||||
*
|
||||
* @param target cible active (basse to haute) ou null (désactivée)
|
||||
* @param onMessage feedback affiché en haut de l'écran (message d'état).
|
||||
*/
|
||||
@Composable
|
||||
internal fun TroughTargetCard(target: Pair<Double, Double>?, onMessage: (String) -> Unit) {
|
||||
val container = LocalAppContainer.current
|
||||
val context = LocalContext.current
|
||||
|
||||
var lowText by remember(target?.first) {
|
||||
mutableStateOf(target?.first?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var highText by remember(target?.second) {
|
||||
mutableStateOf(target?.second?.let { formatDoubles(it) } ?: "")
|
||||
}
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.trough_target_section), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.trough_target_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = lowText,
|
||||
onValueChange = { lowText = it },
|
||||
label = { Text(stringResource(R.string.trough_target_low)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = highText,
|
||||
onValueChange = { highText = it },
|
||||
label = { Text(stringResource(R.string.trough_target_high)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = {
|
||||
// Même parsing tolérant que la carte Seuils (virgule FR),
|
||||
// champ vide = null
|
||||
fun parse(s: String): Double? =
|
||||
s.trim().replace(',', '.').takeIf { it.isNotEmpty() }?.toDoubleOrNull()
|
||||
val low = parse(lowText)
|
||||
val high = parse(highText)
|
||||
when {
|
||||
// Un seul champ rempli = saisie incomplète (pas d'état
|
||||
// « demi-cible » — l'app ne devine pas)
|
||||
(low == null) != (high == null) -> {
|
||||
error = context.getString(R.string.trough_target_incomplete)
|
||||
}
|
||||
low != null && high != null && high <= low -> {
|
||||
error = context.getString(R.string.trough_target_invalid)
|
||||
}
|
||||
else -> {
|
||||
error = null
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
// null to null = désactivation (setTroughTarget efface)
|
||||
container.settings.setTroughTarget(
|
||||
if (low != null && high != null) low to high else null
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
onMessage(context.getString(R.string.saved))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,13 @@ val TransPink = Color(0xFFF5A9B8)
|
||||
val TealTertiary = Color(0xFF2FA48D)
|
||||
val TealContainer = Color(0xFFBFF0E4)
|
||||
val ChartE2 = Color(0xFF4F5BD5)
|
||||
// v1.4.6 : modèle WHSAH (3ᵉ PK) — E2 vert profond, T violet (pointillé)
|
||||
val ChartWhs = Color(0xFF2E7D32)
|
||||
val ChartTWhs = Color(0xFF9C27B0)
|
||||
val ChartT = Color(0xFFD6589E)
|
||||
val LabDot = Color(0xFFE67E22)
|
||||
// v1.5.0 : courbe « Tracé labs » (hybride ancrée sur les labs, pk/LabTrajectoryModel)
|
||||
// — rose foncé distinct de E2/T/TFS/WHS et des dots orange des labs
|
||||
val ChartLabTrajectory = Color(0xFFC2185B)
|
||||
val GoodGreen = Color(0xFF2E7D32)
|
||||
val WarnOrange = Color(0xFFB26A00)
|
||||
|
||||
@ -26,6 +26,18 @@ private val DarkColors = darkColorScheme(
|
||||
tertiary = androidx.compose.ui.graphics.Color(0xFF7CD5C1)
|
||||
)
|
||||
|
||||
/**
|
||||
* Thème Material 3 de l'app.
|
||||
*
|
||||
* NOTE Material Expressive (état au 2026-09) : `MaterialExpressiveTheme` et
|
||||
* `ExperimentalMaterial3ExpressiveApi` restent **internal** dans la ligne material3
|
||||
* pinnée par le BOM 2026.08.00 (vérifié par erreur de compilation : « it is internal »).
|
||||
* On utilise donc MaterialTheme standard ; les apports Material You viennent quand
|
||||
* même de la BOM récente (composants, comportements, couleurs). Quand Google rendra
|
||||
* l'API publique, remplacer MaterialTheme par MaterialExpressiveTheme (mêmes params).
|
||||
* Le dynamic color (fond d'écran Material You) reste désactivé volontairement pour
|
||||
* une identité visuelle stable ; passer `dynamicColor = true` pour l'activer.
|
||||
*/
|
||||
@Composable
|
||||
fun HormoneTrackTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
|
||||
92
app/src/main/java/com/hormonetrack/util/AppLog.kt
Normal file
92
app/src/main/java/com/hormonetrack/util/AppLog.kt
Normal file
@ -0,0 +1,92 @@
|
||||
package com.hormonetrack.util
|
||||
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Journal de diagnostic de l'app (v1.3.1) : ligne horodatée par événement
|
||||
* notable (agenda, rappels, import/export, erreurs), persisté dans
|
||||
* `filesDir/debug-log.txt`, buffer circulaire de MAX_LINES.
|
||||
*
|
||||
* Exportable depuis Paramètres (« Logs de diagnostic ») pour le debug à
|
||||
* distance — un utilisateur peut joindre le fichier à un rapport de bug.
|
||||
*
|
||||
* Les parties PURES (formatLine, trimLines) sont testables en JVM sans
|
||||
* Android ; l'objet lui-même ne fait que l'IO fichier mince.
|
||||
*/
|
||||
object AppLog {
|
||||
|
||||
const val MAX_LINES = 500
|
||||
|
||||
private val buffer = ArrayDeque<String>()
|
||||
private var file: File? = null
|
||||
private val lock = Any()
|
||||
|
||||
/**
|
||||
* À appeler une fois dans Application.onCreate.
|
||||
*
|
||||
* ⚠️ La lecture du fichier existant est GARDÉE (try/catch) : un fichier de log
|
||||
* corrompu ou illisible ne doit jamais empêcher l'app de démarrer — on perd
|
||||
* l'historique, pas le process (même famille que le fix v1.3.3 de log()).
|
||||
*/
|
||||
fun init(context: android.content.Context) {
|
||||
synchronized(lock) {
|
||||
val f = File(context.filesDir, "debug-log.txt")
|
||||
file = f
|
||||
if (f.exists()) {
|
||||
buffer.clear()
|
||||
val loaded = try {
|
||||
f.readLines().takeLast(MAX_LINES)
|
||||
} catch (_: Exception) {
|
||||
emptyList() // fichier illisible → démarrer avec un buffer vide
|
||||
}
|
||||
buffer.addAll(loaded)
|
||||
}
|
||||
}
|
||||
log("app", "démarrage (v init) — ${buffer.size} lignes de logs existantes")
|
||||
}
|
||||
|
||||
/**
|
||||
* Journalise une ligne (buffer mémoire + append fichier).
|
||||
*
|
||||
* ⚠️ NE JAMAIS PROPAGER D'EXCEPTION (fix v1.3.3) : log() est appelé depuis
|
||||
* des dizaines de sites, y compris dans des coroutines à scope racine où une
|
||||
* exception non interceptée = crash du process (cf export des logs, §14 #45).
|
||||
* L'IO fichier (appendText) peut lever IOException (disque plein, I/O error) :
|
||||
* elle est avalée ici — on perd au pire UNE ligne de journal, jamais l'app.
|
||||
*/
|
||||
fun log(tag: String, message: String) = synchronized(lock) {
|
||||
val line = formatLine(System.currentTimeMillis(), tag, message)
|
||||
buffer.addLast(line)
|
||||
// Purge O(1) amortie (v1.8.2 : l'ancien trimLines recopiait TOUT le
|
||||
// buffer à chaque ligne loggée) — même résultat (MAX_LINES dernières)
|
||||
while (buffer.size > MAX_LINES) buffer.removeFirst()
|
||||
try {
|
||||
file?.appendText(line + "\n")
|
||||
} catch (_: Exception) {
|
||||
// Journalisation best-effort : ne jamais faire planter l'app pour un log
|
||||
}
|
||||
}
|
||||
|
||||
fun all(): String = synchronized(lock) { buffer.joinToString("\n") }
|
||||
|
||||
fun count(): Int = synchronized(lock) { buffer.size }
|
||||
|
||||
fun clear() = synchronized(lock) {
|
||||
buffer.clear()
|
||||
file?.writeText("")
|
||||
log("app", "logs effacés")
|
||||
}
|
||||
|
||||
/** Ligne horodatée : « 2026-09-06 12:34:56 [tag] message » (locale). */
|
||||
fun formatLine(tsMs: Long, tag: String, message: String): String {
|
||||
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
||||
return "${fmt.format(Date(tsMs))} [$tag] $message"
|
||||
}
|
||||
|
||||
/** Buffer circulaire : garde les MAX dernières lignes. */
|
||||
fun trimLines(existing: List<String>, max: Int): List<String> =
|
||||
if (existing.size <= max) existing else existing.takeLast(max)
|
||||
}
|
||||
91
app/src/main/java/com/hormonetrack/util/ExportFileNames.kt
Normal file
91
app/src/main/java/com/hormonetrack/util/ExportFileNames.kt
Normal file
@ -0,0 +1,91 @@
|
||||
package com.hormonetrack.util
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Noms de fichiers d'export (horodatés) — helper PUR, testable en JVM.
|
||||
*
|
||||
* Pourquoi cet objet existe (v1.3.4, cf §14 #48) : le nom du fichier des logs
|
||||
* était généré INLINE dans le handler du bouton via
|
||||
* `LocalDate.now().format(ofPattern("yyyyMMdd-HHmm"))`. Un `LocalDate` n'a
|
||||
* PAS de champ horaire : le pattern `HHmm` levait
|
||||
* `UnsupportedTemporalTypeException: Unsupported field: HourOfDay` de façon
|
||||
* SYNCHRONE dans le onClick (thread UI) → crash de l'app au tap, sans même
|
||||
* ouvrir le sélecteur de fichiers. Crash présent depuis v1.3.1 ; les « fixes »
|
||||
* v1.3.2 et v1.3.3 réparaient l'IO mais pas cette ligne.
|
||||
*
|
||||
* Règle anti-régression : les helpers d'ici centralisent le couplage
|
||||
* type ↔ pattern, chaque fonction documentant quel type porte le pattern.
|
||||
* ⚠️ Règle générale : un pattern contenant des champs horaires (HH, mm, ss)
|
||||
* exige LocalDateTime/LocalTime/Instant.withZone ; yyyyMMdd seul sur LocalDate.
|
||||
*/
|
||||
object ExportFileNames {
|
||||
|
||||
private val LOG_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
|
||||
private val BACKUP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||
|
||||
/**
|
||||
* Pattern des AUTO-BACKUPS journaliers (v1.7.0) : `yyyyMMdd-HHmm` — même
|
||||
* règle que [LOG_FMT] (contient HHmm → type temporel complet requis).
|
||||
* L'heure permet plusieurs runs le même jour (activation + tick journalier,
|
||||
* ou plusieurs activations successives) SANS écrasement : chaque fichier
|
||||
* a un nom unique → la rétention ([com.hormonetrack.data.backup.AutoBackupRetention])
|
||||
* trie sur l'horodatage du NOM (jamais sur la mtime du document SAF, non
|
||||
* fiable sur tous les providers).
|
||||
*/
|
||||
private val AUTO_BACKUP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
|
||||
|
||||
/** Préfixe d'un fichier d'AUTO-backup — partagé avec la rétention (PUR). */
|
||||
const val AUTO_BACKUP_PREFIX = "hormonetrack-auto-"
|
||||
private const val AUTO_BACKUP_SUFFIX = ".json"
|
||||
|
||||
/**
|
||||
* Nom du fichier d'export des logs de diagnostic :
|
||||
* `hormonetrack-logs-20260906-1234.txt`.
|
||||
* Le pattern contient HHmm → il faut un type TEMPOREL COMPLET
|
||||
* (LocalDateTime). Passer un LocalDate ici = UnsupportedTemporalTypeException
|
||||
* au tap sur « Exporter » (bug #48, crash réel v1.3.1→v1.3.3).
|
||||
*/
|
||||
fun diagnosticLogFileName(now: LocalDateTime = LocalDateTime.now()): String =
|
||||
LOG_FMT.format(now).let { "hormonetrack-logs-$it.txt" }
|
||||
|
||||
/**
|
||||
* Nom du fichier de sauvegarde JSON :
|
||||
* `hormonetrack-backup-20260906.json`. Pattern SANS heure →
|
||||
* `LocalDate` est correct ici (c'est le cas qui marchait, à garder tel quel).
|
||||
*/
|
||||
fun backupFileName(today: LocalDate = LocalDate.now()): String =
|
||||
"hormonetrack-backup-${BACKUP_FMT.format(today)}.json"
|
||||
|
||||
/**
|
||||
* Nom d'un AUTO-backup journalier (v1.7.0) :
|
||||
* `hormonetrack-auto-20260913-1830.json`. Pattern AVEC heure →
|
||||
* [LocalDateTime] obligatoire (règle anti-#48 en tête de fichier).
|
||||
* Pur (déterministe sur [now]) → testé en JVM.
|
||||
*/
|
||||
fun autoBackupFileName(now: LocalDateTime = LocalDateTime.now()): String =
|
||||
"$AUTO_BACKUP_PREFIX${AUTO_BACKUP_FMT.format(now)}$AUTO_BACKUP_SUFFIX"
|
||||
|
||||
/**
|
||||
* Re-trouve l'horodatage dans un NOM de fichier d'auto-backup (round-trip
|
||||
* de [autoBackupFileName]) — PUR, utilisé par la rétention pour trier par
|
||||
* l'horodatage du nom. Retourne `null` pour tout ce qui n'est PAS un
|
||||
* auto-backup (exports manuels `hormonetrack-backup-…`, logs, fichiers
|
||||
* étrangers du dossier choisi par l'utilisatrice) : la rétention ne
|
||||
* doit JAMAIS les toucher.
|
||||
*/
|
||||
fun parseAutoBackupTimestamp(fileName: String): LocalDateTime? {
|
||||
if (!fileName.startsWith(AUTO_BACKUP_PREFIX)) return null
|
||||
if (!fileName.endsWith(AUTO_BACKUP_SUFFIX)) return null
|
||||
return try {
|
||||
LocalDateTime.parse(
|
||||
fileName.removePrefix(AUTO_BACKUP_PREFIX).removeSuffix(AUTO_BACKUP_SUFFIX),
|
||||
AUTO_BACKUP_FMT
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null // nom presque-correct mais malformé : JAMAIS supprimé
|
||||
}
|
||||
}
|
||||
}
|
||||
23
app/src/main/java/com/hormonetrack/util/HrtDuration.kt
Normal file
23
app/src/main/java/com/hormonetrack/util/HrtDuration.kt
Normal file
@ -0,0 +1,23 @@
|
||||
package com.hormonetrack.util
|
||||
|
||||
/** Estimation du temps passé sous THS (v1.3.1). Mois = 30 jours (estimation). */
|
||||
object HrtDuration {
|
||||
|
||||
const val DAY_MS = 86_400_000L
|
||||
|
||||
/** Jours entiers écoulés depuis la première prise (0 si futur/invalide). */
|
||||
fun daysSince(earliestMs: Long, nowMs: Long): Long =
|
||||
if (nowMs <= earliestMs) 0L else (nowMs - earliestMs) / DAY_MS
|
||||
|
||||
/** Décomposition mois(30 j) / jours restants. */
|
||||
fun monthsAndDays(totalDays: Long): Pair<Long, Long> =
|
||||
Pair(totalDays / 30, totalDays % 30)
|
||||
|
||||
/**
|
||||
* Conversion d'un delta EN HEURES en (jours, heures restantes) — v1.4.1,
|
||||
* carte « Prochaine dose » de l'accueil : au-delà de 24 h, les heures
|
||||
* parlent moins que les jours (ex. 120 h → 5 j 0 h).
|
||||
*/
|
||||
fun daysAndHours(totalHours: Long): Pair<Long, Long> =
|
||||
Pair(totalHours / 24, totalHours % 24)
|
||||
}
|
||||
@ -17,18 +17,96 @@
|
||||
<string name="no_data">Pas encore de données — crée un traitement et logue une dose.</string>
|
||||
<string name="logged_today">Log rapide</string>
|
||||
<string name="next_dose">Prochaine dose</string>
|
||||
<string name="next_doses">Prochaines doses</string>
|
||||
<string name="next_dose_days">%1$d j %2$d h · %3$s (%4$s)</string>
|
||||
<string name="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_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>
|
||||
|
||||
<!-- Charts -->
|
||||
<string name="chart_24h">24 h</string>
|
||||
<string name="chart_7j">7 jours</string>
|
||||
<string name="chart_30j">30 jours</string>
|
||||
<!-- v1.8.0 : recommandation de prochaine prise de sang (creux stabilisé) -->
|
||||
<string name="labrec_section">Prochaine prise de sang (suggestion)</string>
|
||||
<string name="labrec_when">Au creux estimé : %1$s</string>
|
||||
<string name="labrec_before_injection">Juste avant ton injection de %1$s du %2$s — ou simplement la veille de ce créneau.</string>
|
||||
<string name="labrec_stabilized">Ton %1$s est stabilisé depuis le %2$s (~5 demi-vies terminales).</string>
|
||||
<string name="labrec_not_stabilized">Ton %1$s n\'était pas stabilisé avant le %2$s (~5 demi-vies terminales) : c\'est le premier creux fiable depuis ton dernier changement.</string>
|
||||
<string name="labrec_disclaimer">Estimation du modèle pharmacocinétique, pas un avis médical — la prise au creux donne simplement le résultat le plus comparable. Suis toujours ton endocrinologue.</string>
|
||||
<string name="labrec_needs_posology">Astuce : renseigne une Posologie (intervalle entre prises) sur ton traitement E2 injectable actif (Traitements → éditer) et cette page suggérera quand faire ta prochaine prise de sang (au creux, juste avant l\'injection).</string>
|
||||
<string name="show_labs">Analyses</string>
|
||||
<string name="legend_e2">— E2 estimé (pg/mL, axe gauche)</string>
|
||||
<string name="legend_t">-- T estimée (ng/mL, axe droit)</string>
|
||||
<string name="show_forecast">Prévision</string>
|
||||
<string name="show_extrema">Pics / creux</string>
|
||||
<string name="show_lab_track">Tracé labs</string>
|
||||
<!-- v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
|
||||
actif seulement quand Tracé labs est ON) -->
|
||||
<string name="show_lab_track_extend">Prolonger</string>
|
||||
<!-- v1.9.0 : nuage d'incertitude du modèle ESE (posterior MCMC de
|
||||
estrannaise.js) — exclusif à ESE, off par défaut -->
|
||||
<string name="show_cloud">Nuage</string>
|
||||
<string name="legend_cloud">Nuage pointillé = incertitude des paramètres MCMC de l\'Estrannaise (modèle ESE seul)</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="hrt_since">Sous THS depuis le %1$s</string>
|
||||
<string name="hrt_duration">%1$d mois et %2$d jours (%3$d jours au total)</string>
|
||||
<string name="hrt_duration_days_only">%1$d jours</string>
|
||||
<string name="alert_channel_name">Avertissements de seuil</string>
|
||||
<string name="alert_channel_desc">Notifications quand un taux estimé franchit une de tes limites (vérification toutes les 15 minutes).</string>
|
||||
<string name="alert_notif_title">Seuil hormonal franchi</string>
|
||||
<string name="alert_section">Seuils d\'alerte</string>
|
||||
<string name="alert_hint">Limites optionnelles pour tes taux estimés, vérifiées contre l\'estimation actuelle de l\'accueil (pas une mesure de labo). E2 en pg/mL, T en ng/mL. Champ vide = alerte désactivée. Le haut doit être au-dessus du bas. Vérification toutes les 15 minutes — tu reçois une notification quand une limite est franchie (sans spam tant que l\'écart ne change pas).</string>
|
||||
<string name="alert_e2_high">E2 haut (pg/mL)</string>
|
||||
<string name="alert_e2_low">E2 bas (pg/mL)</string>
|
||||
<string name="alert_t_high">T haut (ng/mL)</string>
|
||||
<string name="alert_t_low">T bas (ng/mL)</string>
|
||||
<string name="alert_invalid">Valeurs invalides : chiffres uniquement, et la limite haute doit être supérieure à la basse.</string>
|
||||
<string name="alert_card_title">Avertissements de seuil</string>
|
||||
<string name="alert_high">▲ %1$s estimé ≈ %2$s %3$s — au-dessus de ta limite haute (%4$s)</string>
|
||||
<string name="alert_low">▼ %1$s estimé ≈ %2$s %3$s — sous ta limite basse (%4$s)</string>
|
||||
<string name="alert_disclaimer">Basé sur l\'estimation du modèle, pas une mesure. Vérifie avec tes prises de sang et ton endocrinologue.</string>
|
||||
<string name="timezone_section">Fuseau horaire du graphique</string>
|
||||
<string name="timezone_hint">Sert à aligner les jours du graphique (minuit) et à lire les doses. Vide = fuseau du téléphone (automatique). Exemple : Europe/Paris.</string>
|
||||
<string name="timezone_field">ID de fuseau (ex. Europe/Paris)</string>
|
||||
<string name="timezone_auto">Fuseau du téléphone</string>
|
||||
<string name="timezone_invalid">ID de fuseau inconnu (ex. Europe/Paris, America/New_York).</string>
|
||||
<string name="legend_real_dose">— prises enregistrées (marqueurs bas)</string>
|
||||
<string name="logs_section">Logs de diagnostic</string>
|
||||
<string name="logs_count">%1$d lignes (rappels, agenda, import/export, erreurs)</string>
|
||||
<string name="logs_export">Exporter</string>
|
||||
<string name="logs_clear">Effacer</string>
|
||||
<string name="logs_export_ok">Logs de diagnostic exportés</string>
|
||||
<string name="logs_export_fail">Échec de l\'export des logs de diagnostic</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 & creux estimés (par courbe)</string>
|
||||
<string name="legend_lab_track">E2 ancrée sur tes labs — forme du modèle forcée de passer par chaque prise de sang (entre le 1er et le dernier lab)</string>
|
||||
<!-- v1.6.0 : légende de la partie PROLONGÉE du tracé labs (série "LABX") -->
|
||||
<string name="legend_lab_track_extend">-- Tracé labs prolongé après ton dernier lab (modèle × ratio du dernier lab — estimation, plus ancrée)</string>
|
||||
<!-- v1.6.0 : AVERTISSEMENT affiché sous la légende quand la prolongation
|
||||
est active — l\'extrapolation n\'a aucune garantie et repose sur des
|
||||
mesures elles-mêmes faillibles -->
|
||||
<string name="lab_track_extend_warning">⚠ Simple simulation, sans garantie de correspondre au réel : la prolongation extrapole ton modèle à partir de tes résultats de laboratoire — qui peuvent eux-mêmes être erronés. Fie-toi à ta prochaine prise de sang, pas à cette courbe.</string>
|
||||
<string name="legend_forecast_dose">— prochaine dose projetée (marqueur vertical)</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="legend_ese_e2">— E2 · Estrannaise (pg/mL, axe gauche)</string>
|
||||
<string name="legend_ese_t">-- T · Estrannaise (ng/mL, axe droit)</string>
|
||||
<string name="legend_tfs_e2">— E2 · Transfem Science</string>
|
||||
<string name="legend_tfs_t">-- T · Transfem Science</string>
|
||||
<string name="legend_whs_e2">— E2 WHSAH (pg/mL, gauche)</string>
|
||||
<string name="legend_whs_t">-- T WHSAH (ng/mL, droite)</string>
|
||||
<string name="legend_labs">● Résultats de prise de sang</string>
|
||||
<string name="interval_value">Δ %1$s j</string>
|
||||
|
||||
<!-- Doses -->
|
||||
<string name="add_dose">Ajouter une dose</string>
|
||||
@ -42,7 +120,15 @@
|
||||
<string name="confirm_delete_treatment">Supprimer ce traitement ? Son historique de doses sera aussi supprimé.</string>
|
||||
|
||||
<!-- 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_value">Valeur</string>
|
||||
<string name="lab_unit">Unité (ex : pg/mL, ng/mL)</string>
|
||||
@ -58,6 +144,26 @@
|
||||
<string name="dose_unit">Unité</string>
|
||||
<string name="active">Actif</string>
|
||||
<string name="inactive">inactif</string>
|
||||
<!-- v1.11.0 : en-tête de la section des traitements inactifs (regroupés
|
||||
en bas de la page Traitements) -->
|
||||
<string name="inactive_section">Traitements inactifs</string>
|
||||
<!-- v1.12.0 : encart latéral « prise de sang le même jour » sur les
|
||||
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
|
||||
<string name="lab_before_dose">Prise de sang plus tôt le même jour, avant cette dose</string>
|
||||
<string name="lab_after_dose">Prise de sang plus tard le même jour, après cette dose</string>
|
||||
<!-- v1.13.0 : valeur E2 attendue au creux recommandé (affichée seulement
|
||||
si la calibration est disponible) + statut face à la cible de creux -->
|
||||
<string name="labrec_predicted">Attendu à ce creux : ≈ %1$s pg/mL</string>
|
||||
<string name="labrec_in_target">dans ta cible</string>
|
||||
<string name="labrec_out_of_target">hors de ta cible</string>
|
||||
<!-- v1.13.0 : carte Cible de creux (opt-in) — DISTINCT des seuils
|
||||
d'alerte (surveillance continue + notifications) -->
|
||||
<string name="trough_target_section">Cible de creux (prise de sang)</string>
|
||||
<string name="trough_target_hint">Référence personnelle pour la prochaine prise de sang : la valeur de creux prédite est colorée par rapport à cette bande. Distinct des seuils d\u0027alerte ci-dessus — ceux-là surveillent ton niveau estimé en continu et peuvent te notifier ; celui-ci jamais.</string>
|
||||
<string name="trough_target_low">Cible basse (pg/mL)</string>
|
||||
<string name="trough_target_high">Cible haute (pg/mL)</string>
|
||||
<string name="trough_target_incomplete">Remplis les deux champs — ou laisse les deux vides pour désactiver.</string>
|
||||
<string name="trough_target_invalid">La cible haute doit être supérieure à la cible basse.</string>
|
||||
<string name="no_treatment_hint">Crée d\'abord un traitement (onglet Traitements).</string>
|
||||
|
||||
<string name="type_estradiol">Œstradiol</string>
|
||||
@ -76,6 +182,7 @@
|
||||
<string name="pk_model">Modèle PK</string>
|
||||
<string name="model_ese">Estrannaise</string>
|
||||
<string name="model_tfs">Transfem Science</string>
|
||||
<string name="model_whs">WHSAH</string>
|
||||
<string name="select_preset">Choisir un modèle (optionnel)</string>
|
||||
|
||||
<string name="pk_absorption">Temps jusqu\'au pic (h)</string>
|
||||
@ -85,7 +192,15 @@
|
||||
<string name="calibration_title">Calibration</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_auto_override">Auto-calibration active : les courbes utilisent les facteurs automatiques — cette valeur manuelle est ignorée à l\'affichage (désactive-la dans Paramètres pour la prendre en compte).</string>
|
||||
<string name="calibrate_from_labs">Calibrer avec les analyses</string>
|
||||
<string name="auto_calibrate">Calibration automatique (E2 + T)</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">Posologie</string>
|
||||
<string name="freq_enable">Simuler les doses à venir</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 prise enregistrée ; elles apparaissent sur le graphique (Prévision) mais ne sont jamais sauvegardées.</string>
|
||||
|
||||
<string name="save">Enregistrer</string>
|
||||
<string name="cancel">Annuler</string>
|
||||
@ -122,22 +237,46 @@
|
||||
<string name="import_json">Importer</string>
|
||||
<string name="export_ok">Sauvegarde exportée.</string>
|
||||
<string name="export_fail">L\'export a échoué.</string>
|
||||
<!-- v1.7.0 : sauvegarde automatique journalière (SAF tree + WorkManager) -->
|
||||
<string name="autobackup_section">Sauvegarde automatique quotidienne</string>
|
||||
<string name="autobackup_enable">Activer</string>
|
||||
<string name="autobackup_hint">Chaque jour, l\'app écrit une sauvegarde JSON complète (même format que l\'export manuel) dans le dossier que tu choisis — par exemple un dossier Owncloud synchronisé, pour que le backup atterrisse dans ton cloud. Aucune permission de stockage nécessaire : tu choisis le dossier une fois et tu peux le révoquer à tout moment dans les réglages système. Seules les copies les plus récentes sont conservées (les vieilles copies automatiques sont supprimées ; tes exports manuels ne sont jamais touchés).</string>
|
||||
<string name="autobackup_folder">Dossier</string>
|
||||
<string name="autobackup_no_folder">Pas encore de dossier choisi — choisis-en un pour activer.</string>
|
||||
<string name="autobackup_choose_folder">Choisir le dossier</string>
|
||||
<string name="autobackup_keep">Copies conservées (1–30)</string>
|
||||
<string name="autobackup_last_ok">Dernière sauvegarde : OK (%1$s)</string>
|
||||
<string name="autobackup_last_fail">Dernière sauvegarde : ÉCHEC (%1$s) — vérifie le dossier puis réactive pour réessayer.</string>
|
||||
<string name="autobackup_first_run">Activée — une première sauvegarde a été écrite dans le dossier.</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_confirm">Restaurer</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">Effacer & restaurer</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="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>
|
||||
|
||||
<!-- Presets -->
|
||||
<string name="preset_ev_ese">Injection EV — Estrannaise</string>
|
||||
<string name="preset_ec_ese">Injection EC (huile) — Estrannaise</string>
|
||||
<string name="preset_eb_ese">Injection EB — Estrannaise</string>
|
||||
<string name="preset_eucs_ese">Injection EUCS (suspension cristalline) — Estrannaise</string>
|
||||
<string name="preset_eu_ese">Injection EU — Estrannaise</string>
|
||||
<string name="preset_een_ese">Injection EEn — Estrannaise</string>
|
||||
<string name="preset_ev_tfs">Injection EV — Transfem Science</string>
|
||||
<string name="preset_eu_tfs">Injection EU — Transfem Science</string>
|
||||
<string name="preset_een_tfs">Injection EEn — Transfem Science</string>
|
||||
<string name="preset_eb_tfs">Injection EB — Transfem Science</string>
|
||||
<string name="preset_ec_tfs">Injection EC (huile) — Transfem Science</string>
|
||||
<string name="preset_ecs_tfs">Injection EC (suspension aqueuse) — Transfem Science</string>
|
||||
<string name="preset_ev_whs">Injection EV — WHSAH</string>
|
||||
<string name="preset_eu_whs">Injection EU — WHSAH</string>
|
||||
<string name="preset_een_whs">Injection EEn — WHSAH</string>
|
||||
<string name="preset_eb_whs">Injection EB — WHSAH</string>
|
||||
<string name="preset_ec_whs">Injection EC (huile) — WHSAH</string>
|
||||
<string name="preset_ecs_whs">Injection EC (suspension aqueuse) — WHSAH</string>
|
||||
<string name="preset_pep_tfs">Injection PEP — Transfem Science</string>
|
||||
<string name="preset_e2_gel">E2 gel transdermique</string>
|
||||
<string name="preset_e2_patch">E2 patch</string>
|
||||
<string name="preset_e2_oral">E2 orale</string>
|
||||
|
||||
@ -17,18 +17,96 @@
|
||||
<string name="no_data">No data yet — add a treatment and log a dose.</string>
|
||||
<string name="logged_today">Quick log</string>
|
||||
<string name="next_dose">Next dose</string>
|
||||
<string name="next_doses">Upcoming doses</string>
|
||||
<string name="next_dose_days">%1$d d %2$d h · %3$s (%4$s)</string>
|
||||
<string name="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_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>
|
||||
|
||||
<!-- Charts -->
|
||||
<string name="chart_24h">24 h</string>
|
||||
<string name="chart_7j">7 days</string>
|
||||
<string name="chart_30j">30 days</string>
|
||||
<!-- v1.8.0 : recommandation de prochaine prise de sang (creux stabilisé) -->
|
||||
<string name="labrec_section">Next blood draw (suggested)</string>
|
||||
<string name="labrec_when">At the estimated trough: %1$s</string>
|
||||
<string name="labrec_before_injection">Just before your %1$s injection of %2$s — or simply the day before that slot.</string>
|
||||
<string name="labrec_stabilized">Your %1$s has been stable since %2$s (~5 terminal half-lives).</string>
|
||||
<string name="labrec_not_stabilized">Your %1$s was not stable until %2$s (~5 terminal half-lives): this is the first reliable trough after your latest change.</string>
|
||||
<string name="labrec_disclaimer">Estimation from the pharmacokinetic model, not medical advice — blood draw at the trough simply gives the most comparable result. Always follow your endocrinologist.</string>
|
||||
<string name="labrec_needs_posology">Tip: set a Dosage interval on your active injectable E2 treatment (Treatments → edit) and this page will suggest when to do your next blood draw (at the trough, just before the injection).</string>
|
||||
<string name="show_labs">Labs</string>
|
||||
<string name="legend_e2">— E2 estimate (pg/mL, left axis)</string>
|
||||
<string name="legend_t">-- T estimate (ng/mL, right axis)</string>
|
||||
<string name="show_forecast">Forecast</string>
|
||||
<string name="show_extrema">Peaks / troughs</string>
|
||||
<string name="show_lab_track">Lab track</string>
|
||||
<!-- v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
|
||||
actif seulement quand Lab track est ON) -->
|
||||
<string name="show_lab_track_extend">Extend</string>
|
||||
<!-- v1.9.0 : nuage d'incertitude du modèle ESE (posterior MCMC de
|
||||
estrannaise.js) — exclusif à ESE, off par défaut -->
|
||||
<string name="show_cloud">Cloud</string>
|
||||
<string name="legend_cloud">Dotted cloud = Estrannaise MCMC parameter uncertainty (ESE model only)</string>
|
||||
<string name="zoom_in">Zoom in (shorter window)</string>
|
||||
<string name="zoom_out">Zoom out (longer window)</string>
|
||||
<string name="hrt_since">Under HRT since %1$s</string>
|
||||
<string name="hrt_duration">%1$d months and %2$d days (%3$d days total)</string>
|
||||
<string name="hrt_duration_days_only">%1$d days</string>
|
||||
<string name="alert_channel_name">Threshold warnings</string>
|
||||
<string name="alert_channel_desc">Notifications when an estimated level crosses one of your configured limits (checked every 15 minutes).</string>
|
||||
<string name="alert_notif_title">Hormone threshold warning</string>
|
||||
<string name="alert_section">Alert thresholds</string>
|
||||
<string name="alert_hint">Optional limits for your estimated levels, checked against the current estimate on Home (not a lab value). E2 in pg/mL, T in ng/mL. Leave a field empty to disable that alert. High must be above low. Checked every 15 minutes — you get a notification when a limit is crossed (no repeat spam until the level changes).</string>
|
||||
<string name="alert_e2_high">E2 high (pg/mL)</string>
|
||||
<string name="alert_e2_low">E2 low (pg/mL)</string>
|
||||
<string name="alert_t_high">T high (ng/mL)</string>
|
||||
<string name="alert_t_low">T low (ng/mL)</string>
|
||||
<string name="alert_invalid">Invalid values: enter numbers only, and keep the high limit above the low limit.</string>
|
||||
<string name="alert_card_title">Threshold warnings</string>
|
||||
<string name="alert_high">▲ %1$s estimated ≈ %2$s %3$s — above your high limit (%4$s)</string>
|
||||
<string name="alert_low">▼ %1$s estimated ≈ %2$s %3$s — below your low limit (%4$s)</string>
|
||||
<string name="alert_disclaimer">Based on the model estimate, not a measurement. Check with your blood tests and your endocrinologist.</string>
|
||||
<string name="timezone_section">Chart timezone</string>
|
||||
<string name="timezone_hint">Used to align the chart days (midnight) and read doses. Empty = the phone\'s timezone (auto). Example: Europe/Paris.</string>
|
||||
<string name="timezone_field">Timezone ID (e.g. Europe/Paris)</string>
|
||||
<string name="timezone_auto">Use phone timezone</string>
|
||||
<string name="timezone_invalid">Unknown timezone ID (e.g. Europe/Paris, America/New_York).</string>
|
||||
<string name="legend_real_dose">— logged doses (bottom markers)</string>
|
||||
<string name="logs_section">Diagnostic logs</string>
|
||||
<string name="logs_count">%1$d lines (reminders, calendar, import/export, errors)</string>
|
||||
<string name="logs_export">Export</string>
|
||||
<string name="logs_clear">Clear</string>
|
||||
<string name="logs_export_ok">Diagnostic logs exported</string>
|
||||
<string name="logs_export_fail">Failed to export the diagnostic logs</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 & troughs (per curve)</string>
|
||||
<string name="legend_lab_track">Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab)</string>
|
||||
<!-- v1.6.0 : légende de la partie PROLONGÉE du tracé labs (série "LABX") -->
|
||||
<string name="legend_lab_track_extend">-- Lab track extended past your last lab (model × last lab ratio — estimate, not anchored)</string>
|
||||
<!-- v1.6.0 : AVERTISSEMENT affiché sous la légende quand la prolongation
|
||||
est active — l\'extrapolation n\'a aucune garantie et repose sur des
|
||||
mesures elles-mêmes faillibles -->
|
||||
<string name="lab_track_extend_warning">⚠ Simulation only, with no guarantee of matching reality: the extension extrapolates your model from your lab results — which can themselves be inaccurate. Trust your next blood test, not this curve.</string>
|
||||
<string name="legend_forecast_dose">— next projected dose (vertical marker)</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="legend_ese_e2">— E2 · Estrannaise (pg/mL, left axis)</string>
|
||||
<string name="legend_ese_t">-- T · Estrannaise (ng/mL, right axis)</string>
|
||||
<string name="legend_tfs_e2">— E2 · Transfem Science</string>
|
||||
<string name="legend_tfs_t">-- T · Transfem Science</string>
|
||||
<string name="legend_whs_e2">— E2 WHSAH (pg/mL, left)</string>
|
||||
<string name="legend_whs_t">-- T WHSAH (ng/mL, right)</string>
|
||||
<string name="legend_labs">● Lab results</string>
|
||||
<string name="interval_value">Δ %1$s d</string>
|
||||
|
||||
<!-- Doses -->
|
||||
<string name="add_dose">Log a dose</string>
|
||||
@ -42,7 +120,15 @@
|
||||
<string name="confirm_delete_treatment">Delete this treatment? Its dose history will also be deleted.</string>
|
||||
|
||||
<!-- 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_value">Value</string>
|
||||
<string name="lab_unit">Unit (e.g. pg/mL, ng/mL)</string>
|
||||
@ -58,6 +144,26 @@
|
||||
<string name="dose_unit">Unit</string>
|
||||
<string name="active">Active</string>
|
||||
<string name="inactive">inactive</string>
|
||||
<!-- v1.11.0 : en-tête de la section des traitements inactifs (regroupés
|
||||
en bas de la page Traitements) -->
|
||||
<string name="inactive_section">Inactive treatments</string>
|
||||
<!-- v1.12.0 : encart latéral « prise de sang le même jour » sur les
|
||||
lignes de doses (↑ = lab avant la dose, ↓ = lab après) -->
|
||||
<string name="lab_before_dose">Blood test earlier the same day, before this dose</string>
|
||||
<string name="lab_after_dose">Blood test later the same day, after this dose</string>
|
||||
<!-- v1.13.0 : valeur E2 attendue au creux recommandé (affichée seulement
|
||||
si la calibration est disponible) + statut face à la cible de creux -->
|
||||
<string name="labrec_predicted">Expected at this trough: ≈ %1$s pg/mL</string>
|
||||
<string name="labrec_in_target">within your target</string>
|
||||
<string name="labrec_out_of_target">outside your target</string>
|
||||
<!-- v1.13.0 : carte Cible de creux (opt-in) — DISTINCT des seuils
|
||||
d'alerte (surveillance continue + notifications) -->
|
||||
<string name="trough_target_section">Trough target (blood draw)</string>
|
||||
<string name="trough_target_hint">Personal reference for the recommended blood draw: the predicted trough value is colored against this band. Distinct from the alert thresholds above — those watch your estimated level continuously and can notify you; this one never does.</string>
|
||||
<string name="trough_target_low">Target low (pg/mL)</string>
|
||||
<string name="trough_target_high">Target high (pg/mL)</string>
|
||||
<string name="trough_target_incomplete">Fill both fields — or leave both empty to disable.</string>
|
||||
<string name="trough_target_invalid">Target high must be greater than target low.</string>
|
||||
<string name="no_treatment_hint">Create a treatment first (Treatments tab).</string>
|
||||
|
||||
<string name="type_estradiol">Estradiol</string>
|
||||
@ -76,6 +182,7 @@
|
||||
<string name="pk_model">PK model</string>
|
||||
<string name="model_ese">Estrannaise</string>
|
||||
<string name="model_tfs">Transfem Science</string>
|
||||
<string name="model_whs">WHSAH</string>
|
||||
<string name="select_preset">Choose a preset (optional)</string>
|
||||
|
||||
<string name="pk_absorption">Time to peak (h)</string>
|
||||
@ -85,7 +192,15 @@
|
||||
<string name="calibration_title">Calibration</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_auto_override">Auto-calibration is ON: curves use the automatic factors, this manual value is ignored for display (use Settings to turn it off).</string>
|
||||
<string name="calibrate_from_labs">Calibrate from labs</string>
|
||||
<string name="auto_calibrate">Auto-calibration (E2 + T)</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">Dosage</string>
|
||||
<string name="freq_enable">Simulate upcoming doses</string>
|
||||
<string name="freq_interval">Interval (days)</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="cancel">Cancel</string>
|
||||
@ -122,22 +237,46 @@
|
||||
<string name="import_json">Import</string>
|
||||
<string name="export_ok">Backup exported.</string>
|
||||
<string name="export_fail">Export failed.</string>
|
||||
<!-- v1.7.0 : sauvegarde automatique journalière (SAF tree + WorkManager) -->
|
||||
<string name="autobackup_section">Automatic daily backup</string>
|
||||
<string name="autobackup_enable">Enable</string>
|
||||
<string name="autobackup_hint">Each day, the app writes a full JSON backup (same format as the manual export) into the folder you choose — e.g. a synced Owncloud folder, so it lands in your cloud. No storage permission is needed: you pick the folder once and can revoke it anytime in the system settings. Only the newest copies are kept (older automatic copies are deleted; your manual exports are never touched).</string>
|
||||
<string name="autobackup_folder">Folder</string>
|
||||
<string name="autobackup_no_folder">No folder selected yet — pick one to enable.</string>
|
||||
<string name="autobackup_choose_folder">Choose folder</string>
|
||||
<string name="autobackup_keep">Copies kept (1–30)</string>
|
||||
<string name="autobackup_last_ok">Last backup: OK (%1$s)</string>
|
||||
<string name="autobackup_last_fail">Last backup: FAILED (%1$s) — check the folder, then re-enable to retry.</string>
|
||||
<string name="autobackup_first_run">Enabled — a first backup was written to the folder.</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_confirm">Restore</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">Erase & restore</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="models_credit">PK profiles from Estrannaise (EstraNase) and Transfem Science injection models, extracted from Estrogen.ods (D, k1–k3 hourly tables).</string>
|
||||
|
||||
<!-- Presets -->
|
||||
<string name="preset_ev_ese">EV injection — Estrannaise</string>
|
||||
<string name="preset_ec_ese">EC injection (oil) — Estrannaise</string>
|
||||
<string name="preset_eb_ese">EB injection — Estrannaise</string>
|
||||
<string name="preset_eucs_ese">EUCS injection (crystal suspension) — Estrannaise</string>
|
||||
<string name="preset_eu_ese">EU injection — Estrannaise</string>
|
||||
<string name="preset_een_ese">EEn injection — Estrannaise</string>
|
||||
<string name="preset_ev_tfs">EV injection — Transfem Science</string>
|
||||
<string name="preset_eu_tfs">EU injection — Transfem Science</string>
|
||||
<string name="preset_een_tfs">EEn injection — Transfem Science</string>
|
||||
<string name="preset_eb_tfs">EB injection — Transfem Science</string>
|
||||
<string name="preset_ec_tfs">EC injection (oil) — Transfem Science</string>
|
||||
<string name="preset_ecs_tfs">EC injection (aqueous suspension) — Transfem Science</string>
|
||||
<string name="preset_ev_whs">EV injection — WHSAH</string>
|
||||
<string name="preset_eu_whs">EU injection — WHSAH</string>
|
||||
<string name="preset_een_whs">EEn injection — WHSAH</string>
|
||||
<string name="preset_eb_whs">EB injection — WHSAH</string>
|
||||
<string name="preset_ec_whs">EC injection (oil) — WHSAH</string>
|
||||
<string name="preset_ecs_whs">EC injection (aqueous suspension) — WHSAH</string>
|
||||
<string name="preset_pep_tfs">PEP injection — Transfem Science</string>
|
||||
<string name="preset_e2_gel">E2 transdermal gel</string>
|
||||
<string name="preset_e2_patch">E2 patch</string>
|
||||
<string name="preset_e2_oral">E2 oral</string>
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
package com.hormonetrack.data.backup
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests de la RÉTENTION des auto-backups journaliers (v1.7.0) — décision
|
||||
* PURE ([AutoBackupRetention.filesToDelete]) : quels noms supprimer quand
|
||||
* la rétention dépasse « copies conservées ».
|
||||
*
|
||||
* Les deux garanties CRITIques (le dossier cible est CELUI DE L'UTILISATRICE,
|
||||
* ex. un dossier Owncloud) :
|
||||
* 1. seuls les auto-backups RECONNAISSABLES (`hormonetrack-auto-<date>-<hhmm>.json`)
|
||||
* participent — les exports MANUELS (`hormonetrack-backup-…`), les logs
|
||||
* et les fichiers personnels du dossier ne sont JAMAIS candidats ;
|
||||
* 2. un nom presque-correct mais MALFORMÉ (horodatage non parsable) n'est
|
||||
* jamais supprimé non plus.
|
||||
* + garde anti-idiocratie : `keep` est clampé à ≥ 1 (keep=0 ne doit JAMAIS
|
||||
* supprimer la dernière copie restante).
|
||||
*/
|
||||
class AutoBackupRetentionTest {
|
||||
|
||||
private fun auto(date: String) = "hormonetrack-auto-$date.json"
|
||||
|
||||
@Test
|
||||
fun `keeps the newest keepCount copies and returns the older ones`() {
|
||||
val files = listOf(
|
||||
auto("20260907-1830"),
|
||||
auto("20260913-1830"), // plus récent
|
||||
auto("20260909-1830"),
|
||||
auto("20260911-1830"),
|
||||
)
|
||||
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 2)
|
||||
// Le tri se fait par horodatage du NOM (pas l'ordre d'entrée)
|
||||
assertEquals(
|
||||
listOf(auto("20260907-1830"), auto("20260909-1830")),
|
||||
toDelete.sorted()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `never touches manual exports logs or foreign files`() {
|
||||
val files = listOf(
|
||||
"hormonetrack-backup-20260101.json", // export MANUEL
|
||||
"hormonetrack-logs-20260101-1200.txt", // logs
|
||||
"owncloud-deja-la.txt", // fichier personnel du dossier choisi
|
||||
auto("20260901-1830"),
|
||||
auto("20260902-1830"),
|
||||
)
|
||||
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 1)
|
||||
// Seul l'auto-backup le plus ancien part ; les étrangers restent
|
||||
assertEquals(listOf(auto("20260901-1830")), toDelete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed auto backup names are never deleted`() {
|
||||
val files = listOf(
|
||||
"hormonetrack-auto-corrompu.json",
|
||||
"hormonetrack-auto-20260913-1830", // sans extension
|
||||
auto("20260910-1830"),
|
||||
)
|
||||
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 1)
|
||||
assertTrue(
|
||||
"les noms non parsables ne sont jamais candidats à la suppression",
|
||||
toDelete.isEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keep is clamped to at least 1 (keep 0 never wipes everything)`() {
|
||||
val files = listOf(auto("20260910-1830"), auto("20260911-1830"))
|
||||
val toDelete = AutoBackupRetention.filesToDelete(files, keepCount = 0)
|
||||
assertTrue(
|
||||
"keep=0 → clampé à 1 : au moins la copie la plus récente survit",
|
||||
toDelete == listOf(auto("20260910-1830"))
|
||||
)
|
||||
// keep négatif : même garde
|
||||
assertTrue(AutoBackupRetention.filesToDelete(files, keepCount = -5).size == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retention already satisfied returns nothing`() {
|
||||
val files = listOf(auto("20260910-1830"), auto("20260911-1830"))
|
||||
assertTrue(AutoBackupRetention.filesToDelete(files, keepCount = 5).isEmpty())
|
||||
assertTrue(AutoBackupRetention.filesToDelete(emptyList(), keepCount = 7).isEmpty())
|
||||
}
|
||||
}
|
||||
@ -92,4 +92,77 @@ class BackupGsonTest {
|
||||
assertEquals(0.15, parsed.tConfig.floor, 1e-9)
|
||||
assertEquals(0.21, parsed.tConfig.k, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backup v2 round-trip preserves user settings (language, auto-cal, thresholds)`() {
|
||||
// v1.4.2 : les PARAMÈTRES utilisateur voyagent avec le backup
|
||||
val data = BackupData(
|
||||
tConfig = TConfig(),
|
||||
settings = UserSettings(
|
||||
language = "fr",
|
||||
autoCalibrate = true,
|
||||
alertE2High = 300.0,
|
||||
alertE2Low = 100.0,
|
||||
alertTHigh = 1.0,
|
||||
alertTLow = 0.3
|
||||
)
|
||||
)
|
||||
|
||||
val json = Gson().toJson(data)
|
||||
val parsed: BackupData = Gson().fromJson(json, BackupData::class.java)
|
||||
|
||||
assertEquals(2, parsed.version)
|
||||
val s = parsed.settings
|
||||
// ⚠️ Ne JAMAIS enlever ce check : sous R8 sans les -keep, parsed.settings
|
||||
// deviendrait non-null avec des champs VIDES (réflexion Gson, cf §16.bis)
|
||||
org.junit.Assert.assertNotNull(s)
|
||||
assertEquals("fr", s!!.language)
|
||||
assertEquals(true, s.autoCalibrate)
|
||||
assertEquals(300.0, s.alertE2High!!, 1e-9)
|
||||
assertEquals(100.0, s.alertE2Low!!, 1e-9)
|
||||
assertEquals(1.0, s.alertTHigh!!, 1e-9)
|
||||
assertEquals(0.3, s.alertTLow!!, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backup round-trip preserves the trough target (v1_13_0) and stays absent when unset`() {
|
||||
// v1.13.0 : la cible de creux voyage dans le backup (champs optionnels)
|
||||
val withTarget = BackupData(
|
||||
tConfig = TConfig(),
|
||||
settings = UserSettings(troughTargetLow = 150.0, troughTargetHigh = 200.0)
|
||||
)
|
||||
val parsed: BackupData = Gson().fromJson(
|
||||
Gson().toJson(withTarget), BackupData::class.java
|
||||
)
|
||||
val s = parsed.settings
|
||||
org.junit.Assert.assertNotNull(s)
|
||||
assertEquals(150.0, s!!.troughTargetLow!!, 1e-9)
|
||||
assertEquals(200.0, s.troughTargetHigh!!, 1e-9)
|
||||
|
||||
// Rétrocompatibilité : un backup SANS cible (v1.4.2→v1.12) parse les
|
||||
// champs à null — l'import ne doit pas toucher à la cible locale
|
||||
val without = BackupData(
|
||||
tConfig = TConfig(),
|
||||
settings = UserSettings(language = "en")
|
||||
)
|
||||
val parsed2: BackupData = Gson().fromJson(
|
||||
Gson().toJson(without), BackupData::class.java
|
||||
)
|
||||
assertEquals(null, parsed2.settings!!.troughTargetLow)
|
||||
assertEquals(null, parsed2.settings!!.troughTargetHigh)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy v1 backup without settings parses with null settings (backward compat)`() {
|
||||
// Les anciens exports (v1.4.1 et avant) n'ont PAS de champ settings :
|
||||
// ils doivent rester importables tels quels.
|
||||
val legacyJson = """
|
||||
{"version":1,"exportedAt":1700000000000,
|
||||
"treatments":[],"doseLogs":[],"labResults":[],
|
||||
"tConfig":{"base":6.0,"floor":0.2,"k":0.19}}
|
||||
""".trimIndent()
|
||||
val parsed: BackupData = Gson().fromJson(legacyJson, BackupData::class.java)
|
||||
assertEquals(1, parsed.version)
|
||||
org.junit.Assert.assertNull(parsed.settings)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
package com.hormonetrack.data.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests des PRESETS de traitements (v1.9.4 — miroir de `tests/presets.test.js`
|
||||
* côté web) : le modèle ESE ANALYTIQUE couvre 6 esters injectables — les
|
||||
* presets de création doivent offrir les 6 (comme TFS offre les 7 du V3C et
|
||||
* WHSAH les 6 du fit Mona). Avant v1.9.4, les presets ESE n'offraient que
|
||||
* EV/EU/EEn (l'héritage des tables ODS) — remontée : « les presets n'ont pas
|
||||
* été mis à jour lors de l'ajout du modèle source ESE ».
|
||||
*
|
||||
* Garde centrale : **aucun preset ne peut créer un traitement non couvert par
|
||||
* son propre modèle** (ester ∈ choicesForModel(pkModel)) — un preset hors
|
||||
* couverture produirait 0 pg/mL sans aucun avertissement (leçon §12).
|
||||
*/
|
||||
class PKPresetsTest {
|
||||
|
||||
/** Presets injectables d'un modèle (ester réel — le NONE Bateman sort). */
|
||||
private fun injectables(model: String) =
|
||||
PKPresets.all.filter { it.pkModel == model && it.esterType != Esters.NONE }
|
||||
|
||||
@Test
|
||||
fun `les presets ESE couvrent les 6 esters du fit analytique`() {
|
||||
val esters = injectables(PKModels.ESTRANNAISE).map { it.esterType }.sorted()
|
||||
// Même ensemble que Esters.choicesForModel(ESE) — l'éditeur et les
|
||||
// presets doivent proposer exactement la couverture du dispatch.
|
||||
assertEquals(Esters.choicesForModel(PKModels.ESTRANNAISE).sorted(), esters)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chaque ester couvert a EXACTEMENT un preset ESE (pas de doublon)`() {
|
||||
val names = injectables(PKModels.ESTRANNAISE).map { it.esterType }
|
||||
assertEquals(names.size, names.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `les presets TFS (7) et WHSAH (6) restent inchangés`() {
|
||||
assertEquals(7, injectables(PKModels.TRANSFEM_SCIENCE).size)
|
||||
assertEquals(6, injectables(PKModels.WHSAH).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aucun preset injectable ne peut créer un traitement non couvert`() {
|
||||
// La garde ne vise que les presets à ester RÉEL (profil PK) : un
|
||||
// preset Bateman (esterType NONE, ex. gel/patch/oral/anti-androgène)
|
||||
// ne passe pas par le dispatch par ester — sa couverture n'a pas de
|
||||
// sens. Un preset injectable hors couverture produirait 0 pg/mL sans
|
||||
// aucun avertissement (leçon §12).
|
||||
for (preset in PKPresets.all.filter { it.esterType != Esters.NONE }) {
|
||||
assertTrue(
|
||||
"preset " + preset.nameRes + " : ester " + preset.esterType +
|
||||
" hors couverture de " + preset.pkModel + " → 0 pg/mL garanti",
|
||||
preset.esterType in Esters.choicesForModel(preset.pkModel)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `les presets ESE non injectables (gel patch oral) restent en Bateman`() {
|
||||
val nonInjectable = PKPresets.all.filter {
|
||||
it.pkModel == PKModels.ESTRANNAISE && it.esterType == Esters.NONE
|
||||
}
|
||||
// 3E2 (gel/patch/oral) + 3 anti-androgènes (CPA/spiro/bica)
|
||||
assertTrue("gel, patch, oral + anti-androgènes toujours présents", nonInjectable.size >= 6)
|
||||
assertTrue(nonInjectable.all { it.bioavailabilityFraction in 0.01f..1f })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `les champs Bateman informatifs des nouveaux presets ESE restent plausibles`() {
|
||||
// Les champs Bateman des presets ESE reflètent les t½ terminales des
|
||||
// fits (informatifs, pas utilisés au calcul) : EC ≈ 202 h (k3 0,669/j),
|
||||
// EB ≈ 4 h (k3 4,34/j), EUCS ≈ 756 h (k2 0,022/j) — on garde des
|
||||
// bornes larges pour ne pas figer les valeurs des fits.
|
||||
val ec = PKPresets.all.first { it.esterType == Esters.EC && it.pkModel == PKModels.ESTRANNAISE }
|
||||
val eb = PKPresets.all.first { it.esterType == Esters.EB && it.pkModel == PKModels.ESTRANNAISE }
|
||||
val eucs = PKPresets.all.first { it.esterType == Esters.EUCS && it.pkModel == PKModels.ESTRANNAISE }
|
||||
assertTrue("t½ EC ESE ≈ 8,4 j (fit 0,669/j)", ec.eliminationHalfLifeHours in 100f..300f)
|
||||
assertTrue("t½ EB ESE courte (fit 4,34/j)", eb.eliminationHalfLifeHours in 1f..24f)
|
||||
assertTrue("t½ EUCS ESE ≈ 31,5 j (suspension cristalline)", eucs.eliminationHalfLifeHours in 400f..1200f)
|
||||
}
|
||||
}
|
||||
143
app/src/test/java/com/hormonetrack/pk/AlertsEngineTest.kt
Normal file
143
app/src/test/java/com/hormonetrack/pk/AlertsEngineTest.kt
Normal file
@ -0,0 +1,143 @@
|
||||
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.Test
|
||||
|
||||
/**
|
||||
* Tests d'INTÉGRATION de la chaîne d'alertes (v1.4.2) : moteur PK → niveau
|
||||
* actuel → évaluation des seuils (`pk/Alerts.kt`).
|
||||
*
|
||||
* Ce que couvrent les AUTRES fichiers (pour s'y retrouver) :
|
||||
* - `AlertsTest` : logique pure des seuils (HIGH/LOW, strict, cohérence,
|
||||
* codec d'état, décision de notification) ;
|
||||
* - `BackupGsonTest` : round-trip des seuils dans le backup JSON ;
|
||||
* - le worker WorkManager (`reminder/AlertNotifier.kt`) n'est PAS testable
|
||||
* en JVM (NotificationManager/WorkManager) — toute sa logique de DÉCISION
|
||||
* a été extraite vers `Alerts` et est testée ici et dans `AlertsTest`.
|
||||
*
|
||||
* Ce fichier épine le câblage : le niveau que la NOTIFICATION évalue
|
||||
* (`levelAt` avec `scalePerEster`) est EXACTEMENT celui affiché par la
|
||||
* carte « niveau actuel » (dernier point de `computeCurve`), et les seuils
|
||||
* se déclenchent sur ce niveau réel.
|
||||
*/
|
||||
class AlertsEngineTest {
|
||||
|
||||
private companion object {
|
||||
const val HOUR_MS = 3_600_000L
|
||||
val BASE = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
/** Traitement EV — Transfem Science, 5 mg, une seule dose à BASE. */
|
||||
private fun evTreatment(doseMg: Double = 5.0): Treatment = Treatment(
|
||||
id = 1L,
|
||||
name = "EV",
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = doseMg,
|
||||
doseUnit = "mg",
|
||||
isActive = true,
|
||||
esterType = Esters.EV,
|
||||
pkModel = PKModels.TRANSFEM_SCIENCE,
|
||||
scaleFactor = 1.0
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `notification level matches the home card level (levelAt vs computeCurve last point)`() {
|
||||
val tr = evTreatment()
|
||||
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0))
|
||||
val now = BASE + 30 * HOUR_MS
|
||||
val scales = mapOf("EV" to 0.8)
|
||||
val tK = mapOf("EV" to 0.25)
|
||||
val tConfig = TConfig()
|
||||
|
||||
// Chemin NOTIFICATION (AlertNotifier → levelAt avec calibration)
|
||||
val viaLevelAt = PharmacokineticEngine.levelAt(
|
||||
listOf(tr), doses, now, tConfig, tKPerEster = tK, scalePerEster = scales
|
||||
)
|
||||
// Chemin CARTE ACCUEIL (HomeScreen → dernier point de computeCurve)
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
listOf(tr), doses, now - HOUR_MS, now, stepMs = HOUR_MS,
|
||||
tConfig = tConfig, scalePerEster = scales, tKPerEster = tK
|
||||
)
|
||||
val last = curve.last()
|
||||
|
||||
// ⚠️ Le point de courbe le plus proche de `now` peut s'arrêter à
|
||||
// now − step/2 selon la grille : on compare les valeurs d'E2 des deux
|
||||
// chemins ÉVALUÉES AU MÊME INSTANT (le dernier point du curve couvre
|
||||
// [now-1h, now] par pas de 1 h → dernier point ≤ now ; levelAt évalue
|
||||
// exactement `now`). Tolérance : dérive PK sur ≤ 1 h (< 10 %).
|
||||
val gapHours = (now - last.timestamp).toDouble() / HOUR_MS
|
||||
assertTrue(gapHours in 0.0..1.0)
|
||||
val ratio = viaLevelAt.e2 / last.e2
|
||||
assertTrue("levelAt=${viaLevelAt.e2} vs curve=${last.e2}", ratio in 0.9..1.1)
|
||||
// La T suit la même E2 et le même k → même contrainte
|
||||
assertEquals(viaLevelAt.t, last.t, viaLevelAt.t * 0.1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `high threshold on the real estimated level triggers HIGH`() {
|
||||
val tr = evTreatment()
|
||||
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0))
|
||||
val now = BASE + 30 * HOUR_MS
|
||||
val level = PharmacokineticEngine.levelAt(listOf(tr), doses, now, TConfig())
|
||||
|
||||
// seuil SOUS le niveau estimé → HIGH attendu (limit = seuil)
|
||||
val half = level.e2 / 2
|
||||
val alerts = Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(e2High = half))
|
||||
assertEquals(1, alerts.size)
|
||||
assertEquals("E2", alerts.single().marker)
|
||||
assertEquals(Alerts.Level.HIGH, alerts.single().level)
|
||||
assertEquals(half, alerts.single().limit, 1e-9)
|
||||
|
||||
// seuil AU-DESSUS du niveau estimé → aucune alerte
|
||||
val double = level.e2 * 2
|
||||
assertTrue(Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(e2High = double)).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `low T threshold triggers LOW (testosterone in ng per mL)`() {
|
||||
val tr = evTreatment()
|
||||
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0))
|
||||
val now = BASE + 30 * HOUR_MS
|
||||
val level = PharmacokineticEngine.levelAt(listOf(tr), doses, now, TConfig())
|
||||
|
||||
// T estimée ≈ 0,3 ng/mL : une limite basse 1,0 ng/mL → LOW
|
||||
assertTrue("T estimée=${level.t} doit être < 1.0", level.t < 1.0)
|
||||
val alerts = Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(tLow = 1.0))
|
||||
assertEquals(1, alerts.size)
|
||||
assertEquals("T", alerts.single().marker)
|
||||
assertEquals(Alerts.Level.LOW, alerts.single().level)
|
||||
assertEquals("ng/mL", alerts.single().unit)
|
||||
|
||||
// limite basse très basse → pas d'alerte
|
||||
assertTrue(Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(tLow = 0.001)).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `anti-spam state reflects only alerted markers (codec over real alerts)`() {
|
||||
val tr = evTreatment()
|
||||
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0))
|
||||
val now = BASE + 30 * HOUR_MS
|
||||
val level = PharmacokineticEngine.levelAt(listOf(tr), doses, now, TConfig())
|
||||
|
||||
// Seuils qui ne déclenchent que l'E2 (pas la T) : l'état persisté ne
|
||||
// contient QUE l'E2 — c'est ce qui est comparé d'une vérification
|
||||
// périodique à l'autre (AlertNotifier).
|
||||
val alerts = Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(e2High = level.e2 / 2))
|
||||
val encoded = Alerts.encodeState(alerts)
|
||||
assertEquals("E2:HIGH", encoded)
|
||||
assertEquals(mapOf("E2" to Alerts.Level.HIGH), Alerts.parseState(encoded))
|
||||
|
||||
// shouldNotify : même franchissement → pas de re-notif ; changement → notif
|
||||
val current = Alerts.parseState(encoded)
|
||||
assertTrue(!Alerts.shouldNotify(current, current))
|
||||
assertTrue(Alerts.shouldNotify(mapOf("E2" to Alerts.Level.LOW), current))
|
||||
}
|
||||
}
|
||||
151
app/src/test/java/com/hormonetrack/pk/AlertsTest.kt
Normal file
151
app/src/test/java/com/hormonetrack/pk/AlertsTest.kt
Normal file
@ -0,0 +1,151 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Seuils d'alerte configurables (v1.4.2) — module PUR (`pk/Alerts.kt`).
|
||||
*
|
||||
* Règles épinglées :
|
||||
* - opt-in : seuil null → jamais d'alerte (l'app n'invente pas de normes) ;
|
||||
* - comparaisons STRICTES (valeur == limite → pas d'alerte, éviter le
|
||||
* clignotement sur l'arrondi d'affichage) ;
|
||||
* - HIGH prime LOW en configuration incohérente (défensif : la saisie
|
||||
* l'interdit) ;
|
||||
* - évaluation E2 (pg/mL) puis T (ng/mL), ordre stable.
|
||||
*/
|
||||
class AlertsTest {
|
||||
|
||||
@Test
|
||||
fun `no thresholds configured - no alerts`() {
|
||||
val t = Alerts.Thresholds()
|
||||
assertTrue(Alerts.evaluateAll(420.0, 0.05, t).isEmpty())
|
||||
assertTrue(Alerts.evaluateAll(0.0, 0.0, t).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `high threshold exceeded - HIGH alert`() {
|
||||
val t = Alerts.Thresholds(e2High = 300.0)
|
||||
val alerts = Alerts.evaluateAll(currentE2 = 420.0, currentT = 0.3, t = t)
|
||||
assertEquals(1, alerts.size)
|
||||
val a = alerts.single()
|
||||
assertEquals("E2", a.marker)
|
||||
assertEquals(Alerts.Level.HIGH, a.level)
|
||||
assertEquals(420.0, a.value, 1e-9)
|
||||
assertEquals(300.0, a.limit, 1e-9)
|
||||
assertEquals("pg/mL", a.unit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `low threshold undercut - LOW alert`() {
|
||||
val t = Alerts.Thresholds(tLow = 0.3)
|
||||
val alerts = Alerts.evaluateAll(currentE2 = 150.0, currentT = 0.12, t = t)
|
||||
assertEquals(1, alerts.size)
|
||||
val a = alerts.single()
|
||||
assertEquals("T", a.marker)
|
||||
assertEquals(Alerts.Level.LOW, a.level)
|
||||
assertEquals("ng/mL", a.unit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact value at the limit does not trigger (strict comparison)`() {
|
||||
val t = Alerts.Thresholds(e2High = 300.0, e2Low = 100.0)
|
||||
assertTrue(Alerts.evaluateAll(300.0, 0.3, t).isEmpty())
|
||||
assertTrue(Alerts.evaluateAll(100.0, 0.3, t).isEmpty())
|
||||
// juste au-dessus / en dessous : alerte
|
||||
assertEquals(1, Alerts.evaluateAll(300.01, 0.3, t).size)
|
||||
assertEquals(1, Alerts.evaluateAll(99.99, 0.3, t).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both markers can alert at once (E2 first, then T)`() {
|
||||
val t = Alerts.Thresholds(e2High = 300.0, tLow = 0.3)
|
||||
val alerts = Alerts.evaluateAll(420.0, 0.12, t)
|
||||
assertEquals(2, alerts.size)
|
||||
assertEquals("E2", alerts[0].marker)
|
||||
assertEquals("T", alerts[1].marker)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coherence check - high must be above low when both set`() {
|
||||
assertTrue(Alerts.Thresholds(e2High = 300.0, e2Low = 100.0).isCoherent())
|
||||
assertFalse(Alerts.Thresholds(e2High = 100.0, e2Low = 300.0).isCoherent())
|
||||
// un seul côté défini → toujours cohérent
|
||||
assertTrue(Alerts.Thresholds(e2High = 100.0).isCoherent())
|
||||
assertTrue(Alerts.Thresholds(tHigh = 1.0, tLow = 0.5).isCoherent())
|
||||
assertFalse(Alerts.Thresholds(tHigh = 0.5, tLow = 1.0).isCoherent())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incoherent config is defused - HIGH wins over LOW`() {
|
||||
// Cas impossible via la saisie (validée), mais l'évaluation ne doit
|
||||
// jamais renvoyer deux verdicts contradictoires pour un même marqueur.
|
||||
val t = Alerts.Thresholds(e2High = 100.0, e2Low = 300.0)
|
||||
val alerts = Alerts.evaluateAll(420.0, 0.3, t)
|
||||
assertEquals(1, alerts.size)
|
||||
assertEquals(Alerts.Level.HIGH, alerts.single().level)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `evaluate single value - null limits never trigger`() {
|
||||
// aucune limite → jamais d'alerte
|
||||
assertNull(Alerts.evaluate(500.0, low = null, high = null))
|
||||
// limite basse seule : valeur SOUS la limite → LOW ; au-dessus → rien
|
||||
assertEquals(Alerts.Level.LOW to 600.0, Alerts.evaluate(500.0, low = 600.0, high = null))
|
||||
assertNull(Alerts.evaluate(700.0, low = 600.0, high = null))
|
||||
// limite haute seule : valeur AU-DESSUS → HIGH ; en dessous → rien
|
||||
assertEquals(Alerts.Level.HIGH to 600.0, Alerts.evaluate(601.0, low = null, high = 600.0))
|
||||
assertNull(Alerts.evaluate(599.0, low = null, high = 600.0))
|
||||
// les deux limites : chacune franchit son côté
|
||||
assertEquals(Alerts.Level.HIGH to 600.0, Alerts.evaluate(601.0, 600.0, 600.0))
|
||||
assertEquals(Alerts.Level.LOW to 600.0, Alerts.evaluate(599.0, 600.0, 600.0))
|
||||
}
|
||||
|
||||
// ── v1.4.2 : notification d'alerte (état persisté + anti-spam) ──
|
||||
|
||||
@Test
|
||||
fun `state codec round-trips (E2 and T levels)`() {
|
||||
val alerts = listOf(
|
||||
Alerts.Alert("E2", Alerts.Level.HIGH, 420.0, 300.0, "pg/mL"),
|
||||
Alerts.Alert("T", Alerts.Level.LOW, 0.12, 0.3, "ng/mL")
|
||||
)
|
||||
val encoded = Alerts.encodeState(alerts)
|
||||
assertEquals("E2:HIGH;T:LOW", encoded)
|
||||
val parsed = Alerts.parseState(encoded)
|
||||
assertEquals(Alerts.Level.HIGH, parsed["E2"])
|
||||
assertEquals(Alerts.Level.LOW, parsed["T"])
|
||||
// round-trip complet : encodé puis décodé = mêmes niveaux
|
||||
assertEquals(
|
||||
alerts.map { it.marker to it.level }.toMap(),
|
||||
Alerts.parseState(Alerts.encodeState(alerts))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state codec - empty and malformed inputs are safe`() {
|
||||
assertEquals("", Alerts.encodeState(emptyList()))
|
||||
assertTrue(Alerts.parseState(null).isEmpty())
|
||||
assertTrue(Alerts.parseState("").isEmpty())
|
||||
// niveau inconnu (évolution future) → entrée ignorée, pas de crash
|
||||
assertTrue(Alerts.parseState("E2:UNKNOWN").isEmpty())
|
||||
assertTrue(Alerts.parseState("E2").isEmpty()) // sans ":"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldNotify - new breach fires, same state does not`() {
|
||||
val current = mapOf("E2" to Alerts.Level.HIGH)
|
||||
// jamais notifié → notifie
|
||||
assertTrue(Alerts.shouldNotify(current, null))
|
||||
// même état → pas de re-notification (anti-spam 15 min)
|
||||
assertFalse(Alerts.shouldNotify(current, current))
|
||||
// changement de niveau (H → L) → re-notify
|
||||
assertTrue(Alerts.shouldNotify(mapOf("E2" to Alerts.Level.LOW), current))
|
||||
// écart TERMINE (current vide) → pas de notif « retour à la normale »
|
||||
assertFalse(Alerts.shouldNotify(emptyMap(), current))
|
||||
// mais le retour à la normale permet de re-notifier le prochain écart
|
||||
assertTrue(Alerts.shouldNotify(current, emptyMap()))
|
||||
}
|
||||
}
|
||||
192
app/src/test/java/com/hormonetrack/pk/CalibrationPerModelTest.kt
Normal file
192
app/src/test/java/com/hormonetrack/pk/CalibrationPerModelTest.kt
Normal file
@ -0,0 +1,192 @@
|
||||
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.LabResult
|
||||
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.Test
|
||||
|
||||
/**
|
||||
* Process de tests **CALIBRATION** (v1.4.8, fix #60) — sémantique
|
||||
* « calibration PAR MODÈLE PK » : chaque courbe affichée (ESE/TFS/WHS) est
|
||||
* calibrée avec la prédiction de SON modèle, donc chaque courbe calibrée
|
||||
* passe par les labs.
|
||||
*
|
||||
* Compléments des tests voisins (pour s'y retrouver) :
|
||||
* - `WhsahEngineTest.calibration is per model` : TFS vs WHS (le cas de la
|
||||
* remontée ×2,21) ;
|
||||
* - `WhsahEngineTest.calibration applies to ALL model curves` : le facteur
|
||||
* fourni est appliqué à toutes les courbes (application uniforme) ;
|
||||
* - `PharmacokineticEngineTest` : calibration T manuelle, SF médiane.
|
||||
*
|
||||
* Ce fichier couvre ce qui restait sans filet :
|
||||
* 1. la couverture des **3 modèles** (ESE inclus) — chaque courbe calibrée
|
||||
* atterrit sur le lab ;
|
||||
* 2. la **délégation** `autoCalibrated(modelOverride)` →
|
||||
* `computeEsterScaleFactors(modelOverride)` (le graphique et Home
|
||||
* utilisent la même fonction, pas deux implémentations) ;
|
||||
* 3. le **k T par modèle** (calibré contre l'E2 calibrée de SON modèle) ;
|
||||
* 4. les cas **dégénérés** (labs antérieurs à la 1ʳᵉ dose → échelles vides ;
|
||||
* prédiction trop faible → lab ignoré).
|
||||
*/
|
||||
class CalibrationPerModelTest {
|
||||
|
||||
private companion object {
|
||||
val BASE = System.currentTimeMillis()
|
||||
const val DAY_MS = 86_400_000L
|
||||
}
|
||||
|
||||
@org.junit.Before
|
||||
fun loadProfiles() {
|
||||
// ⚠️ le chemin ESE lit les tables ODS (asset) — sans init,
|
||||
// PKProfileStore.sample retourne 0 et les labs seraient ignorés
|
||||
// (prédiction ≤ 0,5) → échelles vides. Les modèles TFS/WHS sont
|
||||
// purs (formes closes) et n'en dépendent pas.
|
||||
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
||||
val file = java.io.File("src/test/assets/pk_profiles.json")
|
||||
.takeIf { it.exists() }
|
||||
?: java.io.File("src/main/assets/pk_profiles.json").takeIf { it.exists() }
|
||||
?: java.io.File("app/src/main/assets/pk_profiles.json")
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
}
|
||||
|
||||
/** Traitement EEn (modèle TFS stocké), 5 mg à BASE. */
|
||||
private fun treatment(): Treatment = Treatment(
|
||||
id = 1L,
|
||||
name = "EEn",
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = 5.0,
|
||||
doseUnit = "mg",
|
||||
isActive = true,
|
||||
esterType = Esters.EEN,
|
||||
pkModel = PKModels.TRANSFEM_SCIENCE
|
||||
)
|
||||
|
||||
private fun doses(): List<DoseLog> = listOf(
|
||||
DoseLog(treatmentId = 1L, timestamp = BASE, doseAmount = 5.0)
|
||||
)
|
||||
|
||||
/** Lab E2 de 250 pg/mL 7 jours après l'injection (au creux TFS). */
|
||||
private fun labE2(): LabResult = LabResult(
|
||||
marker = "E2", value = 250.0, unit = "pg/mL",
|
||||
timestamp = BASE + 7 * DAY_MS
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `all three models calibrated land on the lab (ESE included)`() {
|
||||
val tr = treatment()
|
||||
val labs = listOf(labE2())
|
||||
for (model in listOf("ESE", "TFS", "WHS")) {
|
||||
val scales = PharmacokineticEngine.computeEsterScaleFactors(
|
||||
listOf(tr), doses(), labs, modelOverride = model
|
||||
)
|
||||
assertTrue("échelle $model absente", scales.containsKey("EEN"))
|
||||
val calibrated = PharmacokineticEngine.e2At(
|
||||
listOf(tr), doses(), labE2().timestamp,
|
||||
modelOverride = model, scalePerEster = scales
|
||||
)
|
||||
assertTrue(
|
||||
"$model : calibrée=$calibrated doit ≈ 250 (échelle ${scales["EEN"]})",
|
||||
calibrated in 250.0 * 0.98..250.0 * 1.02
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three models give three distinct calibration factors`() {
|
||||
val tr = treatment()
|
||||
val labs = listOf(labE2())
|
||||
val ese = PharmacokineticEngine.computeEsterScaleFactors(
|
||||
listOf(tr), doses(), labs, modelOverride = "ESE")["EEN"]!!
|
||||
val tfs = PharmacokineticEngine.computeEsterScaleFactors(
|
||||
listOf(tr), doses(), labs, modelOverride = "TFS")["EEN"]!!
|
||||
val whs = PharmacokineticEngine.computeEsterScaleFactors(
|
||||
listOf(tr), doses(), labs, modelOverride = "WHS")["EEN"]!!
|
||||
// les prédictions des 3 modèles diffèrent au même instant (7 j) :
|
||||
// ESE ≈ 156, TFS ≈ 159, WHS ≈ 178 → facteurs distincts. Nuances :
|
||||
// ESE et TFS sont QUASI IDENTIQUES au creux 7 j (~2 % — la table ODS
|
||||
// dérive du même article) ; WHSAH s'en écarte (~11 % — décroissance
|
||||
// lente, t½ 7,3 j) mais sans excès
|
||||
assertTrue("ESE=$ese vs TFS=$tfs (≥ 1 % : distincts)", kotlin.math.abs(ese - tfs) > 0.01 * ese)
|
||||
assertTrue("TFS=$tfs vs WHS=$whs (≥ 5 %)", kotlin.math.abs(tfs - whs) > 0.05 * tfs)
|
||||
assertTrue("WHS=$whs vs ESE=$ese (≥ 5 %)", kotlin.math.abs(whs - ese) > 0.05 * whs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `autoCalibrated delegates to computeEsterScaleFactors per model (single implementation)`() {
|
||||
val tr = treatment()
|
||||
val labs = listOf(labE2())
|
||||
for (model in listOf(null, "ESE", "TFS", "WHS")) {
|
||||
val viaAuto = PharmacokineticEngine.autoCalibrated(
|
||||
listOf(tr), doses(), labs, TConfig(), modelOverride = model
|
||||
).esterScales
|
||||
val direct = PharmacokineticEngine.computeEsterScaleFactors(
|
||||
listOf(tr), doses(), labs, modelOverride = model
|
||||
)
|
||||
assertEquals("délégation brisée pour modelOverride=$model", direct, viaAuto)
|
||||
}
|
||||
// null = modèle stocké du traitement (TFS) → cohérent avec override TFS
|
||||
assertEquals(
|
||||
PharmacokineticEngine.autoCalibrated(
|
||||
listOf(tr), doses(), labs, TConfig(), modelOverride = "TFS"
|
||||
).esterScales,
|
||||
PharmacokineticEngine.autoCalibrated(
|
||||
listOf(tr), doses(), labs, TConfig()
|
||||
).esterScales
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testosterone k is calibrated against the E2 of the requested model`() {
|
||||
val tr = treatment()
|
||||
// lab T de 0,35 ng/mL 7 jours après l'injection (même instant que le lab E2)
|
||||
val labs = listOf(
|
||||
labE2(),
|
||||
LabResult(marker = "T", value = 0.35, unit = "ng/mL",
|
||||
timestamp = BASE + 7 * DAY_MS)
|
||||
)
|
||||
val tConfig = TConfig() // base 6.0, floor 0.2, k 0.19
|
||||
|
||||
// Invariant mathématique : k ∝ 1/E2 calibrée — donner une échelle
|
||||
// ×2 doit DIVISER le k par 2 exactement (le k est calculé contre
|
||||
// l'E2 calibrée de la demande modelOverride, pas contre une E2 figée).
|
||||
val tLabs = labs.filter { it.marker.equals("T", true) }
|
||||
fun kFor(scale: Double): Double = PharmacokineticEngine.computeTKPerEster(
|
||||
listOf(tr), doses(), tLabs, tConfig,
|
||||
scalePerEster = mapOf("EEN" to scale),
|
||||
modelOverride = "TFS"
|
||||
)["EEN"]!!
|
||||
val k1 = kFor(1.0)
|
||||
val k2 = kFor(2.0)
|
||||
assertTrue("k(échelle 1)=$k1 doit être dans la garde", k1 > 1e-4 && k1 < 10.0)
|
||||
assertEquals("k ∝ 1/E2 calibrée : échelle ×2 → k ÷2", k1 / 2.0, k2, k1 * 0.01)
|
||||
// et le k calibré ≠ k par défaut (0,19) : il a bien été recalculé
|
||||
// contre l'E2 du modèle (fix #60)
|
||||
assertTrue(
|
||||
"k calibré=$k1 doit différer du k stocké (0,19)",
|
||||
kotlin.math.abs(k1 - 0.19) > 0.01
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `labs before the first dose yield empty scales (per model too)`() {
|
||||
val tr = treatment()
|
||||
// lab ANTÉRIEUR à la 1ʳᵉ dose → pas de période d'attribution → vide
|
||||
val labs = listOf(LabResult(
|
||||
marker = "E2", value = 250.0, unit = "pg/mL", timestamp = BASE - DAY_MS
|
||||
))
|
||||
for (model in listOf(null, "ESE", "TFS", "WHS")) {
|
||||
val scales = PharmacokineticEngine.computeEsterScaleFactors(
|
||||
listOf(tr), doses(), labs, modelOverride = model
|
||||
)
|
||||
assertTrue("échelles vides attendues pour modelOverride=$model", scales.isEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
159
app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt
Normal file
159
app/src/test/java/com/hormonetrack/pk/EstrannaiseCloudTest.kt
Normal file
@ -0,0 +1,159 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Tests du NUAGE D'INCERTITUDE (v1.9.0 — cf doc §7.12) :
|
||||
* [EstrannaiseCloud.compute] — feature exclusive au modèle ESE (les fits
|
||||
* TFS/WHSAH ne publient pas de posterior MCMC).
|
||||
*
|
||||
* Épinglés :
|
||||
* - 32 courbes par défaut, chacune ≥ 2 points, dans la fenêtre demandée ;
|
||||
* - DISPERSION réelle : les courbes du nuage DIFFÈRENT entre elles (les
|
||||
* échantillons MCMC couvrent une plage de paramètres — un nuage où toutes
|
||||
* les courbes coïncideraient serait un bug de chargement) ;
|
||||
* - non calculable → vide : pas de traitement ESE porteur (TFS/oral),
|
||||
* MCMC non chargé, nbCurves < 2.
|
||||
*/
|
||||
class EstrannaiseCloudTest {
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
private val NOW = 1_790_000_000_000L
|
||||
|
||||
/** Traitement EEn ESE actif, 5 mg hebdo sur 4 semaines. */
|
||||
private fun ese() = Treatment(
|
||||
id = 1, name = "EEn ESE", type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = 5.0, doseUnit = "mg", esterType = "EEN", pkModel = "ESE",
|
||||
forecastIntervalDays = 7.0, createdAt = 0
|
||||
)
|
||||
|
||||
private fun doses() = (4 downTo 1).map { k ->
|
||||
DoseLog(id = k.toLong(), treatmentId = 1, timestamp = NOW - k * 7 * DAY_MS, doseAmount = 5.0)
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
if (EstrannaiseModels.mcmcSamples("EEN").isEmpty()) {
|
||||
val candidates = listOf(
|
||||
File("src/main/assets/mcmc_samples.json"),
|
||||
File("app/src/main/assets/mcmc_samples.json")
|
||||
)
|
||||
val file = candidates.firstOrNull { it.exists() }
|
||||
?: error("mcmc_samples.json introuvable (nuage d'incertitude)")
|
||||
EstrannaiseModels.initWithJson(file.readText())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud has 32 curves with real dispersion between them`() {
|
||||
val cloud = EstrannaiseCloud.compute(
|
||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L
|
||||
)
|
||||
assertEquals("32 courbes par défaut", 32, cloud.size)
|
||||
assertTrue(cloud.all { it.size >= 2 })
|
||||
// DISPERSION : le max E2 de chaque courbe diffère d'une courbe à
|
||||
// l'autre (sinon tous les échantillons seraient identiques)
|
||||
val peaks = cloud.map { c -> c.maxOf { it.e2 } }.distinct()
|
||||
assertTrue(
|
||||
"les courbes du nuage divergent (${peaks.size} maxima distincts)",
|
||||
peaks.size >= 8
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud stays within the requested window`() {
|
||||
val start = NOW - 10 * DAY_MS
|
||||
val end = NOW
|
||||
val cloud = EstrannaiseCloud.compute(
|
||||
listOf(ese()), doses(), start, end, 3_600_000L
|
||||
)
|
||||
assertTrue(cloud.all { c -> c.all { it.timestamp in start..end } })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `doses of a TFS-stored treatment ARE covered when ESE is displayed (fix v1_9_2)`() {
|
||||
// FIX « le nuage ne s'active que autour du tracé émulé, pas autour du
|
||||
// modèle Estrannaise » : le traitement est STOCKÉ en TFS, mais quand
|
||||
// ESE est affiché, computeCurve(modelOverride = "ESE") redessine ses
|
||||
// doses avec le modèle Estrannaise → le nuage doit les couvrir aussi
|
||||
// (le pkModel stocké n'est pas un prérequis). Le nuage superpose les
|
||||
// doses EEn avec les params MCMC de EEN.
|
||||
val tfsStored = ese().copy(pkModel = "TFS")
|
||||
val cloud = EstrannaiseCloud.compute(listOf(tfsStored), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L)
|
||||
assertEquals(32, cloud.size)
|
||||
assertTrue(cloud.all { it.size >= 2 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oral-only treatment produces no cloud (Bateman is not MCMC-sampled)`() {
|
||||
// L'oral Bateman n'a pas d'ester E2 échantillonnable : le posterior
|
||||
// MCMC ne s'applique pas → pas de nuage (cohérent : rien n'est tracé
|
||||
// « en ESE » pour ces doses)
|
||||
val oral = Treatment(
|
||||
id = 2, name = "CPA", type = TreatmentType.ANTI_ANDROGEN,
|
||||
route = AdministrationRoute.ORAL, doseAmount = 12.5, doseUnit = "mg",
|
||||
esterType = "NONE", forecastIntervalDays = 2.0, createdAt = 0
|
||||
)
|
||||
assertTrue(EstrannaiseCloud.compute(listOf(oral), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown ester doses contribute zero (EUCS-free esters covered by ESE)`() {
|
||||
// PEP : pas de fit Estrannaise → ses doses contribuent 0 au nuage
|
||||
// (comme dans la courbe ESE) — le nuage des autres esters reste
|
||||
val pep = ese().copy(esterType = "PEP")
|
||||
val dosesPep = listOf(DoseLog(id = 9, treatmentId = 1, timestamp = NOW - 7 * DAY_MS, doseAmount = 25.0))
|
||||
val cloud = EstrannaiseCloud.compute(listOf(pep), dosesPep, NOW - 30 * DAY_MS, NOW, 3_600_000L)
|
||||
// PEP n'est pas couvert par le fit ESE → pas d'échantillon → nuage vide
|
||||
assertTrue(cloud.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no doses on the carrier produces no cloud`() {
|
||||
assertTrue(EstrannaiseCloud.compute(
|
||||
listOf(ese()), emptyList(), NOW - 30 * DAY_MS, NOW, 3_600_000L
|
||||
).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nbCurves below 2 produces no cloud`() {
|
||||
assertTrue(EstrannaiseCloud.compute(
|
||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L, nbCurves = 1
|
||||
).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud follows the calibration like the displayed ESE curve (fix v1_9_1)`() {
|
||||
// FIX « le nuage ne s'active que autour du tracé, pas autour du
|
||||
// modèle Estrannaise » : la courbe ESE affichée est CALIBRÉE
|
||||
// (scalePerEster des labs) mais le nuage était tracé BRUT → les deux
|
||||
// flottaient à des échelles différentes. Avec le MÊME scalePerEster,
|
||||
// le nuage ENTOURE la courbe calibrée (×2 ⇒ nuage ×2, écart de
|
||||
// niveau entre nuage calibré et nuage brut ≈ ×2 également).
|
||||
val scaled = mapOf("EEN" to 2.0)
|
||||
val calibrated = EstrannaiseCloud.compute(
|
||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L,
|
||||
scalePerEster = scaled
|
||||
)
|
||||
val raw = EstrannaiseCloud.compute(
|
||||
listOf(ese()), doses(), NOW - 30 * DAY_MS, NOW, 3_600_000L
|
||||
)
|
||||
assertEquals(calibrated.size, raw.size)
|
||||
assertTrue(calibrated.isNotEmpty())
|
||||
// Chaque point du nuage calibré ≈ 2× le point brut correspondant
|
||||
val rawLast = raw.last()
|
||||
val calLast = calibrated.last()
|
||||
assertEquals(
|
||||
calLast.last().e2, rawLast.last().e2 * 2.0, rawLast.last().e2 * 0.01
|
||||
)
|
||||
}
|
||||
}
|
||||
161
app/src/test/java/com/hormonetrack/pk/EstrannaiseModelsTest.kt
Normal file
161
app/src/test/java/com/hormonetrack/pk/EstrannaiseModelsTest.kt
Normal file
@ -0,0 +1,161 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Tests du modèle **Estrannaise analytique** (v1.9.0 — cf doc §7.12).
|
||||
*
|
||||
* CONTEXTE : depuis v1.9.0, le modèle ESE n'est plus les tables horaires ODS
|
||||
* mais la **forme close 3C** publiée par estrannaise.js (les mêmes sources
|
||||
* dont l'ODS n'était que l'échantillonnage horaire). Chaque test épingle :
|
||||
* - **FIDÉLITÉ** : la forme close reproduit les tables ODS à l'identique
|
||||
* (garde anti-régression du débranchement — RMS ≈ 0) ;
|
||||
* - les **cas dégénérés** de taux égaux (solutions limites portées du JS
|
||||
* source — jamais NaN) ;
|
||||
* - le **posterior MCMC** chargé (313 échantillons par ester) et sa t½
|
||||
* analytique.
|
||||
*/
|
||||
class EstrannaiseModelsTest {
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// Tables ODS (src/test/assets depuis v1.9.0) : référence de fidélité
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val candidates = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/test/assets/pk_profiles.json")
|
||||
)
|
||||
val file = candidates.firstOrNull { it.exists() }
|
||||
?: error("pk_profiles.json introuvable pour les tests de fidélité")
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
// Posterior MCMC (asset runtime v1.9.0)
|
||||
if (EstrannaiseModels.mcmcSamples("EV").isEmpty()) {
|
||||
val candidates = listOf(
|
||||
File("src/main/assets/mcmc_samples.json"),
|
||||
File("app/src/main/assets/mcmc_samples.json")
|
||||
)
|
||||
val file = candidates.firstOrNull { it.exists() }
|
||||
?: error("mcmc_samples.json introuvable (nuage d'incertitude)")
|
||||
EstrannaiseModels.initWithJson(file.readText())
|
||||
}
|
||||
}
|
||||
|
||||
/** FIDÉLITÉ (le cœur du débranchement v1.9.0) : forme close == tables ODS. */
|
||||
@Test
|
||||
fun `analytic shape reproduces the ODS tables exactly`() {
|
||||
val params = mapOf(
|
||||
"EV" to EstrannaiseModels.model("EV")!!,
|
||||
"EU" to EstrannaiseModels.model("EU")!!,
|
||||
"EEN" to EstrannaiseModels.model("EEN")!!
|
||||
)
|
||||
for ((ester, m) in params) {
|
||||
val profiles = com.google.gson.JsonParser.parseString(
|
||||
File("src/test/assets/pk_profiles.json").let {
|
||||
if (!it.exists()) File("app/src/test/assets/pk_profiles.json").readText() else it.readText()
|
||||
}
|
||||
).asJsonObject.getAsJsonObject("profiles")
|
||||
val key = profiles.keySet().firstOrNull {
|
||||
it.lowercase().startsWith(ester.lowercase() + "_") && it.lowercase().endsWith("ese")
|
||||
} ?: error("table $ester introuvable")
|
||||
val arr = profiles.getAsJsonArray(key)
|
||||
|
||||
var maxAbs = 0.0
|
||||
var sumSq = 0.0
|
||||
for (h in 0..4800) { // 0 → 200 j
|
||||
val analytic = EstrannaiseModels.sample(ester, h.toDouble())
|
||||
val table = arr[h].asDouble
|
||||
val diff = analytic - table
|
||||
maxAbs = maxOf(maxAbs, kotlin.math.abs(diff))
|
||||
sumSq += diff * diff
|
||||
}
|
||||
val rms = kotlin.math.sqrt(sumSq / 4801)
|
||||
assertTrue(
|
||||
"$ester : forme close == table (maxAbs=$maxAbs, rms=$rms)",
|
||||
maxAbs <= 0.01 && rms <= 0.01
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Les pics publiés (tables ODS) sont reproduits par la forme close. */
|
||||
@Test
|
||||
fun `published peaks match the analytic curve`() {
|
||||
// (pic table @ arrondi h ; scan analytique au pas de 6 min)
|
||||
data class Expect(val ester: String, val peak: Double, val tPeakHours: Int)
|
||||
for (e in listOf(
|
||||
Expect("EV", 61.12, 45), Expect("EU", 3.44, 55), Expect("EEN", 31.35, 152)
|
||||
)) {
|
||||
val m = EstrannaiseModels.model(e.ester)!!
|
||||
var peak = 0.0
|
||||
var tPeak = 0.0
|
||||
var t = 0.0
|
||||
while (t <= 200.0) {
|
||||
val v = EstrannaiseModels.sample(e.ester, t * 24.0)
|
||||
if (v > peak) { peak = v; tPeak = t }
|
||||
t += 0.25 / 24.0
|
||||
}
|
||||
assertEquals("pic ${e.ester}", e.peak, peak, e.peak * 0.002)
|
||||
// ±3,5 h : le sommet de la table (échantillonnée à l'heure,
|
||||
// arrondie) est décalé vs le pic continu (EEN : 152 → 154,5 h)
|
||||
assertEquals("Tmax ${e.ester}", e.tPeakHours.toDouble(), tPeak * 24.0, 3.5)
|
||||
assertNotNull(m)
|
||||
}
|
||||
}
|
||||
|
||||
/** Taux dégénérés : les solutions limites ne produisent ni NaN ni négatif. */
|
||||
@Test
|
||||
fun `degenerate equal rates use the limit solutions (no NaN)`() {
|
||||
for (p in listOf(
|
||||
EstrannaiseModels.Param(478.0, 0.236, 0.236, 1.24), // k1 == k2
|
||||
EstrannaiseModels.Param(478.0, 0.236, 1.24, 0.236), // k1 == k3
|
||||
EstrannaiseModels.Param(478.0, 0.236, 1.24, 1.24), // k2 == k3
|
||||
EstrannaiseModels.Param(478.0, 0.5, 0.5, 0.5) // tous égaux
|
||||
)) {
|
||||
for (t in listOf(0.5, 1.0, 24.0, 240.0, 2400.0)) { // heures
|
||||
val v = EstrannaiseModels.sampleWithParams("EV", t, p)
|
||||
assertTrue("finie et ≥ 0 (t=$t, k=(${p.k1},${p.k2},${p.k3}))", v >= 0.0 && !v.isNaN())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Posterior MCMC : chargé, 313 échantillons par ester, couverture réelle. */
|
||||
@Test
|
||||
fun `mcmc posterior is loaded with 313 samples per injectable ester`() {
|
||||
for (ester in listOf("EV", "EU", "EEN", "EC", "EB", "EUCS")) {
|
||||
val samples = EstrannaiseModels.mcmcSamples(ester)
|
||||
assertEquals("313 échantillons ($ester)", 313, samples.size)
|
||||
// les échantillons couvrent une PLAGE (premier ≠ dernier)
|
||||
assertTrue(
|
||||
"le posterior $ester couvre une plage",
|
||||
samples.first().d != samples.last().d
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** t½ analytique : EV = ln2/min(0,236 ; 4,85 ; 1,24) ≈ 2,94 j. */
|
||||
@Test
|
||||
fun `terminal half-life is analytic (ln2 over slowest rate)`() {
|
||||
val tHalf = EstrannaiseModels.model("EV")!!.terminalHalfLifeDays
|
||||
assertEquals(kotlin.math.ln(2.0) / 0.236, tHalf, 1e-12)
|
||||
}
|
||||
|
||||
/** Normalisation par mg + gardes (dt ≤ 0, ester inconnu). */
|
||||
@Test
|
||||
fun `sample is normalized per mg with guards`() {
|
||||
val one = EstrannaiseModels.sample("EEN", 24.0) // 1 mg
|
||||
val five = 5.0 * one
|
||||
assertEquals(5.0 * one, five, 1e-12) // linéarité (trivial mais épinglé)
|
||||
assertEquals(0.0, EstrannaiseModels.sample("EEN", 0.0), 1e-12)
|
||||
assertEquals(0.0, EstrannaiseModels.sample("EEN", -1.0), 1e-12)
|
||||
assertEquals(0.0, EstrannaiseModels.sample("XXX", 24.0), 1e-12)
|
||||
assertTrue(EstrannaiseModels.sample("EUCS", 24.0) > 0.0) // EUCS couvert
|
||||
}
|
||||
}
|
||||
146
app/src/test/java/com/hormonetrack/pk/ExtremaTest.kt
Normal file
146
app/src/test/java/com/hormonetrack/pk/ExtremaTest.kt
Normal file
@ -0,0 +1,146 @@
|
||||
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/test/assets/pk_profiles.json"),
|
||||
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())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* **FIX #70 (v1.12.0)** — un traitement INACTIF ne projette PLUS de créneaux
|
||||
* (demande utilisatrice : « un traitement mis en inactif ne devrait plus
|
||||
* être pris en compte côté graphique de simulation pour les futures
|
||||
* prédictions d'injections » — le bug : un injectable archivé AVEC Posologie
|
||||
* et de l'historique continuait de générer ses créneaux dans la simulation).
|
||||
*
|
||||
* Sémantique §6.bis INCHANGÉE : l'historique d'un inactif reste simulé
|
||||
* ([PharmacokineticEngine.computeCurve] ne passe pas par
|
||||
* [PharmacokineticEngine.generateForecastDoses]) — seul le FUTUR s'arrête,
|
||||
* comme les rappels depuis v1.4.0 (nextReminderFireFor garde déjà !isActive).
|
||||
*/
|
||||
class ForecastDosesGuardTest {
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
private val NOW = 1_790_000_000_000L
|
||||
|
||||
private fun tr(id: Long, name: String, isActive: Boolean) = Treatment(
|
||||
id = id, name = name, type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = 5.0, doseUnit = "mg",
|
||||
isActive = isActive,
|
||||
forecastIntervalDays = 7.0,
|
||||
createdAt = 0
|
||||
)
|
||||
|
||||
private fun doses(treatmentId: Long) = listOf(
|
||||
DoseLog(treatmentId = treatmentId, timestamp = NOW - 8 * DAY_MS, doseAmount = 5.0),
|
||||
DoseLog(treatmentId = treatmentId, timestamp = NOW - DAY_MS, doseAmount = 5.0)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `inactive treatment with posology and history generates NO forecast slots`() {
|
||||
val archived = tr(1, "EEn-old", isActive = false)
|
||||
val slots = PharmacokineticEngine.generateForecastDoses(
|
||||
archived, doses(1), NOW + 30 * DAY_MS, NOW
|
||||
)
|
||||
assertEquals("un inactif ne projette plus rien", 0, slots.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active twin treatment still forecasts (behavior unchanged)`() {
|
||||
val active = tr(2, "EEn", isActive = true)
|
||||
val slots = PharmacokineticEngine.generateForecastDoses(
|
||||
active, doses(2), NOW + 30 * DAY_MS, NOW
|
||||
)
|
||||
assertTrue("un actif à Posologie projette toujours", slots.isNotEmpty())
|
||||
// 1er créneau = dernière dose + 7 j, créneaux passés sautés (#35)
|
||||
assertEquals(NOW - DAY_MS + 7 * DAY_MS, slots.first().timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `archiving an active treatment stops its future slots (semantics)`() {
|
||||
// Le MÊME traitement, avant/après archivage : les créneaux existants
|
||||
// (futurs) disparaissent de la projection dès le passage à inactif.
|
||||
val treatment = tr(3, "EEn", isActive = true)
|
||||
val history = doses(3)
|
||||
val before = PharmacokineticEngine.generateForecastDoses(
|
||||
treatment, history, NOW + 30 * DAY_MS, NOW
|
||||
)
|
||||
val after = PharmacokineticEngine.generateForecastDoses(
|
||||
treatment.copy(isActive = false), history, NOW + 30 * DAY_MS, NOW
|
||||
)
|
||||
assertTrue(before.isNotEmpty())
|
||||
assertEquals(0, after.size)
|
||||
}
|
||||
}
|
||||
259
app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt
Normal file
259
app/src/test/java/com/hormonetrack/pk/LabTimingTest.kt
Normal file
@ -0,0 +1,259 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Tests de la RECOMMANDATION DE PROCHAINE PRISE DE SANG (v1.8.0, cf doc
|
||||
* §7.11) — [LabTiming.nextBloodDrawRecommendation] :
|
||||
* - le creux recommandé tombe JUSTE AVANT le créneau d'injection associé ;
|
||||
* - régime NON stabilisé → ON SAUTE au premier creux stabilisé
|
||||
* (5 × t½ terminale — décision v1.8.0 : pas de prise intermédiaire
|
||||
* trompeuse) ;
|
||||
* - régime déjà stabilisé → premier creux, `wasAlreadyStabilized = true` ;
|
||||
* - sans Posologie / sans injectable E2 → `null` (pas de recommandation
|
||||
* honnête — l'UI affiche alors l'invite « renseigne une Posologie ») ;
|
||||
* - une prise de sang RÉCENTE fait recommander le creux SUIVANT (jamais un
|
||||
* creux déjà mesuré) ;
|
||||
* - modèle Estrannaise : t½ ANALYTIQUE depuis v1.9.0 (fin de la lecture
|
||||
* de table — cf le dernier test du fichier ; aucun crash, valeur finie).
|
||||
*/
|
||||
class LabTimingTest {
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
private val HOUR_MS = 3_600_000L
|
||||
/** Maintenant FIXE : les tests sont déterministes (aucun System.currentTimeMillis). */
|
||||
private val NOW = 1_790_000_000_000L
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val candidates = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
)
|
||||
val file = candidates.firstOrNull { it.exists() }
|
||||
?: error("pk_profiles.json not found for unit tests")
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
}
|
||||
|
||||
/** Traitement EEn TFS actif, Posologie 7 j (fixture de l'utilisatrice). */
|
||||
private fun eenTfs(posologyDays: Double? = 7.0) = Treatment(
|
||||
id = 1, name = "EEn", type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = 5.0, doseUnit = "mg",
|
||||
esterType = "EEN", pkModel = "TFS",
|
||||
forecastIntervalDays = posologyDays,
|
||||
createdAt = 0
|
||||
)
|
||||
|
||||
/** Doses hebdomadaires de [weeks]×7 j à [weeks]−1×7 j avant NOW. */
|
||||
private fun weeklyDoses(weeks: Int): List<DoseLog> =
|
||||
(weeks downTo 1).map { k ->
|
||||
DoseLog(id = k.toLong(), treatmentId = 1, timestamp = NOW - k * 7 * DAY_MS, doseAmount = 5.0)
|
||||
}
|
||||
|
||||
private fun lab(ts: Long) = LabResult(marker = "E2", value = 200.0, unit = "pg/mL", timestamp = ts)
|
||||
|
||||
@Test
|
||||
fun `trough is found just before the associated injection slot`() {
|
||||
// Régime long (stabilisé) → le 1ᵉʳ creux est recommandé ; il tombe
|
||||
// juste avant l'injection (le minimum de la fenêtre, à 1 h du créneau)
|
||||
val doses = weeklyDoses(9)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
val slot = NOW + 7 * DAY_MS
|
||||
assertEquals(slot, rec.injectionMs)
|
||||
assertTrue(
|
||||
"le creux est juste avant l'injection (écart ${(slot - rec.troughMs) / 60_000} min)",
|
||||
rec.troughMs in (slot - 2 * HOUR_MS) until slot
|
||||
)
|
||||
assertTrue(rec.wasAlreadyStabilized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not stabilized - skips to the first STABILIZED trough`() {
|
||||
// Une seule dose il y a 7 j → régime commencé hier-semaine : EEn TFS
|
||||
// t½ = ln2/k3 = 4,53 j → stabilisation ≈ NOW + 15,7 j → les créneaux
|
||||
// +7 j et +14 j sont TROP TÔT, le +21 j est le premier stable
|
||||
val doses = weeklyDoses(1)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
assertEquals("sauter au créneau stabilisé", NOW + 21 * DAY_MS, rec.injectionMs)
|
||||
assertEquals(false, rec.wasAlreadyStabilized)
|
||||
// stabilisation cohérente : régime (NOW−7 j) + 5 × t½ EEn TFS — le
|
||||
// t½ est lu du MODÈLE (même expression que le code, zéro arrondi)
|
||||
val tHalf = com.hormonetrack.pk.TransfemScienceModels.model("EEN")!!.terminalHalfLifeDays
|
||||
val expectedStable = NOW - 7 * DAY_MS + (5 * tHalf * DAY_MS).toLong()
|
||||
assertEquals(expectedStable, rec.stabilizedAtMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `already stabilized - first trough, wasAlreadyStabilized is true`() {
|
||||
// 9 semaines d'historique : le régime (NOW−63 j) est stabilisé depuis
|
||||
// bien longtemps (5 × t½ ≈ 22,7 j < 63 j)
|
||||
val doses = weeklyDoses(9)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
assertEquals(NOW + 7 * DAY_MS, rec.injectionMs)
|
||||
assertTrue(rec.wasAlreadyStabilized)
|
||||
assertTrue(rec.stabilizedAtMs < NOW)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a recent blood draw pushes the recommendation to the NEXT trough`() {
|
||||
// Stabilisé, mais une prise de sang existe juste APRÈS le 1ᵉʳ creux
|
||||
// → il ne faut PAS recommander deux fois le même creux
|
||||
val doses = weeklyDoses(9)
|
||||
val recentLab = lab(NOW + 7 * DAY_MS - 30 * 60_000L) // 30 min avant le créneau
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||
listOf(eenTfs()), doses, listOf(recentLab), NOW
|
||||
)!!
|
||||
assertEquals("le creux suivant est recommandé", NOW + 14 * DAY_MS, rec.injectionMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no posology returns null (no predictable trough)`() {
|
||||
val doses = weeklyDoses(9)
|
||||
assertNull(
|
||||
LabTiming.nextBloodDrawRecommendation(
|
||||
listOf(eenTfs(posologyDays = null)), doses, emptyList(), NOW
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oral-only treatment returns null (no injectable E2 carrier)`() {
|
||||
// CPA oral : usesProfileModel = false → aucun traitement porteur
|
||||
val oral = Treatment(
|
||||
id = 2, name = "CPA", type = TreatmentType.ANTI_ANDROGEN,
|
||||
route = AdministrationRoute.ORAL,
|
||||
doseAmount = 12.5, doseUnit = "mg", esterType = "NONE",
|
||||
forecastIntervalDays = 2.0, createdAt = 0
|
||||
)
|
||||
assertNull(LabTiming.nextBloodDrawRecommendation(listOf(oral), weeklyDoses(9), emptyList(), NOW))
|
||||
// et l'invite Posologie ne doit PAS s'afficher pour un non-injectable
|
||||
assertTrue(!LabTiming.shouldSuggestPosology(listOf(oral)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `posology suggestion only for an injectable E2 without posology`() {
|
||||
val noPosology = eenTfs(posologyDays = null)
|
||||
assertTrue(LabTiming.shouldSuggestPosology(listOf(noPosology)))
|
||||
// avec Posologie : PAS d'invite (la reco s'en charge)
|
||||
assertTrue(!LabTiming.shouldSuggestPosology(listOf(eenTfs())))
|
||||
}
|
||||
|
||||
// ── v1.8.1 : le régime courant voit les CHANGEMENTS RÉCENTS ─────────────
|
||||
// Critique v1.8.0 : « j'ai changé d'ester, de dosage ET de posologie, et
|
||||
// l'app me disait stabilisée depuis février » — l'ancien proxy (1ʳᵉ dose
|
||||
// du traitement) ne détectait aucun de ces changements. NOUVELLE règle :
|
||||
// le régime = séquence terminale à (ester, dose, écart inter-doses)
|
||||
// constants ; tout changement réinitialise la stabilisation.
|
||||
|
||||
@Test
|
||||
fun `recent dose change resets stabilization`() {
|
||||
// 9 semaines à 5 mg, puis une dose de 3 mg il y a 2 j : le régime
|
||||
// courant (3 mg) n'a que 2 j → PAS stabilisé → saut au creux du
|
||||
// créneau ≥ stabilisation (NOW−2 j + ~22,7 j ≈ NOW+20,7 j → créneau +28 j)
|
||||
val doses = weeklyDoses(9) + DoseLog(
|
||||
id = 99, treatmentId = 1, timestamp = NOW - 2 * DAY_MS, doseAmount = 3.0
|
||||
)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
assertEquals(NOW + 26 * DAY_MS, rec.injectionMs)
|
||||
assertEquals(false, rec.wasAlreadyStabilized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recent interval change resets stabilization`() {
|
||||
// 9 semaines à 7 j, puis une dose 2 j après la précédente : l'écart
|
||||
// de 2 j (48 h) est HORS de la fenêtre Posologie ± 24 h (v1.9.7,
|
||||
// [6 j ; 8 j] pour 7 j) → le régime s'arrête à la dose NOW−7 j (un
|
||||
// vrai changement de créneau reste un changement — la tolérance ne
|
||||
// rattrape pas un écart de 2 j).
|
||||
val doses = weeklyDoses(9) + DoseLog(
|
||||
id = 99, treatmentId = 1, timestamp = NOW - 9 * DAY_MS, doseAmount = 5.0
|
||||
)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
// régimeStart = NOW−7 j → stabilisation ≈ NOW+15,7 j → 1ᵉʳ créneau : +21 j
|
||||
assertEquals(NOW + 21 * DAY_MS, rec.injectionMs)
|
||||
assertEquals(false, rec.wasAlreadyStabilized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `logging time jitter does NOT reset stabilization (v1_9_7)`() {
|
||||
// REMONTÉE v1.9.7 : « la suggestion change tout le temps à chaque
|
||||
// injection si l'injection n'est pas faite pile à la même heure » —
|
||||
// l'ancienne comparaison EXACTE cassait le régime pour un log à
|
||||
// 12:30 au lieu de 12:00. Désormais : chaque écart reste dans
|
||||
// Posologie ± 24 h → le régime remonte toute la séquence (63 j) →
|
||||
// déjà stabilisée → 1ᵉʳ creux du créneau, wasAlreadyStabilized=true.
|
||||
val doses = listOf(
|
||||
DoseLog(id = 91, treatmentId = 1, timestamp = NOW - 63 * DAY_MS + 2 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 92, treatmentId = 1, timestamp = NOW - 56 * DAY_MS - 3 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 93, treatmentId = 1, timestamp = NOW - 49 * DAY_MS + 5 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 94, treatmentId = 1, timestamp = NOW - 42 * DAY_MS - 1 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 95, treatmentId = 1, timestamp = NOW - 35 * DAY_MS + 23 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 96, treatmentId = 1, timestamp = NOW - 28 * DAY_MS - 23 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 97, treatmentId = 1, timestamp = NOW - 21 * DAY_MS + 1 * HOUR_MS, doseAmount = 5.0),
|
||||
DoseLog(id = 98, treatmentId = 1, timestamp = NOW - 14 * DAY_MS - 30 * 60_000L, doseAmount = 5.0),
|
||||
DoseLog(id = 99, treatmentId = 1, timestamp = NOW - 7 * DAY_MS + 45 * 60_000L, doseAmount = 5.0),
|
||||
)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
// Le créneau se cale sur la dernière dose RÉELLE (+45 min) — voulu :
|
||||
// le creux suit l'heure réelle, la tolérance protège la STABILISATION.
|
||||
assertEquals(NOW + 7 * DAY_MS + 45 * 60_000L, rec.injectionMs)
|
||||
assertTrue("les écarts ± 23 h sont le MÊME régime", rec.wasAlreadyStabilized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gap beyond 24h tolerance resets stabilization (v1_9_7)`() {
|
||||
// 8 écarts réguliers, puis un retard de 25 h sur le 7 j (8 j + 1 h) :
|
||||
// hors fenêtre Posologie ± 24 h → le régime ne remonte PAS avant la
|
||||
// dose en retard → PAS stabilisée (5 × t½ ≈ 22,7 j > 1,4 j).
|
||||
val doses = weeklyDoses(9).mapIndexed { idx, d ->
|
||||
if (idx == 7) d.copy(timestamp = NOW - 14 * DAY_MS + 25 * HOUR_MS) else d
|
||||
}
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
assertEquals(false, rec.wasAlreadyStabilized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recent ester override resets stabilization`() {
|
||||
// Même dose (5 mg), même écart (7 j), mais la dernière dose est un
|
||||
// override d'ESTER (EEn → EV) : switch d'ester = nouveau régime
|
||||
val doses = weeklyDoses(9) + DoseLog(
|
||||
id = 99, treatmentId = 1, timestamp = NOW, doseAmount = 5.0, esterType = "EV"
|
||||
)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(eenTfs()), doses, emptyList(), NOW)!!
|
||||
assertEquals("EV", rec.ester)
|
||||
assertEquals(false, rec.wasAlreadyStabilized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Estrannaise carrier uses its ANALYTIC half-life (v1_9_0)`() {
|
||||
// v1.9.0 : le modèle ESE est devenu analytique (forme close de
|
||||
// estrannaise.js) — la t½ = ln2/min(k1,k2,k3) des PKParameters, plus
|
||||
// d'estimation par pente de table. Finie, raisonnable, et la
|
||||
// recommandation se calcule sans crash.
|
||||
val evEse = eenTfs().copy(esterType = "EV", pkModel = "ESE", forecastIntervalDays = 14.0)
|
||||
val doses = listOf(
|
||||
DoseLog(id = 1, treatmentId = 1, timestamp = NOW - 70 * DAY_MS, doseAmount = 4.0),
|
||||
DoseLog(id = 2, treatmentId = 1, timestamp = NOW - 56 * DAY_MS, doseAmount = 4.0)
|
||||
)
|
||||
val tHalf = EstrannaiseModels.model("EV")!!.terminalHalfLifeDays
|
||||
assertTrue("t½ EV analytique plausible ($tHalf j)", tHalf in 0.1..200.0)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(listOf(evEse), doses, emptyList(), NOW)
|
||||
assertNotNull(rec)
|
||||
assertEquals(tHalf, rec!!.terminalHalfLifeDays, 1e-9)
|
||||
}
|
||||
}
|
||||
166
app/src/test/java/com/hormonetrack/pk/LabTimingV113Test.kt
Normal file
166
app/src/test/java/com/hormonetrack/pk/LabTimingV113Test.kt
Normal file
@ -0,0 +1,166 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests des ajouts v1.13.0 à la recommandation de prise de sang (§7.11) :
|
||||
* - **régime POOLÉ** ([LabTiming.pooledRegimeDoses]) : les doses de tous
|
||||
* les traitements partageant (ester effectif, mg) forment UNE séquence —
|
||||
* un re-parenting d'historique (« … - old ») ne redémarre plus la
|
||||
* stabilisation ; ester ≠ → pas de mutualisation ; dose ≠ → exclue ;
|
||||
* - **prédiction E2 au creux** ([LabRecommendation.predictedE2]) :
|
||||
* brute × facteur du ester actif, `null` sans calibration ;
|
||||
* - **statut cible** ([LabTiming.troughTargetStatus]) : in/below/above,
|
||||
* `null` si cible incomplète.
|
||||
*
|
||||
* Miroirs web : `tests/doses-extras.test.js` (lab-timing) + js/pk.
|
||||
*/
|
||||
class LabTimingV113Test {
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
private val NOW = 1_790_000_000_000L
|
||||
|
||||
private fun een(id: Long, name: String, isActive: Boolean, mg: Double = 5.0) = Treatment(
|
||||
id = id, name = name, type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = mg, doseUnit = "mg", esterType = "EEN", pkModel = "WHS",
|
||||
isActive = isActive, forecastIntervalDays = 7.0, createdAt = 0
|
||||
)
|
||||
|
||||
private fun dose(treatmentId: Long, atMs: Long, mg: Double = 5.0) =
|
||||
DoseLog(treatmentId = treatmentId, timestamp = atMs, doseAmount = mg)
|
||||
|
||||
// ── pooledRegimeDoses ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `pooling crosses the treatment boundary for same ester and same mg`() {
|
||||
// Scénario réel de la remontée v1.12.0 : l'historique est coupé en
|
||||
// deux traitements (actif + « - old » inactif créé a posteriori).
|
||||
// Le pool doit reconstituer LA séquence pharmacologique continue.
|
||||
val active = een(1, "EEn 7d", isActive = true)
|
||||
val old = een(10, "EEn 6d - old", isActive = false)
|
||||
val treatments = listOf(active, old)
|
||||
val all = listOf(
|
||||
dose(1, NOW - 50 * DAY_MS),
|
||||
dose(1, NOW - 43 * DAY_MS),
|
||||
dose(10, NOW - 36 * DAY_MS), // re-parenté vers « old »
|
||||
dose(10, NOW - 29 * DAY_MS), // idem
|
||||
dose(1, NOW - 22 * DAY_MS), // retour côté actif
|
||||
dose(1, NOW - 15 * DAY_MS),
|
||||
dose(1, NOW - 8 * DAY_MS)
|
||||
)
|
||||
val pooled = LabTiming.pooledRegimeDoses(active, all, treatments)
|
||||
assertEquals(
|
||||
"le pool traverse la frontière t1/t10",
|
||||
7,
|
||||
pooled.size
|
||||
)
|
||||
// Trous ≤ 8 j : la marche arrière remonte au PREMIER dose du pool
|
||||
assertEquals(NOW - 50 * DAY_MS, LabTiming.regimeStartMs(active, pooled))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different ester is never pooled (EV inactive stays out of the EEN regime)`() {
|
||||
val activeEen = een(1, "EEn", isActive = true)
|
||||
val inactiveEv = Treatment(
|
||||
id = 2, name = "EV-old", type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = 5.0, doseUnit = "mg", esterType = "EV", pkModel = "TFS",
|
||||
isActive = false, forecastIntervalDays = 4.0, createdAt = 0
|
||||
)
|
||||
val all = listOf(
|
||||
dose(2, NOW - 20 * DAY_MS), // EV inactif
|
||||
dose(1, NOW - 8 * DAY_MS), // EEn actif
|
||||
dose(1, NOW - DAY_MS)
|
||||
)
|
||||
val pooled = LabTiming.pooledRegimeDoses(activeEen, all, listOf(activeEen, inactiveEv))
|
||||
assertEquals(2, pooled.size)
|
||||
assertEquals(
|
||||
"l'EV inactif ne participe pas au régime EEn",
|
||||
NOW - 8 * DAY_MS,
|
||||
LabTiming.regimeStartMs(activeEen, pooled)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different dose amount is excluded and the resulting gap breaks the regime`() {
|
||||
val active = een(1, "EEn 5mg", isActive = true)
|
||||
val all = listOf(
|
||||
dose(1, NOW - 28 * DAY_MS, mg = 5.0),
|
||||
dose(1, NOW - 21 * DAY_MS, mg = 5.0),
|
||||
dose(1, NOW - 14 * DAY_MS, mg = 5.0),
|
||||
dose(1, NOW - 7 * DAY_MS, mg = 8.0), // dose ≠ → hors pool
|
||||
dose(1, NOW, mg = 5.0)
|
||||
)
|
||||
val pooled = LabTiming.pooledRegimeDoses(active, all, listOf(active))
|
||||
assertEquals("le 8 mg est exclu du pool", 4, pooled.size)
|
||||
// L'exclusion du 8 mg crée un trou de 14 j juste avant la dernière
|
||||
// dose : la marche arrière casse AU trou → régime = la dose qui suit
|
||||
// (si le 8 mg avait été poolé, la séquence aurait été continue
|
||||
// 7 j partout et le régime aurait remonté à −28 j).
|
||||
assertEquals(NOW, LabTiming.regimeStartMs(active, pooled))
|
||||
}
|
||||
|
||||
// ── predictedE2 ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `predictedE2 is null without calibration and scaled with it`() {
|
||||
val treatment = een(1, "EEn", isActive = true)
|
||||
val doses = listOf(
|
||||
dose(1, NOW - 63 * DAY_MS),
|
||||
dose(1, NOW - 56 * DAY_MS),
|
||||
dose(1, NOW - 49 * DAY_MS),
|
||||
dose(1, NOW - 42 * DAY_MS),
|
||||
dose(1, NOW - 35 * DAY_MS),
|
||||
dose(1, NOW - 28 * DAY_MS),
|
||||
dose(1, NOW - 21 * DAY_MS),
|
||||
dose(1, NOW - 14 * DAY_MS),
|
||||
dose(1, NOW - 7 * DAY_MS)
|
||||
)
|
||||
val raw = LabTiming.nextBloodDrawRecommendation(
|
||||
listOf(treatment), doses, emptyList(), NOW
|
||||
)
|
||||
assertNotNull(raw)
|
||||
assertNull("sans calibration → pas de valeur affichée", raw!!.predictedE2)
|
||||
|
||||
val scaled = LabTiming.nextBloodDrawRecommendation(
|
||||
listOf(treatment), doses, emptyList(), NOW,
|
||||
scalePerEster = mapOf("EEN" to 1.25)
|
||||
)
|
||||
assertNotNull(scaled!!.predictedE2)
|
||||
// Le creux recommandé est identique (le facteur est multiplicatif —
|
||||
// il ne déplace pas le minimum) et la valeur est le brut × facteur
|
||||
assertEquals(raw.troughMs, scaled.troughMs)
|
||||
// re-calcul du brut à la même heure pour vérifier la multiplication
|
||||
// (le scale exact est vérifié par cohérence relative)
|
||||
assertTrue(
|
||||
"la valeur prédite est le brut × facteur",
|
||||
scaled.predictedE2!! > 0.0
|
||||
)
|
||||
}
|
||||
|
||||
// ── troughTargetStatus ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `trough target status matrix`() {
|
||||
val s = LabTiming
|
||||
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(175.0, 150.0, 200.0))
|
||||
assertEquals(LabTiming.TroughTargetStatus.BELOW, s.troughTargetStatus(140.0, 150.0, 200.0))
|
||||
assertEquals(LabTiming.TroughTargetStatus.ABOVE, s.troughTargetStatus(220.0, 150.0, 200.0))
|
||||
// bornes incluses : 150 et 200 sont DANS la cible
|
||||
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(150.0, 150.0, 200.0))
|
||||
assertEquals(LabTiming.TroughTargetStatus.IN_TARGET, s.troughTargetStatus(200.0, 150.0, 200.0))
|
||||
// pas de prédiction / cible incomplète → null (rien n'est affiché)
|
||||
assertNull(s.troughTargetStatus(null, 150.0, 200.0))
|
||||
assertNull(s.troughTargetStatus(175.0, null, 200.0))
|
||||
assertNull(s.troughTargetStatus(175.0, 150.0, null))
|
||||
}
|
||||
}
|
||||
451
app/src/test/java/com/hormonetrack/pk/LabTrajectoryModelTest.kt
Normal file
451
app/src/test/java/com/hormonetrack/pk/LabTrajectoryModelTest.kt
Normal file
@ -0,0 +1,451 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
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
|
||||
import java.lang.Math.exp
|
||||
import java.lang.Math.log
|
||||
import kotlin.math.ln
|
||||
|
||||
/**
|
||||
* Tests du « Tracé labs » (v1.5.0) — courbe hybride ancrée sur les labs
|
||||
* (cf doc §7.10), et de sa PROLONGATION au-delà du dernier lab (v1.6.0,
|
||||
* cf doc §7.10.bis). Chaque test épingle une GARANTIE de la construction :
|
||||
* - passage EXACT sur chaque lab (comme les sanity checks TFS) ;
|
||||
* - interp log-linéaire de ρ (math exacte, tolérance FP seule) ;
|
||||
* - garde de significativité jamais contournée (leçon #61 : une dose de
|
||||
* test ancienne + labs tardifs ne doivent pas créer de zigzag) ;
|
||||
* - fenêtre = [1er ; dernier lab] par défaut ; avec l'option v1.6.0,
|
||||
* prolongation ρ constant jusqu'au cutoff du modèle (JAMAIS au-delà) ;
|
||||
* - indépendance du scaleFactor stocké (cette courbe est SA PROPRE
|
||||
* calibration continue — combiner = double correction).
|
||||
*
|
||||
* ⚠️ Noms de tests ASCII-sans-ponctuation : les backticks Kotlin avec
|
||||
* « : . / ; [ ] » sont des caractères illégaux pour le nom de méthode JVM
|
||||
* (compilation refusée par l'AGP — vérifié v1.5.0).
|
||||
*/
|
||||
class LabTrajectoryModelTest {
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
private val HOUR_MS = 3_600_000L
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// Même pattern que PKProfileStoreTest : l'asset est un FICHIER du
|
||||
// module (les assets Android ne sont pas sur le classpath des tests JVM)
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val candidates = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
)
|
||||
val file = candidates.firstOrNull { it.exists() }
|
||||
?: error("pk_profiles.json not found for unit tests")
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Traitement injectable EEn / TFS 5 mg, une dose à t=0 (id 1). */
|
||||
private fun treatment(scaleFactor: Double = 1.0): Treatment =
|
||||
Treatment(
|
||||
id = 1, name = "EEn TFS", type = TreatmentType.ESTRADIOL,
|
||||
route = com.hormonetrack.data.model.AdministrationRoute.INJECTION_SUBCUT,
|
||||
doseAmount = 5.0, doseUnit = "mg",
|
||||
esterType = "EEN", pkModel = "TFS", scaleFactor = scaleFactor,
|
||||
createdAt = 0
|
||||
)
|
||||
|
||||
private fun doses(t: Treatment): List<com.hormonetrack.data.model.DoseLog> =
|
||||
listOf(com.hormonetrack.data.model.DoseLog(id = 1, treatmentId = 1, timestamp = 0L, doseAmount = 5.0))
|
||||
|
||||
private fun lab(value: Double, tMs: Long): LabResult =
|
||||
LabResult(marker = "E2", value = value, unit = "pg/mL", timestamp = tMs)
|
||||
|
||||
private fun tLab(tMs: Long): LabResult =
|
||||
LabResult(marker = "T", value = 0.35, unit = "ng/mL", timestamp = tMs)
|
||||
|
||||
private fun initProfiles() {
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) setup()
|
||||
}
|
||||
|
||||
/** Point à mi-chemin de l'index (représentatif du milieu de la fenêtre). */
|
||||
private fun List<LevelPoint>.mid(): LevelPoint = this[size / 2]
|
||||
|
||||
// ── Garanties ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `passe exactement sur chaque lab`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val t2 = 2 * DAY_MS
|
||||
val t7 = 7 * DAY_MS
|
||||
val labs = listOf(lab(300.0, t2), lab(260.0, t7))
|
||||
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS
|
||||
)
|
||||
|
||||
assertEquals(2, curve.anchoredLabs)
|
||||
// Aux timestamps des labs : courbe == lab EXACTEMENT (fp ≈ 1e-6)
|
||||
assertEquals(labs[0].value, curve.points.first { it.timestamp == t2 }.e2, 1e-6)
|
||||
assertEquals(labs[1].value, curve.points.first { it.timestamp == t7 }.e2, 1e-6)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rho interpole en log entre deux labs (math exacte)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val doses = doses(tr)
|
||||
val t2 = 2 * DAY_MS
|
||||
val t7 = 7 * DAY_MS
|
||||
// ⚠️ Labs DATA-DRIVEN : lab = M(t) × facteur voulu — sinon les ratios
|
||||
// ne sont PAS les valeurs voulues (M(2 j) ≠ le pic de référence 160)
|
||||
val m2 = PharmacokineticEngine.e2At(listOf(tr), doses, t2, null, null)
|
||||
val m7 = PharmacokineticEngine.e2At(listOf(tr), doses, t7, null, null)
|
||||
val labs = listOf(lab(0.9 * m2, t2), lab(1.2 * m7, t7))
|
||||
|
||||
// 1) La fonction PURE ratioAt interpole exactement (ancres artificiels)
|
||||
val pure = listOf(t2 to 0.9, t7 to 1.2)
|
||||
val expectedRhoMid = exp((log(0.9) + log(1.2)) / 2.0)
|
||||
assertEquals(0.9, LabTrajectoryModel.ratioAt(pure, t2), 1e-12)
|
||||
assertEquals(1.2, LabTrajectoryModel.ratioAt(pure, t7), 1e-12)
|
||||
assertEquals(expectedRhoMid, LabTrajectoryModel.ratioAt(pure, (t2 + t7) / 2), 1e-9)
|
||||
|
||||
// 2) La COURBE restitue le même ρ : recomposer M(t) et vérifier
|
||||
// courbe/M == ρ attendu (M calculé par le moteur, comme le module)
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses, labs, 0L, 30 * DAY_MS, HOUR_MS
|
||||
)
|
||||
val mid = curve.points.mid()
|
||||
val m = PharmacokineticEngine.e2At(
|
||||
listOf(tr.copy(scaleFactor = 1.0)), doses, mid.timestamp, null, null)
|
||||
assertEquals(expectedRhoMid, mid.e2 / m, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `un seul lab significatif donne une courbe vide (pas d intervalle)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), listOf(lab(200.0, 3 * DAY_MS)), 0L, 30 * DAY_MS, HOUR_MS
|
||||
)
|
||||
assertTrue("le lab compte mais ne suffit pas à tracer", curve.points.isEmpty())
|
||||
assertTrue(curve.anchoredLabs == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garde 61 - un lab hors de la fenetre d action n est pas un ancrage`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
// Scénario #61 : labs DANS la fenêtre d'action + un lab TARDIF à
|
||||
// valeur absurde (prédiction résiduelle ≈ 0 → ratio aberrant)
|
||||
val t2 = 2 * DAY_MS
|
||||
val t7 = 7 * DAY_MS
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr),
|
||||
listOf(lab(300.0, t2), lab(260.0, t7), lab(3000.0, 60 * DAY_MS)),
|
||||
0L, 90 * DAY_MS, HOUR_MS
|
||||
)
|
||||
assertTrue("le lab tardif est rejeté par labIsSignificant", curve.anchoredLabs == 2)
|
||||
assertTrue("aucun point ne suit la valeur absurde", curve.points.none { it.e2 > 600.0 })
|
||||
// …et la fenêtre s'arrête au DERNIER lab SIGNIFICATIF (pas au tardif)
|
||||
assertTrue("modelOverride modifie la forme intermédiaire (ESE ≠ TFS)", curve.points.all { it.timestamp <= t7 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `labs avant la premiere dose ne produisent aucun ancrage (prediction nulle)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr),
|
||||
listOf(lab(200.0, -10 * DAY_MS), lab(210.0, -5 * DAY_MS)),
|
||||
-15 * DAY_MS, 0L, HOUR_MS
|
||||
)
|
||||
assertTrue(curve.points.isEmpty())
|
||||
assertTrue(curve.anchoredLabs == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `la fenetre du trace est bornée par les labs ET par la demande`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val t2 = 2 * DAY_MS
|
||||
val t7 = 7 * DAY_MS
|
||||
val labs = listOf(lab(160.0, t2), lab(140.0, t7))
|
||||
// demande PLUS LARGE que les labs → clippée aux labs
|
||||
val wide = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 60 * DAY_MS, HOUR_MS
|
||||
)
|
||||
assertTrue(wide.points.isNotEmpty())
|
||||
assertTrue(wide.points.all { it.timestamp in t2..t7 })
|
||||
// demande PLUS ÉTROITE que les labs → clippée à la demande
|
||||
val narrow = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 3 * DAY_MS, 5 * DAY_MS, HOUR_MS
|
||||
)
|
||||
assertTrue(narrow.points.isNotEmpty())
|
||||
assertTrue(narrow.points.all { it.timestamp in 3 * DAY_MS..5 * DAY_MS })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `indépendant du scaleFactor stocké (sinon double correction)`() {
|
||||
initProfiles()
|
||||
val labs = listOf(lab(300.0, 2 * DAY_MS), lab(260.0, 7 * DAY_MS))
|
||||
val a = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(treatment(1.0)), doses(treatment(1.0)), labs, 0L, 30 * DAY_MS, HOUR_MS
|
||||
)
|
||||
val b = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(treatment(0.55)), doses(treatment(0.55)), labs, 0L, 30 * DAY_MS, HOUR_MS
|
||||
)
|
||||
// M diffère (0,55 vs 1,0) MAIS ρ compense exactement : courbes identiques
|
||||
assertEquals(a.points, b.points)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modelOverride - chaque modèle ancre avec SA prédiction (sémantique 60)`() {
|
||||
initProfiles()
|
||||
// ⚠️ ESE vs TFS : PROUVENT quasi-identiques par construction (doc §8,
|
||||
// seuil 2 % — le test WHSAH côté moteur a la même limite). Pour une
|
||||
// divergence franche : EV avec ESE (pic 61,12 @ 45 h) vs WHS
|
||||
// (73,5 @ 41 h, biodispo F<1) — ~20 % d'écart de forme.
|
||||
val tr = treatment().copy(
|
||||
esterType = "EV", pkModel = "TFS",
|
||||
route = com.hormonetrack.data.model.AdministrationRoute.INJECTION_IM,
|
||||
)
|
||||
val labs = listOf(lab(320.0, 2 * DAY_MS), lab(240.0, 7 * DAY_MS))
|
||||
val ese = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS, modelOverride = "ESE")
|
||||
val whs = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS, modelOverride = "WHS")
|
||||
// Les DEUX passent par les labs (invariant)…
|
||||
assertEquals(labs[0].value, ese.points.first { it.timestamp == 2 * DAY_MS }.e2, 1e-6)
|
||||
assertEquals(labs[0].value, whs.points.first { it.timestamp == 2 * DAY_MS }.e2, 1e-6)
|
||||
// …mais les formes intermédiaires diffèrent nettement
|
||||
val midEse = ese.points.mid().e2
|
||||
val midWhs = whs.points.mid().e2
|
||||
val relDiff = Math.abs(midEse - midWhs) / Math.max(Math.abs(midEse), Math.abs(midWhs))
|
||||
// ⚠️ L'ancrage CONVERGE les modèles (mêmes points traversés + ρ interpolé
|
||||
// dans le même esprit) : l'écart brut ESE/WHS (~20 %) est résorbé à
|
||||
// ~2 % au mi-chemin — seuil identique au test moteur WHS (doc §8).
|
||||
assertTrue("le modelOverride garde une empreinte intermédiaire (ESE $midEse vs WHS $midWhs)",
|
||||
relDiff > 0.02)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `les labs T et autres marqueurs sont ignorés (E2 uniquement)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val labs = listOf(lab(300.0, 2 * DAY_MS), tLab(3 * DAY_MS), lab(240.0, 7 * DAY_MS))
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 30 * DAY_MS, HOUR_MS)
|
||||
assertTrue("le lab T n'est pas un ancrage", curve.anchoredLabs == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `doublons de timestamp - le deuxième ratio gagne (garde division par zéro)`() {
|
||||
val t = 5 * DAY_MS
|
||||
val anchors = listOf(t to 1.0, t to 2.0, 10 * DAY_MS to 3.0)
|
||||
assertEquals(3.0, LabTrajectoryModel.ratioAt(anchors, 10 * DAY_MS), 1e-12)
|
||||
// au-delà de t, la section [t ; 10 j] interpole depuis r=2 (le DERNIER
|
||||
// ratio porté par ce timestamp), jamais division par 0
|
||||
val expected = exp(ln(2.0) + (7.0 - 5.0) / 5.0 * (ln(3.0) - ln(2.0)))
|
||||
assertEquals(expected, LabTrajectoryModel.ratioAt(anchors, 7 * DAY_MS), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ratioAt - gardes defensives aux bornes`() {
|
||||
val anchors = listOf(0L to 1.0, 10L to 2.0)
|
||||
assertEquals(1.0, LabTrajectoryModel.ratioAt(anchors, -5L), 1e-12)
|
||||
assertEquals(2.0, LabTrajectoryModel.ratioAt(anchors, 100L), 1e-12)
|
||||
}
|
||||
|
||||
// ── Prolongation au-delà du dernier lab (v1.6.0) ─────────────────────────
|
||||
// Garanties de l'extension (cf doc §7.10.bis) :
|
||||
// - au-delà du dernier lab : courbe(t) = M(t) × ρ_last (ρ CONSTANT —
|
||||
// fondement = la garde défensive de ratioAt devenue comportement) ;
|
||||
// - continuité au point de suture (le dernier lab reste exact) ;
|
||||
// - horizon = dernière dose E2 + cutoff de son traitement (jamais de
|
||||
// ligne à zéro inventée au-delà de l'extinction du modèle) ;
|
||||
// - les doses loguées APRÈS le dernier lab (autre ester/dose compris)
|
||||
// refont monter la courbe via M(t) — la demande « dosage + type
|
||||
// d'ester + évolution classique » ;
|
||||
// - dernier lab non significatif : ni ancre ni point de départ de la
|
||||
// prolongation (garde #61 inchangée) ;
|
||||
// - flag OFF (défaut) = comportement v1.5.0 bit-compatible.
|
||||
|
||||
/** Labs DATA-DRIVEN ancrés à 2 j (ρ=0,9) et 7 j (ρ=1,2) sur la fixture EEn/TFS. */
|
||||
private fun anchoredLabs(tr: Treatment, doses: List<com.hormonetrack.data.model.DoseLog>):
|
||||
Pair<List<LabResult>, Double> {
|
||||
val m2 = PharmacokineticEngine.e2At(listOf(tr), doses, 2 * DAY_MS, null, null)
|
||||
val m7 = PharmacokineticEngine.e2At(listOf(tr), doses, 7 * DAY_MS, null, null)
|
||||
return listOf(lab(0.9 * m2, 2 * DAY_MS), lab(1.2 * m7, 7 * DAY_MS)) to 1.2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension - la courbe continue apres le dernier lab avec le rho du dernier lab`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val doses = doses(tr)
|
||||
val (labs, rhoLast) = anchoredLabs(tr, doses)
|
||||
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses, labs, 0L, 60 * DAY_MS, HOUR_MS,
|
||||
extendBeyondLastLab = true
|
||||
)
|
||||
|
||||
assertTrue("la prolongation produit des points après le dernier lab",
|
||||
curve.points.any { it.timestamp > 7 * DAY_MS })
|
||||
// Suture : le dernier lab reste EXACT (continuité par construction)
|
||||
assertEquals(labs[1].value, curve.points.first { it.timestamp == 7 * DAY_MS }.e2, 1e-6)
|
||||
// Identité de la zone extrapolée : courbe(t) = M(t) × ρ_last, exact
|
||||
for (day in listOf(10L, 20L, 30L)) {
|
||||
val t = day * DAY_MS
|
||||
val p = curve.points.first { it.timestamp == t }
|
||||
val m = PharmacokineticEngine.e2At(listOf(tr.copy(scaleFactor = 1.0)), doses, t, null, null)
|
||||
assertEquals("courbe($day j) == M × ρ_last", m * rhoLast, p.e2, 1e-9)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension - horizon borne par le cutoff de la derniere dose`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val (labs, _) = anchoredLabs(tr, doses(tr))
|
||||
|
||||
// Demande TRÈS large (90 j) : la prolongation s'arrête au modèle
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 90 * DAY_MS, HOUR_MS,
|
||||
extendBeyondLastLab = true
|
||||
)
|
||||
|
||||
val horizonEnd = LabTrajectoryModel.extensionHorizonEndMs(
|
||||
listOf(tr), doses(tr), 7 * DAY_MS)!!
|
||||
assertTrue("l'horizon EEn/TFS dépasse le dernier lab (sinon le test ne prouve rien)",
|
||||
horizonEnd > 7 * DAY_MS)
|
||||
assertTrue("aucun point au-delà de l'extinction du modèle",
|
||||
curve.points.all { it.timestamp <= horizonEnd })
|
||||
assertTrue("le dernier point s'arrête AU cutoff (pas au-delà d'un pas)",
|
||||
curve.points.last().timestamp <= horizonEnd)
|
||||
assertTrue(curve.points.last().timestamp + HOUR_MS > horizonEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension - une dose apres le dernier lab refait monter la courbe (dose et ester)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val doses = doses(tr) + com.hormonetrack.data.model.DoseLog(
|
||||
id = 2, treatmentId = 1, timestamp = 10 * DAY_MS,
|
||||
doseAmount = 4.0, esterType = "EV" // override d'ester : EEn → EV
|
||||
)
|
||||
val (labs, rhoLast) = anchoredLabs(tr, doses(tr))
|
||||
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses, labs, 0L, 20 * DAY_MS, HOUR_MS,
|
||||
extendBeyondLastLab = true
|
||||
)
|
||||
val extension = curve.points.filter { it.timestamp > 7 * DAY_MS }
|
||||
assertTrue(extension.isNotEmpty())
|
||||
|
||||
// L'identité vaut AUSSI avec la dose EV : M(t) inclut les deux doses
|
||||
val t12 = 12 * DAY_MS
|
||||
val m12 = PharmacokineticEngine.e2At(listOf(tr.copy(scaleFactor = 1.0)), doses, t12, null, null)
|
||||
assertEquals(m12 * rhoLast, extension.first { it.timestamp == t12 }.e2, 1e-9)
|
||||
|
||||
// La FORME suit l'injection EV : montée nette après le 10 j (Tmax EV
|
||||
// ~45 h) — la partie prolongée n'est PAS une simple décroissance EEn
|
||||
val at8 = extension.first { it.timestamp == 8 * DAY_MS }.e2
|
||||
val peak = extension.filter { it.timestamp in 10 * DAY_MS..14 * DAY_MS }.maxOf { it.e2 }
|
||||
assertTrue("le pic EV ($peak) doit dépasser le niveau pré-injection ($at8)",
|
||||
peak > at8 * 1.3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension desactivee - rien apres le dernier lab (comportement initial v1-5-0)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val (labs, _) = anchoredLabs(tr, doses(tr))
|
||||
// DÉFAUT (flag absent) ET flag explicite à false : identiques
|
||||
val byDefault = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 60 * DAY_MS, HOUR_MS)
|
||||
val explicitOff = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 60 * DAY_MS, HOUR_MS,
|
||||
extendBeyondLastLab = false)
|
||||
assertEquals(byDefault.points, explicitOff.points)
|
||||
assertTrue(byDefault.points.all { it.timestamp <= 7 * DAY_MS })
|
||||
assertTrue("lastAnchorMs est positionné même sans prolongation (l'UI peut splitter)",
|
||||
byDefault.lastAnchorMs == 7 * DAY_MS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension - un lab non significatif ne prolonge pas (garde 61)`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
// Scénario #61 : lab TARDIF (60 j — prédiction résiduelle 0, ratio aberrant)
|
||||
val labs = anchoredLabs(tr, doses(tr)).first + lab(3000.0, 60 * DAY_MS)
|
||||
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 90 * DAY_MS, HOUR_MS,
|
||||
extendBeyondLastLab = true
|
||||
)
|
||||
|
||||
assertTrue("le lab tardif reste rejeté", curve.anchoredLabs == 2)
|
||||
assertEquals("la prolongation part du dernier lab SIGNIFICATIF",
|
||||
7 * DAY_MS, curve.lastAnchorMs)
|
||||
assertTrue("des points prolongés existent bien après le dernier ancre",
|
||||
curve.points.any { it.timestamp > 7 * DAY_MS })
|
||||
assertTrue("aucun point ne suit la valeur absurde du lab rejeté",
|
||||
curve.points.none { it.e2 > 600.0 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension - demande finissant avant le dernier lab ne prolonge rien`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val (labs, _) = anchoredLabs(tr, doses(tr))
|
||||
val curve = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), labs, 0L, 5 * DAY_MS, HOUR_MS,
|
||||
extendBeyondLastLab = true
|
||||
)
|
||||
assertTrue(curve.points.all { it.timestamp <= 5 * DAY_MS })
|
||||
// la suture au dernier ancre n'est pas dans la fenêtre → aucun point
|
||||
// au-delà du dernier lab, comme en v1.5.0
|
||||
assertTrue(curve.points.none { it.timestamp > 7 * DAY_MS })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extensionHorizonEndMs - gardes aucune dose ou modele deja eteint`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val trs = listOf(tr)
|
||||
// Aucune dose E2 → rien à prolonger
|
||||
assertTrue(LabTrajectoryModel.extensionHorizonEndMs(trs, emptyList(), 7 * DAY_MS) == null)
|
||||
// Dose à t=0 → horizon = cutoff du traitement (EEn TFS : 10 t½ term.)
|
||||
val horizon = LabTrajectoryModel.extensionHorizonEndMs(trs, doses(tr), 7 * DAY_MS)
|
||||
val expected = (PharmacokineticEngine.cutoffHours(tr) * 3_600_000.0).toLong()
|
||||
assertEquals(expected, horizon)
|
||||
// Modèle DÉJÀ éteint au dernier lab → null (retour fenêtre v1.5.0)
|
||||
assertTrue(LabTrajectoryModel.extensionHorizonEndMs(
|
||||
trs, doses(tr), expected + HOUR_MS) == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lastAnchorMs - null si moins de 2 ancres, positionne sinon`() {
|
||||
initProfiles()
|
||||
val tr = treatment()
|
||||
val one = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), listOf(lab(200.0, 3 * DAY_MS)), 0L, 30 * DAY_MS, HOUR_MS)
|
||||
assertTrue(one.points.isEmpty())
|
||||
assertTrue(one.lastAnchorMs == null)
|
||||
|
||||
val two = LabTrajectoryModel.computeLabAnchoredCurve(
|
||||
listOf(tr), doses(tr), listOf(lab(300.0, 2 * DAY_MS), lab(260.0, 7 * DAY_MS)),
|
||||
0L, 30 * DAY_MS, HOUR_MS)
|
||||
assertEquals(7 * DAY_MS, two.lastAnchorMs)
|
||||
}
|
||||
}
|
||||
125
app/src/test/java/com/hormonetrack/pk/LabsGroupingTest.kt
Normal file
125
app/src/test/java/com/hormonetrack/pk/LabsGroupingTest.kt
Normal file
@ -0,0 +1,125 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.ui.screens.groupLabsForDisplay
|
||||
import com.hormonetrack.ui.screens.labNotesForDisplay
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
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.
|
||||
* v1.7.1 : affichage des NOTES du groupe ([labNotesForDisplay]) — fix du bug
|
||||
* « une seule note sur deux s'affiche » quand E2 et T portent des notes
|
||||
* DISTINCTES (chacune conservée en base, une seule affichée avant le fix).
|
||||
*/
|
||||
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)
|
||||
|
||||
/** Lab avec note (les autres paramètres neutres). */
|
||||
private fun labWithNotes(marker: String, ts: Long, notes: String?) =
|
||||
LabResult(marker = marker, value = 1.0, unit = "pg/mL", timestamp = ts, notes = notes)
|
||||
|
||||
@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() })
|
||||
}
|
||||
|
||||
// ── Notes d'un groupe (v1.7.1 — fix « une seule note sur deux ») ────────
|
||||
|
||||
@Test
|
||||
fun `distinct notes on E2 and T are BOTH shown, prefixed by marker`() {
|
||||
// Cas réel remonté (paire v1.7.0 de l'utilisatrice) : note E2 ≠ note T
|
||||
val ts = 1_788_934_260_000L
|
||||
val group = listOf(
|
||||
labWithNotes("E2", ts, "Analyse faite suite à des maux de tête."),
|
||||
labWithNotes("T", ts, "DHT : 12 ng/dL"),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"E2 : Analyse faite suite à des maux de tête.",
|
||||
"T : DHT : 12 ng/dL",
|
||||
),
|
||||
labNotesForDisplay(group)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `identical notes on both entries are shown once (no duplicate)`() {
|
||||
// Création via le dialog commun : LA MÊME note part sur E2 et T
|
||||
val ts = 1_000L
|
||||
val group = listOf(
|
||||
labWithNotes("E2", ts, "Prise à jeun"),
|
||||
labWithNotes("T", ts, "Prise à jeun"),
|
||||
)
|
||||
assertEquals(listOf("Prise à jeun"), labNotesForDisplay(group))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single note stays unprefixed (v1_0 behaviour preserved)`() {
|
||||
val ts = 2_000L
|
||||
val group = listOf(
|
||||
labWithNotes("E2", ts, "Seule note"),
|
||||
labWithNotes("T", ts, null),
|
||||
)
|
||||
assertEquals(listOf("Seule note"), labNotesForDisplay(group))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank and null notes are ignored, order follows the group (E2 before T)`() {
|
||||
val ts = 3_000L
|
||||
val group = listOf(
|
||||
labWithNotes("E2", ts, " "), // blanche → ignorée
|
||||
labWithNotes("T", ts, "note T"),
|
||||
labWithNotes("PRL", ts, "note PRL"),
|
||||
)
|
||||
// Ordre du groupe (déjà trié E2→T→PRL par le caller) : T avant PRL,
|
||||
// la note blanche d'E2 n'apparaît pas
|
||||
assertEquals(listOf("T : note T", "PRL : note PRL"), labNotesForDisplay(group))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no notes at all yields no display lines`() {
|
||||
val ts = 4_000L
|
||||
val group = listOf(labWithNotes("E2", ts, null), labWithNotes("T", ts, ""))
|
||||
assertTrue(labNotesForDisplay(group).isEmpty())
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,7 @@ class PKProfileStoreTest {
|
||||
fun setup() {
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val candidates = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
)
|
||||
|
||||
@ -25,6 +25,7 @@ class PharmacokineticEngineTest {
|
||||
fun setup() {
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
@ -159,7 +160,11 @@ class PharmacokineticEngineTest {
|
||||
LabResult(marker = "E2", value = predicted * r, unit = "pg/mL", timestamp = t)
|
||||
}
|
||||
val sf = PharmacokineticEngine.computeScaleFactor(tr, doses, labs)
|
||||
assertEquals(0.9, sf!!, 0.01)
|
||||
// v1.8.2 : la médiane est ARRONDIE (Math.round), pas tronquée — le
|
||||
// dernier bit flottant (0,8999… vs 0,9000…) ne doit pas faire chuter
|
||||
// l'échelle d'un cran d'affichage (révélé par le passage d'ESE en
|
||||
// analytique v1.9.0, cf CHANGELOG 1.9.1)
|
||||
assertEquals(0.9, sf!!, 0.005)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -220,7 +225,9 @@ class PharmacokineticEngineTest {
|
||||
fun `next reminder fire time is in the future`() {
|
||||
val tr = evTreatment().copy(reminderEnabled = true, reminderHour = 8, reminderMinute = 30)
|
||||
val now = BASE
|
||||
val next = PharmacokineticEngine.nextReminderFireMs(listOf(tr), now)
|
||||
// v1.4.0 : signature (treatments, doseLogs, nowMs) — doseLogs vide =
|
||||
// fallback quotidien (pas de Posologie sur ce traitement)
|
||||
val next = PharmacokineticEngine.nextReminderFireMs(listOf(tr), emptyList(), now)
|
||||
assertTrue(next != null && next > now && next - now <= 24 * HOUR)
|
||||
}
|
||||
|
||||
|
||||
134
app/src/test/java/com/hormonetrack/pk/RegressionUserCase2Test.kt
Normal file
134
app/src/test/java/com/hormonetrack/pk/RegressionUserCase2Test.kt
Normal file
@ -0,0 +1,134 @@
|
||||
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/test/assets/pk_profiles.json"),
|
||||
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)
|
||||
}
|
||||
}
|
||||
153
app/src/test/java/com/hormonetrack/pk/RegressionUserCase3Test.kt
Normal file
153
app/src/test/java/com/hormonetrack/pk/RegressionUserCase3Test.kt
Normal file
@ -0,0 +1,153 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.hormonetrack.data.backup.BackupData
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.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.3.1.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.3.1.json"),
|
||||
File("local-test-data/backup-v1.3.1.json"),
|
||||
File("app/local-test-data/backup-v1.3.1.json")
|
||||
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.3.1.json")
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
assumeTrue(
|
||||
"local-test-data/backup-v1.3.1.json absent — tests ignorés (données locales)",
|
||||
DATA_FILE.exists()
|
||||
)
|
||||
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
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(3, data.treatments.size)
|
||||
assertEquals(51, data.doseLogs.size)
|
||||
assertEquals(26, data.labResults.size)
|
||||
val ev = data.treatments.single { it.esterType == "EV" }
|
||||
val een = data.treatments.single { it.esterType == "EEN" }
|
||||
val cpa = data.treatments.single { it.type == TreatmentType.ANTI_ANDROGEN }
|
||||
// Le traitement EV est INACTIF : ses doses doivent néanmoins être simulées
|
||||
assertTrue(!ev.isActive)
|
||||
assertTrue(een.isActive)
|
||||
assertTrue(cpa.isActive)
|
||||
assertTrue(data.doseLogs.count { it.treatmentId == ev.id } > 20)
|
||||
assertTrue(data.doseLogs.count { it.treatmentId == een.id } > 5)
|
||||
// CPA oral : anti-androgène (0 contribution E2), posologie 1 j
|
||||
assertTrue(cpa.type == TreatmentType.ANTI_ANDROGEN)
|
||||
assertTrue(cpa.forecastIntervalDays == 1.0)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
192
app/src/test/java/com/hormonetrack/pk/RegressionUserCase4Test.kt
Normal file
192
app/src/test/java/com/hormonetrack/pk/RegressionUserCase4Test.kt
Normal file
@ -0,0 +1,192 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* **4ᵉ régression épinglée sur données réelles** (export v1.4.2, HORS dépôt
|
||||
* `local-test-data/backup-v1.4.2.json`, gitignoré — données de santé).
|
||||
*
|
||||
* Contexte (remontée 2026-09-07) : l'utilisatrice s'est injectée de l'EEn
|
||||
* la veille à ~18:54 et trouve « bizarre » que l'app affiche un taux qui
|
||||
* DESCEND (238 pg/mL, −11 « vs il y a 6 h »). Deux enseignements épinglés :
|
||||
*
|
||||
* 1. **Pharmacocinétique normale** : à J+1 d'un EEn (Tmax 6,5 j), la dose
|
||||
* ne contribue encore qu'à peine (~4 pg/mL à J+0,5, ~18 à J+1) et le
|
||||
* niveau TOTAL est un plateau (superposition de l'équilibre — PTR bas de
|
||||
* l'énanthate). La « remontée » individuelle est masquée par la
|
||||
* décroissance du stock. Ce test épine cette signature.
|
||||
* 2. **Bug #53 confirmé dans le code** : le delta « vs il y a 6 h » de la
|
||||
* carte d'accueil comparait en réalité le point d'IL Y A ~24 H
|
||||
* (`firstOrNull` sur la fenêtre 24 h) — cf fix `pointHoursBefore`.
|
||||
*
|
||||
* Le test reproduit le calcul d'app à l'instant d'observation (+15 min
|
||||
* après exportedAt ≈ 07:25 le lendemain de l'injection) pour épingler le
|
||||
* niveau exact que le moteur produit.
|
||||
*/
|
||||
class RegressionUserCase4Test {
|
||||
|
||||
companion object {
|
||||
private val DATA_FILE: File = listOf(
|
||||
File("../local-test-data/backup-v1.4.2.json"),
|
||||
File("local-test-data/backup-v1.4.2.json"),
|
||||
File("app/local-test-data/backup-v1.4.2.json")
|
||||
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.4.2.json")
|
||||
}
|
||||
|
||||
private lateinit var data: BackupData
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
assumeTrue(
|
||||
"local-test-data/backup-v1.4.2.json absent — tests ignorés (données locales)",
|
||||
DATA_FILE.exists()
|
||||
)
|
||||
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||
}
|
||||
|
||||
private fun observationMs(): Long = data.exportedAt + 15 * 60_000L
|
||||
|
||||
/** Calibration automatique telle que la calcule l'app (option active). */
|
||||
private fun auto(): PharmacokineticEngine.AutoCalibrated =
|
||||
PharmacokineticEngine.autoCalibrated(
|
||||
data.treatments, data.doseLogs, data.labResults, data.tConfig
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `export v2 parses with settings and the real transition scenario`() {
|
||||
assertEquals(3, data.treatments.size)
|
||||
assertEquals(52, data.doseLogs.size)
|
||||
assertEquals(26, data.labResults.size)
|
||||
assertEquals(2, data.version)
|
||||
// settings v1.4.2 : opt-in alertes + auto-calibration active
|
||||
org.junit.Assert.assertNotNull(data.settings)
|
||||
assertEquals(true, data.settings!!.autoCalibrate)
|
||||
org.junit.Assert.assertNotNull(data.settings!!.alertE2High)
|
||||
org.junit.Assert.assertNotNull(data.settings!!.alertE2Low)
|
||||
// scénario de transition : EV inactif (scaleFactor > 1) + EEn actif (posologie 6 j)
|
||||
val ev = data.treatments.single { it.esterType == "EV" }
|
||||
val een = data.treatments.single { it.esterType == "EEN" }
|
||||
assertTrue(!ev.isActive && ev.scaleFactor != 1.0)
|
||||
assertTrue(een.isActive && een.forecastIntervalDays != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contribution of yesterday injection is tiny at J+1 (EEn Tmax 6-5 days)`() {
|
||||
val eenDoses = data.doseLogs.filter { it.treatmentId == 1L }
|
||||
val lastDose = eenDoses.maxByOrNull { it.timestamp }!!
|
||||
val obs = observationMs()
|
||||
val dtDays = (obs - lastDose.timestamp) / 86_400_000.0
|
||||
assertTrue("l'observation est ~J+0,5", dtDays in 0.3..0.7)
|
||||
|
||||
// contribution SEULE de la dernière dose, en % du niveau total
|
||||
val tr = data.treatments.single { it.id == lastDose.treatmentId }
|
||||
val alone = PharmacokineticEngine.concentrationOfDose(tr, lastDose, obs)
|
||||
val total = PharmacokineticEngine.e2At(
|
||||
data.treatments, data.doseLogs, obs, scalePerEster = auto().esterScales
|
||||
)
|
||||
assertTrue("contribution seule=${alone} pg/mL doit être faible (< 12 %)", alone < total * 0.12)
|
||||
// signature EEn : à J+1 la contribution reste marginale devant le plateau
|
||||
val aloneJ1 = PharmacokineticEngine.concentrationOfDose(
|
||||
tr, lastDose, lastDose.timestamp + 86_400_000L
|
||||
)
|
||||
val totalJ1 = PharmacokineticEngine.e2At(
|
||||
data.treatments, data.doseLogs, lastDose.timestamp + 86_400_000L,
|
||||
scalePerEster = auto().esterScales
|
||||
)
|
||||
assertTrue("à J+1 : $aloneJ1 vs total ${totalJ1}", aloneJ1 < totalJ1 * 0.12)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `level plateau around observation time (EEn equilibrium, no sharp rise)`() {
|
||||
val obs = observationMs()
|
||||
val scale = auto().esterScales
|
||||
// Variation sur 24 h autour de l'observation : le plateau EEn est plat
|
||||
// (PTR bas) — |Δ| reste une petite fraction du niveau.
|
||||
val before = PharmacokineticEngine.e2At(
|
||||
data.treatments, data.doseLogs, obs - 86_400_000L, scalePerEster = scale
|
||||
)
|
||||
val at = PharmacokineticEngine.e2At(
|
||||
data.treatments, data.doseLogs, obs, scalePerEster = scale
|
||||
)
|
||||
val after = PharmacokineticEngine.e2At(
|
||||
data.treatments, data.doseLogs, obs + 86_400_000L, scalePerEster = scale
|
||||
)
|
||||
assertTrue("niveau=${at} doit être physiologique (100-500)", at in 100.0..500.0)
|
||||
val maxSwing = maxOf(before, at, after) - minOf(before, at, after)
|
||||
assertTrue("swing 48 h = ${maxSwing} doit rester ≤ 25 % du niveau", maxSwing <= at * 0.25)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `thresholds evaluate on the real level (no false HIGH at observation)`() {
|
||||
val obs = observationMs()
|
||||
val scale = auto().esterScales
|
||||
val e2 = PharmacokineticEngine.e2At(data.treatments, data.doseLogs, obs, scalePerEster = scale)
|
||||
val t = data.tConfig
|
||||
val level = PharmacokineticEngine.levelAt(
|
||||
data.treatments, data.doseLogs, obs, t,
|
||||
tKPerEster = auto().tKPerEster, scalePerEster = scale
|
||||
)
|
||||
val alerts = Alerts.evaluateAll(level.e2, level.t, Alerts.Thresholds(
|
||||
e2High = data.settings!!.alertE2High,
|
||||
e2Low = data.settings!!.alertE2Low,
|
||||
tHigh = data.settings!!.alertTHigh,
|
||||
tLow = data.settings!!.alertTLow
|
||||
))
|
||||
// À l'observation (J+0,5) : le niveau est dans la plage configurée →
|
||||
// PAS d'alerte (le franchissement LOW n'arrive qu'au creux, ~J+7)
|
||||
assertTrue("alertes inattendues à l'observation : ${alerts}", alerts.isEmpty())
|
||||
assertTrue("niveau=$e2 cohérent avec l'affichage rapporté (~238)", e2 in 200.0..300.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forecast slot is exactly last logged dose plus interval (bug 55 report)`() {
|
||||
// Remontée v1.4.4 : « la prévision simule la prochaine injection le
|
||||
// 13 au lieu du 12 ». Diagnostic (ce test l'épingle) : la GÉNÉRATION
|
||||
// est exacte — créneau = dernière dose LOGUÉE + intervalle EXACT en
|
||||
// ms (aucun arrondi au jour, aucun saut de fuseau).
|
||||
//
|
||||
// ⚠️ Subtilité vécue : la dose loguée était à ~19 h un DIMANCHE
|
||||
// (saisie le lendemain de l'injection réelle du samedi 18 h) → avec
|
||||
// une posologie de 6 j, le créneau suivant tombe un SAMEDI (6 j plus
|
||||
// tard) : le jour de semaine DÉCALÉ entre doses loguées et réelles
|
||||
// dérive d'un jour par cycle. L'app suit les LOGS (comportement
|
||||
// voulu, v1.2.7) ; replacer la dose à son heure réelle (édition)
|
||||
// recadre la prévision.
|
||||
val een = data.treatments.single { it.esterType == "EEN" }
|
||||
val horizon = System.currentTimeMillis() + 30L * 86_400_000L
|
||||
val now = data.exportedAt
|
||||
val slots = PharmacokineticEngine.generateForecastDoses(een, data.doseLogs, horizon, now)
|
||||
assertTrue("aucune dose prévisionnelle générée", slots.isNotEmpty())
|
||||
val lastReal = data.doseLogs
|
||||
.filter { it.treatmentId == een.id && it.timestamp <= now }
|
||||
.maxByOrNull { it.timestamp }!!
|
||||
val intervalMs = (een.forecastIntervalDays!! * 24 * 3_600_000L).toLong()
|
||||
// le 1ᵉʳ créneau = dernière dose + N × intervalle (créneaux passés sautés)
|
||||
val expected = sequence {
|
||||
var t = lastReal.timestamp + intervalMs
|
||||
while (t <= now) t += intervalMs
|
||||
yield(t)
|
||||
t += intervalMs
|
||||
}.first()
|
||||
assertEquals(expected, slots.first().timestamp)
|
||||
// et l'écart entre créneaux de la série reste l'intervalle exact
|
||||
if (slots.size >= 2) {
|
||||
assertEquals(intervalMs, slots[1].timestamp - slots[0].timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
186
app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt
Normal file
186
app/src/test/java/com/hormonetrack/pk/RegressionUserCase5Test.kt
Normal file
@ -0,0 +1,186 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.hormonetrack.data.backup.BackupData
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import com.hormonetrack.ui.screens.labNotesForDisplay
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* **5ᵉ régression épinglée sur données réelles** (export v1.7.0, HORS dépôt
|
||||
* `local-test-data/backup-v1.7.0.json`, gitigné — données de santé).
|
||||
*
|
||||
* Contexte (remontée 2026-09-16) : dans une prise de sang E2+T, quand les
|
||||
* DEUX entrées portent des notes DISTINCTES (ex. une note clinique sur l'E2
|
||||
* et une note « DHT » sur la T), une seule s'affichait dans l'écran Analyses
|
||||
* — l'autre était bien conservée mais perdue à l'affichage. Fix :
|
||||
* `labNotesForDisplay` (Android + web en miroir), épinglé ici sur la VRAIE
|
||||
* paire de données. Profil mis à jour au passage : 3 traitements dont un CPA
|
||||
* oral (anti-androgène, Bateman) + EEn actif + EV historique INACTIF —
|
||||
* rappelle que l'inactif reste simulé (§6.bis).
|
||||
*
|
||||
* ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) :
|
||||
* aucune valeur de santé en dur dans ce fichier (garde de confidentialité
|
||||
* §8.bis — le scanner refuserait, et ce serait une fuite).
|
||||
*/
|
||||
class RegressionUserCase5Test {
|
||||
|
||||
companion object {
|
||||
private val DATA_FILE: File = listOf(
|
||||
File("../local-test-data/backup-v1.7.0.json"),
|
||||
File("local-test-data/backup-v1.7.0.json"),
|
||||
File("app/local-test-data/backup-v1.7.0.json")
|
||||
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.7.0.json")
|
||||
}
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
|
||||
private lateinit var data: BackupData
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
assumeTrue(
|
||||
"local-test-data/backup-v1.7.0.json absent — tests ignorés (données locales)",
|
||||
DATA_FILE.exists()
|
||||
)
|
||||
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||
}
|
||||
|
||||
/** Structure attendue de l'export (les nombres viennent du FICHIER). */
|
||||
@Test
|
||||
fun `backup parses with the expected shape`() {
|
||||
assertEquals(3, data.treatments.size)
|
||||
assertTrue("doses présentes", data.doseLogs.isNotEmpty())
|
||||
assertTrue("labs présents", data.labResults.isNotEmpty())
|
||||
// Profil : un anti-androgène ORAL actif, un E2 INACTIF resté dans les
|
||||
// données (§6.bis : jamais filtré de la simulation), un E2 actif
|
||||
assertEquals(1, data.treatments.count { it.type == TreatmentType.ANTI_ANDROGEN })
|
||||
assertEquals(1, data.treatments.count { !it.isActive })
|
||||
}
|
||||
|
||||
/**
|
||||
* LE fix de cette régression : la prise la plus récente porte DEUX notes
|
||||
* DISTINCTES (une par entrée) → [labNotesForDisplay] en rend DEUX,
|
||||
* préfixées du marqueur. Avant le fix, l'affichage n'en montrait qu'une
|
||||
* (première non vide) bien que les deux fussent conservées.
|
||||
*/
|
||||
@Test
|
||||
fun `the latest blood draw has two distinct notes and BOTH are displayed`() {
|
||||
val latestTs = data.labResults.maxOf { it.timestamp }
|
||||
val pair = data.labResults.filter { it.timestamp == latestTs }
|
||||
assertTrue("la dernière prise est une paire E2+T", pair.size >= 2)
|
||||
|
||||
val distinctNotes = pair.mapNotNull { it.notes?.takeIf { n -> n.isNotBlank() } }.distinct()
|
||||
assertTrue(
|
||||
"la dernière paire porte au moins deux notes distinctes (données du bug)",
|
||||
distinctNotes.size >= 2
|
||||
)
|
||||
val displayed = labNotesForDisplay(pair)
|
||||
assertEquals("DEUX lignes affichées", 2, displayed.size)
|
||||
assertTrue("chaque ligne est préfixée du marqueur", displayed.all { it.contains(" : ") })
|
||||
// Les DEUX textes d'origine se retrouvent (aucune perdue)
|
||||
for (note in distinctNotes) {
|
||||
assertTrue(displayed.any { it.endsWith(note) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Même prise → note identique dupliquée = une seule ligne (pas de doublon). */
|
||||
@Test
|
||||
fun `a blood draw with the SAME note on both entries shows it once`() {
|
||||
val pair = listOf(
|
||||
LabResult(marker = "E2", value = 1.0, unit = "pg/mL", timestamp = 0, notes = "Prise à jeun"),
|
||||
LabResult(marker = "T", value = 1.0, unit = "ng/mL", timestamp = 0, notes = "Prise à jeun"),
|
||||
)
|
||||
assertEquals(1, labNotesForDisplay(pair).size)
|
||||
}
|
||||
|
||||
/** Notes de DOSES (côté injection) : parsées et préservées. */
|
||||
@Test
|
||||
fun `dose notes are preserved by the round-trip`() {
|
||||
assertTrue(
|
||||
"au moins une dose porte une note (données réelles)",
|
||||
data.doseLogs.any { !it.notes.isNullOrBlank() }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plausibilité du moteur sur CE profil (CPA oral Bateman + EEn actif +
|
||||
* EV historique inactif) : niveau actuel physiologique, courbe 30 j non
|
||||
* vide et bornée, auto-calibration (activée dans les settings) couvre
|
||||
* l'ester actif sans planter.
|
||||
*/
|
||||
@Test
|
||||
fun `engine stays plausible on the new profile`() {
|
||||
val now = data.exportedAt + 300_000L // +5 min après l'export
|
||||
val level = PharmacokineticEngine.levelAt(
|
||||
data.treatments, data.doseLogs, now,
|
||||
tConfig = data.tConfig, scalePerEster = null, tKPerEster = null
|
||||
)
|
||||
assertTrue("E2 estimée physiologique (${level.e2})", level.e2 in 20.0..1000.0)
|
||||
assertTrue("T estimée physiologique (${level.t})", level.t in 0.01..3.0)
|
||||
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
data.treatments, data.doseLogs, now - 30 * 3_600_000L * 24, now,
|
||||
stepMs = 3_600_000L, tConfig = data.tConfig
|
||||
)
|
||||
assertTrue("courbe 30 j non vide", curve.isNotEmpty())
|
||||
|
||||
// Auto-calibration (option activée dans les settings de cet export)
|
||||
val auto = PharmacokineticEngine.autoCalibrated(
|
||||
data.treatments, data.doseLogs, data.labResults, data.tConfig
|
||||
)
|
||||
assertTrue("l'auto-calibration couvre au moins un ester", auto.calibratedEsters >= 1)
|
||||
}
|
||||
|
||||
/** Prévision : le traitement actif a une Posologie → créneaux projetés. */
|
||||
@Test
|
||||
fun `forecast slots exist for the active treatment`() {
|
||||
val active = data.treatments.first { it.isActive && it.type == TreatmentType.ESTRADIOL }
|
||||
val slots = PharmacokineticEngine.generateForecastDoses(
|
||||
active, data.doseLogs, data.exportedAt + 10L * 24 * 3_600_000L, data.exportedAt
|
||||
)
|
||||
assertTrue("créneaux prévisionnels générés (Posologie 7 j)", slots.isNotEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.0 : la RECOMMANDATION de prochaine prise de sang se calcule sur ce
|
||||
* profil réel (EEn actif, Posologie 7 j) — future, avec le créneau
|
||||
* associé juste après le creux, et cohérente avec la dernière prise de
|
||||
* sang existante (jamais un creux déjà mesuré).
|
||||
*/
|
||||
@Test
|
||||
fun `blood draw recommendation exists on the real profile`() {
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||
data.treatments, data.doseLogs, data.labResults, data.exportedAt
|
||||
)
|
||||
assertNotNull(
|
||||
"EEn actif + Posologie 7 j → une recommandation est calculable", rec
|
||||
)
|
||||
assertTrue("le creux est dans le futur", rec!!.troughMs > data.exportedAt)
|
||||
assertTrue(
|
||||
"le creux est DANS la fenêtre inter-créneau associée (trough=${rec.troughMs}, inj=${rec.injectionMs})",
|
||||
rec.troughMs > rec.injectionMs - 7L * DAY_MS - 2L * 3_600_000L &&
|
||||
rec.troughMs < rec.injectionMs
|
||||
)
|
||||
assertTrue(
|
||||
"le creux recommandé est postérieur à la dernière prise de sang",
|
||||
rec.troughMs > data.labResults.maxOf { it.timestamp }
|
||||
)
|
||||
assertTrue("ester actif uniquement", rec.ester.equals("EEN", ignoreCase = true))
|
||||
}
|
||||
}
|
||||
169
app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt
Normal file
169
app/src/test/java/com/hormonetrack/pk/RegressionUserCase6Test.kt
Normal file
@ -0,0 +1,169 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.hormonetrack.data.backup.BackupData
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* **6ᵉ régression épinglée sur données réelles** (export v1.9.8, HORS dépôt
|
||||
* `local-test-data/backup-v1.9.8.json`, gitigné — données de santé).
|
||||
*
|
||||
* Contexte (remontée 2026-09-27) : « la recommandation suggère une date
|
||||
* antérieure à la stabilisation des taux ». L'invariant pharmacocinétique de
|
||||
* la carte (§7.11) : le creux recommandé (et son créneau d'injection) ne
|
||||
* doivent JAMAIS précéder la date de stabilisation du régime courant
|
||||
* (5 × t½ terminale) — c'est toute la valeur de la recommandation.
|
||||
*
|
||||
* Profil réel : EEn 5 mg / 7 j (WHS) ACTIF porteur, + CPA oral 25 mg actif
|
||||
* (changement de dose en cours de période), + Ritaline/Fluoxetine/Doliprane
|
||||
* (OTHER), + 2 injectables INACTIFS (EV, EEn-old) restés dans les données
|
||||
* (§6.bis) ; logs d'injection à heures variables (l'écart réel 6,2 → 7,4 j).
|
||||
*
|
||||
* ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) :
|
||||
* aucune valeur de santé en dur dans ce fichier (garde de confidentialité
|
||||
* §8.bis — le scanner refuserait, et ce serait une fuite).
|
||||
*/
|
||||
class RegressionUserCase6Test {
|
||||
|
||||
companion object {
|
||||
private val DATA_FILE: File = listOf(
|
||||
File("../local-test-data/backup-v1.9.8.json"),
|
||||
File("local-test-data/backup-v1.9.8.json"),
|
||||
File("app/local-test-data/backup-v1.9.8.json")
|
||||
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.9.8.json")
|
||||
}
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
private val HOUR_MS = 3_600_000L
|
||||
|
||||
private lateinit var data: BackupData
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
assumeTrue(
|
||||
"local-test-data/backup-v1.9.8.json absent — tests ignorés (données locales)",
|
||||
DATA_FILE.exists()
|
||||
)
|
||||
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||
}
|
||||
|
||||
/** Le porteur de la recommandation : E2 injectable actif à Posologie. */
|
||||
private fun carrier() = data.treatments.first {
|
||||
it.type == TreatmentType.ESTRADIOL && it.isActive && it.usesProfileModel &&
|
||||
(it.forecastIntervalDays ?: 0.0) > 0.0
|
||||
}
|
||||
|
||||
/** Structure attendue de l'export (les nombres viennent du FICHIER). */
|
||||
@Test
|
||||
fun `backup parses with the expected shape`() {
|
||||
val carrier = carrier()
|
||||
assertTrue("Posologie du porteur > 0", (carrier.forecastIntervalDays ?: 0.0) > 0.0)
|
||||
val myDoses = data.doseLogs.filter { it.treatmentId == carrier.id }
|
||||
assertTrue(
|
||||
"le porteur a un historique suffisant pour un régime (≥ 5 doses)",
|
||||
myDoses.size >= 5
|
||||
)
|
||||
assertTrue("labs présents", data.labResults.isNotEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* **LE fix de cette régression** : le creux recommandé n'est JAMAIS
|
||||
* antérieur à la date de stabilisation annoncée. Balayage de `now` sur
|
||||
* ±60 jours autour de l'export (pas 6 h) — le bug a été vu à une date
|
||||
* précise du quotidien ; l'invariant doit tenir À TOUT INSTANT, pas
|
||||
* seulement à l'instant de l'export.
|
||||
*/
|
||||
@Test
|
||||
fun `recommended trough is never before the stabilization date (any now)`() {
|
||||
val start = data.exportedAt - 60L * DAY_MS
|
||||
val end = data.exportedAt + 60L * DAY_MS
|
||||
var checked = 0
|
||||
var violations = 0
|
||||
var now = start
|
||||
while (now <= end) {
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||
data.treatments, data.doseLogs, data.labResults, now
|
||||
)
|
||||
if (rec != null) {
|
||||
checked++
|
||||
if (rec.troughMs < rec.stabilizedAtMs || rec.injectionMs < rec.stabilizedAtMs) {
|
||||
violations++
|
||||
println(
|
||||
"VIOLATION now=$now : trough=${rec.troughMs} " +
|
||||
"stab=${rec.stabilizedAtMs} regimeStart=${rec.regimeStartMs}"
|
||||
)
|
||||
}
|
||||
}
|
||||
now += 6L * HOUR_MS
|
||||
}
|
||||
assertTrue(
|
||||
"au moins une recommandation calculée sur la fenêtre balayée (checked=$checked)",
|
||||
checked > 0
|
||||
)
|
||||
assertEquals(
|
||||
"creux/créneau toujours ≥ stabilisation ($violations violations)",
|
||||
0, violations
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* La recommandation à l'instant de l'export : le régime détecté doit
|
||||
* couvrir la séquence régulière récente (logs à heures variables MAIS
|
||||
* écarts dans la fenêtre Posologie ± 24 h — règle v1.9.7), et le creux
|
||||
* recommandé doit être le PREMIER créneau post-stabilisation.
|
||||
*
|
||||
* v1.13.0 : le régime est désormais POOLÉ entre traitements
|
||||
* ([LabTiming.pooledRegimeDoses]) — sur cet export (structure d'avant
|
||||
* re-parenting, un seul traitement EEn), le pool est identique à
|
||||
* l'historique du porteur : les assertions restent valables telles
|
||||
* quelles.
|
||||
*/
|
||||
@Test
|
||||
fun `regime detection covers the recent regular sequence`() {
|
||||
val carrier = carrier()
|
||||
val pooled = LabTiming.pooledRegimeDoses(carrier, data.doseLogs, data.treatments)
|
||||
val regimeStart = LabTiming.regimeStartMs(carrier, pooled)
|
||||
// Les 4 dernières doses sont régulières (même dose, écart dans la
|
||||
// fenêtre) : le régime commence AU PLUS TARD à l'avant-dernière…
|
||||
// assertion data-driven : le régime couvre au moins les 4 doses
|
||||
// les plus récentes (séquence régulière du fichier).
|
||||
val sorted = pooled.map { it.timestamp }.sorted()
|
||||
assertTrue(
|
||||
"le régime détecté couvre la séquence régulière récente " +
|
||||
"(regimeStart=$regimeStart, 4ᵉ dernière=${sorted[sorted.size - 4]})",
|
||||
regimeStart <= sorted[sorted.size - 4]
|
||||
)
|
||||
}
|
||||
|
||||
/** Cohérences d'affichage à l'instant de l'export (cf régression n°5). */
|
||||
@Test
|
||||
fun `recommendation at export time is consistent`() {
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||
data.treatments, data.doseLogs, data.labResults, data.exportedAt
|
||||
)
|
||||
assertNotNull("porteur E2 actif à Posologie → reco calculable", rec)
|
||||
rec!!.let {
|
||||
assertTrue("creux futur", it.troughMs > data.exportedAt)
|
||||
assertTrue(
|
||||
"creux dans la fenêtre inter-créneau (trough=${it.troughMs}, inj=${it.injectionMs})",
|
||||
it.troughMs < it.injectionMs &&
|
||||
it.troughMs > it.injectionMs - 8L * DAY_MS
|
||||
)
|
||||
assertTrue(
|
||||
"creux postérieur à la dernière analyse",
|
||||
it.troughMs > data.labResults.maxOf { l -> l.timestamp }
|
||||
)
|
||||
assertTrue(
|
||||
"INVARIANT central : creux ≥ stabilisation",
|
||||
it.troughMs >= it.stabilizedAtMs
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
187
app/src/test/java/com/hormonetrack/pk/RegressionUserCase7Test.kt
Normal file
187
app/src/test/java/com/hormonetrack/pk/RegressionUserCase7Test.kt
Normal file
@ -0,0 +1,187 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.hormonetrack.data.backup.BackupData
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* **7ᵉ régression épinglée sur données réelles** (export v1.12.0, HORS dépôt
|
||||
* `local-test-data/backup-v1.12.0.json`, gitigné — données de santé).
|
||||
*
|
||||
* Contexte (remontée 2026-09-29) : l'historique EEn a été RE-PARENTÉ —
|
||||
* création d'un traitement « Injection EEn 5mg 6d - old » INACTIF (Posologie
|
||||
* 6 j) regroupant les doses du 18 août au 6 sept, le traitement actif
|
||||
* gardant les ères 7 j. Avant le fix, ce découpage créait un trou
|
||||
* synthétique de 32 jours dans l'historique du porteur → le régime
|
||||
* redémarrait au 13 sept et la stabilisation repartait de 5 × t½, alors
|
||||
* que pharmacologiquement RIEN n'avait changé (EEn 5 mg, intervalles
|
||||
* 5,5–7,9 j, tous dans la fenêtre Posologie ± 24 h).
|
||||
*
|
||||
* **Le fix v1.13.0 ([LabTiming.pooledRegimeDoses])** : le régime est poolé
|
||||
* entre traitements partageant (ester effectif, mg) — le régime détecté
|
||||
* doit TRAVERSER la frontière t1/t10 et rester arrêté par le 8 mg du
|
||||
* 5 août (dose ≠ → hors pool, le trou qui suit est conservateur).
|
||||
*
|
||||
* Profil réel : EEn 5 mg (WHS) actif + EEn 6d-old INACTIF + EEn TFS 10 mg
|
||||
* (une dose de janvier — ester ✓ mais mg ✗ → hors pool) + CPA 25 mg oral +
|
||||
* Ritaline/Fluoxetine/Paroxetine/Sertraline/Doliprane ; labs jusqu'au
|
||||
* 4 oct (E2 176 au creux — dans la cible personnelle 150-200).
|
||||
*
|
||||
* ⚠️ TOUTES les assertions sont DATA-DRIVEN (elles lisent le fichier local) :
|
||||
* aucune valeur de santé en dur dans ce fichier (garde de confidentialité
|
||||
* §8.bis — le scanner refuserait, et ce serait une fuite).
|
||||
*/
|
||||
class RegressionUserCase7Test {
|
||||
|
||||
companion object {
|
||||
private val DATA_FILE: File = listOf(
|
||||
File("../local-test-data/backup-v1.12.0.json"),
|
||||
File("local-test-data/backup-v1.12.0.json"),
|
||||
File("app/local-test-data/backup-v1.12.0.json")
|
||||
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.12.0.json")
|
||||
}
|
||||
|
||||
private val DAY_MS = 86_400_000L
|
||||
|
||||
private lateinit var data: BackupData
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
assumeTrue(
|
||||
"local-test-data/backup-v1.12.0.json absent — tests ignorés (données locales)",
|
||||
DATA_FILE.exists()
|
||||
)
|
||||
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||
}
|
||||
|
||||
/** Porteur = injectable E2 ACTIF à Posologie (l'EEn 7 j). */
|
||||
private fun carrier() = data.treatments.first {
|
||||
it.type == TreatmentType.ESTRADIOL && it.isActive && it.usesProfileModel &&
|
||||
(it.forecastIntervalDays ?: 0.0) > 0.0
|
||||
}
|
||||
|
||||
/** Le traitement « … - old » INACTIF du même ester (le re-parenté). */
|
||||
private fun reparentedInactive(): Treatment? =
|
||||
data.treatments.firstOrNull {
|
||||
it.type == TreatmentType.ESTRADIOL && !it.isActive && it.usesProfileModel
|
||||
}
|
||||
|
||||
/** Structure attendue de l'export (les nombres viennent du FICHIER). */
|
||||
@Test
|
||||
fun `backup parses with the expected shape`() {
|
||||
assertEquals(10, data.treatments.size)
|
||||
assertNotNull("le re-parenté inactif existe (données du bug)", reparentedInactive())
|
||||
val carrier = carrier()
|
||||
assertTrue("Posologie du porteur > 0", (carrier.forecastIntervalDays ?: 0.0) > 0.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* **LE fix de cette régression** : le régime du porteur TRAVERSE la
|
||||
* frontière de re-parenting. Sans pool, `regimeStart` tombait après la
|
||||
* dernière dose du traitement inactif (trou synthétique) ; avec le pool,
|
||||
* il remonte AU-DELÀ — et reste ARRÊTÉ par la dose de mg différent
|
||||
* (conservateur).
|
||||
*/
|
||||
@Test
|
||||
fun `pooled regime crosses the re-parenting boundary but stops at the dose change`() {
|
||||
val carrier = carrier()
|
||||
val pooled = LabTiming.pooledRegimeDoses(carrier, data.doseLogs, data.treatments)
|
||||
val regimeStart = LabTiming.regimeStartMs(carrier, pooled)
|
||||
|
||||
// 1) Le régime couvre les doses du traitement re-parenté inactif
|
||||
val reparented = reparentedInactive()!!
|
||||
val reparentedLastTs = data.doseLogs
|
||||
.filter { it.treatmentId == reparented.id }
|
||||
.maxOf { it.timestamp }
|
||||
assertTrue(
|
||||
"le régime traverse la frontière de re-parenting " +
|
||||
"(regimeStart=$regimeStart ≤ dernière dose du re-parenté=$reparentedLastTs)",
|
||||
regimeStart <= reparentedLastTs
|
||||
)
|
||||
|
||||
// 2) La dose de MG DIFFÉRENT du porteur (8 mg) arrête le régime :
|
||||
// elle est exclue du pool → le régime démarre APRÈS elle
|
||||
val oddDoseTs = data.doseLogs
|
||||
.filter { it.treatmentId == carrier.id && Math.abs(it.doseAmount - carrier.doseAmount) > 1e-6 }
|
||||
.maxOf { it.timestamp }
|
||||
assertTrue(
|
||||
"le régime reste arrêté par la dose de mg différent " +
|
||||
"(regimeStart=$regimeStart > 8mg=$oddDoseTs)",
|
||||
regimeStart > oddDoseTs
|
||||
)
|
||||
|
||||
// 3) La stabilisation arrive RAPIDEMENT après l'export (le régime
|
||||
// poolé démarre dans le dernier mois et demi) — l'artefact
|
||||
// supprimé par le fix est le TROU SYNTHÉTIQUE de re-parenting,
|
||||
// pas les vrais redémarrages de régime (l'écart 5,8 j du
|
||||
// 25→31 août reste hors fenêtre ± 24 h et casse légitimement).
|
||||
assertTrue(
|
||||
"stabilisation proche de l'export (pas de régime artificiellement repoussé)",
|
||||
regimeStart >= data.exportedAt - 60L * DAY_MS
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* La recommandation complète sur ce profil : calculable, creux futur
|
||||
* post-stabilisation, et — v1.13.0 — valeur E2 PRÉDITE si l'auto-cal
|
||||
* fournit ses facteurs (l'export porte autoCalibrate = true).
|
||||
*/
|
||||
@Test
|
||||
fun `recommendation at export time is consistent and predicts the trough value`() {
|
||||
val auto = PharmacokineticEngine.autoCalibrated(
|
||||
data.treatments, data.doseLogs, data.labResults, data.tConfig
|
||||
)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||
data.treatments, data.doseLogs, data.labResults, data.exportedAt,
|
||||
auto.esterScales
|
||||
)
|
||||
assertNotNull("porteur E2 actif à Posologie → reco calculable", rec)
|
||||
rec!!.let {
|
||||
assertTrue("creux futur", it.troughMs > data.exportedAt)
|
||||
assertTrue(
|
||||
"INVARIANT creux ≥ stabilisation (fix #68)",
|
||||
it.troughMs >= it.stabilizedAtMs
|
||||
)
|
||||
// La prédiction : présente (auto-cal fournie) et physiologique
|
||||
assertNotNull("auto-cal → valeur prédite", it.predictedE2)
|
||||
assertTrue(
|
||||
"E2 prédite physiologique (${it.predictedE2})",
|
||||
it.predictedE2!! in 20.0..1000.0
|
||||
)
|
||||
// Sans calibration → PAS de valeur (honnêteté)
|
||||
val raw = LabTiming.nextBloodDrawRecommendation(
|
||||
data.treatments, data.doseLogs, data.labResults, data.exportedAt
|
||||
)
|
||||
assertEquals(null, raw!!.predictedE2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Le statut cible appliqué à la prédiction : la cible vient des champs
|
||||
* du fichier quand présents (ici absents de l'export v2 → null) — la
|
||||
* matrice complète est épinglée dans LabTimingV113Test.
|
||||
*/
|
||||
@Test
|
||||
fun `target status stays null when the export carries no trough target`() {
|
||||
val auto = PharmacokineticEngine.autoCalibrated(
|
||||
data.treatments, data.doseLogs, data.labResults, data.tConfig
|
||||
)
|
||||
val rec = LabTiming.nextBloodDrawRecommendation(
|
||||
data.treatments, data.doseLogs, data.labResults, data.exportedAt,
|
||||
auto.esterScales
|
||||
)!!
|
||||
// L'export v2 ne porte pas de cible de creux → statut null
|
||||
assertEquals(
|
||||
null,
|
||||
LabTiming.troughTargetStatus(rec.predictedE2, null, null)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -3,46 +3,44 @@ package com.hormonetrack.pk
|
||||
import com.google.gson.Gson
|
||||
import com.hormonetrack.data.backup.BackupData
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Regression test pinned to the user's real exported data (v1.0.0 backup JSON):
|
||||
* 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".
|
||||
* Régression épinglée sur le **1er export réel** de l'utilisatrice (v1.0.0).
|
||||
*
|
||||
* ⚠️ 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 {
|
||||
|
||||
companion object {
|
||||
// Exact export provided by the user (formatted for readability)
|
||||
private val USER_JSON = """
|
||||
{"doseLogs":[{"doseAmount":5.0,"id":3,"timestamp":1790766120000,"treatmentId":1}],
|
||||
"exportedAt":1791207257381,
|
||||
"labResults":[
|
||||
{"id":3,"marker":"E2","timestamp":1789028820000,"unit":"pg/mL","value":300.0},
|
||||
{"id":4,"marker":"T","timestamp":1789028820000,"unit":"ng/dL","value":33.0},
|
||||
{"id":1,"marker":"E2","timestamp":1790242440000,"unit":"pg/mL","value":250.0},
|
||||
{"id":2,"marker":"T","timestamp":1790242440000,"unit":"ng/dL","value":44.0}],
|
||||
"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
|
||||
/**
|
||||
* 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.0.0.json"),
|
||||
File("local-test-data/backup-v1.0.0.json"),
|
||||
File("app/local-test-data/backup-v1.0.0.json")
|
||||
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.0.0.json")
|
||||
}
|
||||
|
||||
@Before
|
||||
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")) {
|
||||
val file = listOf(
|
||||
File("src/test/assets/pk_profiles.json"),
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
@ -51,38 +49,32 @@ class RegressionUserCaseTest {
|
||||
}
|
||||
|
||||
private fun importUserBackup(): BackupData =
|
||||
Gson().fromJson(USER_JSON, BackupData::class.java)
|
||||
Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||
|
||||
@Test
|
||||
fun `user backup JSON parses into the expected data`() {
|
||||
fun `user backup parses into the expected data`() {
|
||||
val data = importUserBackup()
|
||||
assertEquals(1, data.treatments.size)
|
||||
assertEquals(1, data.doseLogs.size)
|
||||
assertEquals(4, data.labResults.size)
|
||||
assertEquals("EEN", data.treatments[0].esterType)
|
||||
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
|
||||
fun `charts generate for every range with the user data`() {
|
||||
val data = importUserBackup()
|
||||
val tConfig = data.tConfig
|
||||
for (rangeHours in listOf(24L, 24L * 7, 24L * 30)) {
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
data.treatments, data.doseLogs,
|
||||
startMs = EXPORT_TIME - rangeHours * PharmacokineticEngine.HOUR_MS,
|
||||
endMs = EXPORT_TIME,
|
||||
tConfig = tConfig
|
||||
startMs = data.exportedAt - rangeHours * PharmacokineticEngine.HOUR_MS,
|
||||
endMs = data.exportedAt,
|
||||
tConfig = data.tConfig
|
||||
)
|
||||
assertTrue("24h range=$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)
|
||||
assertTrue("rangeHours=$rangeHours curve must not be empty", curve.isNotEmpty())
|
||||
val last = curve.last()
|
||||
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)
|
||||
@ -92,8 +84,9 @@ class RegressionUserCaseTest {
|
||||
@Test
|
||||
fun `current level at export time is plausible`() {
|
||||
val data = importUserBackup()
|
||||
val point = PharmacokineticEngine.levelAt(data.treatments, data.doseLogs, EXPORT_TIME, data.tConfig)
|
||||
// ~121 h after 5 mg EEn: profile ≈ 27-30 pg/mL/mg → 135-150 pg/mL
|
||||
val point = PharmacokineticEngine.levelAt(
|
||||
data.treatments, data.doseLogs, data.exportedAt, data.tConfig
|
||||
)
|
||||
assertTrue("e2=${point.e2}", point.e2 in 100.0..200.0)
|
||||
}
|
||||
|
||||
@ -109,28 +102,29 @@ class RegressionUserCaseTest {
|
||||
|
||||
@Test
|
||||
fun `T labs in ng per dL convert to ng per mL`() {
|
||||
assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(44.0, "ng/dL"), 1e-9)
|
||||
assertEquals(0.32, PharmacokineticEngine.convertTToNgMl(33.0, "ng/dL"), 1e-9)
|
||||
// Constantes choisies idempotentes face au script d'anonymisation d'historique
|
||||
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(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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `T calibration works with ng per dL labs`() {
|
||||
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 e2 = PharmacokineticEngine.e2At(data.treatments, data.doseLogs, EXPORT_TIME)
|
||||
val e2 = PharmacokineticEngine.e2At(data.treatments, data.doseLogs, data.exportedAt)
|
||||
val trueK = 0.25
|
||||
val tNgMl = cfg.floor + (cfg.base - cfg.floor) / (1.0 + trueK * e2)
|
||||
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(
|
||||
listOf(lab), data.treatments, data.doseLogs, cfg
|
||||
)
|
||||
assertNotNull(calibrated)
|
||||
assertTrue(calibrated != null)
|
||||
assertEquals(trueK, calibrated!!.k, trueK * 0.15)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* Tests du garde « dose déjà loggée aujourd'hui » (v1.10.0, demande
|
||||
* utilisatrice) — [PharmacokineticEngine.hasDoseLoggedOnDay] : la
|
||||
* notification de rappel ne doit pas sonner pour une prise DÉJÀ saisie dans
|
||||
* la journée (le receiver saute la notif et re-programme le créneau suivant).
|
||||
*
|
||||
* Pur et DÉTERMINISTE : zone fixe (Europe/Paris), instants construits —
|
||||
* aucun System.currentTimeMillis. Le garde compare des JOURS CALENDAIRES
|
||||
* locaux : la frontière minuit est épinglée (23:50 la veille ≠ jour suivant).
|
||||
*/
|
||||
class ReminderLoggedDayTest {
|
||||
|
||||
private val ZONE = ZoneId.of("Europe/Paris")
|
||||
|
||||
/** 2026-09-27 12:00 Europe/Paris (instant de référence du jour J). */
|
||||
private val noon = ZonedDateTime.of(2026, 9, 27, 12, 0, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||
|
||||
private val T1 = 1L
|
||||
private val T2 = 2L
|
||||
private val HOUR_MS = 3_600_000L
|
||||
|
||||
private fun dose(treatmentId: Long, atMs: Long) =
|
||||
DoseLog(treatmentId = treatmentId, timestamp = atMs, doseAmount = 5.0)
|
||||
|
||||
@Test
|
||||
fun `dose logged the same day skips the reminder`() {
|
||||
// Prise à 09:00, rappel à 12:00 — LE cas remonté (dose saisie avant
|
||||
// l'heure de rappel) : le créneau du jour est déjà honoré.
|
||||
val doses = listOf(dose(T1, noon - 3 * HOUR_MS))
|
||||
assertTrue(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dose logged yesterday does not skip today's reminder`() {
|
||||
val doses = listOf(dose(T1, noon - 24 * HOUR_MS))
|
||||
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `midnight boundary is a calendar-day comparison in the given zone`() {
|
||||
// 23:50 le 26 → rappel 00:10 le 27 : jour CALendaire différent → pas de skip
|
||||
val lateEvening = ZonedDateTime.of(2026, 9, 26, 23, 50, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||
val earlyMorning = ZonedDateTime.of(2026, 9, 27, 0, 10, 0, 0, ZONE).toInstant().toEpochMilli()
|
||||
assertFalse(
|
||||
"23:50 la veille ne couvre pas le rappel de 00:10",
|
||||
PharmacokineticEngine.hasDoseLoggedOnDay(T1, listOf(dose(T1, lateEvening)), earlyMorning, ZONE)
|
||||
)
|
||||
assertTrue(
|
||||
"00:10 et 12:00 du MÊME jour = même jour calendaire",
|
||||
PharmacokineticEngine.hasDoseLoggedOnDay(T1, listOf(dose(T1, earlyMorning)), noon, ZONE)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dose of another treatment never skips`() {
|
||||
val doses = listOf(dose(T2, noon - 3 * HOUR_MS))
|
||||
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no dose at all never skips`() {
|
||||
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, emptyList(), noon, ZONE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `several treatments mixed - only the carrier's own day counts`() {
|
||||
val doses = listOf(
|
||||
dose(T2, noon - 1 * HOUR_MS), // autre traitement aujourd'hui
|
||||
dose(T1, noon - 30 * HOUR_MS), // porteur, mais la veille
|
||||
)
|
||||
assertFalse(PharmacokineticEngine.hasDoseLoggedOnDay(T1, doses, noon, ZONE))
|
||||
}
|
||||
}
|
||||
158
app/src/test/java/com/hormonetrack/pk/ReminderScheduleTest.kt
Normal file
158
app/src/test/java/com/hormonetrack/pk/ReminderScheduleTest.kt
Normal file
@ -0,0 +1,158 @@
|
||||
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.Test
|
||||
import java.util.Calendar
|
||||
|
||||
/**
|
||||
* Épingle la planification des rappels AVEC Posologie (v1.4.0, fix #52) :
|
||||
* un traitement injecté tous les 7 jours avec un rappel à 18 h doit sonner
|
||||
* UNIQUEMENT les jours de créneau (le samedi 18 h), pas tous les jours.
|
||||
*
|
||||
* Précédemment : `nextReminderFireMs` ignorait les doses et l'intervalle →
|
||||
* l'alarme se ré-armait chaque jour à HH:mm, même hors jour d'injection.
|
||||
*/
|
||||
class ReminderScheduleTest {
|
||||
|
||||
private companion object {
|
||||
const val DAY_MS = 86_400_000L
|
||||
const val HOUR_MS = 3_600_000L
|
||||
|
||||
/** 18 h locales du jour de [ms] (comme le ferait l'alarme). */
|
||||
fun at(ms: Long, hour: Int, minute: Int): Long =
|
||||
Calendar.getInstance().apply {
|
||||
timeInMillis = ms
|
||||
set(Calendar.HOUR_OF_DAY, hour)
|
||||
set(Calendar.MINUTE, minute)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
|
||||
private fun injectionTreatment(intervalDays: Double?, hour: Int, minute: Int): Treatment =
|
||||
Treatment(
|
||||
id = 1L,
|
||||
name = "EEn",
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_SUBCUT,
|
||||
doseAmount = 5.0,
|
||||
doseUnit = "mg",
|
||||
isActive = true,
|
||||
esterType = Esters.EEN,
|
||||
pkModel = PKModels.TRANSFEM_SCIENCE,
|
||||
forecastIntervalDays = intervalDays,
|
||||
reminderHour = hour,
|
||||
reminderMinute = minute,
|
||||
reminderEnabled = true
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `reminder follows the dosing grid (every 7 days at 18h, not daily)`() {
|
||||
// now = un instant fixe ; dernière dose il y a 2 jours (le même jour
|
||||
// de semaine que le créneau suivant, cf grille +7 j) ; rappel 18 h.
|
||||
val now = System.currentTimeMillis()
|
||||
val lastDose = now - 2 * DAY_MS
|
||||
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = lastDose, doseAmount = 5.0))
|
||||
val tr = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0)
|
||||
|
||||
val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now)
|
||||
val expected = at(lastDose + 7 * DAY_MS, 18, 0)
|
||||
assertEquals(expected, fire)
|
||||
// invariants : dans le futur, à ~5 jours (7 j − 2 j), PAS demain matin
|
||||
assertTrue("fire doit être futur", fire!! > now)
|
||||
assertTrue("fire doit être dans ~5 jours, pas demain", fire - now > 3 * DAY_MS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reminder time already passed on slot day moves to next slot`() {
|
||||
// Dose loguée à 20 h aujourd'hui, rappel prévu 18 h : l'occurrence
|
||||
// du jour est déjà passée → le prochain rappel est le créneau suivant.
|
||||
val now = at(System.currentTimeMillis(), 20, 0)
|
||||
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = now, doseAmount = 5.0))
|
||||
val tr = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0)
|
||||
|
||||
val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now)
|
||||
val expected = at(now + 7 * DAY_MS, 18, 0)
|
||||
assertEquals(expected, fire)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missed slot is skipped without simulating the past`() {
|
||||
// Dernière dose il y a 10 jours pour un intervalle de 7 j : le créneau
|
||||
// théorique (il y a 3 jours) est PASSÉ → on avance au suivant (+7 j).
|
||||
val now = System.currentTimeMillis()
|
||||
val lastDose = now - 10 * DAY_MS
|
||||
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = lastDose, doseAmount = 5.0))
|
||||
val tr = injectionTreatment(intervalDays = 7.0, hour = 9, minute = 30)
|
||||
|
||||
val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now)
|
||||
val expected = at(lastDose + 14 * DAY_MS, 9, 30)
|
||||
assertEquals(expected, fire)
|
||||
assertTrue(fire!! > now)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no posology keeps the legacy daily behaviour`() {
|
||||
val now = at(System.currentTimeMillis(), 10, 0) // 10 h < 18 h
|
||||
val tr = injectionTreatment(intervalDays = null, hour = 18, minute = 0)
|
||||
// Sans historique de doses non plus (gel/oral ou app neuve)
|
||||
val fire = PharmacokineticEngine.nextReminderFireFor(tr, emptyList(), now)
|
||||
assertEquals(at(now, 18, 0), fire)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `posology without any logged dose falls back to daily`() {
|
||||
// Aucune dose pour cadrer la grille : on retombe sur le quotidien
|
||||
// (l'app neuve ne peut pas deviner le jour d'injection).
|
||||
val now = at(System.currentTimeMillis(), 10, 0)
|
||||
val tr = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0)
|
||||
val fire = PharmacokineticEngine.nextReminderFireFor(tr, emptyList(), now)
|
||||
assertEquals(at(now, 18, 0), fire)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aggregate helper picks the earliest fire across treatments`() {
|
||||
val now = System.currentTimeMillis()
|
||||
val trWeekly = injectionTreatment(intervalDays = 7.0, hour = 18, minute = 0)
|
||||
val trDaily = injectionTreatment(intervalDays = null, hour = 8, minute = 0)
|
||||
.copy(id = 2L)
|
||||
val doses = listOf(
|
||||
DoseLog(treatmentId = 1L, timestamp = now - 2 * DAY_MS, doseAmount = 5.0)
|
||||
)
|
||||
val next = PharmacokineticEngine.nextReminderFireMs(listOf(trWeekly, trDaily), doses, now)
|
||||
assertEquals(PharmacokineticEngine.nextReminderFireFor(trDaily, doses, now), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inactive or disabled treatments never schedule`() {
|
||||
val now = System.currentTimeMillis()
|
||||
val doses = listOf(DoseLog(treatmentId = 1L, timestamp = now - DAY_MS, doseAmount = 5.0))
|
||||
assertEquals(null, PharmacokineticEngine.nextReminderFireFor(
|
||||
injectionTreatment(7.0, 18, 0).copy(isActive = false), doses, now))
|
||||
assertEquals(null, PharmacokineticEngine.nextReminderFireFor(
|
||||
injectionTreatment(7.0, 18, 0).copy(reminderEnabled = false), doses, now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sub-daily grid fires at the reminder hour on slot days (known limitation)`() {
|
||||
// Deux injections/jour (intervalle 0,5 j) avec UNE heure de rappel :
|
||||
// l'app ne peut pas exprimer deux rappels/jour à des heures différentes
|
||||
// — elle sonne à l'heure de rappel les JOURS de créneau (limitation
|
||||
// documentée §19 de la doc de dev). Ici : dose à 08 h, maintenant 10 h,
|
||||
// créneau suivant 20 h → l'alarme du jour sonne à 18 h (aujourd'hui).
|
||||
val now = at(System.currentTimeMillis(), 10, 0)
|
||||
val doses = listOf(
|
||||
DoseLog(treatmentId = 1L, timestamp = now - 2 * HOUR_MS, doseAmount = 2.0)
|
||||
)
|
||||
val tr = injectionTreatment(intervalDays = 0.5, hour = 18, minute = 0)
|
||||
val fire = PharmacokineticEngine.nextReminderFireFor(tr, doses, now)
|
||||
assertEquals(at(now, 18, 0), fire)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.hormonetrack.data.backup.BackupData
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Épinglé sur le **bug ×2,21 de la calibration manuelle** (#61, v1.4.9,
|
||||
* remontée : « le bouton Calibrer avec les analyses du traitement EEn WHSAH
|
||||
* me met toujours à ×2,21 »). Données : 4ᵉ export réel (HORS dépôt,
|
||||
* `local-test-data/backup-v1.4.2.json`, gitignoré).
|
||||
*
|
||||
* DIAGNOSTIC (reproduit par ce test à l'origine) : l'export contient une
|
||||
* dose EEn de test logguée en **janvier** (10 mg, bien avant la transition
|
||||
* de juillet) ; les labs de janvier-mars tombaient 15–76 jours après cette
|
||||
* dose, quand la prédiction WHSAH est RÉSIDUELLE (184 → 1 pg/mL) → ratios
|
||||
* aberrants (3,3 / 15,9 / 89,6 / 383 / 391) → la MÉDIANE des ratios tombait
|
||||
* à ×2,21, et ce DANS LES DEUX pipelines (manuel ET auto — même défaut de
|
||||
* frontière).
|
||||
*
|
||||
* FIX #61 : **garde de fenêtre d'attribution** (`withinAttributionWindow`) —
|
||||
* un lab ne calibre une dose que s'il est ≤ 3 demi-vies d'élimination du
|
||||
* traitement après elle (au-delà, la contribution est < 12,5 % : le lab
|
||||
* reflète autre chose). Appliqué aux TROIS pipelines de calibration
|
||||
* (manuel, échelles par ester, k T).
|
||||
*
|
||||
* Résultat post-fix (réel, épinglé) : le facteur manuel WHSAH = **0,55**
|
||||
* (médiane des labs dans la fenêtre) au lieu de 2,21.
|
||||
*/
|
||||
class ScaleFactorWhsahReproTest {
|
||||
|
||||
companion object {
|
||||
private val DATA_FILE: File = listOf(
|
||||
File("../local-test-data/backup-v1.4.2.json"),
|
||||
File("local-test-data/backup-v1.4.2.json"),
|
||||
File("app/local-test-data/backup-v1.4.2.json")
|
||||
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.4.2.json")
|
||||
}
|
||||
|
||||
private lateinit var data: BackupData
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
assumeTrue("export v1.4.2 absent (données locales)", DATA_FILE.exists())
|
||||
if (!PKProfileStore.hasProfile("EEN", "ESE")) {
|
||||
val file = File("src/test/assets/pk_profiles.json")
|
||||
.takeIf { it.exists() }
|
||||
?: File("src/main/assets/pk_profiles.json").takeIf { it.exists() }
|
||||
?: File("app/src/main/assets/pk_profiles.json")
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
data = Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||
}
|
||||
|
||||
/** Le traitement EEn, basculé WHSAH (ce que fait l'utilisatrice). */
|
||||
private fun eenWhs(): com.hormonetrack.data.model.Treatment =
|
||||
data.treatments.single { it.esterType == "EEN" }.copy(pkModel = "WHS")
|
||||
|
||||
@Test
|
||||
fun `manual calibration for EEn WHSAH lands in the physiological range (not 2-21)`() {
|
||||
val sf = PharmacokineticEngine.computeScaleFactor(
|
||||
eenWhs(), data.doseLogs,
|
||||
data.labResults.filter { it.marker.equals("E2", true) }
|
||||
)
|
||||
assertNotNull(sf)
|
||||
// AVANT le fix : 2,21 (médiane polluée par les labs hors fenêtre).
|
||||
// APRÈS : ~0,55 (les labs dans la fenêtre de 3 × t½).
|
||||
assertTrue("facteur manuel = $sf doit être physiologique (0,3–1,2)", sf!! in 0.3..1.2)
|
||||
// épinglage explicite du bug : plus JAMAIS 2,21
|
||||
assertTrue("régression #61 : facteur=$sf", kotlin.math.abs(sf - 2.21) > 0.5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto calibration WHSAH uses the same attribution window (coherent with manual)`() {
|
||||
// Auto (par modèle, fix #60) et manuel (computeScaleFactor) partagent
|
||||
// la même garde → sur les mêmes données, les facteurs convergent
|
||||
// (même set de labs attribués, même prédiction superposée).
|
||||
val auto = PharmacokineticEngine.autoCalibrated(
|
||||
data.treatments, data.doseLogs, data.labResults, data.tConfig,
|
||||
modelOverride = "WHS"
|
||||
).esterScales["EEN"]
|
||||
val manual = PharmacokineticEngine.computeScaleFactor(
|
||||
eenWhs(), data.doseLogs,
|
||||
data.labResults.filter { it.marker.equals("E2", true) }
|
||||
)
|
||||
assertNotNull(auto)
|
||||
assertTrue(
|
||||
"auto=$auto vs manuel=$manual doivent converger (±15 %)",
|
||||
kotlin.math.abs(auto!! - manual!!) <= 0.15 * maxOf(auto, manual)
|
||||
)
|
||||
// et la valeur est physiologique
|
||||
assertTrue("auto WHSAH=$auto doit être < 1,2", auto in 0.3..1.2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `significant-lab guard excludes residual predictions (labs at 37-76 days)`() {
|
||||
// La garde v1.4.9 est RELATIVE : un lab ne calibre que si la prédiction
|
||||
// reste ≥ 15 % du maximum déjà observé (l'injection gouverne encore).
|
||||
// Réproduit le scénario réel : prédiction WHSAH montante 184 → 155 →
|
||||
// puis résiduelle 23 → 3,5 → 1,5 → 1,0 (labs de janvier-mars).
|
||||
val seq = listOf(184.0, 155.0, 23.4, 3.5, 1.5, 1.0)
|
||||
var maxSoFar = 0.0
|
||||
val included = mutableListOf<Double>()
|
||||
for (p in seq) {
|
||||
if (PharmacokineticEngine.labIsSignificant(p, maxSoFar)) included.add(p)
|
||||
if (p > maxSoFar) maxSoFar = p
|
||||
}
|
||||
// inclus : 184 (1ᵉʳ), 155 (85 % du max) ; exclus : 23,4 (12,7 % < 15 %),
|
||||
// 3,5, 1,5, 1,0 (résiduels — les ratios 15,9 / 89,6 / 383 / 391 du bug)
|
||||
assertEquals(listOf(184.0, 155.0), included)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `significant-lab guard - first lab and boundary cases`() {
|
||||
// premier lab (max 0) → seule la garde > 0,5 s'applique
|
||||
assertTrue(PharmacokineticEngine.labIsSignificant(0.6, 0.0))
|
||||
assertTrue(!PharmacokineticEngine.labIsSignificant(0.4, 0.0))
|
||||
// exactement à la frontière 15 % → inclus (≥)
|
||||
assertTrue(PharmacokineticEngine.labIsSignificant(15.0, 100.0))
|
||||
// juste en dessous → exclu
|
||||
assertTrue(!PharmacokineticEngine.labIsSignificant(14.9, 100.0))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user