Compare commits
33 Commits
322b8ff0bf
...
cdd34c2313
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd34c2313 | |||
| 750cb91179 | |||
| a6a248dfc7 | |||
| 3848250ecf | |||
| a622650c94 | |||
| 41b9c82a52 | |||
| 8cf6ad54ab | |||
| 8aa4fe9ac0 | |||
| c422c801ae | |||
| 53bf87c0f4 | |||
| 4b9d5904a8 | |||
| 1249dcdc79 | |||
| ac5187b3b7 | |||
| 5edf4bc7e4 | |||
| 4aa0d439d7 | |||
| e3be3b3657 | |||
| 33e747de0c | |||
| 2fb0d02814 | |||
| d5180adb81 | |||
| 9232907cbc | |||
| 64365bd66a | |||
| 1900cb377d | |||
| 4210e5b21e | |||
| f57ab5f99f | |||
| 3f9b2bd8a0 | |||
| 9301b96902 | |||
| 54bd236506 | |||
| 80d49244d5 | |||
| 470bf5973d | |||
| 851c185f69 | |||
| fdd3fee8f6 | |||
| b4a81351bf | |||
| 463cdfb8e2 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -28,3 +28,6 @@ Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
local-test-data/
|
||||
app/build/generated/changelog/
|
||||
app/src/main/assets/changelog.md
|
||||
|
||||
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>.
|
||||
65
README.md
65
README.md
@ -1,5 +1,12 @@
|
||||
# HormoneTrack
|
||||
|
||||
> **🤖 Développé avec l'IA** : ce projet a été conçu et codé avec un assistant IA.
|
||||
> La contribution humaine a été **essentielle** : feedback continu, retours
|
||||
> utilisateur (tests sur vrai téléphone, rapports de bugs accompagnés d'exports
|
||||
> réels), suggestions d'améliorations et validation de chaque release. Le
|
||||
> détail session par session est documenté dans
|
||||
> [docs/DEVELOPPEMENT.md §2](docs/DEVELOPPEMENT.md).
|
||||
|
||||
Suivi de thérapie hormonale (THS) sur Android, avec courbes estimées **heure par heure**
|
||||
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,7 +16,7 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
||||
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
|
||||
> sang et aux consignes de ton endocrinologue.
|
||||
|
||||
- **Statut** : v1.2.0 — build Android ✅, **36 tests unitaires** ✅ (dont régression sur données réelles), intégration montre = notifications ✅, **repo git avec releases taguées** ✅
|
||||
- **Statut** : v1.3.0 — build Android ✅, **78 tests unitaires** ✅ (44 sans les données de test locales ; 3 régressions épinglées sur données réelles **non versionnées**), intégration montre = notifications ✅, **dépôt Gitea privé + releases avec APK** ✅
|
||||
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
||||
- **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)
|
||||
@ -18,25 +25,39 @@ sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
|
||||
## Fonctionnalités
|
||||
|
||||
- **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j,
|
||||
**panoramique** (glisser pour remonter dans le passé)
|
||||
**panoramique** (glisser pour remonter dans le passé), **zoom** (pinch ou boutons
|
||||
− / +, 6 h → 300 j, échantillonnage adaptatif), **pics & creux** affichables avec
|
||||
leurs **valeurs estimées** (triangles ▲▼ aux extrema locaux, toggle)
|
||||
- **Deux modèles PK au choix, superposables** (issus de la feuille `Estrogen.ods`) :
|
||||
**Estrannaise (EstraNase)** et **Transfem Science** pour les injections EV / EU / EEn,
|
||||
affichés côte à côte avec toggles indépendants
|
||||
- **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel,
|
||||
patch et voie orale
|
||||
- **Simulation prévisionnelle** : configurer la **fréquence d'injection** (jours) sur un
|
||||
- **Simulation prévisionnelle** : configurer la **posologie** (intervalle en jours) sur un
|
||||
traitement → projection des doses à venir sur le graphique (jamais sauvegardées)
|
||||
- **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),
|
||||
**intervalle en jours entre dosages** affiché
|
||||
- **Analyses de sang** (E2, T, PRL…) tracées sur les courbes comme points de calibration
|
||||
- **Traitements inactifs** : un traitement archivé n'apparaît plus dans la saisie
|
||||
ni dans les rappels, mais **tout son historique reste simulé et calibré** —
|
||||
utile pour une transition valerate → enanthate
|
||||
- **Analyses de sang** : **E2 + T en une seule entrée** (chacune optionnelle), affichées
|
||||
**côte à côte** quand elles partagent la même date/heure, éditables (tap → choix de
|
||||
l'entrée) ; unités T : ng/mL, ng/dL, ng/L, nmol/L
|
||||
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
|
||||
calculé automatiquement (« Scale factor » du `.ods`, automatisé) — ou **calibration
|
||||
automatique permanente** (option, désactivée par défaut)
|
||||
- **Estimation T** empirique `T = plancher + (base − plancher) ÷ (1 + k·E2)`, calibrable
|
||||
avec tes résultats T (unités ng/mL, ng/dL, ng/L, nmol/L acceptées)
|
||||
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)
|
||||
- **Nouveautés à chaque mise à jour** : dialog de changelog automatique
|
||||
(fermé = ne réapparaît pas avant la prochaine version)
|
||||
- **Événements d'agenda** : les rappels de prises peuvent créer un événement
|
||||
récurrent (posologie) dans un calendrier « HormoneTrack » de ton téléphone
|
||||
- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T)
|
||||
- **FR + EN** (langue par app, indépendante du système)
|
||||
- UI Jetpack Compose récente (BOM 2026.08, Material You) ; 100 % local, aucun compte
|
||||
@ -59,12 +80,16 @@ Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INS
|
||||
|
||||
## Git
|
||||
|
||||
Dépôt initialisé avec des **releases taguées** (`v1.1.0`, `v1.2.0`, …) :
|
||||
Dépôts : **gitea.cloudyfy.fr** et **gitea.farewell.dev** (miroir) —
|
||||
`Siphonight/HormoneTrack` sur les deux (privé), avec
|
||||
**releases taguées** (`v1.1.0` → `v1.2.5`) et **deux APK par release**
|
||||
(téléchargeables sans compiler, cf [docs/DEVELOPPEMENT.md §16.bis](docs/DEVELOPPEMENT.md)) :
|
||||
`-release.apk` (**recommandé**, optimisé R8, 2,4 Mo) et `-debug.apk` (20 Mo) :
|
||||
|
||||
```bash
|
||||
git tag # lister les releases
|
||||
git log --oneline # historique par couches (toolchain / moteur / UI / docs)
|
||||
git push -u origin main --tags # après ajout d'un remote
|
||||
git push -u origin main --tags
|
||||
```
|
||||
|
||||
Chaque commit de release passe `./gradlew testDebugUnitTest` (vert obligatoire) et est
|
||||
@ -90,10 +115,17 @@ La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste
|
||||
|
||||
## 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.)
|
||||
- `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
|
||||
|
||||
@ -103,11 +135,16 @@ HormoneTrack/
|
||||
├── 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 une release (corps = CHANGELOG + APK)
|
||||
├── local-test-data/ ← gitignoré : backups réels pour les tests
|
||||
│ de régression (JAMAIS dans le dépôt, cf §8.bis)
|
||||
├── build.gradle.kts config Gradle racine (AGP/Kotlin/KSP épinglés)
|
||||
├── 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…)
|
||||
@ -139,6 +176,6 @@ HormoneTrack/
|
||||
|
||||
## 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)).
|
||||
|
||||
@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.hormonetrack"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 3
|
||||
versionName = "1.2.0"
|
||||
versionCode = 14
|
||||
versionName = "1.3.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@ -23,11 +23,19 @@ 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"
|
||||
)
|
||||
// ⚠️ Signé avec la CLÉ DEBUG volontairement : l'APK release reste
|
||||
// installable PAR-DESSUS l'APK debug déjà installé (même signature),
|
||||
// sans désinstallation ni perte de données, tout en bénéficiant de
|
||||
// R8. Si un jour on distribue largement : générer un vrai keystore
|
||||
// (signature différente → migration backup→désinstall→réimport).
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +47,8 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
// BuildConfig.VERSION_NAME : écran Paramètres + dialog de changelog
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
@ -48,6 +58,16 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronise docs/CHANGELOG.md → src/main/assets/changelog.md (dialog
|
||||
// « Nouveautés » affiché après chaque mise à jour). Automatique à chaque
|
||||
// build ; le fichier généré est gitignoré (source de vérité = docs/).
|
||||
tasks.register<Copy>("copyChangelog") {
|
||||
from(rootProject.file("docs/CHANGELOG.md"))
|
||||
into(file("src/main/assets"))
|
||||
rename { "changelog.md" }
|
||||
}
|
||||
tasks.named("preBuild") { dependsOn("copyChangelog") }
|
||||
|
||||
dependencies {
|
||||
// BOM Compose : épingle toutes les versions des artefacts androidx.compose.*
|
||||
// (material3 >= 1.4 = Material 3 Expressive)
|
||||
|
||||
16
app/proguard-rules.pro
vendored
16
app/proguard-rules.pro
vendored
@ -1,2 +1,16 @@
|
||||
# Keep Room entities' enum fields (auto-converters use enum names via reflection in Gson backup)
|
||||
# Règles R8/ProGuard — HormoneTrack
|
||||
# Build release : isMinifyEnabled + isShrinkResources actifs (v1.2.5).
|
||||
# ⚠️ R8 en full mode (AGP 9) est agressif : TOUT usage de réflexion doit être
|
||||
# couvert explicitement, sinon crash à l'exécution uniquement en release.
|
||||
|
||||
# --- Gson (backup JSON + calibration) ---
|
||||
# Gson lit/écrit les champs par réflexion (noms de champs) : sans ces règles,
|
||||
# l'export/import JSON produirait des objets vides ou planterait EN RELEASE SEULE.
|
||||
-keepattributes Signature, InnerClasses, EnclosingMethod, *Annotation*
|
||||
|
||||
-keep class com.hormonetrack.data.model.** { <fields>; }
|
||||
-keep class com.hormonetrack.data.backup.BackupData { <fields>; }
|
||||
-keep class com.hormonetrack.pk.TConfig { <fields>; }
|
||||
|
||||
# Énumérations : converties en String par Room et Gson (par nom)
|
||||
-keepclassmembers enum com.hormonetrack.data.model.** { *; }
|
||||
|
||||
@ -6,6 +6,7 @@ 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
|
||||
@ -25,6 +26,13 @@ class MainActivity : AppCompatActivity() {
|
||||
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") {
|
||||
|
||||
@ -23,7 +23,7 @@ import com.hormonetrack.data.model.Treatment
|
||||
*/
|
||||
@Database(
|
||||
entities = [Treatment::class, DoseLog::class, LabResult::class],
|
||||
version = 2,
|
||||
version = 3,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
@ -43,6 +43,16 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 → v3 (2026-09-05) : ajout de calendarEventId (nullable, INTEGER) —
|
||||
* id de l'événement d'agenda récurrent (CalendarContract) du traitement.
|
||||
*/
|
||||
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE treatments ADD COLUMN calendarEventId INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
@ -53,7 +63,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
AppDatabase::class.java,
|
||||
"hormonetrack.db"
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
|
||||
@ -31,8 +31,26 @@ object BackupManager {
|
||||
return Gson().toJson(data)
|
||||
}
|
||||
|
||||
data class ImportResult(val treatments: Int, val doseLogs: Int, val labResults: Int)
|
||||
data class ImportResult(
|
||||
val treatments: Int,
|
||||
val doseLogs: Int,
|
||||
val labResults: Int,
|
||||
/** Réglages T du backup, à restaurer dans DataStore par l'appelant. */
|
||||
val tConfig: TConfig
|
||||
)
|
||||
|
||||
/**
|
||||
* Restaure un backup en mode **ÉCRASEMENT** (v1.2.6) : les données actuelles
|
||||
* sont effacées AVANT l'insertion — sinon les IDs du backup (conservés pour
|
||||
* les FK dose→traitement) entrent en conflit avec les IDs existants et
|
||||
* l'import échouait dès que l'app contenait déjà des données.
|
||||
*
|
||||
* Ordre d'effacement : dose_logs et lab_results d'abord, treatments ensuite
|
||||
* (FK CASCADE des doses vers les traitements).
|
||||
*
|
||||
* @return le nombre d'entrées restaurées par table + le tConfig du backup
|
||||
* (à restaurer dans DataStore par l'appelant).
|
||||
*/
|
||||
suspend fun importJson(
|
||||
repo: HormoneRepository,
|
||||
json: String
|
||||
@ -40,6 +58,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 +70,8 @@ object BackupManager {
|
||||
return ImportResult(
|
||||
data.treatments.size,
|
||||
data.doseLogs.size,
|
||||
data.labResults.size
|
||||
data.labResults.size,
|
||||
data.tConfig
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,10 @@ interface DoseLogDao {
|
||||
@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
|
||||
|
||||
|
||||
@ -18,6 +18,10 @@ interface LabResultDao {
|
||||
@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
|
||||
|
||||
|
||||
@ -21,6 +21,11 @@ interface TreatmentDao {
|
||||
@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
|
||||
|
||||
|
||||
@ -64,6 +64,11 @@ data class Treatment(
|
||||
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
|
||||
|
||||
@ -44,4 +44,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()
|
||||
}
|
||||
|
||||
@ -117,7 +117,14 @@ object PharmacokineticEngine {
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
tMs: Long,
|
||||
modelOverride: String? = null
|
||||
modelOverride: String? = null,
|
||||
/**
|
||||
* Facteurs d'échelle PAR ESTER (v1.2.1) : chaque dose est multipliée par le
|
||||
* facteur de son ester (doseEster), fallback = scaleFactor du traitement.
|
||||
* Utilisé par la calibration automatique : les labs valerate calibrent les
|
||||
* doses valerate, les labs enanthate les doses enanthate.
|
||||
*/
|
||||
scalePerEster: Map<String, Double>? = null
|
||||
): Double {
|
||||
val batemanCache = HashMap<Long, BatemanParams>()
|
||||
var total = 0.0
|
||||
@ -133,7 +140,11 @@ object PharmacokineticEngine {
|
||||
val c = concentrationOfDose(
|
||||
treatment, dose, tMs, batemanCache[treatment.id], modelOverride
|
||||
)
|
||||
if (c > 0.0) total += c * treatment.scaleFactor
|
||||
if (c > 0.0) {
|
||||
val scale = scalePerEster?.get(doseEster(treatment, dose))
|
||||
?: treatment.scaleFactor
|
||||
total += c * scale
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
@ -147,12 +158,17 @@ object PharmacokineticEngine {
|
||||
/**
|
||||
* The T model works in ng/mL. Users enter labs in ng/mL, ng/dL, ng/L or nmol/L —
|
||||
* 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
|
||||
}
|
||||
@ -162,18 +178,42 @@ object PharmacokineticEngine {
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
tMs: Long,
|
||||
tConfig: TConfig
|
||||
tConfig: TConfig,
|
||||
tKPerEster: Map<String, Double>? = null
|
||||
): LevelPoint {
|
||||
val e2 = e2At(treatments, doseLogs, tMs)
|
||||
return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig))
|
||||
val k = activeEsterAt(treatments, doseLogs, tMs)
|
||||
?.let { tKPerEster?.get(it) } ?: tConfig.k
|
||||
return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig.copy(k = k)))
|
||||
}
|
||||
|
||||
/**
|
||||
* L'ester « actif » à l'instant t = celui de la dernière dose E2 ≤ t
|
||||
* (une prise de sang / un point de courbe reflète l'injection qui précède).
|
||||
* null avant la première dose ou si l'ester est NONE.
|
||||
*/
|
||||
fun activeEsterAt(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
tMs: Long
|
||||
): String? {
|
||||
val last = doseLogs
|
||||
.filter { d ->
|
||||
d.timestamp <= tMs &&
|
||||
treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL }
|
||||
}
|
||||
.maxByOrNull { it.timestamp } ?: return null
|
||||
val tr = treatments.first { it.id == last.treatmentId }
|
||||
return doseEster(tr, last).takeIf { it != Esters.NONE }
|
||||
}
|
||||
|
||||
fun currentLevel(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
tConfig: TConfig,
|
||||
tKPerEster: Map<String, Double>? = null,
|
||||
nowMs: Long = System.currentTimeMillis()
|
||||
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig)
|
||||
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster)
|
||||
|
||||
fun computeCurve(
|
||||
treatments: List<Treatment>,
|
||||
@ -183,7 +223,15 @@ object PharmacokineticEngine {
|
||||
stepMs: Long = HOUR_MS,
|
||||
tConfig: TConfig,
|
||||
/** Force un modèle PK (ESE/TFS) pour les traitements par profil. */
|
||||
modelOverride: String? = null
|
||||
modelOverride: String? = null,
|
||||
/** Facteurs d'échelle par ester (calibration automatique, cf e2At). */
|
||||
scalePerEster: Map<String, Double>? = null,
|
||||
/**
|
||||
* Constante k du modèle T PAR PÉRIODE D'ESTER (v1.2.3) : à chaque instant,
|
||||
* le k utilisé est celui de l'ester de la dernière dose ≤ t (la suppression
|
||||
* n'est pas la même selon l'ester). Fallback = tConfig.k.
|
||||
*/
|
||||
tKPerEster: Map<String, Double>? = null
|
||||
): List<LevelPoint> {
|
||||
if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList()
|
||||
|
||||
@ -198,10 +246,25 @@ object PharmacokineticEngine {
|
||||
val batemanCache = HashMap<Long, BatemanParams>()
|
||||
relevantTreatments.forEach { batemanCache[it.id] = batemanParams(it) }
|
||||
|
||||
// Doses triées pour suivre l'« ester actif » le long de la grille (pour le k de T)
|
||||
val sortedEstrogenDoses = doseLogs
|
||||
.filter { d -> relevantTreatments.any { it.id == d.treatmentId } }
|
||||
.sortedBy { it.timestamp }
|
||||
var doseCursor = 0
|
||||
var activeEster: String? = null
|
||||
|
||||
val points = mutableListOf<LevelPoint>()
|
||||
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 p = batemanCache[treatment.id]
|
||||
@ -210,10 +273,14 @@ object PharmacokineticEngine {
|
||||
val dtH = (t - dose.timestamp) / 3_600_000.0
|
||||
if (dtH > cutoff) continue
|
||||
val c = concentrationOfDose(treatment, dose, t, p, modelOverride)
|
||||
if (c > 0.0) e2 += c * treatment.scaleFactor
|
||||
if (c > 0.0) {
|
||||
val scale = scalePerEster?.get(doseEster(treatment, dose))
|
||||
?: treatment.scaleFactor
|
||||
e2 += c * scale
|
||||
}
|
||||
}
|
||||
}
|
||||
points.add(LevelPoint(t, e2, testosteroneAt(e2, tConfig)))
|
||||
points.add(LevelPoint(t, e2, testosteroneAt(e2, tConfig.copy(k = k))))
|
||||
t += stepMs
|
||||
}
|
||||
return points
|
||||
@ -252,6 +319,15 @@ object PharmacokineticEngine {
|
||||
|
||||
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(
|
||||
@ -267,19 +343,133 @@ object PharmacokineticEngine {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calibration automatique (option « Auto-calibration » des Paramètres) :
|
||||
* recalcul à la volée du facteur d'échelle de chaque traitement E2 (médiane
|
||||
* lab ÷ prédiction) et de la constante k du modèle T, SANS toucher aux valeurs
|
||||
* stockées — les traitements renvoyés sont des copies à utiliser uniquement pour
|
||||
* l'affichage des courbes.
|
||||
* 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>
|
||||
): Map<String, Double> {
|
||||
val estrogenTreatments = treatments.filter { it.type == TreatmentType.ESTRADIOL }
|
||||
val estrogenDoses = doseLogs.filter { d ->
|
||||
estrogenTreatments.any { it.id == d.treatmentId }
|
||||
}
|
||||
if (estrogenDoses.isEmpty()) return emptyMap()
|
||||
|
||||
// Prédictions non calibrées (scaleFactor forcé à 1) pour chaque lab
|
||||
val unscaled = estrogenTreatments.map { it.copy(scaleFactor = 1.0) }
|
||||
val ratiosByEster = HashMap<String, MutableList<Double>>()
|
||||
for (lab in e2Labs) {
|
||||
val predicted = e2At(unscaled, doseLogs, lab.timestamp)
|
||||
if (predicted <= 0.5) continue
|
||||
|
||||
// Attribution : dernière dose E2 ≤ lab → son ester (override compris)
|
||||
val attributedDose = estrogenDoses
|
||||
.filter { it.timestamp <= lab.timestamp }
|
||||
.maxByOrNull { it.timestamp } ?: continue
|
||||
val attributedTreatment =
|
||||
estrogenTreatments.first { it.id == attributedDose.treatmentId }
|
||||
val ester = doseEster(attributedTreatment, attributedDose)
|
||||
if (ester == Esters.NONE) continue
|
||||
|
||||
ratiosByEster.getOrPut(ester) { mutableListOf() }
|
||||
.add(lab.value / predicted)
|
||||
}
|
||||
return ratiosByEster.mapValues { (_, ratios) ->
|
||||
val sorted = ratios.sorted()
|
||||
val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2]
|
||||
else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
|
||||
(median * 100).toInt() / 100.0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calibration du k du modèle T **PAR PÉRIODE D'ESTER** (v1.2.3).
|
||||
*
|
||||
* La suppression de la testostérone n'est pas la même selon l'ester (un valerate
|
||||
* donne des pics hauts et courts, un enanthate un plateau plus doux) : chaque lab
|
||||
* T est attribué à la période d'injection dans laquelle il tombe (dernière dose
|
||||
* E2 ≤ lab → son ester) et le k final = médiane des k de CETTE période.
|
||||
*
|
||||
* L'E2 utilisé dans la formule est la version **calibrée** (scalePerEster) :
|
||||
* calibrer k contre une E2 non calibrée donnerait des k faussés.
|
||||
*
|
||||
* @return k par ester ("EV"/"EU"/"EEN"), seulement pour les périodes ayant au
|
||||
* moins un lab T exploitable (valeur > floor, E2 estimée > 1 pg/mL, k ∈ (1e-4, 10)).
|
||||
*/
|
||||
fun computeTKPerEster(
|
||||
treatments: List<Treatment>,
|
||||
doseLogs: List<DoseLog>,
|
||||
tLabs: List<LabResult>,
|
||||
current: TConfig,
|
||||
scalePerEster: Map<String, Double>? = null
|
||||
): Map<String, Double> {
|
||||
val estrogenDoses = doseLogs.filter { d ->
|
||||
treatments.any { it.id == d.treatmentId && it.type == TreatmentType.ESTRADIOL }
|
||||
}
|
||||
if (estrogenDoses.isEmpty()) return emptyMap()
|
||||
|
||||
val ksByEster = HashMap<String, MutableList<Double>>()
|
||||
for (lab in tLabs) {
|
||||
val tNgMl = convertTToNgMl(lab.value, lab.unit)
|
||||
if (tNgMl <= current.floor + 0.02) continue
|
||||
val attributed = estrogenDoses
|
||||
.filter { it.timestamp <= lab.timestamp }
|
||||
.maxByOrNull { it.timestamp } ?: continue
|
||||
val tr = treatments.first { it.id == attributed.treatmentId }
|
||||
val ester = doseEster(tr, attributed)
|
||||
if (ester == Esters.NONE) continue
|
||||
|
||||
val e2 = e2At(treatments, doseLogs, lab.timestamp, scalePerEster = scalePerEster)
|
||||
if (e2 <= 1.0) continue
|
||||
val k = ((current.base - current.floor) / (tNgMl - current.floor) - 1.0) / e2
|
||||
if (k > 1e-4 && k < 10.0) {
|
||||
ksByEster.getOrPut(ester) { mutableListOf() }.add(k)
|
||||
}
|
||||
}
|
||||
return ksByEster.mapValues { (_, ks) ->
|
||||
val sorted = ks.sorted()
|
||||
val median = if (sorted.size % 2 == 1) sorted[sorted.size / 2]
|
||||
else (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
|
||||
(median * 1000).toInt() / 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calibration automatique (option « Auto-calibration » des Paramètres), v1.2.3 :
|
||||
* - facteurs d'échelle E2 **par période d'ester** ([computeEsterScaleFactors]) ;
|
||||
* - k du modèle T **par période d'ester** ([computeTKPerEster]), calibré contre
|
||||
* l'E2 déjà calibrée — la suppression T diffère selon l'ester.
|
||||
*
|
||||
* Tout est renvoyé en copies/paramètres, UNIQUEMENT pour l'affichage : les
|
||||
* traitements et réglages stockés ne sont jamais modifiés. Pour un ester sans
|
||||
* lab : E2 → scaleFactor stocké, T → k stocké (tConfig.k).
|
||||
*/
|
||||
data class AutoCalibrated(
|
||||
val treatments: List<Treatment>,
|
||||
val tConfig: TConfig,
|
||||
/** Nombre de traitements dont le facteur d'échelle a été ajusté. */
|
||||
val calibratedTreatments: Int,
|
||||
/** true si le modèle T a pu être recalibré. */
|
||||
val tRecalibrated: Boolean
|
||||
/** 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(
|
||||
@ -291,21 +481,17 @@ object PharmacokineticEngine {
|
||||
val e2Labs = labs.filter { it.marker.equals("E2", true) }
|
||||
val tLabs = labs.filter { it.marker.equals("T", true) }
|
||||
|
||||
var calibrated = 0
|
||||
val newTreatments = treatments.map { tr ->
|
||||
if (tr.type != TreatmentType.ESTRADIOL) return@map tr
|
||||
val sf = computeScaleFactor(tr, doseLogs, e2Labs)
|
||||
if (sf != null && sf != tr.scaleFactor) {
|
||||
calibrated++
|
||||
tr.copy(scaleFactor = sf)
|
||||
} else tr
|
||||
}
|
||||
val newT = computeTConfigCalibration(tLabs, newTreatments, doseLogs, tConfig)
|
||||
val esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs)
|
||||
val tKPerEster = computeTKPerEster(
|
||||
treatments, doseLogs, tLabs, tConfig, scalePerEster = esterScales
|
||||
)
|
||||
return AutoCalibrated(
|
||||
treatments = newTreatments,
|
||||
tConfig = newT ?: tConfig,
|
||||
calibratedTreatments = calibrated,
|
||||
tRecalibrated = newT != null
|
||||
treatments = treatments,
|
||||
tConfig = tConfig,
|
||||
esterScales = esterScales,
|
||||
calibratedEsters = esterScales.size,
|
||||
tRecalibrated = tKPerEster.isNotEmpty(),
|
||||
tKPerEster = tKPerEster
|
||||
)
|
||||
}
|
||||
|
||||
@ -379,7 +565,8 @@ object PharmacokineticEngine {
|
||||
var next: Long? = null
|
||||
val cal = java.util.Calendar.getInstance()
|
||||
for (tr in treatments) {
|
||||
if (!tr.reminderEnabled) continue
|
||||
// Un traitement inactif ne génère plus de rappels
|
||||
if (!tr.reminderEnabled || !tr.isActive) continue
|
||||
val h = tr.reminderHour ?: continue
|
||||
val m = tr.reminderMinute ?: continue
|
||||
cal.timeInMillis = nowMs
|
||||
@ -396,4 +583,64 @@ object PharmacokineticEngine {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
164
app/src/main/java/com/hormonetrack/reminder/CalendarEvents.kt
Normal file
164
app/src/main/java/com/hormonetrack/reminder/CalendarEvents.kt
Normal file
@ -0,0 +1,164 @@
|
||||
package com.hormonetrack.reminder
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Gestion des événements d'agenda récurrents pour les prises hormonales
|
||||
* (v1.3.0) : un événement est inséré dans un calendrier LOCAL « HormoneTrack »
|
||||
* (visible dans l'agenda du téléphone) avec une répétition RRULE dérivée de la
|
||||
* **Posologie** du traitement (forecastIntervalDays).
|
||||
*
|
||||
* Cycle de vie :
|
||||
* - activation (switch « Événement d'agenda » dans l'éditeur de traitement,
|
||||
* sous « Rappels ») → [upsertEvent] crée/met à jour l'événement et l'id est
|
||||
* stocké sur le traitement ;
|
||||
* - désactivation / suppression / changement de posologie → l'ancien
|
||||
* événement est supprimé puis recréé au save.
|
||||
*
|
||||
* Permission : WRITE_CALENDAR (+ READ_CALENDAR pour les requêtes), demandée à
|
||||
* l'exécution par l'éditeur au moment de l'activation du switch.
|
||||
*/
|
||||
object CalendarEvents {
|
||||
|
||||
const val CALENDAR_NAME = "HormoneTrack"
|
||||
|
||||
/**
|
||||
* RRULE de répétition dérivé de la posologie (jours) : répétition DAILY
|
||||
* tous les N jours (INTERVAL = N). L'intervalle est arrondi à l'entier
|
||||
* (RRULE n'accepte pas de décimales) — documenté dans l'UI.
|
||||
* ⚠️ Arrondi demi-supérieur EXPLICITE via floor(x + 0.5) : kotlin.math.round
|
||||
* arrondit les ties vers l'entier PAIR (6,5 → 6 !) — piégé en v1.3.0.
|
||||
*/
|
||||
fun rruleForIntervalDays(intervalDays: Double): String =
|
||||
"FREQ=DAILY;INTERVAL=${maxOf(1, kotlin.math.floor(intervalDays + 0.5).toInt())}"
|
||||
|
||||
/**
|
||||
* Prochaine occurrence (ms) d'une prise à hh:mm : aujourd'hui si pas encore
|
||||
* passé, sinon demain. Heure par défaut 12:00 si aucun rappel configuré.
|
||||
*/
|
||||
fun nextOccurrenceMs(nowMs: Long, hour: Int, minute: Int): Long {
|
||||
val cal = Calendar.getInstance(TimeZone.getDefault()).apply {
|
||||
timeInMillis = nowMs
|
||||
set(Calendar.HOUR_OF_DAY, hour)
|
||||
set(Calendar.MINUTE, minute)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
if (cal.timeInMillis <= nowMs) cal.add(Calendar.DAY_OF_MONTH, 1)
|
||||
return cal.timeInMillis
|
||||
}
|
||||
|
||||
fun defaultStartMs(treatment: Treatment, nowMs: Long): Long {
|
||||
val hour = treatment.reminderHour ?: 12
|
||||
val minute = treatment.reminderMinute ?: 0
|
||||
return nextOccurrenceMs(nowMs, hour, minute)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'id du calendrier local « HormoneTrack » (le crée s'il manque).
|
||||
* Nécessite WRITE_CALENDAR (l'insertion d'un calendrier local se fait avec
|
||||
* CALLER_IS_SYNCADAPTER=true).
|
||||
*/
|
||||
fun ensureCalendar(context: Context): Long {
|
||||
val calUri = CalendarContract.Calendars.CONTENT_URI
|
||||
.buildUpon()
|
||||
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.build()
|
||||
|
||||
context.contentResolver.query(
|
||||
calUri,
|
||||
arrayOf(CalendarContract.Calendars._ID, CalendarContract.Calendars.NAME),
|
||||
"${CalendarContract.Calendars.NAME} = ?",
|
||||
arrayOf(CALENDAR_NAME),
|
||||
null
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
}
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.NAME, CALENDAR_NAME)
|
||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, CALENDAR_NAME)
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, CALENDAR_NAME)
|
||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
put(CalendarContract.Calendars.OWNER_ACCOUNT, CALENDAR_NAME)
|
||||
put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFF4F5BD5.toInt())
|
||||
put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
|
||||
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||
}
|
||||
val newUri = context.contentResolver.insert(calUri, values)!!
|
||||
return ContentUris.parseId(newUri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée OU met à jour l'événement récurrent du traitement :
|
||||
* - début = prochaine occurrence à l'heure de rappel (ou 12:00) ;
|
||||
* - répétition = posologie (RRULE FREQ=DAILY;INTERVAL=N) ;
|
||||
* - si l'événement précédent existe toujours, il est supprimé puis recréé
|
||||
* (la modification d'un RRULE via l'API étant capricieuse selon les
|
||||
* fournisseurs d'agenda — supprimer/recréer est fiable).
|
||||
*
|
||||
* @return le nouvel id d'événement (à stocker sur le traitement).
|
||||
*/
|
||||
fun upsertEvent(context: Context, calendarId: Long, treatment: Treatment): Long {
|
||||
treatment.calendarEventId?.let { deleteEvent(context, it) }
|
||||
|
||||
val start = defaultStartMs(treatment, System.currentTimeMillis())
|
||||
val end = start + 30 * 60_000L // 30 min de bloc
|
||||
val tz = TimeZone.getDefault().id
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||
put(CalendarContract.Events.TITLE, "${treatment.name} · ${formatDoseLine(treatment)}")
|
||||
put(
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
"Prise hormonale récurrente (Posologie : tous les " +
|
||||
"${formatInterval(treatment)} j). Généré par HormoneTrack."
|
||||
)
|
||||
put(CalendarContract.Events.DTSTART, start)
|
||||
put(CalendarContract.Events.DTEND, end)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, tz)
|
||||
put(
|
||||
CalendarContract.Events.RRULE,
|
||||
rruleForIntervalDays(treatment.forecastIntervalDays ?: 1.0)
|
||||
)
|
||||
}
|
||||
val eventUri = CalendarContract.Events.CONTENT_URI
|
||||
.buildUpon()
|
||||
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, CALENDAR_NAME)
|
||||
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
.build()
|
||||
val eventUriInserted = context.contentResolver.insert(eventUri, values)!!
|
||||
return ContentUris.parseId(eventUriInserted)
|
||||
}
|
||||
|
||||
/** Supprime l'événement d'agenda du traitement (si présent). */
|
||||
fun deleteEvent(context: Context, eventId: Long) {
|
||||
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
|
||||
.buildUpon()
|
||||
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.build()
|
||||
try {
|
||||
context.contentResolver.delete(uri, null, null)
|
||||
} catch (_: Exception) {
|
||||
// L'événement peut déjà avoir été supprimé côté agenda — ignorer.
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDoseLine(treatment: Treatment): String =
|
||||
"${formatDose(treatment.doseAmount)} ${treatment.doseUnit}"
|
||||
|
||||
private fun formatInterval(treatment: Treatment): String =
|
||||
treatment.forecastIntervalDays?.let { formatDose(it) } ?: "?"
|
||||
|
||||
private fun formatDose(d: Double): String =
|
||||
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.1f".format(d)
|
||||
}
|
||||
@ -114,7 +114,8 @@ class AlarmScheduler(private val context: Context) {
|
||||
|
||||
fun rescheduleAll(treatments: List<Treatment>) {
|
||||
treatments.forEach { tr ->
|
||||
if (tr.reminderEnabled) {
|
||||
// Un traitement inactif ne rappelle plus : son alarme est annulée
|
||||
if (tr.reminderEnabled && tr.isActive) {
|
||||
scheduleDaily(tr)
|
||||
} else {
|
||||
cancel(tr.id)
|
||||
@ -211,7 +212,9 @@ class BootReceiver : BroadcastReceiver() {
|
||||
Thread {
|
||||
try {
|
||||
val treatments = kotlinx.coroutines.runBlocking {
|
||||
AppDatabase.getInstance(context).treatmentDao().getActiveOnce()
|
||||
// TOUS les traitements : les inactifs doivent voir leur alarme
|
||||
// annulée au boot (sinon une alarme périmée continue de sonner)
|
||||
AppDatabase.getInstance(context).treatmentDao().getAllOnce()
|
||||
}
|
||||
AlarmScheduler(context).rescheduleAll(treatments)
|
||||
} finally {
|
||||
|
||||
@ -27,6 +27,9 @@ class AppSettings(private val context: Context) {
|
||||
|
||||
/** 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")
|
||||
}
|
||||
|
||||
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
|
||||
@ -46,6 +49,11 @@ class AppSettings(private val context: Context) {
|
||||
prefs[Keys.AUTO_CALIBRATE] ?: false
|
||||
}
|
||||
|
||||
/** Version dont les nouveautés ont déjà été montrées (null = jamais). */
|
||||
val changelogSeenVersion: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||
prefs[Keys.CHANGELOG_SEEN]
|
||||
}
|
||||
|
||||
suspend fun setTConfig(config: TConfig) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.T_BASE] = config.base
|
||||
@ -65,4 +73,10 @@ class AppSettings(private val context: Context) {
|
||||
prefs[Keys.AUTO_CALIBRATE] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setChangelogSeenVersion(version: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.CHANGELOG_SEEN] = version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
package com.hormonetrack.settings
|
||||
|
||||
/**
|
||||
* Utilitaires pour le dialog « Nouveautés » affiché après chaque mise à jour
|
||||
* (v1.3.0). Pur (aucune dépendance Android) → testable en JVM.
|
||||
*
|
||||
* Le contenu vient de `docs/CHANGELOG.md` embarqué comme asset (synchronisé
|
||||
* automatiquement à chaque build par la tâche Gradle `copyChangelog`).
|
||||
*/
|
||||
object ChangelogHelper {
|
||||
|
||||
/**
|
||||
* Compare deux versions SemVer « X.Y.Z » NUMÉRIQUEMENT (composant par
|
||||
* composant) — une comparaison lexicographique considérerait à tort
|
||||
* « 1.2.9 > 1.2.10 ».
|
||||
*/
|
||||
fun isVersionNewer(current: String, reference: String?): Boolean {
|
||||
if (reference.isNullOrBlank()) return true // première installation
|
||||
val c = parse(current) ?: return true
|
||||
val r = parse(reference) ?: return true
|
||||
for (i in 0 until maxOf(c.size, r.size)) {
|
||||
val a = c.getOrElse(i) { 0 }
|
||||
val b = r.getOrElse(i) { 0 }
|
||||
if (a != b) return a > b
|
||||
}
|
||||
return false // identiques
|
||||
}
|
||||
|
||||
private fun parse(version: String): List<Int>? {
|
||||
val cleaned = version.trim().removePrefix("v")
|
||||
if (!Regex("^\\d+(\\.\\d+)*$").matches(cleaned)) return null
|
||||
return cleaned.split(".").map { it.toInt() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait de la CHANGELOG (markdown) les sections des versions STRICTEMENT
|
||||
* plus récentes que `lastSeenVersion` (null = première installation → la
|
||||
* version courante seule), jusqu'à `currentVersion` incluse.
|
||||
*
|
||||
* Format attendu : des sections « ## [X.Y.Z] — … » avec leur corps, la plus
|
||||
* récente EN PREMIER (convention Keep a Changelog du dépôt).
|
||||
*
|
||||
* @return le markdown concaténé des nouveautés ("" si rien de nouveau).
|
||||
*/
|
||||
fun sectionsSince(markdown: String, lastSeenVersion: String?, currentVersion: String): String {
|
||||
val pattern = Regex("## \\[(\\d+\\.\\d+\\.\\d+)\\][^\\n]*\\n(.*?)(?=\\n## \\[|\\Z)", RegexOption.DOT_MATCHES_ALL)
|
||||
val out = StringBuilder()
|
||||
for (match in pattern.findAll(markdown)) {
|
||||
val version = match.groupValues[1]
|
||||
// Sections triées de la plus récente à la plus ancienne :
|
||||
// - inclure ce qui est STRICTEMENT plus récent que la dernière version
|
||||
// vue (lastSeen null = première installation → section courante seule) ;
|
||||
// - s'arrêter dès qu'on descend sous lastSeen (déjà vu).
|
||||
if (lastSeenVersion != null && !isVersionNewer(version, lastSeenVersion)) break
|
||||
if (lastSeenVersion == null && version != currentVersion) continue
|
||||
if (out.isNotEmpty()) out.append("\n\n---\n\n")
|
||||
out.append("## Version ").append(version).append("\n\n")
|
||||
out.append(match.groupValues[2].trim())
|
||||
}
|
||||
return out.toString().trim()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -42,6 +48,34 @@ private data class TabItem(val route: String, val labelRes: Int, val icon: andro
|
||||
@Composable
|
||||
fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: 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 seen = app.container.settings.changelogSeenVersion.first()
|
||||
val markdown = try {
|
||||
context.assets.open("changelog.md").bufferedReader().use { it.readText() }
|
||||
} catch (_: Exception) {
|
||||
"" // asset manquant (build sans copyChangelog) → pas de dialog
|
||||
}
|
||||
val text = com.hormonetrack.settings.ChangelogHelper.sectionsSince(
|
||||
markdown, seen, com.hormonetrack.BuildConfig.VERSION_NAME
|
||||
)
|
||||
if (text.isNotBlank()) changelogText = text
|
||||
app.container.settings.setChangelogSeenVersion(com.hormonetrack.BuildConfig.VERSION_NAME)
|
||||
}
|
||||
changelogText?.let { md ->
|
||||
com.hormonetrack.ui.components.ChangelogDialog(
|
||||
changelogMarkdown = md,
|
||||
version = com.hormonetrack.BuildConfig.VERSION_NAME,
|
||||
onDismiss = { changelogText = null }
|
||||
)
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalAppContainer provides app.container) {
|
||||
val navController = rememberNavController()
|
||||
val tabs = listOf(
|
||||
@ -55,6 +89,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 {
|
||||
@ -87,6 +125,16 @@ fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
|
||||
HomeScreen(
|
||||
openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -46,6 +46,8 @@ data class ChartSeries(
|
||||
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()
|
||||
)
|
||||
|
||||
@ -64,6 +66,17 @@ fun niceCeil(value: Double): Double {
|
||||
return nice * exp
|
||||
}
|
||||
|
||||
/**
|
||||
* Pas d'échantillonnage de la courbe selon la plage affichée (v1.2.9) :
|
||||
* plus on zoome, plus le pas finit pour des courbes lisses
|
||||
* (≤ 12 h → 15 min ; ≤ 24 h → 30 min ; sinon 1 h).
|
||||
*/
|
||||
fun stepForRange(rangeHours: Long): Long = when {
|
||||
rangeHours <= 12L -> 900_000L // 15 min
|
||||
rangeHours <= 24L -> 1_800_000L // 30 min
|
||||
else -> 3_600_000L // 1 h
|
||||
}
|
||||
|
||||
/**
|
||||
* Graphique Canvas pur, multi-séries :
|
||||
* - axe gauche = E2 (pg/mL), axe droit = T (ng/mL) ;
|
||||
@ -165,6 +178,23 @@ fun CurveChart(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pics et creux (v1.2.3) : triangles aux extrema locaux + valeur estimée
|
||||
// (v1.2.6 : le triangle est accompagné de la valeur simulée du pic/creux)
|
||||
if (options.showExtrema) {
|
||||
PharmacokineticEngine.detectExtrema(s.points, { it.e2 }, minSwingAbs = 2.0)
|
||||
.forEach { ex ->
|
||||
drawExtremum(xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color)
|
||||
}
|
||||
if (options.showT) {
|
||||
s.tStyle?.let { tStyle ->
|
||||
PharmacokineticEngine.detectExtrema(s.points, { it.t }, minSwingAbs = 0.02)
|
||||
.forEach { ex ->
|
||||
drawExtremum(xOf(ex.timestamp), yT(ex.value), ex, tStyle.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Labs (points/carrés oranges)
|
||||
@ -201,8 +231,50 @@ fun CurveChart(
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) {
|
||||
for (i in 0..rows) {
|
||||
/**
|
||||
* Triangle ▲ (pic, au-dessus du point) ou ▼ (creux, en dessous) + la VALEUR
|
||||
* estimée de l'extremum (v1.2.6) : valeur au-dessus d'un pic, en dessous d'un
|
||||
* creux — dans la couleur de la série, unité implicite (celle de l'axe).
|
||||
*/
|
||||
private fun DrawScope.drawExtremum(cx: Float, cy: Float, extremum: com.hormonetrack.pk.PharmacokineticEngine.Extremum, color: Color) {
|
||||
val r = 5.dp.toPx()
|
||||
val offset = 6.dp.toPx()
|
||||
val path = Path()
|
||||
val valueY: Float
|
||||
if (extremum.isPeak) {
|
||||
val y = cy - offset
|
||||
path.moveTo(cx, y - r)
|
||||
path.lineTo(cx - r, y + r * 0.5f)
|
||||
path.lineTo(cx + r, y + r * 0.5f)
|
||||
valueY = y - r - 3.dp.toPx()
|
||||
} else {
|
||||
val y = cy + offset
|
||||
path.moveTo(cx, y + r)
|
||||
path.lineTo(cx - r, y - r * 0.5f)
|
||||
path.lineTo(cx + r, y - r * 0.5f)
|
||||
valueY = y + r + 11.dp.toPx()
|
||||
}
|
||||
path.close()
|
||||
drawPath(path, color)
|
||||
// Valeur estimée du pic/creux, dans la couleur de la courbe
|
||||
val paint = android.graphics.Paint().apply {
|
||||
this.color = android.graphics.Color.argb(
|
||||
(color.alpha * 255).toInt(),
|
||||
(color.red * 255).toInt(),
|
||||
(color.green * 255).toInt(),
|
||||
(color.blue * 255).toInt()
|
||||
)
|
||||
textSize = 9.dp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.CENTER
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = true
|
||||
}
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
formatValue(extremum.value), cx, valueY, paint
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawGrid(color: Color, padLeft: Float, padTop: Float, w: Float, h: Float, rows: Int) { for (i in 0..rows) {
|
||||
val y = padTop + h * i / rows
|
||||
drawLine(color, Offset(padLeft, y), Offset(padLeft + w, y), strokeWidth = 1f)
|
||||
}
|
||||
@ -248,7 +320,10 @@ private fun DrawScope.drawXLabels(
|
||||
) {
|
||||
val paint = labelPaint(android.graphics.Paint.Align.CENTER)
|
||||
val 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
|
||||
|
||||
@ -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,6 +1,6 @@
|
||||
package com.hormonetrack.ui.screens
|
||||
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@ -9,12 +9,18 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.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
|
||||
@ -51,6 +57,9 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
private const val HOUR_MS = 3_600_000L
|
||||
private const val DAY_MS = 24 * HOUR_MS
|
||||
// Zoom (v1.2.9) : plage temporelle bornée 6 h → 300 j
|
||||
private const val MIN_RANGE_H = 6L
|
||||
private const val MAX_RANGE_H = 24L * 300
|
||||
|
||||
/**
|
||||
* Écran Graphiques.
|
||||
@ -70,12 +79,16 @@ private const val DAY_MS = 24 * HOUR_MS
|
||||
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)
|
||||
|
||||
// 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) }
|
||||
@ -83,6 +96,7 @@ fun ChartScreen() {
|
||||
var showEse by remember { mutableStateOf(true) }
|
||||
var showTfs by remember { mutableStateOf(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
|
||||
@ -94,18 +108,16 @@ fun ChartScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Traitement + TConfig éventuellement recalibrés à la volée (option auto-calibration) ;
|
||||
// les valeurs stockées ne sont jamais modifiées, on ne travaille que sur des copies.
|
||||
val effective by produceState(
|
||||
treatments to tConfig,
|
||||
treatments, doseLogs, labResults, tConfig, autoCalibrate
|
||||
// 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.
|
||||
val effectiveAuto by produceState<PharmacokineticEngine.AutoCalibrated?>(
|
||||
null, treatments, doseLogs, labResults, tConfig, autoCalibrate
|
||||
) {
|
||||
value = if (!autoCalibrate) {
|
||||
treatments to tConfig
|
||||
null
|
||||
} else {
|
||||
withContext(Dispatchers.Default) {
|
||||
val r = PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig)
|
||||
r.treatments to r.tConfig
|
||||
PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -125,13 +137,16 @@ fun ChartScreen() {
|
||||
// Une liste de courbes par modèle sélectionné (clé = "ESE" / "TFS")
|
||||
val curves by produceState(
|
||||
emptyList<Pair<String, List<LevelPoint>>>(),
|
||||
effective.first, effective.second, doseLogs, startMs, endMs, showEse, showTfs, showForecast
|
||||
effectiveAuto, doseLogs, startMs, endMs, showEse, showTfs, showForecast
|
||||
) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val list = mutableListOf<Pair<String, List<LevelPoint>>>()
|
||||
if (showEse || showTfs) {
|
||||
// Valeurs actuelles (traitement + TConfig), auto-calibrées si l'option est active
|
||||
val (effTreatments, effTConfig) = effective
|
||||
// Valeurs actuelles : échelles par ester si auto-calibration active,
|
||||
// sinon le scaleFactor stocké de chaque traitement
|
||||
val effTreatments = effectiveAuto?.treatments ?: treatments
|
||||
val effTConfig = effectiveAuto?.tConfig ?: tConfig
|
||||
val effScales = effectiveAuto?.esterScales
|
||||
// Doses réelles + doses prévisionnelles éventuelles
|
||||
val forecastDoses = if (showForecast) {
|
||||
effTreatments.flatMap {
|
||||
@ -139,11 +154,16 @@ fun ChartScreen() {
|
||||
}
|
||||
} else emptyList()
|
||||
val allDoses = doseLogs + forecastDoses
|
||||
// Pas d'échantillonnage adaptatif au zoom (v1.2.9)
|
||||
val step = com.hormonetrack.ui.components.stepForRange(rangeHours)
|
||||
if (showEse) {
|
||||
list.add(
|
||||
"ESE" to PharmacokineticEngine.computeCurve(
|
||||
effTreatments, allDoses, startMs, endMs,
|
||||
tConfig = effTConfig, modelOverride = "ESE"
|
||||
stepMs = step,
|
||||
tConfig = effTConfig, modelOverride = "ESE",
|
||||
scalePerEster = effScales,
|
||||
tKPerEster = effectiveAuto?.tKPerEster
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -151,7 +171,10 @@ fun ChartScreen() {
|
||||
list.add(
|
||||
"TFS" to PharmacokineticEngine.computeCurve(
|
||||
effTreatments, allDoses, startMs, endMs,
|
||||
tConfig = effTConfig, modelOverride = "TFS"
|
||||
stepMs = step,
|
||||
tConfig = effTConfig, modelOverride = "TFS",
|
||||
scalePerEster = effScales,
|
||||
tKPerEster = effectiveAuto?.tKPerEster
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -165,7 +188,10 @@ fun ChartScreen() {
|
||||
allPoints.minOf { it.timestamp } to allPoints.maxOf { it.timestamp }
|
||||
} 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é.
|
||||
@ -205,6 +231,23 @@ 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))
|
||||
@ -227,6 +270,11 @@ fun ChartScreen() {
|
||||
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))
|
||||
@ -251,15 +299,33 @@ fun ChartScreen() {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { chartWidthPx = it.width }
|
||||
.pointerInput(rangeHours, maxPanHours) {
|
||||
// Glisser vers la droite = remonter dans le passé
|
||||
detectHorizontalDragGestures { change, dragAmount ->
|
||||
change.consume()
|
||||
if (chartWidthPx > 0 && maxPanHours > 0) {
|
||||
val hoursDragged = (dragAmount / chartWidthPx * rangeHours).toLong()
|
||||
.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, _ ->
|
||||
val forecastMs =
|
||||
(if (showForecast) (forecastHorizonHours ?: 0L) * HOUR_MS else 0L)
|
||||
if (chartWidthPx > 0) {
|
||||
// Pan horizontal : glisser à droite = remonter le temps
|
||||
val hoursDragged = (pan.x / chartWidthPx * rangeHours).toLong()
|
||||
if (hoursDragged != 0L) {
|
||||
panHours = (panHours + hoursDragged).coerceIn(0L, maxPanHours)
|
||||
}
|
||||
// Zoom autour du point focal : le temps sous le doigt
|
||||
// reste fixe pendant le pinch
|
||||
if (zoom != 1f && zoom > 0f) {
|
||||
val f = (centroid.x / chartWidthPx.toDouble()).coerceIn(0.0, 1.0)
|
||||
val newRange = (rangeHours / zoom).toLong()
|
||||
.coerceIn(MIN_RANGE_H, MAX_RANGE_H)
|
||||
if (newRange != rangeHours) {
|
||||
val endAnchor = nowMs + forecastMs - panHours * HOUR_MS
|
||||
val tFocal = endAnchor - ((1.0 - f) * rangeHours * HOUR_MS).toLong()
|
||||
val newEnd = tFocal + ((1.0 - f) * newRange * HOUR_MS).toLong()
|
||||
val newPan = ((nowMs + forecastMs - newEnd) / HOUR_MS).toLong()
|
||||
panHours = newPan.coerceIn(0L, maxPanHours)
|
||||
rangeHours = newRange
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -276,7 +342,12 @@ fun ChartScreen() {
|
||||
series = series,
|
||||
e2Labs = e2Labs,
|
||||
tLabs = tLabs,
|
||||
options = ChartOptions(showT = showT, showLabs = showLabs, nowMs = nowMs),
|
||||
options = ChartOptions(
|
||||
showT = showT,
|
||||
showLabs = showLabs,
|
||||
showExtrema = showExtrema,
|
||||
nowMs = nowMs
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(320.dp)
|
||||
@ -327,6 +398,13 @@ fun ChartScreen() {
|
||||
color = LabDot
|
||||
)
|
||||
}
|
||||
if (showExtrema) {
|
||||
Text(
|
||||
stringResource(R.string.legend_extrema),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.drag_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
|
||||
@ -2,6 +2,7 @@ package com.hormonetrack.ui.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@ -87,6 +88,8 @@ fun DosesScreen() {
|
||||
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))
|
||||
@ -156,7 +159,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 = {
|
||||
|
||||
@ -77,25 +77,33 @@ private val HOUR_MS = 3_600_000L
|
||||
fun HomeScreen(
|
||||
openLogDoseForTreatmentId: Long?,
|
||||
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)
|
||||
|
||||
// Données « effectives » : si l'option auto-calibration est active, on recalcule
|
||||
// à la volée les facteurs d'échelle et le modèle T depuis les labs (affichage seul)
|
||||
val effective by produceState(treatments to tConfig, treatments, doseLogs, labResults, tConfig, autoCalibrate) {
|
||||
// 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) {
|
||||
treatments to tConfig
|
||||
null
|
||||
} else {
|
||||
withContext(Dispatchers.Default) {
|
||||
val r = PharmacokineticEngine.autoCalibrated(treatments, doseLogs, labResults, tConfig)
|
||||
r.treatments to r.tConfig
|
||||
PharmacokineticEngine.autoCalibrated(allTreatments, doseLogs, labResults, tConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -118,14 +126,20 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val curve by produceState<List<LevelPoint>>(emptyList(), effective, doseLogs, tick) {
|
||||
val curve by produceState<List<LevelPoint>>(emptyList(), effectiveAuto, doseLogs, tick) {
|
||||
withContext(Dispatchers.Default) {
|
||||
val end = System.currentTimeMillis()
|
||||
value = PharmacokineticEngine.computeCurve(
|
||||
effective.first, doseLogs,
|
||||
effectiveAuto?.treatments ?: allTreatments,
|
||||
doseLogs,
|
||||
startMs = end - 24 * HOUR_MS,
|
||||
endMs = end,
|
||||
tConfig = effective.second
|
||||
tConfig = effectiveAuto?.tConfig ?: tConfig,
|
||||
// Calibration par période d'ester : chaque dose est scalée par le
|
||||
// facteur de SON ester (fallback = scaleFactor du traitement) et le
|
||||
// k de la T est celui de l'ester actif à chaque instant
|
||||
scalePerEster = effectiveAuto?.esterScales,
|
||||
tKPerEster = effectiveAuto?.tKPerEster
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -158,7 +172,7 @@ fun HomeScreen(
|
||||
|
||||
NowLevelCard(curve)
|
||||
|
||||
NextDoseCard(treatments)
|
||||
NextDoseCard(activeTreatments)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@ -174,7 +188,7 @@ fun HomeScreen(
|
||||
Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
treatments.forEach { tr ->
|
||||
activeTreatments.forEach { tr ->
|
||||
AssistChip(
|
||||
onClick = {
|
||||
preselectId = tr.id
|
||||
@ -195,7 +209,10 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Card(
|
||||
onClick = onOpenCharts,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.home_chart_title),
|
||||
@ -218,6 +235,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -232,7 +266,7 @@ fun HomeScreen(
|
||||
|
||||
if (showLogDialog) {
|
||||
DoseDialog(
|
||||
treatments = treatments,
|
||||
treatments = activeTreatments,
|
||||
preselectedTreatmentId = preselectId,
|
||||
existing = null,
|
||||
onDismiss = { showLogDialog = false },
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
package com.hormonetrack.ui.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.padding
|
||||
@ -35,6 +36,7 @@ import com.hormonetrack.R
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
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.launch
|
||||
@ -42,6 +44,24 @@ 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 }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabsScreen() {
|
||||
@ -50,16 +70,20 @@ fun LabsScreen() {
|
||||
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())
|
||||
}
|
||||
|
||||
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 +94,38 @@ fun LabsScreen() {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.nav_labs)) }
|
||||
)
|
||||
if (labs.isEmpty()) {
|
||||
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 (toutes identiques en pratique)
|
||||
group.firstNotNullOfOrNull { it.notes }?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = { toDeleteGroup = group }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -113,38 +134,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,6 +1,7 @@
|
||||
package com.hormonetrack.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@ -183,7 +184,34 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Testosterone model ---
|
||||
// --- Calibration automatique (E2 par période d'ester + T) — UNE seule option ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.auto_calibrate), style = MaterialTheme.typography.titleMedium)
|
||||
Switch(
|
||||
checked = autoCalibrate,
|
||||
onCheckedChange = { enabled ->
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoCalibrate(enabled)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.auto_calibrate_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Estimation T (réglages manuels ; l'auto-calibration ci-dessus les
|
||||
// remplace à l'affichage quand elle est activée) ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
|
||||
@ -247,32 +275,6 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Auto-calibration (option) ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.auto_calibrate), style = MaterialTheme.typography.titleMedium)
|
||||
Switch(
|
||||
checked = autoCalibrate,
|
||||
onCheckedChange = { enabled ->
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
container.settings.setAutoCalibrate(enabled)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.auto_calibrate_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- Reminders / exact alarms ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
@ -320,10 +322,19 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// --- About ---
|
||||
// --- About : version installée + lien releases Gitea ---
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.settings_version, com.hormonetrack.BuildConfig.VERSION_NAME),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
TextButton(onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases"))
|
||||
)
|
||||
}) { Text(stringResource(R.string.releases_link)) }
|
||||
Text(
|
||||
stringResource(R.string.disclaimer),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
@ -347,11 +358,18 @@ 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 et les rappels sont reprogrammés
|
||||
val result = try {
|
||||
BackupManager.importJson(repo, json)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
result?.let {
|
||||
container.settings.setTConfig(it.tConfig)
|
||||
AlarmScheduler(context).rescheduleAll(repo.allTreatmentsOnce())
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
message = if (result != null) {
|
||||
context.getString(R.string.import_ok, result.treatments, result.doseLogs, result.labResults)
|
||||
|
||||
@ -51,7 +51,11 @@ import com.hormonetrack.data.model.PKPresets
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import com.hormonetrack.pk.PharmacokineticEngine
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.hormonetrack.reminder.AlarmScheduler
|
||||
import com.hormonetrack.reminder.CalendarEvents
|
||||
import com.hormonetrack.ui.LocalAppContainer
|
||||
import com.hormonetrack.ui.components.formatDose
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -87,6 +91,13 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
// 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 -> calendarPermissionGranted = grants.values.all { it } }
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
var showPresetMenu by remember { mutableStateOf(false) }
|
||||
var showTimePicker by remember { mutableStateOf(false) }
|
||||
@ -116,6 +127,10 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
forecastEnabled = true
|
||||
forecastDaysText = formatDose(fi)
|
||||
}
|
||||
if (tr.calendarEventId != null) {
|
||||
calendarEnabled = true
|
||||
calendarPermissionGranted = true // déjà accordée pour l'event existant
|
||||
}
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
@ -401,6 +416,58 @@ 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 && !calendarPermissionGranted) {
|
||||
calendarPermissionLauncher.launch(
|
||||
arrayOf(
|
||||
android.Manifest.permission.WRITE_CALENDAR,
|
||||
android.Manifest.permission.READ_CALENDAR
|
||||
)
|
||||
)
|
||||
// activé seulement si la permission est accordée (callback)
|
||||
if (calendarPermissionGranted) calendarEnabled = true
|
||||
} else if (want) {
|
||||
calendarEnabled = true
|
||||
} else {
|
||||
calendarEnabled = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (calendarEnabled) {
|
||||
if (forecastEnabled) {
|
||||
Text(
|
||||
stringResource(R.string.agenda_hint),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.agenda_needs_posology),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.agenda_permission),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@ -421,11 +488,32 @@ fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
|
||||
repo.insertTreatment(tr)
|
||||
}
|
||||
val saved = tr.copy(id = savedId)
|
||||
if (saved.reminderEnabled) {
|
||||
// Rappel actif seulement si le traitement l'est aussi ;
|
||||
// désactiver un traitement annule son alarme
|
||||
if (saved.reminderEnabled && saved.isActive) {
|
||||
scheduler.scheduleDaily(saved)
|
||||
} 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 {
|
||||
if (calendarEnabled && saved.isActive && saved.forecastIntervalDays != null
|
||||
&& calendarPermissionGranted
|
||||
) {
|
||||
val calId = CalendarEvents.ensureCalendar(context)
|
||||
eventToStore = CalendarEvents.upsertEvent(context, calId, saved)
|
||||
} else if (saved.calendarEventId != null) {
|
||||
CalendarEvents.deleteEvent(context, saved.calendarEventId!!)
|
||||
eventToStore = null
|
||||
}
|
||||
if (eventToStore != saved.calendarEventId) {
|
||||
repo.updateTreatment(saved.copy(calendarEventId = eventToStore))
|
||||
}
|
||||
} catch (_: SecurityException) {
|
||||
// permission agenda révoquée entre-temps → ignorer
|
||||
}
|
||||
withContext(Dispatchers.Main) { onDone() }
|
||||
}
|
||||
},
|
||||
|
||||
@ -19,6 +19,9 @@
|
||||
<string name="next_dose">Prochaine dose</string>
|
||||
<string name="delta_6h">%1$s %2$s pg/mL vs il y a 6 h</string>
|
||||
<string name="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 -->
|
||||
@ -27,6 +30,18 @@
|
||||
<string name="chart_30j">30 jours</string>
|
||||
<string name="show_labs">Analyses</string>
|
||||
<string name="show_forecast">Prévision</string>
|
||||
<string name="show_extrema">Pics / creux</string>
|
||||
<string name="zoom_in">Zoom avant (fenêtre plus courte)</string>
|
||||
<string name="zoom_out">Zoom arrière (fenêtre plus longue)</string>
|
||||
<string name="changelog_title">Nouveautés de la version %1$s</string>
|
||||
<string name="settings_version">Version installée : %1$s</string>
|
||||
<string name="releases_link">Dernières versions sur Gitea (télécharger les APK)</string>
|
||||
<string name="agenda_section">Agenda</string>
|
||||
<string name="agenda_switch">Événement d\'agenda récurrent</string>
|
||||
<string name="agenda_needs_posology">Renseigne d\'abord une Posologie (intervalle en jours) — l\'événement se répète tous les N jours dans ton agenda.</string>
|
||||
<string name="agenda_hint">Un événement récurrent est créé dans un calendrier dédié « HormoneTrack », basé sur la Posologie et l\'heure de rappel. Visible et modifiable dans ton application d\'agenda.</string>
|
||||
<string name="agenda_permission">L\'autorisation agenda est demandée à l\'activation.</string>
|
||||
<string name="legend_extrema">▲▼ pics & creux estimés (par courbe)</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>
|
||||
@ -48,7 +63,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>
|
||||
@ -92,13 +115,13 @@
|
||||
<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="calibrate_from_labs">Calibrer avec les analyses</string>
|
||||
<string name="auto_calibrate">Calibration automatique</string>
|
||||
<string name="auto_calibrate_hint">Ajuste automatiquement les facteurs d\'échelle et le modèle T à partir de tes analyses, pour l\'affichage uniquement (les valeurs stockées ne changent pas).</string>
|
||||
<string name="auto_calibrate">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">Fréquence d\'injection</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 injection enregistrée ; elles apparaissent sur le graphique (Prévision) mais ne sont jamais sauvegardées.</string>
|
||||
<string name="freq_hint">Les doses à venir sont simulées à cet intervalle après la dernière prise enregistrée ; elles apparaissent sur le graphique (Prévision) mais ne sont jamais sauvegardées.</string>
|
||||
|
||||
<string name="save">Enregistrer</string>
|
||||
<string name="cancel">Annuler</string>
|
||||
@ -136,10 +159,10 @@
|
||||
<string name="export_ok">Sauvegarde exportée.</string>
|
||||
<string name="export_fail">L\'export a échoué.</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>
|
||||
|
||||
@ -19,6 +19,9 @@
|
||||
<string name="next_dose">Next dose</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 -->
|
||||
@ -27,6 +30,18 @@
|
||||
<string name="chart_30j">30 days</string>
|
||||
<string name="show_labs">Labs</string>
|
||||
<string name="show_forecast">Forecast</string>
|
||||
<string name="show_extrema">Peaks / troughs</string>
|
||||
<string name="zoom_in">Zoom in (shorter window)</string>
|
||||
<string name="zoom_out">Zoom out (longer window)</string>
|
||||
<string name="changelog_title">What\'s new in %1$s</string>
|
||||
<string name="settings_version">Installed version: %1$s</string>
|
||||
<string name="releases_link">Latest releases on Gitea (download APKs)</string>
|
||||
<string name="agenda_section">Calendar</string>
|
||||
<string name="agenda_switch">Recurring calendar event</string>
|
||||
<string name="agenda_needs_posology">Set a Posology interval first — the event repeats every N days in your phone calendar.</string>
|
||||
<string name="agenda_hint">A recurring event is created in a dedicated “HormoneTrack” calendar, based on the Posology interval and reminder time. Visible in your calendar app; editable there.</string>
|
||||
<string name="agenda_permission">Calendar permission is requested when you enable this.</string>
|
||||
<string name="legend_extrema">▲▼ estimated peaks & troughs (per curve)</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>
|
||||
@ -48,7 +63,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>
|
||||
@ -92,13 +115,13 @@
|
||||
<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="calibrate_from_labs">Calibrate from labs</string>
|
||||
<string name="auto_calibrate">Auto-calibration</string>
|
||||
<string name="auto_calibrate_hint">Automatically adjusts scale factors and the T model from your labs, for display only (stored values unchanged).</string>
|
||||
<string name="auto_calibrate">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">Injection frequency</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 injection; they appear on the chart (Forecast) but are never saved.</string>
|
||||
<string name="freq_hint">Upcoming doses are simulated at this interval after the last logged dose; they appear on the chart (Forecast) but are never saved.</string>
|
||||
|
||||
<string name="save">Save</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
@ -136,10 +159,10 @@
|
||||
<string name="export_ok">Backup exported.</string>
|
||||
<string name="export_fail">Export failed.</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>
|
||||
|
||||
145
app/src/test/java/com/hormonetrack/pk/ExtremaTest.kt
Normal file
145
app/src/test/java/com/hormonetrack/pk/ExtremaTest.kt
Normal file
@ -0,0 +1,145 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.AdministrationRoute
|
||||
import com.hormonetrack.data.model.DoseLog
|
||||
import com.hormonetrack.data.model.Esters
|
||||
import com.hormonetrack.data.model.PKModels
|
||||
import com.hormonetrack.data.model.Treatment
|
||||
import com.hormonetrack.data.model.TreatmentType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Détection des pics et creux (v1.2.3) : en régime d'équilibre, une injection tous
|
||||
* les N jours crée un pic après chaque dose et un creux juste avant la suivante.
|
||||
*/
|
||||
class ExtremaTest {
|
||||
|
||||
companion object {
|
||||
private const val BASE = 1_700_000_000_000L
|
||||
private const val HOUR = 3_600_000L
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
if (!PKProfileStore.hasProfile("EV", "ESE")) {
|
||||
val file = listOf(
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
}
|
||||
|
||||
private fun evTreatment() = Treatment(
|
||||
id = 1,
|
||||
name = "EV",
|
||||
type = TreatmentType.ESTRADIOL,
|
||||
route = AdministrationRoute.INJECTION_IM,
|
||||
doseAmount = 4.0,
|
||||
doseUnit = "mg",
|
||||
esterType = Esters.EV,
|
||||
pkModel = PKModels.ESTRANNAISE
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `steady state curve has alternating peaks and troughs at each dose cycle`() {
|
||||
val tr = evTreatment()
|
||||
// 4 injections hebdomadaires → fenêtre couvrant tout + 7 j
|
||||
val doses = (0..3).map {
|
||||
DoseLog(treatmentId = 1, timestamp = BASE + it * 7 * 24 * HOUR, doseAmount = 4.0)
|
||||
}
|
||||
val start = BASE
|
||||
val end = BASE + 28 * 24 * HOUR
|
||||
val curve = PharmacokineticEngine.computeCurve(listOf(tr), doses, start, end, tConfig = TConfig())
|
||||
|
||||
val extrema = PharmacokineticEngine.detectExtrema(curve, { it.e2 }, minSwingAbs = 2.0)
|
||||
assertTrue("extrema=${extrema.size}", extrema.size >= 4)
|
||||
|
||||
// Alternance stricte pic/creux
|
||||
extrema.zipWithNext { a, b ->
|
||||
assertTrue(a.isPeak != b.isPeak)
|
||||
}
|
||||
// Les pics sont au-dessus des creux voisins
|
||||
extrema.zipWithNext { a, b ->
|
||||
if (a.isPeak) assertTrue(a.value > b.value) else assertTrue(a.value < b.value)
|
||||
}
|
||||
// Tous les extrema sont dans les bornes de la série
|
||||
val minV = curve.minOf { it.e2 }
|
||||
val maxV = curve.maxOf { it.e2 }
|
||||
extrema.forEach { assertTrue(it.value in minV..maxV) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monotonic rising curve has no extrema`() {
|
||||
val tr = evTreatment()
|
||||
// Dose 2 h avant la fenêtre : toute la fenêtre est dans la phase de montée
|
||||
// (pic EV vers 45 h, très loin après) → strictement monotone
|
||||
val doses = listOf(DoseLog(treatmentId = 1, timestamp = BASE - 2 * HOUR, doseAmount = 4.0))
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
listOf(tr), doses,
|
||||
startMs = BASE - HOUR, endMs = BASE,
|
||||
tConfig = TConfig()
|
||||
)
|
||||
assertTrue(PharmacokineticEngine.detectExtrema(curve, { it.e2 }).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flat constant series has no extrema`() {
|
||||
val points = (0..20).map {
|
||||
LevelPoint(timestamp = BASE + it * HOUR, e2 = 100.0, t = 0.4)
|
||||
}
|
||||
assertTrue(PharmacokineticEngine.detectExtrema(points, { it.e2 }).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `too short series has no extrema`() {
|
||||
val points = listOf(
|
||||
LevelPoint(BASE, 100.0, 0.4),
|
||||
LevelPoint(BASE + HOUR, 110.0, 0.4)
|
||||
)
|
||||
assertTrue(PharmacokineticEngine.detectExtrema(points, { it.e2 }).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `min swing filters negligible oscillations`() {
|
||||
// Série oscillant doucement autour de 100 (amplitude 0,5 < seuil 2,0)
|
||||
val points = (0..10).map {
|
||||
LevelPoint(
|
||||
timestamp = BASE + it * HOUR,
|
||||
e2 = 100.0 + if (it % 2 == 0) 0.0 else 0.5,
|
||||
t = 0.4
|
||||
)
|
||||
}
|
||||
// Sémantique zigzag : une oscillation sous le seuil produit UN seul pivot
|
||||
// (le premier sommet local), jamais une alternance pic/creux
|
||||
val filtered = PharmacokineticEngine.detectExtrema(points, { it.e2 }, minSwingAbs = 2.0)
|
||||
assertTrue("size=${filtered.size}", filtered.size <= 1)
|
||||
// Avec un seuil plus bas que l'amplitude, l'alternance complète apparaît
|
||||
val full = PharmacokineticEngine.detectExtrema(points, { it.e2 }, minSwingAbs = 0.1)
|
||||
assertTrue("size=${full.size}", full.size >= 4)
|
||||
full.zipWithNext { a, b -> assertTrue(a.isPeak != b.isPeak) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testosterone extrema work with their own scale`() {
|
||||
val tr = evTreatment()
|
||||
val doses = (0..2).map {
|
||||
DoseLog(treatmentId = 1, timestamp = BASE + it * 7 * 24 * HOUR, doseAmount = 4.0)
|
||||
}
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
listOf(tr), doses, startMs = BASE, endMs = BASE + 21 * 24 * HOUR, tConfig = TConfig()
|
||||
)
|
||||
// La T est l'inverse de l'E2 : creux d'E2 = pics de T (modèle empirique)
|
||||
val e2Ext = PharmacokineticEngine.detectExtrema(curve, { it.e2 }, 2.0)
|
||||
val tExt = PharmacokineticEngine.detectExtrema(curve, { it.t }, 0.02)
|
||||
assertTrue(e2Ext.isNotEmpty())
|
||||
assertTrue(tExt.isNotEmpty())
|
||||
// Un pic d'E2 correspond approximativement à un creux de T
|
||||
val tPeaks = tExt.filter { it.isPeak }
|
||||
assertTrue(tPeaks.isNotEmpty())
|
||||
}
|
||||
}
|
||||
56
app/src/test/java/com/hormonetrack/pk/LabsGroupingTest.kt
Normal file
56
app/src/test/java/com/hormonetrack/pk/LabsGroupingTest.kt
Normal file
@ -0,0 +1,56 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.hormonetrack.data.model.LabResult
|
||||
import com.hormonetrack.ui.screens.groupLabsForDisplay
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Regroupement de l'écran Analyses (v1.2.2) : une prise de sang (E2 + T au même
|
||||
* timestamp) s'affiche côte à côte ; E2 avant T ; tri chronologique décroissant.
|
||||
*/
|
||||
class LabsGroupingTest {
|
||||
|
||||
private fun lab(marker: String, ts: Long, value: Double) =
|
||||
LabResult(marker = marker, value = value, unit = if (marker == "E2") "pg/mL" else "ng/mL", timestamp = ts)
|
||||
|
||||
@Test
|
||||
fun `E2 and T at the same timestamp are grouped into one blood draw`() {
|
||||
val ts = 1_788_621_894_346L
|
||||
val labs = listOf(lab("E2", ts, 300.0), lab("T", ts, 45.0))
|
||||
val groups = groupLabsForDisplay(labs)
|
||||
assertEquals(1, groups.size)
|
||||
assertEquals(ts, groups[0].first)
|
||||
assertEquals(listOf("E2", "T"), groups[0].second.map { it.marker.uppercase() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different timestamps stay separate rows`() {
|
||||
val labs = listOf(
|
||||
lab("E2", 1000L, 300.0),
|
||||
lab("T", 2000L, 45.0)
|
||||
)
|
||||
val groups = groupLabsForDisplay(labs)
|
||||
assertEquals(2, groups.size)
|
||||
// Tri décroissant : le plus récent (2000) d'abord
|
||||
assertEquals(2000L, groups[0].first)
|
||||
assertEquals(1000L, groups[1].first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `labs entered alone also group correctly and E2 sorts before T`() {
|
||||
val ts = 5000L
|
||||
val labs = listOf(lab("T", ts, 45.0), lab("E2", ts, 300.0))
|
||||
val groups = groupLabsForDisplay(labs)
|
||||
assertEquals(1, groups.size)
|
||||
assertEquals(listOf("E2", "T"), groups[0].second.map { it.marker.uppercase() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other markers sort after E2 and T`() {
|
||||
val ts = 6000L
|
||||
val labs = listOf(lab("PRL", ts, 12.0), lab("T", ts, 45.0), lab("E2", ts, 300.0))
|
||||
val groups = groupLabsForDisplay(labs)
|
||||
assertEquals(listOf("E2", "T", "PRL"), groups[0].second.map { it.marker.uppercase() })
|
||||
}
|
||||
}
|
||||
133
app/src/test/java/com/hormonetrack/pk/RegressionUserCase2Test.kt
Normal file
133
app/src/test/java/com/hormonetrack/pk/RegressionUserCase2Test.kt
Normal file
@ -0,0 +1,133 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.hormonetrack.data.backup.BackupData
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Régression épinglée sur le **2ᵉ export réel** de l'utilisatrice (v1.2.0) :
|
||||
* 1 traitement EEn + TFS 5 mg (facteur stocké, fréquence 6 j), 9 doses tous les
|
||||
* 6–7 j, 8 labs (E2 pg/mL, T ng/dL dont un saisi avec une mauvaise unité).
|
||||
*
|
||||
* ⚠️ Données de santé personnelles : le JSON réel vit HORS du dépôt dans
|
||||
* `local-test-data/backup-v1.2.0.json` (gitignoré) ; tests **ignorés** si absent.
|
||||
*
|
||||
* Valide v1.2.1+ : courbe Home en **état d'équilibre EEn** (t½ ≈ 6,7 j pour des
|
||||
* doses tous les 6–7 j → accumulation ×2), calibration PAR PÉRIODE D'ESTER,
|
||||
* lab T en unité aberrante neutralisé, prévision à 6 j.
|
||||
*/
|
||||
class RegressionUserCase2Test {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Le workdir des tests unitaires Gradle est le dossier du module (app/) :
|
||||
* on cherche donc les données locales à plusieurs emplacements.
|
||||
*/
|
||||
private val DATA_FILE: File = listOf(
|
||||
File("../local-test-data/backup-v1.2.0.json"),
|
||||
File("local-test-data/backup-v1.2.0.json"),
|
||||
File("app/local-test-data/backup-v1.2.0.json")
|
||||
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.2.0.json")
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
assumeTrue(
|
||||
"local-test-data/backup-v1.2.0.json absent — tests ignorés (données locales)",
|
||||
DATA_FILE.exists()
|
||||
)
|
||||
if (!PKProfileStore.hasProfile("EEN", "TFS")) {
|
||||
val file = listOf(
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
}
|
||||
|
||||
private fun importUserBackup(): BackupData =
|
||||
Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||
|
||||
@Test
|
||||
fun `second user backup parses with forecast interval and stored scale factor`() {
|
||||
val data = importUserBackup()
|
||||
assertEquals(9, data.doseLogs.size)
|
||||
assertEquals(8, data.labResults.size)
|
||||
val tr = data.treatments.single()
|
||||
assertEquals("TFS", tr.pkModel)
|
||||
assertEquals("EEN", tr.esterType)
|
||||
assertEquals(6.0, tr.forecastIntervalDays!!, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home 24h estimate is plausible in steady state with the stored scale factor`() {
|
||||
val data = importUserBackup()
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
data.treatments, data.doseLogs,
|
||||
startMs = data.exportedAt - 24 * PharmacokineticEngine.HOUR_MS,
|
||||
endMs = data.exportedAt,
|
||||
tConfig = data.tConfig
|
||||
)
|
||||
assertTrue(curve.isNotEmpty())
|
||||
val last = curve.last()
|
||||
// État d'équilibre EEn : t½ ≈ 6,7 j, doses tous les 6–7 j → accumulation ×2 ;
|
||||
// avec le facteur stocké (~0,7) : ~240–280 pg/mL, cohérent avec les labs
|
||||
assertTrue("e2=${last.e2}", last.e2 in 200.0..350.0)
|
||||
assertTrue("t=${last.t}", last.t in 0.3..0.55)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-ester calibration produces a single factor from the labs of that period`() {
|
||||
val data = importUserBackup()
|
||||
val scales = PharmacokineticEngine.computeEsterScaleFactors(
|
||||
data.treatments, data.doseLogs,
|
||||
data.labResults.filter { it.marker.equals("E2", true) }
|
||||
)
|
||||
// Toutes les doses sont du même ester → un seul facteur calibré
|
||||
assertTrue("esters=${scales.keys}", scales.keys == setOf("EEN"))
|
||||
assertTrue("sf=${scales["EEN"]}", scales["EEN"]!! in 0.2..2.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mistyped T lab unit does not destroy the T axis`() {
|
||||
// Un lab T avec une unité aberrante (pg/mL) doit être converti, pas brut
|
||||
assertEquals(0.04, PharmacokineticEngine.convertTToNgMl(40.0, "pg/mL"), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forecast doses follow the configured interval`() {
|
||||
val data = importUserBackup()
|
||||
val intervalDays = data.treatments.single().forecastIntervalDays!!
|
||||
val forecast = PharmacokineticEngine.generateForecastDoses(
|
||||
data.treatments.single(), data.doseLogs,
|
||||
toMs = data.exportedAt + 30 * 24 * 3_600_000L,
|
||||
nowMs = data.exportedAt
|
||||
)
|
||||
val lastReal = data.doseLogs.maxOf { it.timestamp }
|
||||
assertTrue(forecast.isNotEmpty())
|
||||
assertEquals(lastReal + (intervalDays * 24 * 3_600_000L).toLong(), forecast.first().timestamp)
|
||||
forecast.zipWithNext { prev, cur ->
|
||||
assertEquals((intervalDays * 24 * 3_600_000L).toLong(), cur.timestamp - prev.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto calibration is consistent on the real data`() {
|
||||
val data = importUserBackup()
|
||||
val result = PharmacokineticEngine.autoCalibrated(
|
||||
data.treatments, data.doseLogs, data.labResults, data.tConfig
|
||||
)
|
||||
assertTrue(result.calibratedEsters == 1)
|
||||
assertTrue(result.esterScales.values.single() in 0.2..2.0)
|
||||
// Un seul ester → le k T recalibré vit dans la map par ester ;
|
||||
// le k stocké reste le fallback (inchangé dans les réglages)
|
||||
assertTrue(result.tRecalibrated)
|
||||
assertTrue(result.tKPerEster.values.single() in 0.01..1.0)
|
||||
assertEquals(data.tConfig.k, result.tConfig.k, 1e-9)
|
||||
}
|
||||
}
|
||||
146
app/src/test/java/com/hormonetrack/pk/RegressionUserCase3Test.kt
Normal file
146
app/src/test/java/com/hormonetrack/pk/RegressionUserCase3Test.kt
Normal file
@ -0,0 +1,146 @@
|
||||
package com.hormonetrack.pk
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.hormonetrack.data.backup.BackupData
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Régression épinglée sur le **3ᵉ export réel** (v1.2.3) — le scénario complet de
|
||||
* transition : 2 traitements, **EV inactif** (« Injection EV - old », 29 doses de
|
||||
* fin janvier à début juillet, doses 2–8 mg) puis **EEn actif** (9 doses, 5 mg
|
||||
* tous les ~6-7 j), 22 labs couvrant les DEUX périodes.
|
||||
*
|
||||
* ⚠️ Données de santé personnelles : fichier HORS dépôt
|
||||
* (`local-test-data/backup-v1.2.3.json`, gitignoré) ; tests ignorés si absent.
|
||||
*
|
||||
* Cas de bug v1.2.4 : passer un traitement à `isActive = false` faisait disparaître
|
||||
* sa simulation (et faussait la calibration) — « inactif » est un drapeau
|
||||
* administratif (saisie), JAMAIS un filtre de données. Ce test épinglle le
|
||||
* contrat : **le moteur reçoit TOUS les traitements et simule tout l'historique**.
|
||||
*/
|
||||
class RegressionUserCase3Test {
|
||||
|
||||
companion object {
|
||||
private val DATA_FILE: File = listOf(
|
||||
File("../local-test-data/backup-v1.2.3.json"),
|
||||
File("local-test-data/backup-v1.2.3.json"),
|
||||
File("app/local-test-data/backup-v1.2.3.json")
|
||||
).firstOrNull { it.exists() } ?: File("../local-test-data/backup-v1.2.3.json")
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
assumeTrue(
|
||||
"local-test-data/backup-v1.2.3.json absent — tests ignorés (données locales)",
|
||||
DATA_FILE.exists()
|
||||
)
|
||||
if (!PKProfileStore.hasProfile("EV", "TFS")) {
|
||||
val file = listOf(
|
||||
File("src/main/assets/pk_profiles.json"),
|
||||
File("app/src/main/assets/pk_profiles.json")
|
||||
).first { it.exists() }
|
||||
PKProfileStore.initWithJson(file.readText())
|
||||
}
|
||||
}
|
||||
|
||||
private fun importUserBackup(): BackupData =
|
||||
Gson().fromJson(DATA_FILE.readText(), BackupData::class.java)
|
||||
|
||||
@Test
|
||||
fun `third backup parses with an inactive EV treatment and an active EEn one`() {
|
||||
val data = importUserBackup()
|
||||
assertEquals(2, data.treatments.size)
|
||||
assertEquals(38, data.doseLogs.size)
|
||||
assertEquals(22, data.labResults.size)
|
||||
val ev = data.treatments.single { it.esterType == "EV" }
|
||||
val een = data.treatments.single { it.esterType == "EEN" }
|
||||
// Le traitement EV est INACTIF : ses doses doivent néanmoins être simulées
|
||||
assertTrue(!ev.isActive)
|
||||
assertTrue(een.isActive)
|
||||
assertTrue(data.doseLogs.count { it.treatmentId == ev.id } > 20)
|
||||
assertTrue(data.doseLogs.count { it.treatmentId == een.id } > 5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inactive treatment doses are still simulated (bug v1_2_4 contract)`() {
|
||||
val data = importUserBackup()
|
||||
// La fenêtre couvre la PÉRIODE EV : si le code consommateur filtrait sur
|
||||
// isActive, la courbe serait vide ici — le moteur, lui, simule tout ce
|
||||
// qu'on lui passe (les écrans passent TOUS les traitements, cf §10).
|
||||
val evTreatment = data.treatments.single { it.esterType == "EV" }
|
||||
val evDoses = data.doseLogs.filter { it.treatmentId == evTreatment.id }
|
||||
val firstEv = evDoses.minOf { it.timestamp }
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
data.treatments, data.doseLogs,
|
||||
startMs = firstEv,
|
||||
endMs = firstEv + 48 * PharmacokineticEngine.HOUR_MS,
|
||||
tConfig = data.tConfig
|
||||
)
|
||||
assertTrue(curve.isNotEmpty())
|
||||
assertTrue("e2=${curve.last().e2} pendant la période EV (traitement inactif)", curve.last().e2 > 50.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-ester calibration covers BOTH periods (EV and EEn)`() {
|
||||
val data = importUserBackup()
|
||||
val scales = PharmacokineticEngine.computeEsterScaleFactors(
|
||||
data.treatments, data.doseLogs,
|
||||
data.labResults.filter { it.marker.equals("E2", true) }
|
||||
)
|
||||
// Les labs des 2 périodes (valerate ET enanthate) calibrent chacune leur ester
|
||||
assertEquals(setOf("EV", "EEN"), scales.keys)
|
||||
// EV : facteur plausible (l'utilisatrice avait calibré manuellement à 1,4)
|
||||
assertTrue("EV=${scales["EV"]}", scales["EV"]!! in 0.5..2.5)
|
||||
assertTrue("EEN=${scales["EEN"]}", scales["EEN"]!! in 0.2..2.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `T calibration k is also per ester period`() {
|
||||
val data = importUserBackup()
|
||||
val result = PharmacokineticEngine.autoCalibrated(
|
||||
data.treatments, data.doseLogs, data.labResults, data.tConfig
|
||||
)
|
||||
assertTrue(result.tKPerEster.keys.containsAll(setOf("EV", "EEN")))
|
||||
result.tKPerEster.values.forEach { k ->
|
||||
assertTrue("k=$k", k in 0.001..1.0)
|
||||
}
|
||||
// Les labs des deux périodes existent (7 labs T en période EV, 4 en EEn)
|
||||
val tLabs = data.labResults.filter { it.marker.equals("T", true) }
|
||||
assertTrue(tLabs.size >= 10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `current level at export time reflects the active EEn treatment`() {
|
||||
val data = importUserBackup()
|
||||
val point = PharmacokineticEngine.levelAt(
|
||||
data.treatments, data.doseLogs, data.exportedAt, data.tConfig
|
||||
)
|
||||
// ~122 h après 5 mg EEn (TFS) × SF 0,73 → état d'équilibre ~240-280 pg/mL
|
||||
assertTrue("e2=${point.e2}", point.e2 in 200.0..350.0)
|
||||
// k stocké 0,068 → T ≈ 0,2 + 5,8/(1+0,068×268) ≈ 0,48
|
||||
assertTrue("t=${point.t}", point.t in 0.35..0.6)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transition window superposes the tail of EV and the start of EEn`() {
|
||||
val data = importUserBackup()
|
||||
// Fenêtre à cheval sur la transition (EV dernier dose début juillet,
|
||||
// EEn première dose ~5 j après) : la courbe doit rester continue
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
data.treatments, data.doseLogs,
|
||||
startMs = data.exportedAt - 70 * 24 * PharmacokineticEngine.HOUR_MS,
|
||||
endMs = data.exportedAt - 60 * 24 * PharmacokineticEngine.HOUR_MS,
|
||||
tConfig = data.tConfig
|
||||
)
|
||||
assertTrue(curve.isNotEmpty())
|
||||
// Pas de trou : chaque point de la fenêtre a un E2 > 0
|
||||
curve.forEach { p ->
|
||||
assertTrue("e2=0 à ${p.timestamp} — trou de simulation pendant la transition", p.e2 > 0.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,44 +3,41 @@ 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/main/assets/pk_profiles.json"),
|
||||
@ -51,38 +48,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 +83,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 +101,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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,6 +50,46 @@ class V120FeaturesTest {
|
||||
forecastIntervalDays = intervalDays
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `forecast skips past slots after a missed dose (oubli)`() {
|
||||
// Rythme 6 j, dernier rappel... dernière PRISE il y a 12 j (une injection
|
||||
// a été manquée il y a 6 j) : le créneau théorique BASE-6j est passé.
|
||||
val tr = eenTreatment(intervalDays = 6.0)
|
||||
val doses = listOf(
|
||||
DoseLog(treatmentId = 1, timestamp = BASE - 18 * DAY, doseAmount = 5.0),
|
||||
DoseLog(treatmentId = 1, timestamp = BASE - 12 * DAY, doseAmount = 5.0)
|
||||
)
|
||||
val forecast = PharmacokineticEngine.generateForecastDoses(
|
||||
tr, doses, toMs = BASE + 30 * DAY, nowMs = BASE
|
||||
)
|
||||
// Aucun créneau simulé dans le passé : premier = BASE+6 j (dernière+12 j
|
||||
// est le premier créneau strictement futur au rythme de 6 j)
|
||||
assertTrue(forecast.isNotEmpty())
|
||||
forecast.forEach { f ->
|
||||
assertTrue("créneau passé simulé : ${f.timestamp}", f.timestamp > BASE)
|
||||
}
|
||||
assertEquals(BASE + 6 * DAY, forecast.first().timestamp)
|
||||
forecast.zipWithNext { prev, cur ->
|
||||
assertEquals(6 * DAY, cur.timestamp - prev.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forecast follows a delayed last dose (retard d un jour)`() {
|
||||
// Rythme 6 j, dernière prise faite avec 1 jour de retard (à J-5 au lieu
|
||||
// de J-6) : TOUTE la prévision se décale d'un jour, en partant de la
|
||||
// dernière prise réelle.
|
||||
val tr = eenTreatment(intervalDays = 6.0)
|
||||
val doses = listOf(
|
||||
DoseLog(treatmentId = 1, timestamp = BASE - 5 * DAY, doseAmount = 5.0)
|
||||
)
|
||||
val forecast = PharmacokineticEngine.generateForecastDoses(
|
||||
tr, doses, toMs = BASE + 30 * DAY, nowMs = BASE
|
||||
)
|
||||
assertEquals(BASE + 1 * DAY, forecast.first().timestamp)
|
||||
assertTrue(forecast.all { it.timestamp > BASE })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forecast doses follow the configured interval from the last real dose`() {
|
||||
val tr = eenTreatment(intervalDays = 7.0)
|
||||
@ -104,13 +144,13 @@ class V120FeaturesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto calibration replaces scale factors and T model for display only`() {
|
||||
fun `auto calibration computes per-ester scales and leaves stored values untouched`() {
|
||||
val tr = eenTreatment(intervalDays = 7.0, scale = 1.0)
|
||||
val doses = listOf(
|
||||
DoseLog(treatmentId = 1, timestamp = BASE - 7 * DAY, doseAmount = 5.0),
|
||||
DoseLog(treatmentId = 1, timestamp = BASE - 20 * DAY, doseAmount = 5.0)
|
||||
)
|
||||
// Un lab E2 à 0,73 de la prédiction → le scale factor recalculé doit valoir ~0,73
|
||||
// Un lab E2 à 0,73 de la prédiction → le facteur de l'ester EEN doit valoir ~0,73
|
||||
val t = BASE - 2 * DAY
|
||||
val predicted = PharmacokineticEngine.e2At(listOf(tr), doses, t)
|
||||
val labs = listOf(
|
||||
@ -119,11 +159,18 @@ class V120FeaturesTest {
|
||||
)
|
||||
val cfg = TConfig()
|
||||
val result = PharmacokineticEngine.autoCalibrated(listOf(tr), doses, labs, cfg)
|
||||
assertEquals(0.73, result.treatments.first().scaleFactor, 0.01)
|
||||
assertTrue(result.calibratedTreatments == 1)
|
||||
assertEquals(0.73, result.esterScales["EEN"]!!, 0.01)
|
||||
assertTrue(result.calibratedEsters == 1)
|
||||
assertTrue(result.tRecalibrated)
|
||||
// Le traitement d'origine n'est PAS modifié (affichage seul)
|
||||
// Le traitement d'origine n'est PAS modifié (affichage seul) : le scaleFactor
|
||||
// stocké reste 1.0, la calibration passe par scalePerEster au tracé
|
||||
assertEquals(1.0, tr.scaleFactor, 1e-9)
|
||||
assertEquals(1.0, result.treatments.first().scaleFactor, 1e-9)
|
||||
// e2At avec scalePerEster applique le facteur par ester
|
||||
val scaled = PharmacokineticEngine.e2At(
|
||||
listOf(tr), doses, t, scalePerEster = result.esterScales
|
||||
)
|
||||
assertEquals(predicted * 0.73, scaled, predicted * 0.01)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -131,6 +178,98 @@ class V120FeaturesTest {
|
||||
val tr = eenTreatment()
|
||||
val result = PharmacokineticEngine.autoCalibrated(listOf(tr), emptyList(), emptyList(), TConfig())
|
||||
assertEquals(1.0, result.treatments.first().scaleFactor, 1e-9)
|
||||
assertTrue(result.calibratedTreatments == 0)
|
||||
assertTrue(result.calibratedEsters == 0)
|
||||
assertTrue(result.esterScales.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-ester T calibration attributes k by ester period`() {
|
||||
// Scénario valerate → enanthate : la suppression T n'est pas la même selon
|
||||
// l'ester, donc k doit être calibré séparément par période.
|
||||
val tr = eenTreatment().copy(esterType = Esters.EV)
|
||||
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV)
|
||||
val evDose2 = DoseLog(treatmentId = 1, timestamp = BASE - 23 * DAY, doseAmount = 4.0, esterType = Esters.EV)
|
||||
val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 10 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
|
||||
val eenDose2 = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
|
||||
val doses = listOf(evDose, evDose2, eenDose, eenDose2)
|
||||
val cfg = TConfig()
|
||||
|
||||
// Labs T plantés : k_EV = 0,10 pendant la période EV, k_EEN = 0,30 pendant EEn
|
||||
val evLabTime = BASE - 25 * DAY
|
||||
val eenLabTime = BASE - 5 * DAY
|
||||
val e2Ev = PharmacokineticEngine.e2At(listOf(tr), doses, evLabTime)
|
||||
val e2Een = PharmacokineticEngine.e2At(listOf(tr), doses, eenLabTime)
|
||||
fun tFor(k: Double, e2: Double) = cfg.floor + (cfg.base - cfg.floor) / (1.0 + k * e2)
|
||||
val labs = listOf(
|
||||
LabResult(marker = "T", value = tFor(0.10, e2Ev), unit = "ng/mL", timestamp = evLabTime),
|
||||
LabResult(marker = "T", value = tFor(0.30, e2Een), unit = "ng/mL", timestamp = eenLabTime)
|
||||
)
|
||||
val kMap = PharmacokineticEngine.computeTKPerEster(listOf(tr), doses, labs, cfg)
|
||||
assertEquals(0.10, kMap["EV"]!!, 0.10 * 0.15)
|
||||
assertEquals(0.30, kMap["EEN"]!!, 0.30 * 0.15)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `T curve uses the k of the active ester at each point`() {
|
||||
val tr = eenTreatment().copy(esterType = Esters.EV)
|
||||
val doses = listOf(
|
||||
DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV),
|
||||
DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
|
||||
)
|
||||
val tKPerEster = mapOf("EV" to 0.10, "EEN" to 0.30)
|
||||
val cfg = TConfig()
|
||||
val curve = PharmacokineticEngine.computeCurve(
|
||||
listOf(tr), doses,
|
||||
startMs = BASE - 25 * DAY, endMs = BASE,
|
||||
tConfig = cfg, tKPerEster = tKPerEster
|
||||
)
|
||||
// Point en pleine période EV (25 j avant BASE + 24 h ≈ J-29) : k = 0,10
|
||||
val evPoint = curve.first()
|
||||
val evE2 = evPoint.e2
|
||||
assertEquals(cfg.floor + (cfg.base - cfg.floor) / (1.0 + 0.10 * evE2), evPoint.t, 1e-6)
|
||||
// Point en pleine période EEn (fin de fenêtre) : k = 0,30
|
||||
val eenPoint = curve.last()
|
||||
val eenE2 = eenPoint.e2
|
||||
assertEquals(cfg.floor + (cfg.base - cfg.floor) / (1.0 + 0.30 * eenE2), eenPoint.t, 1e-6)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-ester calibration attributes labs by ester period`() {
|
||||
// Scénario de l'utilisatrice : valerate avant, enanthate ensuite.
|
||||
// Même traitement, ester override par dose (comme dans le .ods).
|
||||
val tr = eenTreatment().copy(esterType = Esters.EV)
|
||||
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV)
|
||||
val evDose2 = DoseLog(treatmentId = 1, timestamp = BASE - 23 * DAY, doseAmount = 4.0, esterType = Esters.EV)
|
||||
val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 10 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
|
||||
val eenDose2 = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
|
||||
val doses = listOf(evDose, evDose2, eenDose, eenDose2)
|
||||
|
||||
// Un lab pendant la période EV (à 0,6 de la prédiction) et un lab pendant
|
||||
// la période EEn (à 1,2 de la prédiction)
|
||||
val evLabTime = BASE - 25 * DAY
|
||||
val eenLabTime = BASE - 5 * DAY
|
||||
val predictedEv = PharmacokineticEngine.e2At(listOf(tr), doses, evLabTime)
|
||||
val predictedEen = PharmacokineticEngine.e2At(listOf(tr), doses, eenLabTime)
|
||||
val labs = listOf(
|
||||
LabResult(marker = "E2", value = predictedEv * 0.6, unit = "pg/mL", timestamp = evLabTime),
|
||||
LabResult(marker = "E2", value = predictedEen * 1.2, unit = "pg/mL", timestamp = eenLabTime)
|
||||
)
|
||||
val scales = PharmacokineticEngine.computeEsterScaleFactors(listOf(tr), doses, labs)
|
||||
// Chaque ester est calibré UNIQUEMENT avec les labs de sa période
|
||||
assertEquals(0.6, scales["EV"]!!, 0.01)
|
||||
assertEquals(1.2, scales["EEN"]!!, 0.01)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scalePerEster scales each dose by its own ester factor`() {
|
||||
val tr = eenTreatment().copy(esterType = Esters.EV, scaleFactor = 999.0) // ne doit PAS être utilisé
|
||||
val evDose = DoseLog(treatmentId = 1, timestamp = BASE - 30 * DAY, doseAmount = 4.0, esterType = Esters.EV)
|
||||
val eenDose = DoseLog(treatmentId = 1, timestamp = BASE - 3 * DAY, doseAmount = 5.0, esterType = Esters.EEN)
|
||||
val scales = mapOf("EV" to 0.5, "EEN" to 2.0)
|
||||
val t = BASE
|
||||
val scaled = PharmacokineticEngine.e2At(listOf(tr), listOf(evDose, eenDose), t, scalePerEster = scales)
|
||||
val expected = PharmacokineticEngine.concentrationOfDose(tr, evDose, t) * 0.5 +
|
||||
PharmacokineticEngine.concentrationOfDose(tr, eenDose, t) * 2.0
|
||||
assertEquals(expected, scaled, expected * 1e-6)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
package com.hormonetrack.reminder
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/** RRULE de l'événement d'agenda : répétition DAILY tous les N jours. */
|
||||
class CalendarRruleTest {
|
||||
|
||||
@Test
|
||||
fun `intervalle entier donne un RRULE exact`() {
|
||||
assertEquals("FREQ=DAILY;INTERVAL=6", CalendarEvents.rruleForIntervalDays(6.0))
|
||||
assertEquals("FREQ=DAILY;INTERVAL=14", CalendarEvents.rruleForIntervalDays(14.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `intervalle decimal arrondi a l entier le plus proche`() {
|
||||
assertEquals("FREQ=DAILY;INTERVAL=7", CalendarEvents.rruleForIntervalDays(6.5))
|
||||
assertEquals("FREQ=DAILY;INTERVAL=6", CalendarEvents.rruleForIntervalDays(6.4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `intervalle nul ou negatif est clampe a 1 jour`() {
|
||||
assertEquals("FREQ=DAILY;INTERVAL=1", CalendarEvents.rruleForIntervalDays(0.0))
|
||||
assertEquals("FREQ=DAILY;INTERVAL=1", CalendarEvents.rruleForIntervalDays(-2.0))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
package com.hormonetrack.settings
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Helper « Nouveautés » : comparaison SemVer + extraction de sections. */
|
||||
class ChangelogHelperTest {
|
||||
|
||||
private val markdown = """
|
||||
## [1.3.0] — changelog 1.3.0
|
||||
|
||||
### Ajouté
|
||||
- truc 1.3.0
|
||||
|
||||
## [1.2.10] — changelog 1.2.10
|
||||
|
||||
- truc 1.2.10
|
||||
|
||||
## [1.2.9] — changelog 1.2.9
|
||||
|
||||
- truc 1.2.9
|
||||
""".trim()
|
||||
|
||||
@Test
|
||||
fun `comparaison semver numerique - 1_2_9 plus vieux que 1_2_10`() {
|
||||
// une comparaison lexicographique donnerait 1.2.9 > 1.2.10 — piège épinglé
|
||||
assertTrue(ChangelogHelper.isVersionNewer("1.2.10", "1.2.9"))
|
||||
assertFalse(ChangelogHelper.isVersionNewer("1.2.9", "1.2.10"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `versions identiques ne sont pas plus recentes`() {
|
||||
assertFalse(ChangelogHelper.isVersionNewer("1.2.10", "1.2.10"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `premiere installation - reference nulle est toujours plus ancienne`() {
|
||||
assertTrue(ChangelogHelper.isVersionNewer("1.3.0", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sections extraites depuis la derniere version vue`() {
|
||||
val out = ChangelogHelper.sectionsSince(markdown, "1.2.10", "1.3.0")
|
||||
assertTrue(out.contains("1.3.0"))
|
||||
assertFalse(out.contains("1.2.10"))
|
||||
assertFalse(out.contains("1.2.9"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mise a jour de plusieurs versions - toutes les sections intermediaires`() {
|
||||
// dernière vue 1.2.8 (absente de la markdown) → tout ce qui est plus
|
||||
// récent jusqu'à la version courante est inclus
|
||||
val out = ChangelogHelper.sectionsSince(markdown, "1.2.8", "1.3.0")
|
||||
assertTrue(out.contains("1.3.0"))
|
||||
assertTrue(out.contains("1.2.10"))
|
||||
assertTrue(out.contains("1.2.9"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `premiere installation - section courante seule`() {
|
||||
val out = ChangelogHelper.sectionsSince(markdown, null, "1.3.0")
|
||||
assertTrue(out.contains("1.3.0"))
|
||||
assertFalse(out.contains("1.2.10"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aucune nouveauté - version vue égale à la courante`() {
|
||||
assertEquals("", ChangelogHelper.sectionsSince(markdown, "1.3.0", "1.3.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `versions non numeriques ignorees proprement`() {
|
||||
// markdown avec une section exotique entre les deux
|
||||
val md = markdown.replace("## [1.2.10]", "## [1.2.10-beta]")
|
||||
val out = ChangelogHelper.sectionsSince(md, "1.2.9", "1.3.0")
|
||||
assertTrue(out.contains("1.3.0"))
|
||||
}
|
||||
}
|
||||
31
app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt
Normal file
31
app/src/test/java/com/hormonetrack/ui/ChartZoomTest.kt
Normal file
@ -0,0 +1,31 @@
|
||||
package com.hormonetrack.ui
|
||||
|
||||
import com.hormonetrack.ui.components.stepForRange
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pas d'échantillonnage adaptatif au zoom (v1.2.9) : plus la fenêtre est
|
||||
* courte, plus le pas est fin — sinon les courbes paraissent anguleuses.
|
||||
*/
|
||||
class ChartZoomTest {
|
||||
|
||||
@Test
|
||||
fun `deep zoom uses 15 minute steps`() {
|
||||
assertEquals(900_000L, stepForRange(6L))
|
||||
assertEquals(900_000L, stepForRange(12L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mid zoom uses 30 minute steps`() {
|
||||
assertEquals(1_800_000L, stepForRange(13L))
|
||||
assertEquals(1_800_000L, stepForRange(24L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wide ranges use hourly steps`() {
|
||||
assertEquals(3_600_000L, stepForRange(25L))
|
||||
assertEquals(3_600_000L, stepForRange(24L * 30))
|
||||
assertEquals(3_600_000L, stepForRange(24L * 300))
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,215 @@
|
||||
# Changelog — HormoneTrack
|
||||
|
||||
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
|
||||
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.2.0`, …).
|
||||
Versionnage : [SemVer](https://semver.org/). Releases taguées dans git (`v1.3.0`, …).
|
||||
|
||||
## [1.3.0] — 2026-09-06 (versionCode 14)
|
||||
|
||||
### Ajouté
|
||||
- **Dialog « Nouveautés »** : après chaque mise à jour (et à la première
|
||||
installation), l'app affiche le changelog des versions non vues dans une
|
||||
fenêtre fermable — ne réapparaît pas avant la prochaine mise à jour
|
||||
(version vue mémorisée). Contenu = `docs/CHANGELOG.md` embarqué en asset,
|
||||
synchronisé automatiquement à chaque build (tâche Gradle `copyChangelog`).
|
||||
- **Événements d'agenda récurrents** : dans l'éditeur de traitement, sous
|
||||
« Rappels », un switch « Événement d'agenda récurrent » crée un événement
|
||||
dans un calendrier local dédié « HormoneTrack » (visible dans l'agenda du
|
||||
téléphone) avec répétition RRULE dérivée de la **Posologie** (tous les N
|
||||
jours, arrondi demi-supérieur). Permission agenda demandée à l'activation ;
|
||||
suppression/recréation automatique au save.
|
||||
- **Paramètres : version installée + lien cliquable** vers les releases Gitea
|
||||
(télécharger les APK sans attendre le rappel de mise à jour).
|
||||
- 11 nouveaux tests (78 au total) : ChangelogHelper (8 : comparaison SemVer
|
||||
numérique 1.2.9 < 1.2.10 — lexicographique aurait tort, extraction de
|
||||
sections, première installation), CalendarRruleTest (3 : RRULE, arrondi
|
||||
demi-supérieur — **kotlin.math.round arrondit les ties vers l'entier PAIR
|
||||
(6,5 → 6)**, piège documenté).
|
||||
|
||||
## [1.2.10] — 2026-09-05 (versionCode 13)
|
||||
|
||||
### Corrigé
|
||||
- **Sens des boutons de zoom inversé** (retour utilisateur) : convention
|
||||
carte — « **+** » = zoom **avant** (fenêtre plus courte, plus de détail) et
|
||||
« **−** » = zoom **arrière** (fenêtre plus longue, plus de temps). Avant,
|
||||
le « + » dézoomait (montrait plus de temps) — pas intuitif. Le pinch
|
||||
(écarter = zoom avant) était déjà correct.
|
||||
|
||||
## [1.2.9] — 2026-09-05 (versionCode 12)
|
||||
|
||||
### Ajouté
|
||||
- **Zoom du graphique de simulation** : pinch à 2 doigts **et** boutons − / +
|
||||
(accessibilité), plage bornée **6 h → 300 j** ; le point focal du pinch reste
|
||||
fixe dans le temps ; échantillonnage adaptatif (15 min ≤ 12 h, 30 min ≤
|
||||
24 h, sinon 1 h — courbes lisses à fort zoom) ; labels X adaptatifs
|
||||
(1 h / 3 h). Un seul gestionnaire de gestes gère pan + zoom (pas de
|
||||
conflit de consommation).
|
||||
- **README : disclaimer IA** en en-tête — projet développé avec l'IA,
|
||||
contribution humaine essentielle (feedback, retours utilisateur, suggestions) ;
|
||||
mention factuelle reprise en §1/§2 de la doc de développement.
|
||||
- 3 tests `stepForRange` (67 au total).
|
||||
|
||||
## [1.2.8] — 2026-09-05 (versionCode 11)
|
||||
|
||||
### Modifié
|
||||
- **« Fréquence d'injection » renommé « Posologie »** (section Traitements) :
|
||||
le terme « fréquence d'injection » n'avait pas de sens pour les prises
|
||||
d'hormones non injectables (gel, patch, oral). L'astuce d'intervalle est
|
||||
inchangée ; les textes d'aide disent désormais « dernière **prise**
|
||||
enregistrée » au lieu de « dernière injection » (EN : « Dosage »).
|
||||
|
||||
## [1.2.7] — 2026-09-05 (versionCode 10)
|
||||
|
||||
### Corrigé
|
||||
- **Prévision cassée après un oubli d'injection** : le premier créneau projeté
|
||||
tombait dans le PASSÉ (dernière prise + intervalle = jour de l'injection
|
||||
manquée) → la courbe peignait un **faux pic dans l'historique** et tout le
|
||||
rythme projeté était décalé. Désormais les créneaux déjà passés ne sont
|
||||
jamais simulés : la prévision avance au premier créneau **strictement
|
||||
futur** au rythme configuré. Un simple **retard**, lui, décale naturellement
|
||||
toute la prévision (elle part de la dernière prise réelle — comportement
|
||||
voulu, désormais testé).
|
||||
- 2 nouveaux tests (64 au total) : oubli (créneaux passés sautés, espacement
|
||||
conservé) et retard (décalage complet de la prévision).
|
||||
|
||||
## [1.2.6] — 2026-09-05 (versionCode 9)
|
||||
|
||||
### Ajouté
|
||||
- **Valeurs estimées sur les pics/creux** : chaque triangle ▲▼ du graphique est
|
||||
accompagné de la valeur simulée de l'extremum (au-dessus d'un pic, en dessous
|
||||
d'un creux), dans la couleur de la courbe.
|
||||
|
||||
### Corrigé
|
||||
- **Import JSON en mode ÉCRASEMENT** : l'import échouait dès que l'app
|
||||
contenait déjà des données (conflit d'IDs du backup avec les IDs existants).
|
||||
Désormais l'import efface d'abord les données actuelles (ordre enfants →
|
||||
parents), restaure le backup (IDs conservés → FK valides), restaure le
|
||||
réglage T du backup et **reprogramme les rappels**. Le dialog d'import
|
||||
prévient clairement : « Effacer & restaurer ».
|
||||
|
||||
## [1.2.5] — 2026-09-05 (versionCode 8)
|
||||
|
||||
### Corrigé
|
||||
- **Espace vide en haut de l'écran** (contenu trop bas) : double consommation des
|
||||
insets — la fenêtre poussait déjà le contenu sous la barre de statut (thème
|
||||
AppCompat) ET les `TopAppBar` Material 3 rajoutaient leur padding de barre de
|
||||
statut interne. Passage en **edge-to-edge propre** (`enableEdgeToEdge()`) :
|
||||
- les `TopAppBar` consomment la barre de statut (une seule fois) ;
|
||||
- les `Scaffold` (racine + imbriqués Doses/Analyses) ont
|
||||
`contentWindowInsets = 0` pour ne pas cumuler ;
|
||||
- la `NavigationBar` consomme la barre de navigation système.
|
||||
Le contenu remonte, les listes défilent visuellement sous la barre de statut
|
||||
(comportement Material You standard).
|
||||
- **Builds release optimisés** (R8 + shrink des ressources, signés avec la clé
|
||||
debug → installables par-dessus sans perte de données) : l'APK passe de
|
||||
**20 Mo à 2,4 Mo** et gagne en fluidité. Depuis cette release, chaque version
|
||||
est publiée en deux fichiers :
|
||||
- `HormoneTrack-vX.Y.Z-release.apk` ← **recommandé** (optimisé R8) ;
|
||||
- `HormoneTrack-vX.Y.Z-debug.apk` (comme avant, plus lourd, pour diagnostic).
|
||||
|
||||
## [1.2.4] — 2026-09-05 (versionCode 7)
|
||||
|
||||
### Corrigé
|
||||
- **Traitement inactif = drapeau administratif, pas filtre de données** (bug
|
||||
remonté) : passer un traitement à inactif faisait disparaître sa simulation du
|
||||
graphique **et** sa contribution à la calibration, tout en permettant encore
|
||||
d'y loger des doses (incohérence). Désormais :
|
||||
- Home et Graphiques simulent avec **TOUS les traitements** (actifs + inactifs)
|
||||
→ l'historique EV d'un traitement passé à inactif reste simulé et calibré ;
|
||||
- l'inactivation retire uniquement : les chips « Log rapide », le traitement
|
||||
dans le dropdown des **nouvelles** doses (l'édition d'une dose existante
|
||||
garde son traitement même inactif), et les **rappels** (alarme annulée au
|
||||
save, au boot et dans le calcul de la prochaine dose) ;
|
||||
- la suppression d'une dose reste possible depuis l'historique.
|
||||
|
||||
### Ajouté
|
||||
- **Régression n°3** épinglée sur le 3ᵉ export réel (HORS dépôt, gitignoré) :
|
||||
le scénario complet de transition — 1 traitement **EV inactif** (29 doses,
|
||||
2–8 mg, janvier→juillet) + 1 traitement **EEn actif** (9 doses), 22 labs
|
||||
couvrant les deux périodes. Vérifie : simulation de l'inactif, calibration
|
||||
par période sur les DEUX esters (EV **et** EEN), k T par ester, continuité
|
||||
de la courbe pendant la transition, niveau actuel. 6 nouveaux tests (62 au
|
||||
total, tous verts).
|
||||
|
||||
## [1.2.3] — 2026-09-05 (versionCode 6)
|
||||
|
||||
### Ajouté
|
||||
- **Pics et creux estimés sur le graphique** : chip « Pics / creux » (désactivé par
|
||||
défaut) qui affiche des triangles ▲▼ aux extrema locaux de CHAQUE courbe
|
||||
(E2 et T, pour les deux modèles), colorés comme leur série. Détection dans le
|
||||
moteur (`detectExtrema`) : extrema locaux stricts, plateaux fusionnés,
|
||||
alternance pic/creux imposée, oscillations d'amplitude < seuil ignorées
|
||||
(2 pg/mL pour l'E2, 0,02 ng/mL pour la T — sémantique zigzag : une oscillation
|
||||
sous le seuil produit un seul pivot).
|
||||
- **Calibration T PAR PÉRIODE D'ESTER** : la suppression de la testostérone n'est
|
||||
pas la même selon l'ester (valerate = pics hauts et courts, enanthate = plateau
|
||||
doux) → le k du modèle T est désormais calibré **par période** comme le facteur
|
||||
d'échelle E2 (`computeTKPerEster`) : labs T valerate → k des doses valerate,
|
||||
labs T enanthate → k des doses enanthate. La courbe T utilise à chaque instant
|
||||
le k de l'ester **actif** (dernière dose ≤ t, `activeEsterAt`), fallback = k stocké.
|
||||
Le k T est calibré contre l'E2 **déjà calibrée** (scalePerEster appliqué).
|
||||
- **Données de test hors dépôt (vie privée)** : les tests de régression épinglés
|
||||
sur les exports réels chargent désormais leurs données depuis `local-test-data/`
|
||||
(**gitignoré**) et s'ignorent proprement si le fichier est absent — **aucune
|
||||
donnée de santé personnelle dans le dépôt ni dans son historique** (réécriture
|
||||
d'historique effectuée avant le premier push).
|
||||
- 14 nouveaux tests (56 au total) : détection d'extrema (6) + attribution du k T
|
||||
par période et courbe T utilisant le k de l'ester actif (2), regroupement des
|
||||
prises de sang (4), régression n°2 en version data-driven (6).
|
||||
|
||||
### Corrigé
|
||||
- La calibration automatique de la T utilisait l'E2 **non calibrée** → les k
|
||||
étaient faussés ; elle utilise maintenant l'E2 calibrée par période d'ester.
|
||||
|
||||
## [1.2.2] — 2026-09-05 (versionCode 5)
|
||||
|
||||
### Ajouté
|
||||
- **Prise de sang E2 + T en UNE entrée** : le dialog d'ajout propose les deux
|
||||
marqueurs (chacun **optionnel** — on peut ne saisir que E2 ou que T), avec
|
||||
date/heure et notes partagées ; les entrées sont insérées au même timestamp et
|
||||
s'affichent **côte à côte** dans l'écran Analyses (« E2 306 pg/mL · T 44 ng/dL »).
|
||||
- **Tap sur une paire → sélecteur** (« Quelle entrée veux-tu modifier ? » E2 / T)
|
||||
puis édition pré-remplie de l'entrée choisie.
|
||||
- **Suppression par prise de sang** : la corbeille supprime la prise entière
|
||||
(confirmation listant les valeurs).
|
||||
- **Toggle T du graphique** : masque désormais aussi les **labs T** (avant : seule
|
||||
la courbe T disparaissait, les points restaient).
|
||||
- 4 tests de regroupement (48 au total).
|
||||
|
||||
## [1.2.1] — 2026-09-05 (versionCode 4)
|
||||
|
||||
### Corrigé
|
||||
- **Calibration par PÉRIODE D'ESTER** (le fond du problème « courbes à 250–375 ») :
|
||||
la calibration automatique calculait UN facteur par traitement, mélangeant les labs
|
||||
de toutes les périodes — un lab valerate comparé à une prédiction enanthate donne un
|
||||
ratio aberrant qui gonfle la courbe. Désormais chaque lab est **attribué à la période
|
||||
d'injection dans laquelle il tombe** (dernière dose E2 ≤ lab → son ester) et le
|
||||
facteur est la médiane des ratios de CETTE période : labs valerate → doses
|
||||
valerate, labs enanthate → doses enanthate (`computeEsterScaleFactors` +
|
||||
paramètre `scalePerEster` dans `e2At`/`computeCurve`).
|
||||
Vérifié sur les données réelles : l'état d'équilibre EEn (t½ ≈ 6,7 j, doses tous
|
||||
les 6–7 j → accumulation ×2) donne ~270 pg/mL calibré — cohérent avec les labs
|
||||
300/250 ; les « 375 » rapportés = le même calcul **non calibré** (268/0,72 ≈ 367).
|
||||
- **Lab T saisi en "pg/mL"** (38 "pg/mL" — faute de frappe) : renvoyé brut, il
|
||||
écrasait l'axe T (~40) et **rendait la courbe T invisible** (écrasée en bas).
|
||||
Conversion défensive ajoutée (`pg` → ÷1000, `µg/L` → ÷1000).
|
||||
|
||||
### Ajouté
|
||||
- **Édition des résultats de labo** (comme les doses) : appuyer sur une ligne de
|
||||
l'écran Analyses ouvre le dialog pré-rempli (marqueur, valeur, unité, date/heure,
|
||||
notes). Nouveau formateur `formatLabValue` (préserve les décimales : 0,44 reste
|
||||
0,44, contrairement à formatDose).
|
||||
- **Tap sur le graphique de l'accueil → écran Graphiques** + mini-légende (E2 bleu /
|
||||
T rose) + indication.
|
||||
- **Calibration automatique fusionnée** : UNE seule option dans Paramètres qui couvre
|
||||
**E2 (par période d'ester) ET T** — plus de réglage T isolé ; le bouton manuel
|
||||
« Calibrer avec les analyses » (T) reste comme action ponctuelle.
|
||||
- **8 nouveaux tests** (44 au total) dont `RegressionUserCase2Test` épinglé sur le
|
||||
2ᵉ export réel (9 doses EEn/TFS, 8 labs, fréquence 6 j) — inclut la vérification
|
||||
de l'état d'équilibre et du lab T en unité aberrante.
|
||||
|
||||
### Modifié
|
||||
- `AutoCalibrated` : `treatments` inchangés (plus de copies avec SF modifié), nouveau
|
||||
champ `esterScales: Map<String, Double>` + `calibratedEsters`.
|
||||
|
||||
## [1.2.0] — 2026-09-05 (versionCode 3)
|
||||
|
||||
|
||||
@ -45,6 +45,11 @@ Android, apps tierces au poignet quasi impossibles (cf §17). Utilisatrice équi
|
||||
**Gadgetbridge** (FOSS) sur son téléphone → v1 = **app téléphone + notifications miroir
|
||||
sur la montre** via GB (ou Huawei Health).
|
||||
|
||||
> **Développement IA-assisté** : le code a été produit avec un assistant IA ;
|
||||
> la contribution humaine = feedback continu, retours utilisateur (tests réels
|
||||
> sur téléphone, bugs avec exports), suggestions et validation des releases
|
||||
> (détail §2 — factuel, session par session).
|
||||
|
||||
Fonctionnalités v1 :
|
||||
- Courbes estimées heure par heure : E2 (pg/mL) + T (ng/mL) — 24 h / 7 j / 30 j
|
||||
- Deux modèles PK du `.ods` (Estrannaise / TFS) pour injections EV/EU/EEn ;
|
||||
@ -64,6 +69,19 @@ Fonctionnalités v1 :
|
||||
| 5 sept. 2026 (session tests/docs) | Correction de toutes les erreurs de compilation (dont 3 vrais bugs logiciels trouvés par les tests), **24 tests unitaires verts**, APK debug généré (18 MB), documentation complète (README + docs/), préparation repo git. |
|
||||
| 5 sept. 2026 (session v1.1.0) | Bugs remontés par l'utilisatrice : courbes vides (casse EEn) + pas d'édition des doses → corrigés ; régression épinglée sur ses données réelles ; APK v1.1.0. |
|
||||
| 5 sept. 2026 (session v1.2.0) | **Repo git initialisé** (commits par couche + tags de release) ; montée toolchain AGP 9.4/Gradle 9.7.1/Kotlin 2.3.21/BOM 2026.08.00/compileSdk 37 ; panoramique du chart, superposition des deux modèles, prévision par « Fréquence », calibration automatique optionnelle, intervalles entre doses, TimePicker centré ; **36 tests verts**, APK v1.2.0 (23 MB). |
|
||||
| 5 sept. 2026 (session builds release) | Question debug vs release → **builds release optimisés** (R8 full mode + shrinkResources, 20 Mo → **2,4 Mo**, signés avec la clé debug → upgradables sans perte) ; garde-fous Gson dans `proguard-rules.pro` (réflexion) ; releases publiées avec les **deux APK** (release recommandé + debug) ; 62 tests verts. |
|
||||
| 5 sept. 2026 (session v1.2.5) | Bug graphique : double espace en haut (double insets) → **edge-to-edge propre** (`enableEdgeToEdge` + insets consommés une seule fois) ; 62 tests verts, APK v1.2.5 + release. |
|
||||
| 5 sept. 2026 (session v1.2.4) | Bug `isActive` (simulation effacée) → sémantique « drapeau administratif » (§6.bis) ; régression n°3 sur le 3ᵉ export réel (transition EV inactif → EEn actif, 38 doses, 22 labs) ; **62 tests verts**, APK v1.2.4. |
|
||||
| 5 sept. 2026 (session push Gitea + v1.2.3) | Push initial vers **gitea.cloudyfy.fr/Siphonight/HormoneTrack** (privé) après **anonymisation de l'historique** (filter-branch : les premiers commits embarquaient les valeurs réelles des tests) ; **données de test réelles déplacées hors dépôt** (`local-test-data/` gitignoré, tests `Assume`-skippés) ; **releases avec APK en pièce jointe** ; pics/creux + calibration T par période d'ester ; **56 tests verts**. |
|
||||
| 5 sept. 2026 (session v1.2.1) | Remontées UX/utilisateur : calibration **par période d'ester** (labs EV → doses EV, labs EEn → doses EEn — corrige les courbes gonflées), lab T "pg/mL" neutralisé, **édition des labs**, tap accueil → Graphiques + mini-légende, auto-cal fusionnée (E2+T, un seul switch) ; 2ᵉ régression épinglée sur le nouvel export ; **44 tests verts**, APK v1.2.1. |
|
||||
| 5 sept. 2026 (session v1.2.2) | Toggle T masque aussi les labs T ; **prise de sang E2 + T en une entrée** (chacune optionnelle) affichée **côte à côte** ; sélecteur d'édition par marqueur ; suppression par prise ; 4 tests de regroupement ; **48 tests verts**, APK v1.2.2. |
|
||||
| 5 sept. 2026 (session v1.2.6) | Valeurs estimées affichées sur les pics/creux (drawExtremum) ; **import JSON en mode écrasement** (bug : conflit d'IDs si données existantes) avec restauration du tConfig et reprogrammation des rappels ; docs ; 62 tests verts, APK v1.2.6 + releases. |
|
||||
| 6 sept. 2026 (session farewell) | Repo créé côté farewell → **push SSH (alias `farewell` : giteassh:2222) + 12 releases publiées avec APK vérifiés par téléchargement** ; piège lexicographique `v1.2.10 < v1.2.5` en comparaison de chaînes épinglé (§14 #38) ; les deux instances Gitea sont synchrones. |
|
||||
| 6 sept. 2026 (session v1.3.0) | Dialog « Nouveautés » après mise à jour (CHANGELOG embarqué en asset, tâche `copyChangelog`, version vue en DataStore) ; **événements d'agenda récurrents** (calendrier local HormoneTrack, RRULE posologie, permission runtime, Room v3 `calendarEventId`) ; Paramètres : version + lien releases ; **78 tests verts**, APK v1.3.0 + releases. |
|
||||
| 5 sept. 2026 (session v1.2.10) | Sens des boutons de zoom inversé (+ = zoom avant, convention carte — retour utilisateur) ; 67 tests verts, APK v1.2.10 + releases. |
|
||||
| 5 sept. 2026 (session v1.2.9) | **Zoom du graphique** (pinch + boutons, 6 h → 300 j, focal stable, échantillonnage adaptatif `stepForRange`, labels X 1 h/3 h) ; **README : disclaimer IA-assisté** en en-tête ; 3 tests ; **67 tests verts**, APK v1.2.9 + releases. |
|
||||
| 5 sept. 2026 (session v1.2.8) | « Fréquence d'injection » → **« Posologie »** (terme adapté à toutes les voies) ; quirk Gitea découvert (noms d'assets normalisés à l'upload) → renommage PATCH dans le script ; releases avec les 2 APK ; 64 tests verts. |
|
||||
| 5 sept. 2026 (session v1.2.7) | Prévision : les créneaux passés ne sont plus simulés (bug oubli → faux pic historique) ; retard = décalage voulu ; mismatch doc-code §7.3b corrigé ; 2 tests ; **64 tests verts**, APK v1.2.7 + releases. |
|
||||
|
||||
Leçon importante de la session build : **les erreurs de compilation et les bugs sémantiques
|
||||
(bisection inversée, plancher d'affichage des profils) n'ont été détectés qu'en construisant
|
||||
@ -176,16 +194,36 @@ l'utilisatrice tape autre chose, la calibration ignorera ces labs.
|
||||
`Flow` pour l'UI + one-shots `suspend *Once()` pour backup/boot/calibration :
|
||||
`TreatmentDao.getActiveOnce/getAllOnce`, `DoseLogDao.getAllOnce`, `LabResultDao.getAllOnce`.
|
||||
|
||||
### 6.bis Sémantique `isActive` (v1.2.4) — drapeau administratif, JAMAIS un filtre
|
||||
|
||||
`Treatment.isActive = false` signifie « ce traitement ne produit plus de nouvelles
|
||||
doses ». **Ce n'est PAS un filtre de données** — passer un traitement à inactif ne
|
||||
doit rien effacer (bug v1.2.4 : Home/Chart collectaient `activeTreatments`, la
|
||||
simulation de tout l'historique EV disparaissait) :
|
||||
|
||||
| Effet de l'inactivation | Où |
|
||||
|---|---|
|
||||
| ✅ Retiré des chips « Log rapide » (accueil) | `HomeScreen` → `activeTreatments` |
|
||||
| ✅ Retiré du dropdown des **nouvelles** doses | `DosesScreen` : création → actifs only ; **édition → tous** (une dose existante reste rattachée à son traitement inactif) |
|
||||
| ✅ Rappels annulés (au save, au boot, au calcul de « prochaine dose ») | `rescheduleAll` + `nextReminderFireMs` (filtre `isActive`) ; `BootReceiver` lit `getAllOnce()` pour annuler les alarmes des inactifs |
|
||||
| ❌ JAMAIS retiré de la simulation | Home/Chart passent **`allTreatments`** au moteur — les écrans ne filtrent jamais |
|
||||
| ❌ JAMAIS retiré de la calibration (auto) | `autoCalibrated(allTreatments, …)` |
|
||||
| ❌ JAMAIS supprimé de l'historique des doses | `DosesScreen` liste tout, suppression manuelle uniquement |
|
||||
|
||||
Épinglé par la régression n°3 (`RegressionUserCase3Test`, cf §8) : EV inactif + EEn
|
||||
actif en même temps, les 22 labs couvrant les deux périodes doivent rester simulés
|
||||
et calibrés.
|
||||
|
||||
## 7. Moteur pharmacocinétique
|
||||
|
||||
`pk/PharmacokineticEngine.kt` + `pk/PKProfileStore.kt`.
|
||||
|
||||
### 7.1 Source : `Estrogen.ods`
|
||||
|
||||
- Fichier : `/Users/siphonight/owncloud/Shared_Spreadsheets/Estrogen.ods` (30 MB, 13 tables)
|
||||
- Tables nominatives (« Jenny », « Océane », « Elden », « Alice », « Marie », « Dana ») :
|
||||
historique injections (datetime, cuisse L/R, ester, dose mg, Z-track) + labs (E2 pg/mL,
|
||||
T ng/mL) + **facteur d'échelle** manuel (Jenny 0,9 ; Océane 0,55 ; Elden 0,72 ; Alice 0,5)
|
||||
- Fichier : le tableur `Estrogen.ods` de l'utilisatrice (Owncloud, 30 MB, 13 tables)
|
||||
- 6 tables nominatives (surnoms anonymisés) : historique injections (datetime, cuisse
|
||||
L/R, ester, dose mg, Z-track) + labs (E2 pg/mL, T ng/mL) + **facteur d'échelle**
|
||||
manuel par profil (valeurs entre 0,5 et 1,4)
|
||||
- Table **« Models »** : paramètres D, k1, k2, k3 par ester×modèle + **profils horaires
|
||||
normalisés (pg/mL par mg) sur 8001 h** — ce sont ces tables qui sont consommées
|
||||
- Les profils affichent 2 décimales → **plancher 0,01 / 0,00** en queue (conséquence
|
||||
@ -231,14 +269,17 @@ Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils,
|
||||
dessine les deux modèles côte à côte depuis le même traitement (Bateman non concerné :
|
||||
les deux séries y sont identiques).
|
||||
- **`generateForecastDoses(treatment, doseLogs, toMs, nowMs)`** : projette les doses à
|
||||
venir = dernière dose réelle + k × `forecastIntervalDays` jusqu'à `toMs`, strictement
|
||||
après `nowMs` ; dose = standard du traitement, ester = override de la dernière
|
||||
injection. Jamais persistées : uniquement passées à `computeCurve` par le ChartScreen
|
||||
quand le chip « Prévision » est actif.
|
||||
venir = dernière dose réelle + k × `forecastIntervalDays`, **strictement après
|
||||
`nowMs`** (v1.2.7 : les créneaux déjà passés — cas d'un OUBLI — sont sautés au
|
||||
rythme configuré, sinon faux pic dans l'historique ; un RETARD décale toute la
|
||||
prévision) ; dose = standard du traitement, ester = override de la dernière
|
||||
injection. Jamais persistées : uniquement passées à `computeCurve` par le
|
||||
ChartScreen quand le chip « Prévision » est actif.
|
||||
- **`autoCalibrated(treatments, doseLogs, labs, tConfig)`** : option « Calibration
|
||||
automatique » — renvoie des **copies** de traitements avec les scale factors
|
||||
recalculés (médiane lab ÷ prédiction) + TConfig recalibré. Les valeurs stockées ne
|
||||
bougent jamais ; HomeScreen et ChartScreen branchent dessus quand l'option est active.
|
||||
automatique » (v1.2.1+) — renvoie `esterScales` (facteur **par ester**, cf §7.6)
|
||||
+ `tKPerEster` (k T **par ester**, cf §7.5) recalculés depuis les labs ;
|
||||
`treatments` et `tConfig` **inchangés** (fallback par ester absent = valeurs
|
||||
stockées). HomeScreen et ChartScreen branchent dessus quand l'option est active.
|
||||
|
||||
### 7.4 Bateman (gel/patch/oral)
|
||||
|
||||
@ -249,24 +290,54 @@ Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils,
|
||||
|
||||
### 7.5 Courbe T (empirique)
|
||||
|
||||
`T(t) = floor + (base − floor) / (1 + k·E2(t))` [ng/mL]. Défauts `TConfig` :
|
||||
base 6.0, floor 0.2, k 0.19 (→ T≈0,4 à E2≈150). **Non issu du `.ods`** (qui ne modélise
|
||||
pas la T) — modèle d'inhibition simple, étiqueté « estimation » partout.
|
||||
Calibration : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est(t_lab)`, garde
|
||||
k ∈ (1e-4, 10), **médiane** (plante k=0.25 → recalibre 0.25 ±15 %, testé).
|
||||
`T(t) = floor + (base − floor) / (1 + k(ester actif) · E2_calibrée(t))` [ng/mL].
|
||||
Défauts `TConfig` : base 6.0, floor 0.2, k 0.19 (→ T≈0,4 à E2≈150). **Non issu du
|
||||
`.ods`** (qui ne modélise pas la T) — modèle d'inhibition simple, étiqueté
|
||||
« estimation » partout.
|
||||
|
||||
- **k par période d'ester** (v1.2.3) : la suppression T diffère selon l'ester
|
||||
(valerate = pics hauts et courts, enanthate = plateau plus doux) →
|
||||
`computeTKPerEster` attribue chaque lab T à la période de la dernière dose ≤ lab
|
||||
et k = **médiane** des k de cette période ; la courbe utilise à chaque instant le
|
||||
k de l'ester **actif** (`activeEsterAt`, curseur sur les doses triées dans
|
||||
`computeCurve`), fallback = `tConfig.k` stocké.
|
||||
- Formule : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est(t_lab)`, garde
|
||||
k ∈ (1e-4, 10). ⚠️ L'E2 utilisée est la version **calibrée** (scalePerEster) —
|
||||
calibrer k contre une E2 brute faussait les k (corrigé v1.2.3).
|
||||
- `computeTConfigCalibration` (k global unique) reste pour le bouton manuel
|
||||
« Calibrer avec les analyses » des Paramètres.
|
||||
|
||||
**Unités** : les labs T peuvent être saisis en ng/mL, ng/dL, ng/L ou nmol/L —
|
||||
`convertTToNgMl(value, unit)` normalise (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884) ;
|
||||
appliqué à la calibration ET au rendu du chart (sinon l'axe T est faux d'un facteur 100,
|
||||
bug réel remonté par l'utilisatrice : labs 33/44 ng/dL).
|
||||
`convertTToNgMl(value, unit)` normalise (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884,
|
||||
pg/mL ÷1000 défensif) ; appliqué à la calibration ET au rendu du chart (sinon l'axe
|
||||
T est faux d'un facteur 100, bug réel remonté par l'utilisatrice : labs 33/44 ng/dL).
|
||||
|
||||
### 7.6 Calibration E2 (par traitement)
|
||||
### 7.6 Calibration (v1.2.1 : PAR PÉRIODE D'ESTER pour l'auto)
|
||||
|
||||
`computeScaleFactor(treatment, allDoseLogs, e2Labs)` :
|
||||
`ratio_i = lab.value / Σ contributions du traitement seul (scale=1) à t_lab`
|
||||
→ **médiane** des ratios (garde : prédiction > 0.5 pg/mL), arrondi 2 décimales.
|
||||
C'est l'automatisation de la colonne « Scale factor » du `.ods`. Déclenchable depuis
|
||||
l'éditeur de traitement ; valeur éditable manuellement.
|
||||
**Automatique** (`computeEsterScaleFactors` + `scalePerEster`, option « Auto-calibration ») :
|
||||
- chaque lab est **attribué à la période d'injection dans laquelle il tombe** =
|
||||
dernière dose E2 ≤ lab (une prise de sang reflète d'abord l'injection qui précède) ;
|
||||
- ratio = lab ÷ prédiction **non calibrée** (toutes doses superposées, scaleFactor forcé 1) ;
|
||||
- facteur final par ester = **médiane** des ratios de sa période (EV/EU/EEN) ;
|
||||
- application : `e2At`/`computeCurve` acceptent `scalePerEster: Map<String, Double>?` —
|
||||
chaque dose est scalée par le facteur de **son** ester (`doseEster`, override compris),
|
||||
fallback = `scaleFactor` stocké du traitement pour les esters sans lab.
|
||||
- **Pourquoi** : un facteur unique par traitement mélangeait les périodes (labs valerate
|
||||
mesurés contre une prédiction enanthate → ratio aberrant → courbes gonflées à
|
||||
250–375 pg/mL, remontée v1.2.0). Cas vérifié sur données réelles : EEn 5 mg tous les
|
||||
6–7 j (t½ ≈ 6,7 j) → accumulation ×2 → ~270 pg/mL calibré, cohérent labs 300/250 ;
|
||||
non calibré ≈ 367.
|
||||
- `autoCalibrated()` renvoie `AutoCalibrated(treatments **inchangés**, tConfig recalibré,
|
||||
esterScales, calibratedEsters, tRecalibrated)` — écrans : `scalePerEster = effectiveAuto?.esterScales`.
|
||||
|
||||
**Manuelle** (`computeScaleFactor`, bouton « Calibrer avec les analyses » dans
|
||||
l'éditeur de traitement) : facteur **unique par traitement** (médiane lab ÷ prédiction,
|
||||
garde prédiction > 0,5), écrit le `scaleFactor` stocké. ⚠️ Limite documentée : en cas
|
||||
de changement d'ester dans un même traitement, la manuelle mélange les périodes —
|
||||
préférer l'auto-calibration dans ce cas.
|
||||
|
||||
**T** (`computeTConfigCalibration`) : `k_i = ((base−floor)/(T_lab − floor) − 1)/E2_est`,
|
||||
garde k ∈ (1e-4, 10), **médiane** ; labs normalisés via `convertTToNgMl`.
|
||||
|
||||
### 7.7 API du moteur
|
||||
|
||||
@ -277,7 +348,8 @@ Type de retour : `LevelPoint(timestamp, e2, t)`.
|
||||
|
||||
## 8. Tests unitaires
|
||||
|
||||
**36 tests JVM, tous verts** (`./gradlew testDebugUnitTest`). Dépendance : JUnit 4.13.2.
|
||||
**78 tests JVM, tous verts** (`./gradlew testDebugUnitTest`) — 44 sans les
|
||||
données de test locales (cf §8.bis). Dépendance : JUnit 4.13.2.
|
||||
Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'exécution =
|
||||
`app/` → l'asset est lu via `src/main/assets/pk_profiles.json` (fallback `app/src/…`).
|
||||
|
||||
@ -299,11 +371,67 @@ Emplacement : `app/src/test/java/com/hormonetrack/`. Répertoire de travail d'ex
|
||||
labs antérieurs à la 1ʳᵉ dose ignorés par la calibration SF, conversion ng/dL→ng/mL,
|
||||
calibration T avec labs en ng/dL. **En cas de nouveau bug remonté par l'utilisatrice :
|
||||
exporter le JSON, l'épingler ici, reproduire, corriger.**
|
||||
- **`V120FeaturesTest`** (6) : v1.2.0 — doses prévisionnelles (rythme 7 j depuis la
|
||||
- **`V120FeaturesTest`** (10) : v1.2.0→v1.2.3 — doses prévisionnelles (rythme 7 j depuis la
|
||||
dernière dose réelle, liste exacte J+4/J+11/J+18/J+25 ; vide sans intervalle ou sans
|
||||
doses ; ester override projeté), override de modèle (ESE ≠ TFS à 45 h pour EV ;
|
||||
sans override = modèle du traitement), auto-calibration (SF 0,72 recalculé depuis un
|
||||
lab planté, T recalibré, **originaux non modifiés** ; inchangée sans lab utilisable).
|
||||
sans override = modèle du traitement), auto-calibration v1.2.1 (facteur **par ester**
|
||||
depuis un lab planté, T recalibré, **originaux non modifiés** ; inchangée sans lab
|
||||
utilisable), **attribution des labs par période d'ester** (EV calibré par les labs EV,
|
||||
EEn par les labs EEn — le scénario valerate→enanthate de l'utilisatrice) et
|
||||
application de `scalePerEster` par dose.
|
||||
- **`RegressionUserCase2Test`** (6) : **2ᵉ régression épinglée sur données réelles**
|
||||
(export v1.2.0 : 9 doses EEn/TFS 5 mg ~6-7 j, 8 labs dont un T "pg/mL" par erreur,
|
||||
SF stocké 0,72, fréquence 6 j). Vérifie : parsing, **état d'équilibre EEn** (t½ ≈
|
||||
6,7 j + doses ~6-7 j → accumulation ×2 → e2 ≈ 270 calibré, cohérent labs 300/250 ;
|
||||
non calibré ≈ 367 = les « 375 » rapportés), facteur unique EEN plausible, lab T en
|
||||
unité aberrante neutralisé, prévision 6 j exacte, auto-cal cohérente.
|
||||
**Tout nouvel export utilisateur = un nouveau test de régression.**
|
||||
- **`RegressionUserCase3Test`** (6) : 3ᵉ régression (export v1.2.3) — le scénario
|
||||
**transition** : traitement **EV inactif** (29 doses 2–8 mg, janvier→juillet) +
|
||||
traitement **EEn actif** (9 doses), 22 labs sur les deux périodes. Vérifie :
|
||||
parsing, **l'inactif reste simulé** (bug v1.2.4 — le moteur reçoit TOUS les
|
||||
traitements), calibration par période couvrant EV **et** EEN, k T par ester,
|
||||
continuité de la courbe pendant la transition, niveau actuel ~EEn équilibre.
|
||||
|
||||
### 8.bis Données de test réelles : HORS dépôt (`local-test-data/`)
|
||||
|
||||
Les deux classes de régression (`RegressionUserCase{,2}Test`) épinglent le comportement
|
||||
du moteur sur les **exports réels** de l'utilisatrice. Ce sont des **données de santé
|
||||
personnelles** : elles ne sont **pas versionnées**, pour ne rien divulguer dans le
|
||||
dépôt (ni maintenant, ni si le repo devient public un jour).
|
||||
|
||||
- emplacement : `local-test-data/backup-v1.0.0.json` et `backup-v1.2.0.json`
|
||||
(copiés tels quels depuis l'export JSON de l'app) ;
|
||||
- `.gitignore` contient `local-test-data/` → jamais commités ;
|
||||
- les tests font `Assume.assumeTrue(file.exists())` dans le `@Before` : **sans le
|
||||
fichier, la classe est IGNORÉE** (skipped, pas failed) — un clone neuf ou une CI
|
||||
exécute 44 tests au lieu de 67 ;
|
||||
- le workdir des tests Gradle est le dossier du module (`app/`) → les tests
|
||||
cherchent les fichiers à plusieurs chemins (`../local-test-data/…` en premier) ;
|
||||
- **pour les lancer** : exporter un backup JSON depuis l'app → l'enregistrer sous
|
||||
le nom attendu dans `local-test-data/` → `./gradlew testDebugUnitTest` ;
|
||||
- ⚠️ **ne jamais embarquer ces données dans les tests** (string inline dans le
|
||||
code) : tout nouvel export réel → fichier gitignore + assertions data-driven ;
|
||||
- **l'historique a été nettoyé avant le premier push** : les premiers commits
|
||||
embarquaient les valeurs (tests + doc) → `git filter-branch --tree-filter` avec
|
||||
un script d'anonymisation (timestamps décalés de +30 j, valeurs perturbées dans
|
||||
la prose des docs), tags réécrits, refs purgeées. `git grep` sur **toutes** les
|
||||
révisions ne trouve aucune donnée réelle.
|
||||
- **`LabsGroupingTest`** (4) : v1.2.2 — regroupement de l'écran Analyses (paire E2+T
|
||||
même timestamp ; timestamps différents séparés ; tri E2 avant T avant autres ;
|
||||
ordre chronologique décroissant).
|
||||
- **`ChangelogHelperTest`** (8) : v1.3.0 — comparaison SemVer **numérique**
|
||||
(1.2.9 < 1.2.10 : la comparaison lexicographique serait fausse), extraction
|
||||
de sections depuis la dernière vue, première installation (section courante
|
||||
seule), plusieurs versions intermédiaires, versions non numériques.
|
||||
- **`CalendarRruleTest`** (3) : v1.3.0 — RRULE `FREQ=DAILY;INTERVAL=N`,
|
||||
arrondi demi-supérieur des décimales (6,5 → 7), clamp ≥ 1 jour.
|
||||
- **`ExtremaTest`** (6) : v1.2.3 — détection des pics/creux (`detectExtrema`) :
|
||||
alternance stricte pic/creux en régime d'équilibre (4 doses hebdo → ≥ 4 extrema,
|
||||
pic > creux voisin, valeurs dans les bornes), courbe monotone → vide, série plate →
|
||||
vide, série trop courte → vide, **filtre d'amplitude** (sémantique zigzag : une
|
||||
oscillation sous le seuil produit UN pivot, l'alternance complète apparaît quand le
|
||||
seuil baisse), anti-corrélation E2/T (un pic d'E2 ≈ un creux de T).
|
||||
|
||||
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
|
||||
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
|
||||
@ -330,7 +458,16 @@ la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du mod
|
||||
- `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shot `getActiveOnce()`
|
||||
(jamais un Flow en runBlocking !) → reschedule
|
||||
|
||||
Manifest : `POST_NOTIFICATIONS`, `SCHEDULE_EXACT_ALARM`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`.
|
||||
**Événements d'agenda (v1.3.0, `reminder/CalendarEvents.kt`)** : switch dans
|
||||
l'éditeur (sous Rappels) → insertion dans un **calendrier local dédié
|
||||
« HormoneTrack »** (CalendarContract, ACCOUNT_TYPE_LOCAL) d'un événement
|
||||
récurrent RRULE `FREQ=DAILY;INTERVAL=N` (N = Posologie, arrondi demi-supérieur
|
||||
**explicite** : `floor(x + 0.5)` — kotlin.math.round arrondit les ties vers
|
||||
l'entier PAIR !), début = prochaine occurrence à l'heure de rappel (ou 12:00).
|
||||
L'id est stocké sur le traitement (`calendarEventId`, Room v3) ; au save :
|
||||
supprimer + recréer (fiable) si changé, supprimer si désactivé. Permissions
|
||||
WRITE_CALENDAR + READ_CALENDAR demandées à l'activation du switch.
|
||||
Manifest : `POST_NOTIFICATIONS`, `SCHEDULE_EXACT_ALARM`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`, `WRITE_CALENDAR`, `READ_CALENDAR`.
|
||||
Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
|
||||
|
||||
## 10. UI & navigation
|
||||
@ -339,34 +476,61 @@ Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
|
||||
`settings`, `treatment_edit/{id}` (-1 = nouveau) ; barre masquée sur ces 2 routes
|
||||
- `HomeScreen` : bandeau gradient (TransSky→TransPink, discret), carte **niveau actuel**
|
||||
(E2 ≈ X pg/mL, T ≈ Y ng/mL, delta vs 6 h), carte prochaine dose, chips de log rapide
|
||||
(+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`), disclaimer ; données
|
||||
auto-calibrées si l'option est active ; rafraîchissement `tick` 60 s
|
||||
- `ChartScreen` (v1.2, le plus riche) : plages 24 h/7 j/30 j ; **panoramique**
|
||||
(+ FAB), mini-chart 24 h (multi-séries via `ChartSeries`) **cliquable → écran
|
||||
Graphiques** (v1.2.1) avec mini-légende E2/T ; données auto-calibrées si l'option est
|
||||
active (`scalePerEster = effectiveAuto?.esterScales`) ; rafraîchissement `tick` 60 s
|
||||
- `ChartScreen` (v1.2, le plus riche) : plages 24 h/7 j/30 j + **boutons zoom − / +**
|
||||
(v1.2.9) ; **panoramique**
|
||||
(`detectHorizontalDragGestures` — tirer vers la droite remonte dans le passé,
|
||||
`panHours` borné à [0, âge de la 1ʳᵉ dose + plage], bouton « Revenir à maintenant ») ;
|
||||
**toggles indépendants Estrannaise/TFS** → deux `computeCurve` avec `modelOverride`
|
||||
superposées (E2 ESE bleu plein, E2 TFS turquoise, T ESE rose plein, T TFS rose
|
||||
pointillé) ; **chip Prévision** (doses projetées via `generateForecastDoses`, horizon
|
||||
= 2× le plus grand intervalle configuré, borné 7–30 j) ; **auto-calibration** branchée
|
||||
sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL
|
||||
sur les Paramètres ; légende dynamique ; labs T normalisés en ng/mL ; **toggle T =
|
||||
masque aussi les labs T** (v1.2.2) ; **chip « Pics / creux »** (v1.2.3 : triangles ▲▼
|
||||
aux extrema locaux de chaque courbe, via `detectExtrema` — E2 seuil 2 pg/mL, T seuil
|
||||
0,02 ng/mL)
|
||||
- `DosesScreen` : LazyColumn par jour (desc), **Δ jours depuis la dose précédente du même
|
||||
traitement** (`intervalsByDoseId`, colonne « Interval (d) » du `.ods`), suppression
|
||||
avec confirmation, FAB → `DoseDialog` (création), **tap sur la ligne → édition**
|
||||
- `LabsScreen` : groupée par marqueur, FAB → `LabDialog` (E2/T/PRL, unité suggérée)
|
||||
- `LabsScreen` (v1.2.2) : **prise de sang E2 + T en une entrée** (LabDialog create :
|
||||
deux sections optionnelles → 1 ou 2 LabResult au même timestamp) ; affichage
|
||||
**groupé par timestamp** (`groupLabsForDisplay` : E2 avant T, autres ensuite, tri
|
||||
desc) → « E2 306 pg/mL · T 44 ng/dL » côte à côte ; tap → sélecteur E2/T si paire,
|
||||
édition unitaire pré-remplie ; suppression = **la prise entière** (confirm nommant
|
||||
les valeurs — choix de design : les labs sont prélevés ensemble) ; suggestions
|
||||
d'unités pg/mL, ng/mL, ng/dL, ng/L, nmol/L, mIU/L
|
||||
- `TreatmentsScreen` : cartes (nom, route, dose, chips ester·modèle / Tmax / ×scale / ⏰,
|
||||
badge inactif), FAB → éditeur
|
||||
- `TreatmentEditorScreen` : 12 presets (`PKPresets`, cf `nameRes`) pré-remplissent tout ;
|
||||
champs conditionnels (ester+modèle si injection, Bateman sinon) ; carte Calibration
|
||||
(scaleFactor + « Calibrer avec les analyses ») ; **section « Fréquence » (v1.2 :
|
||||
(scaleFactor + « Calibrer avec les analyses ») ; **section « Posologie » (v1.2 :
|
||||
switch « Simuler les doses à venir » + intervalle en jours)** ; carte Rappel (switch +
|
||||
TimePicker **centré** + avertissement alarmes exactes) ; switch actif ; save →
|
||||
insert/update + schedule/cancel ; delete avec confirmation ; `createdAt` préservé
|
||||
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ; réglages
|
||||
T + calibration manuelle ; **option Calibration automatique (switch, désactivée par
|
||||
défaut)** ; statut alarmes exactes + bouton d'octroi ; Export/Import JSON ; à propos
|
||||
- **Dialog « Nouveautés » (v1.3.0)** : au démarrage, si `BuildConfig.VERSION_NAME`
|
||||
est plus récente que la dernière vue (DataStore `changelog_seen_version`),
|
||||
un AlertDialog affiche les sections CHANGELOG concernées (extraites par
|
||||
`ChangelogHelper.sectionsSince`, asset `changelog.md` synchronisé par la
|
||||
tâche `copyChangelog` — gitignoré, source de vérité = docs/CHANGELOG.md) ;
|
||||
fermable, ne réapparaît pas avant la prochaine mise à jour.
|
||||
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ;
|
||||
**version installée + lien cliquable vers les releases Gitea** (v1.3.0,
|
||||
`BuildConfig.VERSION_NAME`, `enableEdgeToEdge`-friendly) ;
|
||||
**« Calibration automatique (E2 + T) » — UNE option** (par période d'ester + modèle T,
|
||||
v1.2.1, désactivée par défaut) puis réglages T manuels (base/floor/k + bouton
|
||||
« Calibrer avec les analyses » ponctuel) ; statut alarmes exactes + bouton d'octroi ;
|
||||
Export/Import JSON ; à propos
|
||||
- Composants : `CurveChart` (§11), `DateTimeField` (DatePicker+TimePicker Material3,
|
||||
LocalDateTime, **horloge centrée**), `DoseDialog` (create/edit + override d'ester),
|
||||
`LabDialog`, `formatDose()` (top-level, dans `DoseDialog.kt`)
|
||||
- **Insets edge-to-edge (v1.2.5)** : `enableEdgeToEdge()` dans MainActivity ;
|
||||
les insets sont consommés UNE FOIS — TopAppBar M3 (barre de statut, insets
|
||||
par défaut) et NavigationBar (barre système) ; les `Scaffold` (racine
|
||||
HormoneTrackRoot + imbriqués Doses/Labs) ont `contentWindowInsets = 0` pour
|
||||
ne pas cumuler. Avant : fenêtre AppCompat + padding interne TopAppBar =
|
||||
double espace vide en haut.
|
||||
- Thème M3 custom (`ui/theme/Color.kt` : bleu #4F5BD5, rose #D6589E, labs orange, bandeau
|
||||
TransSky/TransPink), dynamic color désactivé ; `MaterialExpressiveTheme` encore
|
||||
**internal** dans la ligne material3 pinnée (cf §3) → `MaterialTheme` standard
|
||||
@ -382,7 +546,21 @@ gauche (pg/mL), T droite (ng/mL). Échelle « nice » (`niceCeil` : 1/2/2.5/5/10
|
||||
partagée entre toutes les séries. Grille 4 lignes ; labels Y gauche/droite ; X : pas
|
||||
6 h/24 h/5 j selon plage (`SimpleDateFormat` HH'h' / dd/MM). Labs : cercles (E2) et
|
||||
carrés (T) orange + valeur, **T convertie en ng/mL** (`convertTToNgMl`) au rendu.
|
||||
Ligne verticale « maintenant ».
|
||||
Ligne verticale « maintenant ». **Pics/creux** (v1.2.3, valeurs v1.2.6) :
|
||||
triangles ▲▼ via `PharmacokineticEngine.detectExtrema` **accompagnés de la
|
||||
valeur estimée** du pic (au-dessus) ou du creux (en dessous), dans la couleur
|
||||
de la série (`drawExtremum`).
|
||||
|
||||
**Zoom (v1.2.9)** : pinch **et** boutons − / + — plage temporelle bornée
|
||||
**6 h → 300 j** (`MIN_RANGE_H`/`MAX_RANGE_H`), point focal du pinch maintenu
|
||||
fixe dans le temps (formule centroid), échantillonnage **adaptatif**
|
||||
(`stepForRange` : 15 min ≤ 12 h, 30 min ≤ 24 h, sinon 1 h — courbes lisses à
|
||||
fort zoom) et labels X adaptatifs (1 h / 3 h ajoutés). Le pan ET le zoom
|
||||
partagent un seul `detectTransformGestures` (un handler = pas de conflit de
|
||||
consommation) ; le zoom/pan restent gérés **par le parent**, le Canvas demeure
|
||||
purement déclaratif. **Convention des boutons** (v1.2.10) : « + » = zoom avant
|
||||
(fenêtre courte), « − » = zoom arrière — le sens initial était inversé (retour
|
||||
utilisateur).
|
||||
|
||||
Pièges :
|
||||
- `DrawScope` implémente `Density` → `X.dp.toPx()` direct ; ne PAS écrire de helper custom
|
||||
@ -408,8 +586,15 @@ Pièges :
|
||||
- `BackupData{version=1, exportedAt, treatments[], doseLogs[], labResults[], tConfig}` → Gson
|
||||
- **Les IDs Room sont conservés** dans l'export et réinsérés tels quels → les FK
|
||||
dose→traitement restent valides
|
||||
- Import = **ajout** (traitements → doses → labs) ; ré-import du même fichier → conflit
|
||||
d'ID unique → exception catchée → `import_fail` (voulu ; un mode « replace » est en §20)
|
||||
- Import : **mode ÉCRASEMENT (v1.2.6)** — les données actuelles sont effacées
|
||||
AVANT l'insertion (`deleteAllDoseLogs` → `deleteAllLabResults` →
|
||||
`deleteAllTreatments`, ordre enfants → parents pour la FK CASCADE), puis
|
||||
restauration avec les IDs du backup conservés (FK valides). Avant (v1.2.5) :
|
||||
insertion en « ajout » → conflit d'ID dès que l'app contenait des données →
|
||||
l'import échouait (bug remonté). Après un import : le `tConfig` du backup est
|
||||
restauré dans DataStore et les rappels sont reprogrammés
|
||||
(`rescheduleAll(allTreatmentsOnce())`). Le dialog prévient que TOUT sera
|
||||
remplacé (bouton « Effacer & restaurer »)
|
||||
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
|
||||
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
|
||||
|
||||
@ -471,10 +656,86 @@ Historique complet — **à ne pas réintroduire** (utile pour diff/revert) :
|
||||
`LogDoseDialog` supprimé (attention : `formatDose` vivait dedans → déplacée
|
||||
top-level dans `DoseDialog.kt`).
|
||||
|
||||
**Session v1.2.1/v1.2.2 (remontées utilisateur) :**
|
||||
25. **Calibration mélangeant les périodes d'ester** — le fond du « 250–375 » :
|
||||
un facteur unique par traitement comparait des labs valerate à des prédictions
|
||||
enanthate → ratios aberrants → courbes gonflées. → calibration **par période**
|
||||
(`computeEsterScaleFactors` + `scalePerEster`, cf §7.6) ; vérifié que l'état
|
||||
d'équilibre EEn (accumulation ×2) est CORRECT — le « 375 » = calcul non calibré.
|
||||
26. **Lab T en unité aberrante (« 38 pg/mL », faute de frappe)** : renvoyé brut il
|
||||
écrasait l'axe T et rendait la courbe T invisible. → `convertTToNgMl` avec
|
||||
branche défensive pg/mL ÷1000 (et µg/L).
|
||||
27. **Pas d'édition des labs** → LabDialog create/edit + tap sur la ligne ;
|
||||
formatLabValue (préserve les décimales, contrairement à formatDose).
|
||||
|
||||
**Session v1.2.3/push Gitea :**
|
||||
28. **Commentaires Kotlin imbriqués** : `/**TFS**` dans un KDoc ouvre un bloc
|
||||
`/**` JAMAIS fermé (Kotlin les imbrique !) → « Unclosed comment » → NE PAS
|
||||
mettre `/**` dans un texte de doc ; écrire « EEn + TFS ».
|
||||
29. **XML de test périmés après échec de compilation** : quand compile échoue,
|
||||
les anciens XML test-results restent → on « corrige » des échecs fantômes.
|
||||
Toujours vérifier que la COMPILATION passe (grep `^e:` du log) avant
|
||||
d'analyser les résultats de tests.
|
||||
30. **filter-branch non idempotent** : le scrub (45.0→44.0) a corrompu une
|
||||
constante d'un test PUR écrit après coup (assert 0.45 vs 44/100). Règle :
|
||||
les remplacements d'anonymisation doivent être idempotents (les valeurs de
|
||||
remplacement ne re-matchent jamais les motifs) et les tests purs doivent
|
||||
utiliser des constantes hors des motifs de scrub.
|
||||
31. **Release script : préfixe « v »** — le tag git = `v1.2.3`, la CHANGELOG
|
||||
titre `[1.2.3]` : l'extraction cherchait `[vX.Y.Z]` → fallback silencieux.
|
||||
→ `scripts/gitea-release.py` (corps = section CHANGELOG, APK attaché).
|
||||
|
||||
34. **Import JSON impossible si l'app contient déjà des données** (v1.2.6) :
|
||||
l'insertion en « ajout » avec les IDs du backup entrait en conflit
|
||||
d'unicité dès la moindre donnée existante → import échouait. → mode
|
||||
ÉCRASEMENT (effacement ordonné enfants→parents puis restauration) +
|
||||
restauration du tConfig + reprogrammation des rappels.
|
||||
37. **Upload d'asset Gitea instable** (v1.2.10) : le `?name=` du POST et le
|
||||
PATCH de renommage peuvent être ignorés (asset au nom générique, APK
|
||||
release disparu de la release). → `ensure_asset` dans le script :
|
||||
upload + vérification nom/taille + retry PATCH + échec bruyant.
|
||||
38. **Comparaison lexicographique de tags/versions** (v1.3.0, publication
|
||||
farewell) : `tag >= "v1.2.5"` en comparaison de CHAÎNES fait `v1.2.10 <
|
||||
v1.2.5` ("1" < "5") → la boucle de publication a sauté v1.2.10. →
|
||||
toujours comparer les versions STRUCTURÉEMENT (tuple numérique, cf
|
||||
ChangelogHelper.isVersionNewer) ; même famille que le bug #22 (casse
|
||||
EEn) : identifiants « presque pareils ».
|
||||
36. **Sens des boutons de zoom inversé** (v1.2.10, retour utilisateur) :
|
||||
le « + » dézoomait (fenêtre plus longue) et le « − » zoomait — contre
|
||||
la convention carte. → « + » = zoom avant (fenêtre courte), « − » =
|
||||
zoom arrière. Le pinch (écarter = zoom avant) était déjà correct.
|
||||
35. **Prévision cassée après un oubli d'injection** (v1.2.7) : le premier
|
||||
créneau projeté tombait dans le passé (dernière + intervalle = jour
|
||||
manqué) → faux pic dans l'historique + rythme décalé. → les créneaux
|
||||
passés sont sautés (`while (t <= nowMs) t += intervalMs`), la prévision
|
||||
démarre au premier créneau futur ; un retard décale toute la prévision
|
||||
(comportement voulu, testé). NB : la doc §7.3b affirmait « strictement
|
||||
après nowMs » alors que le code incluait les créneaux passés —
|
||||
mismatch doc-code détecté par l'utilisatrice.
|
||||
33. **Double insets edge-to-edge** (v1.2.5) : espace vide en haut de l'écran —
|
||||
la fenêtre AppCompat poussait le contenu sous la barre de statut ET les
|
||||
TopAppBar M3 rajoutaient leur padding interne. → `enableEdgeToEdge()` +
|
||||
`contentWindowInsets = 0` sur les Scaffold, insets consommés une seule fois.
|
||||
32. **`isActive` traité comme filtre de données** (v1.2.4) : passer un
|
||||
traitement à inactif effaçait sa simulation du graphique (écrans
|
||||
collectaient `activeTreatments`) tout en permettant d'y loger des doses.
|
||||
→ sémantique documentée §6.bis : l'inactivation ne touche que la SAISIE
|
||||
(chips, dropdown création, rappels) ; simulation et calibration reçoivent
|
||||
TOUS les traitements. Épinglé par la régression n°3.
|
||||
|
||||
**Leçons** : (a) ne jamais croire un build « probablement bon » sans l'avoir lancé ;
|
||||
(b) les tests sémantiques attrapent ce que la compilation ne voit pas ; (c) se méfier des
|
||||
constantes stdlib « de mémoire » (`ln2`), des mélanges Float/Double, et des APIs M3
|
||||
expérimentales sans `@OptIn` ; (d) **un test de régression sur les VRAIES données
|
||||
utilisateur** attrape les bugs de convention (casse, unités) que les tests
|
||||
synthétiques ratent — mais garde ces données **hors du dépôt** (§8.bis) ;
|
||||
(e) attention aux identifiants « presque pareils » entre sources (constantes app
|
||||
vs clés d'asset) ; (f) après un échec de COMPILATION, jeter les résultats de tests
|
||||
de la même passe (XML périmés) ; (g) tout script de réécriture d'historique doit
|
||||
être idempotent ; (h) dans un KDoc, `/**` imbrique. (a) ne jamais croire un build « probablement bon » sans l'avoir lancé ;
|
||||
(b) les tests sémantiques attrapent ce que la compilation ne voit pas ; (c) se méfier des
|
||||
constantes stdlib « de mémoire » (`ln2`), des mélanges Float/Double, et des APIs M3
|
||||
expérimentales sans `@OptIn` ; (d) **un test de régression sur les VRAIES données
|
||||
utilisateur** (`RegressionUserCaseTest` = export JSON réel) attrape les bugs de
|
||||
convention (casse, unités) que les tests synthétiques ratent ; (e) attention aux
|
||||
identifiants « presque pareils » entre sources (constantes app vs clés d'asset).
|
||||
@ -503,18 +764,136 @@ structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour
|
||||
|
||||
```bash
|
||||
cd ~/projects/HormoneTrack
|
||||
./gradlew assembleDebug testDebugUnitTest # build + 36 tests
|
||||
./gradlew lint # linters Android (à configurer)
|
||||
./gradlew assembleDebug testDebugUnitTest # build + 62 tests (44 sans données locales)
|
||||
./gradlew assembleRelease # APK optimisé R8 (cf §16.bis)
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
**Git (initialisé le 2026-09-05, branche `main`)** :
|
||||
**Git (initialisé le 2026-09-05, branche `main`, DEUX remotes Gitea)** :
|
||||
- `origin` → `https://gitea.cloudyfy.fr/Siphonight/HormoneTrack` (privé, HTTPS + trousseau)
|
||||
- `farewell` → `git@farewell:Siphonight/HormoneTrack.git` (SSH, alias `farewell`
|
||||
= `giteassh.farewell.dev:2222` avec clé dédiée, cf `~/.ssh/config`) —
|
||||
**repo créé + push + LES 12 RELEASES publiées avec APK vérifiés par
|
||||
téléchargement (2026-09-06)** ; token `gitea.farewell.dev` au trousseau
|
||||
(scope write:repository)
|
||||
- Historique = commits logiques par couche (toolchain / moteur / UI / docs) ;
|
||||
- **Chaque release = tag annoté** (`v1.1.0`, `v1.2.0`, …) :
|
||||
`git tag -a vX.Y.Z -m "…" && git tag` pour lister ;
|
||||
- `local.properties`, `build/`, `.gradle/`, `.idea/` sont ignorés (`.gitignore`) ;
|
||||
- `local.properties`, `build/`, `.gradle/`, `.idea/`, **`local-test-data/`** sont
|
||||
ignorés (`.gitignore`) — ce dernier protège les données de santé de test ;
|
||||
- Avant chaque commit de release : `./gradlew testDebugUnitTest` doit être vert ;
|
||||
- Prochaine étape repo : ajouter un remote et `git push -u origin main --tags`.
|
||||
- Push : `git push origin main --tags` + `git push farewell main --tags`
|
||||
(farewell : auth SSH par clé dédiée ; le push-to-create y est
|
||||
DÉSACTIVÉ → le repo doit exister au préalable sur l'instance).
|
||||
⚠️ Tokens Gitea : scope `write:repository` suffit pour push, releases et
|
||||
assets ; il NE permet PAS de créer un repo via API (il faut `write:user`)
|
||||
ni d'utiliser push-to-create.
|
||||
|
||||
### 16.bis Releases Gitea avec APK téléchargeable
|
||||
|
||||
Les releases Gitea embarquent l'APK de chaque version — les utilisateurs n'ont pas
|
||||
besoin de compiler. **Le corps de chaque release = la section CHANGELOG de la
|
||||
version** (markdown rendu par Gitea), extrait automatiquement.
|
||||
|
||||
Le tout est automatisé par `scripts/gitea-release.py` — **multi-instances**
|
||||
(`cloudyfy` par défaut, `farewell`) :
|
||||
|
||||
```bash
|
||||
# 0. Le CHANGELOG est embarqué dans l'app (dialog « Nouveautés ») :
|
||||
# la tâche Gradle copyChangelog copie docs/CHANGELOG.md vers
|
||||
# src/main/assets/changelog.md à chaque build (gitignoré, auto)
|
||||
|
||||
# 1. Construire les DEUX APK au niveau du tag (debug + release R8)
|
||||
git checkout vX.Y.Z
|
||||
./gradlew assembleRelease assembleDebug # release = recommandé (R8, cf plus bas)
|
||||
cp app/build/outputs/apk/release/app-release.apk /tmp/apks/HormoneTrack-vX.Y.Z-release.apk
|
||||
cp app/build/outputs/apk/debug/app-debug.apk /tmp/apks/HormoneTrack-vX.Y.Z-debug.apk
|
||||
git checkout main
|
||||
|
||||
# 2. Publier sur chaque instance (corps = CHANGELOG + les 2 APK attachés)
|
||||
# ⚠️ NE JAMAIS comparer les tags en chaînes (v1.2.10 < v1.2.5
|
||||
# lexicographiquement !) — comparer en tuples numériques (§14 #38)
|
||||
# ⚠️ v1.3.0 : deux invocations RAPPROCHÉES du script se sont écrasées
|
||||
# mutuellement (APK release disparu, debug renommé générique). Pattern
|
||||
# validé : purge des assets + upload + VÉRIFICATION PAR TÉLÉCHARGEMENT
|
||||
# des deux, puis RE-VÉRIFIER LES DEUX à la fin du run (listing peut
|
||||
# mentir pendant les uploads rapprochés).
|
||||
for I in cloudyfy farewell; do
|
||||
python3 scripts/gitea-release.py $I vX.Y.Z /tmp/apks/HormoneTrack-vX.Y.Z-release.apk
|
||||
python3 scripts/gitea-release.py $I vX.Y.Z /tmp/apks/HormoneTrack-vX.Y.Z-debug.apk
|
||||
done
|
||||
```
|
||||
|
||||
Le script choisit l'instance (URL, owner) et lit le token Gitea correspondant
|
||||
dans le trousseau macOS (`security find-internet-password -s <hôte> -w`) :
|
||||
`gitea.cloudyfy.fr` ✓ présent ; `gitea.farewell.dev` → à ajouter :
|
||||
`security add-internet-password -s gitea.farewell.dev -a Siphonight -w <TOKEN> -U`.
|
||||
|
||||
Le script :
|
||||
- extrait la section `## [X.Y.Z]` de `docs/CHANGELOG.md` comme corps ;
|
||||
⚠️ piège : le tag git porte le « v » (`v1.2.3`) mais la CHANGELOG non
|
||||
(`[1.2.3]`) — première version du script cherchait `[vX.Y.Z]` et tombait sur
|
||||
le fallback « Voir docs/CHANGELOG.md » ;
|
||||
- crée la release si absente, sinon **met à jour le corps** (PATCH) ;
|
||||
- attache l'APK (remplace l'asset du même nom si présent) — appeler le script
|
||||
DEUX FOIS pour publier les deux APK (noms distincts) ;
|
||||
⚠️ vérification intégrée (`ensure_asset`) : le `?name=` de l'upload et/ou
|
||||
le PATCH peuvent être ignorés par les instances (assets au nom générique /
|
||||
APK disparu — v1.2.6, v1.2.10) → le script vérifie **nom ET taille** après
|
||||
chaque upload, retente le PATCH une fois, et **échoue bruyamment** si
|
||||
l'asset ne colle pas — ne pas retirer ;
|
||||
- **Releases antérieures à v1.2.5 = APK debug unique** : backfill des APK
|
||||
release abandonné (best effort) — les anciens `build.gradle.kts` sortaient
|
||||
un `app-release-unsigned.apk` non signé (pas de signing config à l'époque).
|
||||
Les utilisateurs prennent la dernière version.
|
||||
`HormoneTrack-vX.Y.Z-release.apk` (**recommandé**, R8) et
|
||||
`HormoneTrack-vX.Y.Z-debug.apk` ;
|
||||
- lit le token Gitea dans le trousseau macOS (`security find-internet-password`) ;
|
||||
scope requis : `write:repository` (suffit pour releases + assets, pas pour
|
||||
créer un repo — cf plus haut).
|
||||
|
||||
**Build release (v1.2.5)** : `./gradlew assembleRelease` — R8 full mode +
|
||||
`isShrinkResources` + signé avec la **clé debug** (même signature que les APK
|
||||
debug distribués → mise à jour par-dessus sans perte de données ; 20 Mo →
|
||||
2,4 Mo). Garde-fous dans `proguard-rules.pro` : Gson lit les champs des
|
||||
modèles par réflexion → `-keep` explicites sur `data.model.**`,
|
||||
`BackupData`, `TConfig` (+ attributs `Signature`) — sinon l'export/import
|
||||
JSON casse UNIQUEMENT en release. ⚠️ À chaque activation d'optimisation :
|
||||
tester sur téléphone l'export/import de backup + les graphiques (R8 ne se
|
||||
vérifie pas en tests JVM).
|
||||
|
||||
### Checklist de déploiement complète (de A à Z, pour une session sans contexte)
|
||||
|
||||
1. **Bumper la version** dans `app/build.gradle.kts` : `versionCode = N+1`,
|
||||
`versionName = "X.Y.Z+1"` (SemVer : fix = Z, feature = Y).
|
||||
2. **Tests verts obligatoires** : `./gradlew testDebugUnitTest` — 62 au total,
|
||||
44 si `local-test-data/` est absent (les régressions réelles sont skippées).
|
||||
3. **Docs** : section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md` (le corps des
|
||||
releases Gitea en sera extrait automatiquement par le script), + §14 si bug
|
||||
corrigé, + §2 (historique) si notable.
|
||||
4. **Commit** (message descriptif par couche) + **tag annoté** :
|
||||
`git tag -a vX.Y.Z -m "résumé"`.
|
||||
5. **Push** : `git push origin main --tags` + `git push farewell main --tags`
|
||||
(farewell : le repo doit exister sur l'instance ; push-to-create désactivé).
|
||||
6. **Construire les 2 APK au niveau du tag** :
|
||||
`git checkout vX.Y.Z` → `./gradlew assembleRelease assembleDebug` → copier
|
||||
`app-release.apk` → `/tmp/apks/HormoneTrack-vX.Y.Z-release.apk` et
|
||||
`app-debug.apk` → `HormoneTrack-vX.Y.Z-debug.apk` → `git checkout main`.
|
||||
7. **Publier les releases** (corps = section CHANGELOG + les 2 APK attachés) :
|
||||
`python3 scripts/gitea-release.py cloudyfy vX.Y.Z <release.apk>` puis
|
||||
idem avec `<debug.apk>` ; répéter avec l'instance `farewell` (token
|
||||
`gitea.farewell.dev` requis dans le trousseau, absent à ce jour).
|
||||
8. **Smoke-test R8 sur téléphone** (le release APK n'est pas vérifiable en
|
||||
tests JVM) : installation par-dessus l'existant, graphiques, export ET
|
||||
import d'un backup JSON, rappel. Le debug APK est le repli (même signature).
|
||||
9. **Mettre à jour §2/§8/§19/§21 si besoin** puis pusher la doc.
|
||||
|
||||
Notes :
|
||||
- L'APK est une **build debug** signée avec la clé debug locale — installable en
|
||||
sideload, mises à jour entre versions OK (même signature) ;
|
||||
- Le dépôt est **privé** : le téléchargement des releases exige d'être connecté ;
|
||||
rendre le dépôt public rend les APK téléchargeables sans compte (aucune donnée
|
||||
de santé dans le dépôt, cf §8.bis).
|
||||
|
||||
- Téléphone : mode développeur + Débogage USB (détails : GUIDE_INSTALLATION.md)
|
||||
- À ma charge (assistant) : build + tests JVM ✓ ; émulateur possible sur demande ;
|
||||
@ -555,7 +934,7 @@ manquantes** (licences signées) — c'est comme ça que android-37 est arrivé.
|
||||
Volontaires (v1) :
|
||||
- Pas de ViewModel/DI (couplage UI↔repo via CompositionLocal)
|
||||
- Modèle T empirique (non publié) — étiqueté estimation partout
|
||||
- Import JSON = ajout seulement (pas de mode replace/dédup)
|
||||
- Import JSON = **écrasement** depuis v1.2.6 (mode fusion non implémenté)
|
||||
- `fallbackToDestructiveMigration()` — à retirer à la migration v2 du schéma
|
||||
- WorkManager déclaré non utilisé
|
||||
- Profils par **tables** (pas par formule) : les D/k1–k3 de l'ODS ne sont pas consommés —
|
||||
@ -564,16 +943,28 @@ Volontaires (v1) :
|
||||
prochain reschedule (boot/save) — mineur
|
||||
- Labs : marqueur libre — E2/T exacts requis pour calibration/charts
|
||||
- `allowBackup=false` → seul backup = export JSON manuel
|
||||
- **APK release (R8)** : la réflexion Gson est couverte par des `-keep`
|
||||
explicites, mais R8 ne se vérifie pas en tests JVM → **smoke-test sur
|
||||
téléphone** (export/import backup, graphiques) avant chaque publication ;
|
||||
signé clé debug → upgradable sans perte, mais pas une signature « officielle »
|
||||
|
||||
## 20. Idées d'évolution
|
||||
|
||||
1. **Robolectric + tests Compose** (VM Android en JVM — pas besoin d'appareil)
|
||||
2. **Émulateur local** pour smoke-tests UI (sur demande, ~2–3 Go)
|
||||
3. Mode « planifier les injections » (schedule récurrent → pré-remplir le log)
|
||||
4. Import JSON : mode **replace** (wipe + insert) + détection de doublons
|
||||
5. Migration Room v2 (retirer fallbackToDestructiveMigration)
|
||||
6. Verrou biométrique (BiometricPrompt), widget, export CSV
|
||||
7. Charts : zoom/pan + tooltip au toucher
|
||||
4. Import JSON : détection de doublons / mode fusion optionnel (l'écrasement est fait, v1.2.6)
|
||||
5. Verrou biométrique (BiometricPrompt), widget, export CSV
|
||||
6. Charts : **zoom** + tooltip au toucher (le pan est fait, v1.2.0) ;
|
||||
MaterialExpressiveTheme quand l'API passera publique (cf §3)
|
||||
7. **Vrai keystore de distribution** (signature dédiée ≠ clé debug → nécessite
|
||||
une migration : backup → désinstallation → installation signée → réimport)
|
||||
|
||||
Fait (à ne pas refaire) : pan du chart (v1.2.0), pics/creux (v1.2.3),
|
||||
prévision par fréquence (v1.2.0), calibration par période d'ester E2 **et** T
|
||||
(v1.2.1/v1.2.3), édition doses (v1.1.0) et labs (v1.2.2), E2+T en une entrée
|
||||
(v1.2.2), migration Room v1→v2 sans fallback destructif (v1.2.0), dépôt Gitea
|
||||
+ releases APK (push session).
|
||||
8. Phase 2 montre : watchface `.hwt` custom, puis mini-app Lite Wearable (cf §17)
|
||||
9. Retirer WorkManager ou l'utiliser (reschedule de sécurité quotidien)
|
||||
|
||||
@ -595,6 +986,25 @@ Sur le téléphone de test (à compléter par l'utilisatrice) :
|
||||
- [ ] Redémarrer le téléphone → rappel reprogrammé (BootReceiver)
|
||||
- [ ] Désactiver un rappel → plus de notif (cancel — cf §14.3)
|
||||
- [ ] Tester l'installation d'une watchface `.hwt` via Gadgetbridge (pour la Phase 2)
|
||||
- [ ] **v1.2.x** : graphique panoramique (glisser → passé, bouton « Revenir à maintenant »)
|
||||
- [ ] **v1.2.x** : toggles Estrannaise/TFS indépendants (les deux courbes superposées)
|
||||
- [ ] **v1.2.x** : chip « Prévision » (configurer la Posologie d'un traitement d'abord)
|
||||
- [ ] **v1.2.x** : chip « Pics / creux » (triangles ▲▼ aux extrema E2 et T)
|
||||
- [ ] **v1.2.x** : Calibration automatique ON → courbes ajustées depuis les labs
|
||||
(par période d'ester si changement d'ester), OFF → valeurs stockées
|
||||
- [ ] **v1.2.x** : tap sur le mini-chart de l'accueil → écran Graphiques
|
||||
- [ ] **v1.2.x** : édition d'une dose (tap ligne Doses) et d'un lab (tap ligne
|
||||
Analyses → sélecteur E2/T si paire) ; suppression par prise entière
|
||||
- [ ] **v1.2.x** : prise de sang E2 + T en une entrée (champs optionnels)
|
||||
- [ ] **v1.2.9** : zoom du graphique (pinch 2 doigts + boutons − / + ; labels
|
||||
X adaptatifs ; courbes lisses à fort zoom)
|
||||
- [ ] **v1.2.7** : prévision après un OUBLI (pas de faux pic dans le passé)
|
||||
et après un RETARD (prévision décalée, suivant la dernière prise)
|
||||
- [ ] **v1.2.6** : import d'un backup avec l'app déjà remplie → ÉCRASER &
|
||||
restaurer (message clair), tConfig restauré, rappels reprogrammés
|
||||
- [ ] **v1.2.4** : passer un traitement à inactif → retiré de « Log rapide » et
|
||||
du dropdown des nouvelles doses, rappel annulé, **mais sa simulation reste
|
||||
sur le graphique** et la calibration couvre toujours ses périodes
|
||||
|
||||
---
|
||||
*Doc mise à jour le 5 sept. 2026 (v1.2.0) — build OK, 36/36 tests verts, APK debug 23 MB, repo git avec tags.*
|
||||
*Doc mise à jour le 6 sept. 2026 (v1.3.0) — build OK, 62/62 tests verts (44 sans les données locales), dépôts Gitea (cloudyfy + farewell) avec releases APK, aucune donnée de santé dans le dépôt ni l'historique.*
|
||||
|
||||
@ -16,6 +16,30 @@ App Android de suivi de THS : courbes estimées heure par heure (E2 + T), log de
|
||||
|
||||
Tu n'as pas besoin d'installer Gradle ni le JDK séparément : Android Studio s'en charge.
|
||||
|
||||
## 1.bis Option SANS compilation : télécharger l'APK depuis les releases
|
||||
|
||||
Chaque version taguée est publiée en **release Gitea** avec les APK prêts à
|
||||
installer — pas besoin d'Android Studio ni de faire un build :
|
||||
|
||||
1. Va sur **Releases** du dépôt Gitea (onglet « Releases » à droite) :
|
||||
`https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases`
|
||||
(miroir équivalent : `https://gitea.farewell.dev/Siphonight/HormoneTrack/releases`)
|
||||
2. Deux APK par release :
|
||||
|
||||
| Fichier | Quoi | Pour qui |
|
||||
|---|---|---|
|
||||
| `HormoneTrack-vX.Y.Z-release.apk` | **Optimisé** (R8 : code minifié/compacté, ressources shrinkées) — **2,4 Mo** au lieu de 20 Mo, démarrage et fluidité meilleurs | **Recommandé** — usage quotidien |
|
||||
| `HormoneTrack-vX.Y.Z-debug.apk` | Non optimisé, hookable par un debugger — 20 Mo | Diagnostic/développement uniquement |
|
||||
|
||||
3. Installe : télécharge l'APK → ouvre-le → accepte « installer une application
|
||||
inconnue » (une seule fois). **Mise à jour** : installe la nouvelle version
|
||||
par-dessus l'ancienne — données conservées (les deux APK sont signés avec la
|
||||
même clé debug, donc interchangeables dans les deux sens sans perte).
|
||||
|
||||
> ⚠️ Après l'installation d'un APK **release** (optimisé R8), fais un test
|
||||
> rapide une fois : export/import d'un backup JSON + graphiques — R8 n'est pas
|
||||
> vérifiable par les tests automatisés.
|
||||
|
||||
## 2. Ouvrir le projet
|
||||
|
||||
1. Android Studio → **Open** → sélectionne le dossier `~/projects/HormoneTrack`
|
||||
@ -58,14 +82,28 @@ Les notifications de l'app remontent automatiquement sur la montre via **Huawei
|
||||
## 6. Premiers pas dans l'app
|
||||
|
||||
1. **Traitements → +** → choisis un preset (ex : *Injection EV — Estrannaise*)
|
||||
- Tu as changé d'ester ? Crée (ou garde) le traitement de l'ancien ester et
|
||||
passe-le **inactif** (switch « Actif ») : il disparaît de la saisie et de tes
|
||||
rappels, mais **son historique reste simulé et calibré** sur les graphiques —
|
||||
parfait pour une transition valerate → enanthate
|
||||
- Ester (EV / EU / EEn) + modèle (Estrannaise / Transfem Science) = les courbes du `.ods`
|
||||
- Pour gel/patch/oral : paramètres Bateman (temps au pic, demi-vie, biodispo)
|
||||
2. **Doses → +** → logue tes injections passées (date/heure exactes, dose en mg)
|
||||
2. **Doses → +** → logue tes injections passées
|
||||
- Astuce mise à jour : l'app affiche les **nouveautés** de chaque version au
|
||||
démarrage (fermable) ; la **version installée** et le **lien des releases**
|
||||
sont dans Paramètres (date/heure exactes, dose en mg)
|
||||
- Astuce : tu peux changer l'ester par injection (comme dans ton tableur)
|
||||
- **Modifier une dose existante** : appuie simplement sur sa ligne dans l'écran Doses
|
||||
(traitement, dose, date/heure, notes et ester tout ça éditable) — pas besoin de
|
||||
supprimer/recréer
|
||||
3. **Analyses → +** → entre tes prises de sang (E2 en pg/mL, T en ng/mL)
|
||||
3. **Analyses → +** → une prise de sang complète en une entrée : **E2 et/ou T**
|
||||
(chacune optionnelle), date/heure commune, notes — les deux s'affichent côte à côte
|
||||
dans la liste
|
||||
- **Modifier** : appuie sur la ligne ; si la prise contient E2 **et** T, un sélecteur
|
||||
te demande laquelle modifier
|
||||
- **Supprimer** : la corbeille retire la prise entière (E2 + T ensemble)
|
||||
- Choisis bien l'unité : elle est convertie automatiquement pour l'affichage et la
|
||||
calibration (E2 en pg/mL ; T en ng/mL, ng/dL, ng/L, nmol/L)
|
||||
4. **Calibration** (dans l'édition d'un traitement E2) → « Calibrer avec les analyses »
|
||||
→ calcule le facteur d'échelle = médiane(lab ÷ prédiction), comme le « Scale factor » du `.ods`
|
||||
5. **Paramètres** :
|
||||
@ -99,12 +137,18 @@ Les profils viennent de ta feuille `Estrogen.ods` (table « Models ») :
|
||||
passé (toute ta fenêtre d'historique) ; bouton « Revenir à maintenant » pour revenir
|
||||
- **Toggles Estrannaise / Transfem Science** : les deux courbes peuvent être affichées
|
||||
simultanément (Estrannaise = bleu, Transfem Science = turquoise) pour comparer
|
||||
- **Prévision** : configure la **Fréquence d'injection** (jours) dans un traitement
|
||||
- **Prévision** : configure la **Posologie** (intervalle en jours) dans un traitement
|
||||
(section « Fréquence ») puis active le chip « Prévision » → les doses à venir sont
|
||||
simulées et dessinées après la ligne « maintenant » (jamais sauvegardées)
|
||||
- **Pics / creux** (chip sur le graphique) : triangles ▲▼ aux extrema estimés de
|
||||
chaque courbe (E2 et T, les deux modèles) — pratique pour visualiser d'un coup
|
||||
d'œil les hauts et les bas entre deux injections
|
||||
- **Calibration automatique** (Paramètres, désactivée par défaut) : quand activée, les
|
||||
facteurs d'échelle et le modèle T sont ajustés en continu depuis tes labs — pour
|
||||
l'affichage seulement, tes réglages stockés ne changent pas
|
||||
l'affichage seulement, tes réglages stockés ne changent pas. **Chaque ester est
|
||||
calibré avec les labs faits pendant sa période** : si tu étais sous valerate avant
|
||||
d'être sous enanthate, tes labs valerate calibrent les doses valerate (E2 **et**
|
||||
la suppression T), et inversement pour l'enanthate
|
||||
|
||||
## 9. Dépannage
|
||||
|
||||
|
||||
192
scripts/gitea-release.py
Executable file
192
scripts/gitea-release.py
Executable file
@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publie/met à jour une release Gitea : corps = section CHANGELOG de la version,
|
||||
APK en pièce jointe (optionnel). Réutilisable à chaque release, multi-instances.
|
||||
|
||||
Usage :
|
||||
python3 scripts/gitea-release.py [instance] vX.Y.Z [chemin/vers/app.apk]
|
||||
|
||||
- instance : « cloudyfy » (défaut) ou « farewell » — voir INSTANCES ci-dessous ;
|
||||
- le corps est extrait de docs/CHANGELOG.md (section « ## [X.Y.Z] », SANS « v »
|
||||
— le tag git porte le « v », la CHANGELOG non : piège corrigé, cf §16.bis) ;
|
||||
- si la release existe déjà, son corps est mis à jour (PATCH) ;
|
||||
- si un chemin d'APK est fourni, il est attaché avec VÉRIFICATION PAR
|
||||
TÉLÉCHARGEMENT (cf ensure_asset).
|
||||
|
||||
Auth : le token Gitea est lu dans le trousseau macOS
|
||||
(security find-internet-password -s <hôte> -w) — scope requis :
|
||||
write:repository (suffit pour releases + assets, pas pour créer un repo).
|
||||
Pour ajouter un token au trousseau :
|
||||
security add-internet-password -s gitea.farewell.dev -a Siphonight \
|
||||
-w <TOKEN> -U
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
# Instances connues : clé = nom court, hôte = service du trousseau
|
||||
INSTANCES = {
|
||||
"cloudyfy": {"url": "https://gitea.cloudyfy.fr", "owner": "Siphonight",
|
||||
"host": "gitea.cloudyfy.fr"},
|
||||
"farewell": {"url": "https://gitea.farewell.dev", "owner": "Siphonight",
|
||||
"host": "gitea.farewell.dev"},
|
||||
}
|
||||
|
||||
# Nom du dépôt (identique sur les deux instances)
|
||||
REPO_NAME = "HormoneTrack"
|
||||
CHANGELOG = "docs/CHANGELOG.md"
|
||||
|
||||
|
||||
def gitea_token(host: str) -> str:
|
||||
out = subprocess.run(
|
||||
["security", "find-internet-password", "-s", host, "-w"],
|
||||
capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
if not out:
|
||||
sys.exit(
|
||||
f"Token Gitea introuvable dans le trousseau pour {host}\n"
|
||||
f" Ajoute-le avec : security add-internet-password -s {host} "
|
||||
f"-a Siphonight -w <TOKEN> -U"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def request(url: str, method: str = "GET", data=None, token: str = "") -> dict:
|
||||
"""Requête API JSON. Les réponses vides (DELETE → 204) renvoient {}."""
|
||||
req = urllib.request.Request(url, method=method)
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.data = json.dumps(data).encode()
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
payload = resp.read().decode()
|
||||
return json.loads(payload) if payload.strip() else {}
|
||||
|
||||
|
||||
def _upload_asset(base: str, token: str, release_id: int, name: str, path: str) -> dict:
|
||||
"""POST multipart de l'APK (le ?name= peut être normalisé par l'instance)."""
|
||||
boundary = uuid.uuid4().hex
|
||||
head = (
|
||||
f"--{boundary}\r\n"
|
||||
f"Content-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n"
|
||||
f"Content-Type: application/vnd.android.package-archive\r\n\r\n"
|
||||
)
|
||||
tail = f"\r\n--{boundary}--\r\n"
|
||||
with open(path, "rb") as f:
|
||||
req = urllib.request.Request(
|
||||
f"{base}/releases/{release_id}/assets?name={name}", method="POST",
|
||||
headers={"Authorization": f"token {token}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}"})
|
||||
req.data = head.encode() + f.read() + tail.encode()
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def _delivered_size(base: str, token: str, url: str) -> int:
|
||||
"""Taille RÉELLEMENT délivrée par le lien de téléchargement — le listing
|
||||
de l'API a déjà menti pendant deux uploads rapprochés (v1.2.10)."""
|
||||
r = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(r, timeout=300) as resp:
|
||||
return len(resp.read())
|
||||
|
||||
|
||||
def ensure_asset(base: str, token: str, release_id: int, name: str, path: str) -> dict:
|
||||
"""Attache un APK à une release avec VÉRIFICATION PAR TÉLÉCHARGEMENT.
|
||||
|
||||
Le ?name= du POST, le listing et le PATCH de renommage se sont avérés non
|
||||
fiables sur des uploads rapprochés (v1.2.6 : nom générique ; v1.2.10 :
|
||||
APK release disparu, deux assets délivrant le mauvais fichier). Séquence :
|
||||
upload → vérif listing (nom + taille) → vérif TÉLÉCHARGEMENT (octets
|
||||
délivrés = taille du fichier local) → sinon DELETE + re-upload (×2) →
|
||||
échec bruyant. Retourne l'asset vérifié.
|
||||
"""
|
||||
expected = os.path.getsize(path)
|
||||
for attempt in range(2):
|
||||
uploaded = _upload_asset(base, token, release_id, name, path)
|
||||
release = request(f"{base}/releases/{release_id}", token=token)
|
||||
current = next((a for a in release["assets"] if a["id"] == uploaded["id"]), None)
|
||||
if current is not None:
|
||||
name_ok = current["name"] == name
|
||||
size_ok = abs(current["size"] - expected) < 1000
|
||||
delivered_ok = _delivered_size(base, token, current["browser_download_url"]) == expected
|
||||
if name_ok and size_ok and delivered_ok:
|
||||
return current
|
||||
print(f" asset {name} incorrect (nom_ok={name_ok} taille_ok={size_ok} "
|
||||
f"délivré={delivered_ok}) → DELETE + re-upload (tentative {attempt + 1}/2)")
|
||||
request(f"{base}/releases/{release_id}/assets/{uploaded['id']}",
|
||||
"DELETE", token=token)
|
||||
sys.exit(f"✗ ÉCHEC : {name} pas correctement attaché après retry — "
|
||||
f"la release est incomplète, publier manuellement via l'interface web.")
|
||||
|
||||
|
||||
def changelog_section(version: str) -> str:
|
||||
"""Extrait la section « ## [X.Y.Z] » de la CHANGELOG (version SANS le « v»)."""
|
||||
num = version.removeprefix("v")
|
||||
text = open(CHANGELOG, encoding="utf-8").read()
|
||||
m = re.search(rf"## \[{re.escape(num)}\][^\n]*\n(.*?)(?=\n## \[|\Z)", text, re.S)
|
||||
if not m:
|
||||
sys.exit(f"Section [{num}] introuvable dans {CHANGELOG}")
|
||||
return m.group(1).strip()
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
# Premier argument = instance s'il n'est pas un tag (un tag commence par « v »)
|
||||
instance = "cloudyfy"
|
||||
if args and not args[0].startswith("v"):
|
||||
instance = args.pop(0)
|
||||
if instance not in INSTANCES:
|
||||
sys.exit(f"Instance inconnue : {instance} (attendu : {', '.join(INSTANCES)})")
|
||||
if not args:
|
||||
sys.exit(f"Usage: gitea-release.py [{instance}] vX.Y.Z [app.apk]")
|
||||
tag = args[0]
|
||||
apk_path = args[1] if len(args) > 1 else None
|
||||
|
||||
conf = INSTANCES[instance]
|
||||
base = f"{conf['url']}/api/v1/repos/{conf['owner']}/{REPO_NAME}"
|
||||
token = gitea_token(conf["host"])
|
||||
body = changelog_section(tag)
|
||||
|
||||
# Récupère la release du tag (crée-la si absente)
|
||||
try:
|
||||
release = request(f"{base}/releases/tags/{tag}", token=token)
|
||||
release_id = release["id"]
|
||||
created = False
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
release = request(f"{base}/releases", "POST", token=token, data={
|
||||
"tag_name": tag, "name": tag, "body": body,
|
||||
"draft": False, "prerelease": False,
|
||||
})
|
||||
release_id = release["id"]
|
||||
created = True
|
||||
|
||||
# Corps = section CHANGELOG (PATCH si la release existait déjà)
|
||||
if not created:
|
||||
request(f"{base}/releases/{release_id}", "PATCH", token=token,
|
||||
data={"tag_name": tag, "name": tag, "body": body, "draft": False, "prerelease": False})
|
||||
|
||||
# APK en pièce jointe (vérifié par téléchargement)
|
||||
if apk_path:
|
||||
asset_name = f"HormoneTrack-{tag}.apk"
|
||||
for asset in release.get("assets", []):
|
||||
if asset["name"] == asset_name:
|
||||
request(f"{base}/releases/{release_id}/assets/{asset['id']}",
|
||||
"DELETE", token=token)
|
||||
asset = ensure_asset(base, token, release_id, asset_name, apk_path)
|
||||
link = asset.get("browser_download_url", "?")
|
||||
else:
|
||||
link = "—"
|
||||
|
||||
action = "créée" if created else "mise à jour"
|
||||
print(f"[{instance}] Release {tag} {action} (id {release_id}) — "
|
||||
f"corps {len(body)} caractères, APK : {link}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
x
Reference in New Issue
Block a user