v1.1.0 : suivi THS avec courbes E2/T, modèles Estrannaise & Transfem Science, calibration labs, rappels (GT 3), backup JSON, FR/EN

- Moteur PK par tables horaires (8001 h) extraites de Estrogen.ods + Bateman fallback
- Lookup profils insensible à la casse (fix courbes EEn plates)
- Conversion unités T (ng/dL, ng/L, nmol/L -> ng/mL)
- Édition des doses + override d'ester par injection
- 30 tests unitaires dont régression sur données réelles
- UI Compose Material 3, i18n FR/EN, sauvegarde JSON SAF
This commit is contained in:
Siphonight 2026-09-05 16:32:44 +02:00
commit 387881b4c6
60 changed files with 54345 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Gradle
.gradle/
build/
# Local configuration (machine-specific, never commit)
local.properties
# IntelliJ / Android Studio
.idea/
*.iml
*.ipr
*.iws
# Build outputs
app/build/
captures/
.externalNativeBuild/
.cxx/
*.apk.idsig
*.hprof
# Kotlin / Java
.kotlin/
# OS
.DS_Store
Thumbs.db
# Logs
*.log

123
README.md Normal file
View File

@ -0,0 +1,123 @@
# HormoneTrack
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
sauvegarde JSON. **100 % local, aucun compte, aucun serveur.**
> **⚠️ Avertissement médical** : les courbes sont des **estimations pharmacocinétiques**
> à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de
> sang et aux consignes de ton endocrinologue.
- **Statut** : v1.1.0 — build Android ✅, 30 tests unitaires ✅ (dont régression sur données réelles), intégration montre = notifications ✅
- **Journal des versions** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Guide utilisateur** : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md)
- **Doc de développement** (architecture, maths, décisions, bugs) : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)
- **Montre / Gadgetbridge** : [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md)
## Fonctionnalités
- **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vue 24 h / 7 j / 30 j
- **Deux modèles PK au choix** (issus de la feuille `Estrogen.ods` de l'autrice) :
**Estrannaise (EstraNase)** et **Transfem Science**, pour les injections EV / EU / EEn
- **Modèle Bateman** paramétrable (temps au pic, demi-vie, biodisponibilité) pour gel,
patch et voie orale
- **Log des doses** avec date/heure exacte, dose en mg, **ester par injection**
(switch EV↔EU↔EEn comme dans le tableur), **éditable** (tap sur une ligne dans Doses)
- **Analyses de sang** (E2, T, PRL…) tracées sur les courbes comme points de calibration
- **Calibration** : facteur d'échelle par traitement = médiane(lab ÷ prédiction du modèle),
calculé automatiquement (« Scale factor » du `.ods`, automatisé)
- **Estimation T** empirique `T = plancher + (base − plancher) ÷ (1 + k·E2)`, calibrable
avec tes résultats T
- **Rappels quotidiens** avec actions **« Pris » / « Reporter 1 h »** dans la notification ;
les notifications remontent sur la Watch GT 3 (Gadgetbridge ou Huawei Health)
- **Sauvegarde/Restauration JSON** complète (traitements + doses + analyses + réglages T)
- **FR + EN** (langue par app, indépendante du système)
## Démarrage rapide (build depuis les sources)
Prérequis : JDK 17+ (Java 21 OK), Android SDK (API 34). Le wrapper télécharge Gradle 8.9.
```bash
git clone <repo> && cd HormoneTrack
echo "sdk.dir=/chemin/vers/android-sdk" > local.properties # ou ANDROID_HOME
./gradlew assembleDebug # APK : app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # 24 tests (moteur PK, profils, backup)
```
Installation sur un téléphone : mode développeur + Débogage USB, puis Android Studio
(**Run ▶️**) ou `adb install -r app/build/outputs/apk/debug/app-debug.apk`.
Pas de Play Store : l'app est sideloadée. Détails pas-à-pas : [docs/GUIDE_INSTALLATION.md](docs/GUIDE_INSTALLATION.md).
## Les modèles en bref
Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la réponse normalisée
(pg/mL par mg) issue des tables horaires d'Estrannaise / Transfem Science (8001 h) ;
les contributions se superposent. Pics de référence :
| Profil | Modèle | Pic (pg/mL/mg) | Tmax |
|----------|------------------|----------------|--------|
| EV | Estrannaise | 61,1 | ~45 h |
| EU | Estrannaise | 3,4 | ~55 h (plateau long) |
| EEn | Estrannaise | 31,4 | ~152 h |
| EV | Transfem Science | 59,0 | ~51 h |
| EU | Transfem Science | 10,1 | ~198 h |
| EEn | Transfem Science | 32,0 | ~156 h |
La calibration (facteur d'échelle par traitement, calibré par tes labs) ajuste le modèle
à ton corps, exactement comme la colonne « Scale factor » de la feuille d'origine.
## Vie privée
- Base de données **Room locale** sur le téléphone ; **aucune** télémétrie, aucun réseau
- 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
## Structure du dépôt
```
HormoneTrack/
├── README.md ← ce fichier
├── docs/
│ ├── GUIDE_INSTALLATION.md guide utilisateur (téléphone + montre)
│ ├── DEVELOPPEMENT.md doc de dev complète (architecture, maths, bugs, tests)
│ └── MONTRE-GADGETBRIDGE.md montre Huawei GT 3 : options + limites
├── build.gradle.kts config Gradle racine (AGP/Kotlin/KSP épinglés)
├── settings.gradle.kts
├── gradle.properties
├── gradle/wrapper/ wrapper Gradle 8.9 (jar + properties)
├── gradlew / gradlew.bat
└── app/
├── build.gradle.kts dépendances (Compose, Room, DataStore, Gson…)
├── proguard-rules.pro
└── src/
├── main/
│ ├── AndroidManifest.xml
│ ├── assets/pk_profiles.json ← tables horaires (Estrannaise/TFS)
│ ├── java/com/hormonetrack/
│ │ ├── data/ (Room : models, DAOs, repository, backup)
│ │ ├── pk/ (moteur pharmacocinétique + profils)
│ │ ├── reminder/ (alarmes exactes, notifs + actions, boot)
│ │ ├── settings/ (DataStore : TConfig, langue)
│ │ ├── ui/ (Compose : screens, components, theme)
│ │ ├── HormoneTrackApp.kt
│ │ └── MainActivity.kt
│ └── res/ (strings FR/EN, thème, icônes)
└── test/java/com/hormonetrack/ ← tests unitaires JVM
├── pk/ (moteur + profils)
└── data/backup/ (round-trip Gson)
```
## Feuille de route
- [x] v1 : courbes E2/T, log doses, labs + calibration, rappels, backup JSON, FR/EN
- [ ] Tests UI Compose + compilation release signée
- [ ] Verrou biométrique, widget, export CSV
- [ ] Phase 2 montre : watchface personnalisée et/ou mini-app Lite Wearable (voir [docs/MONTRE-GADGETBRIDGE.md](docs/MONTRE-GADGETBRIDGE.md))
## Licence
À définir avant le premier push public (suggestion : GPL-3.0, cohérent avec l'écosystème
Gadgetbridge). Les modèles PK appartiennent à leurs autrices respectives
([Estrannaise](https://estrannaise.github.io/), [Transfem Science](https://transfemscience.org)).

101
app/build.gradle.kts Normal file
View File

@ -0,0 +1,101 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp")
}
android {
namespace = "com.hormonetrack"
compileSdk = 34
defaultConfig {
applicationId = "com.hormonetrack"
minSdk = 26
targetSdk = 34
versionCode = 2
versionName = "1.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Compose BOM
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation(composeBom)
// Core
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
implementation("androidx.activity:activity-compose:1.9.0")
// Compose UI
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.7")
// AppCompat (per-app language on API < 33)
implementation("androidx.appcompat:appcompat:1.7.0")
// Room
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// DataStore (preferences)
implementation("androidx.datastore:datastore-preferences:1.1.1")
// Work Manager (for periodic tasks)
implementation("androidx.work:work-runtime-ktx:2.9.1")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
// Gson for export
implementation("com.google.code.gson:gson:2.11.0")
// Debug
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// Unit tests (JVM)
testImplementation("junit:junit:4.13.2")
}

2
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,2 @@
# Keep Room entities' enum fields (auto-converters use enum names via reflection in Gson backup)
-keepclassmembers enum com.hormonetrack.data.model.** { *; }

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application
android:name=".HormoneTrackApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.HormoneTrack">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".reminder.ReminderReceiver"
android:exported="false" />
<receiver
android:name=".reminder.DoseActionReceiver"
android:exported="false" />
<receiver
android:name=".reminder.BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,32 @@
package com.hormonetrack
import android.app.Application
import android.content.Context
import com.hormonetrack.data.AppDatabase
import com.hormonetrack.data.repository.HormoneRepository
import com.hormonetrack.pk.PKProfileStore
import com.hormonetrack.reminder.createNotificationChannel
import com.hormonetrack.settings.AppSettings
class HormoneTrackApp : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
PKProfileStore.init(this)
createNotificationChannel(this)
}
}
class AppContainer(appContext: Context) {
private val database = AppDatabase.getInstance(appContext)
val repository = HormoneRepository(
database.treatmentDao(),
database.doseLogDao(),
database.labResultDao()
)
val settings = AppSettings(appContext)
}

View File

@ -0,0 +1,65 @@
package com.hormonetrack
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.lifecycleScope
import com.hormonetrack.ui.HormoneTrackRoot
import com.hormonetrack.ui.theme.HormoneTrackTheme
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val container = (application as HormoneTrackApp).container
lifecycleScope.launch {
val lang = container.settings.language.first()
val locales = if (lang == "system") {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(lang)
}
if (AppCompatDelegate.getApplicationLocales() != locales) {
AppCompatDelegate.setApplicationLocales(locales)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
val openLogDose = intent?.getBooleanExtra("open_log_dose", false) ?: false
val treatmentId = intent?.getLongExtra("treatment_id", -1L) ?: -1L
setContent {
HormoneTrackTheme {
HormoneTrackRoot(
initialOpenLogDose = openLogDose,
initialTreatmentId = treatmentId
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
}

View File

@ -0,0 +1,42 @@
package com.hormonetrack.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.hormonetrack.data.dao.DoseLogDao
import com.hormonetrack.data.dao.LabResultDao
import com.hormonetrack.data.dao.TreatmentDao
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment
@Database(
entities = [Treatment::class, DoseLog::class, LabResult::class],
version = 1,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
abstract fun treatmentDao(): TreatmentDao
abstract fun doseLogDao(): DoseLogDao
abstract fun labResultDao(): LabResultDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"hormonetrack.db"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}

View File

@ -0,0 +1,71 @@
package com.hormonetrack.data.backup
import android.content.Context
import android.net.Uri
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.repository.HormoneRepository
import com.hormonetrack.pk.TConfig
data class BackupData(
val version: Int = 1,
val exportedAt: Long = System.currentTimeMillis(),
val treatments: List<Treatment> = emptyList(),
val doseLogs: List<DoseLog> = emptyList(),
val labResults: List<LabResult> = emptyList(),
val tConfig: TConfig = TConfig()
)
object BackupManager {
suspend fun exportJson(repo: HormoneRepository, tConfig: TConfig): String {
val data = BackupData(
treatments = repo.allTreatmentsOnce(),
doseLogs = repo.allDoseLogsOnce(),
labResults = repo.allLabResultsOnce(),
tConfig = tConfig
)
return Gson().toJson(data)
}
data class ImportResult(val treatments: Int, val doseLogs: Int, val labResults: Int)
suspend fun importJson(
repo: HormoneRepository,
json: String
): ImportResult {
val type = object : TypeToken<BackupData>() {}.type
val data: BackupData = Gson().fromJson(json, type)
data.treatments.forEach { repo.insertTreatment(it) }
data.doseLogs.forEach { repo.insertDoseLog(it) }
data.labResults.forEach { repo.insertLabResult(it) }
return ImportResult(
data.treatments.size,
data.doseLogs.size,
data.labResults.size
)
}
suspend fun writeBackup(context: Context, uri: Uri, json: String): Boolean {
return try {
context.contentResolver.openOutputStream(uri, "wt")?.use { os ->
os.write(json.toByteArray(Charsets.UTF_8))
os.flush()
} ?: return false
true
} catch (e: Exception) {
false
}
}
suspend fun readBackup(context: Context, uri: Uri): String? = try {
context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }
} catch (e: Exception) {
null
}
}

View File

@ -0,0 +1,35 @@
package com.hormonetrack.data.dao
import androidx.room.*
import com.hormonetrack.data.model.DoseLog
import kotlinx.coroutines.flow.Flow
@Dao
interface DoseLogDao {
@Query("SELECT * FROM dose_logs ORDER BY timestamp DESC")
fun getAll(): Flow<List<DoseLog>>
@Query("SELECT * FROM dose_logs WHERE treatmentId = :treatmentId ORDER BY timestamp DESC")
fun getByTreatment(treatmentId: Long): Flow<List<DoseLog>>
@Query("SELECT * FROM dose_logs WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
fun getByTimeRange(start: Long, end: Long): Flow<List<DoseLog>>
@Query("SELECT * FROM dose_logs WHERE treatmentId = :treatmentId AND timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
fun getByTreatmentAndTimeRange(treatmentId: Long, start: Long, end: Long): Flow<List<DoseLog>>
@Query("SELECT * FROM dose_logs ORDER BY timestamp DESC LIMIT 1")
fun getLatest(): Flow<DoseLog?>
@Query("SELECT * FROM dose_logs ORDER BY timestamp ASC")
suspend fun getAllOnce(): List<DoseLog>
@Insert
suspend fun insert(log: DoseLog): Long
@Update
suspend fun update(log: DoseLog)
@Delete
suspend fun delete(log: DoseLog)
}

View File

@ -0,0 +1,29 @@
package com.hormonetrack.data.dao
import androidx.room.*
import com.hormonetrack.data.model.LabResult
import kotlinx.coroutines.flow.Flow
@Dao
interface LabResultDao {
@Query("SELECT * FROM lab_results ORDER BY timestamp DESC")
fun getAll(): Flow<List<LabResult>>
@Query("SELECT * FROM lab_results WHERE marker = :marker ORDER BY timestamp DESC")
fun getByMarker(marker: String): Flow<List<LabResult>>
@Query("SELECT * FROM lab_results WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
fun getByTimeRange(start: Long, end: Long): Flow<List<LabResult>>
@Query("SELECT * FROM lab_results ORDER BY timestamp ASC")
suspend fun getAllOnce(): List<LabResult>
@Insert
suspend fun insert(result: LabResult): Long
@Update
suspend fun update(result: LabResult)
@Delete
suspend fun delete(result: LabResult)
}

View File

@ -0,0 +1,35 @@
package com.hormonetrack.data.dao
import androidx.room.*
import com.hormonetrack.data.model.Treatment
import kotlinx.coroutines.flow.Flow
@Dao
interface TreatmentDao {
@Query("SELECT * FROM treatments WHERE isActive = 1 ORDER BY createdAt DESC")
fun getActiveTreatments(): Flow<List<Treatment>>
@Query("SELECT * FROM treatments ORDER BY createdAt DESC")
fun getAllTreatments(): Flow<List<Treatment>>
@Query("SELECT * FROM treatments WHERE id = :id")
suspend fun getById(id: Long): Treatment?
@Query("SELECT * FROM treatments WHERE isActive = 1 ORDER BY createdAt DESC")
suspend fun getActiveOnce(): List<Treatment>
@Query("SELECT * FROM treatments ORDER BY createdAt DESC")
suspend fun getAllOnce(): List<Treatment>
@Insert
suspend fun insert(treatment: Treatment): Long
@Update
suspend fun update(treatment: Treatment)
@Delete
suspend fun delete(treatment: Treatment)
@Query("UPDATE treatments SET isActive = :active WHERE id = :id")
suspend fun setActive(id: Long, active: Boolean)
}

View File

@ -0,0 +1,29 @@
package com.hormonetrack.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "dose_logs",
foreignKeys = [
ForeignKey(
entity = Treatment::class,
parentColumns = ["id"],
childColumns = ["treatmentId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("treatmentId"), Index("timestamp")]
)
data class DoseLog(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val treatmentId: Long,
val timestamp: Long = System.currentTimeMillis(),
val doseAmount: Double,
val notes: String? = null,
// Per-injection ester override (like the ODS where profiles switch esters over time);
// null = use the treatment's default ester
val esterType: String? = null
)

View File

@ -0,0 +1,14 @@
package com.hormonetrack.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "lab_results")
data class LabResult(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val marker: String,
val value: Double,
val unit: String,
val timestamp: Long = System.currentTimeMillis(),
val notes: String? = null
)

View File

@ -0,0 +1,134 @@
package com.hormonetrack.data.model
import com.hormonetrack.R
data class PKPreset(
val nameRes: Int,
val type: TreatmentType,
val route: AdministrationRoute,
val esterType: String,
val pkModel: String,
val absorptionHours: Float,
val eliminationHalfLifeHours: Float,
val bioavailabilityFraction: Float,
val defaultDoseUnit: String,
val defaultDoseAmount: Double
)
object PKPresets {
val all: List<PKPreset> = listOf(
// --- Injections: Estrannaise models (profiles from the ODS) ---
PKPreset(
nameRes = R.string.preset_ev_ese,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EV,
pkModel = PKModels.ESTRANNAISE,
absorptionHours = 46f, eliminationHalfLifeHours = 100f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 4.0
),
PKPreset(
nameRes = R.string.preset_eu_ese,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EU,
pkModel = PKModels.ESTRANNAISE,
absorptionHours = 55f, eliminationHalfLifeHours = 400f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
),
PKPreset(
nameRes = R.string.preset_een_ese,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_SUBCUT,
esterType = Esters.EEN,
pkModel = PKModels.ESTRANNAISE,
absorptionHours = 152f, eliminationHalfLifeHours = 150f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
),
// --- Injections: Transfem Science models ---
PKPreset(
nameRes = R.string.preset_ev_tfs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EV,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 51f, eliminationHalfLifeHours = 100f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 4.0
),
PKPreset(
nameRes = R.string.preset_eu_tfs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
esterType = Esters.EU,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 198f, eliminationHalfLifeHours = 400f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
),
PKPreset(
nameRes = R.string.preset_een_tfs,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_SUBCUT,
esterType = Esters.EEN,
pkModel = PKModels.TRANSFEM_SCIENCE,
absorptionHours = 156f, eliminationHalfLifeHours = 150f, bioavailabilityFraction = 1f,
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
),
// --- Transdermal / oral (Bateman fallback) ---
PKPreset(
nameRes = R.string.preset_e2_gel,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.TRANSDERMAL_GEL,
esterType = Esters.NONE,
pkModel = PKModels.ESTRANNAISE,
absorptionHours = 4f, eliminationHalfLifeHours = 24f, bioavailabilityFraction = 0.8f,
defaultDoseUnit = "mg", defaultDoseAmount = 2.0
),
PKPreset(
nameRes = R.string.preset_e2_patch,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.TRANSDERMAL_PATCH,
esterType = Esters.NONE,
pkModel = PKModels.ESTRANNAISE,
absorptionHours = 8f, eliminationHalfLifeHours = 24f, bioavailabilityFraction = 0.9f,
defaultDoseUnit = "mg/day", defaultDoseAmount = 0.1
),
PKPreset(
nameRes = R.string.preset_e2_oral,
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.ORAL,
esterType = Esters.NONE,
pkModel = PKModels.ESTRANNAISE,
absorptionHours = 1.5f, eliminationHalfLifeHours = 16f, bioavailabilityFraction = 0.4f,
defaultDoseUnit = "mg", defaultDoseAmount = 2.0
),
// --- Anti-androgens (Bateman) ---
PKPreset(
nameRes = R.string.preset_cpa,
type = TreatmentType.ANTI_ANDROGEN,
route = AdministrationRoute.ORAL,
esterType = Esters.NONE,
pkModel = PKModels.ESTRANNAISE,
absorptionHours = 2f, eliminationHalfLifeHours = 30f, bioavailabilityFraction = 0.8f,
defaultDoseUnit = "mg", defaultDoseAmount = 10.0
),
PKPreset(
nameRes = R.string.preset_spiro,
type = TreatmentType.ANTI_ANDROGEN,
route = AdministrationRoute.ORAL,
esterType = Esters.NONE,
pkModel = PKModels.ESTRANNAISE,
absorptionHours = 1.5f, eliminationHalfLifeHours = 8f, bioavailabilityFraction = 0.7f,
defaultDoseUnit = "mg", defaultDoseAmount = 100.0
),
PKPreset(
nameRes = R.string.preset_bica,
type = TreatmentType.ANTI_ANDROGEN,
route = AdministrationRoute.ORAL,
esterType = Esters.NONE,
pkModel = PKModels.ESTRANNAISE,
absorptionHours = 3f, eliminationHalfLifeHours = 168f, bioavailabilityFraction = 0.9f,
defaultDoseUnit = "mg", defaultDoseAmount = 50.0
)
)
}

View File

@ -0,0 +1,69 @@
package com.hormonetrack.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
object Esters {
const val NONE = "NONE"
const val EV = "EV"
const val EU = "EU"
const val EEN = "EEN"
}
object PKModels {
const val ESTRANNAISE = "ESE"
const val TRANSFEM_SCIENCE = "TFS"
}
enum class TreatmentType {
ESTRADIOL,
ANTI_ANDROGEN,
PROGESTOGEN,
OTHER
}
enum class AdministrationRoute {
ORAL,
TRANSDERMAL_GEL,
TRANSDERMAL_PATCH,
INJECTION_IM,
INJECTION_SUBCUT,
OTHER
}
@Entity(tableName = "treatments")
data class Treatment(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val type: TreatmentType,
val route: AdministrationRoute,
val doseAmount: Double,
val doseUnit: String,
val isActive: Boolean = true,
val notes: String? = null,
// PK: lookup-table model for injections (EV/EU/EEn from Estrannaise / Transfem Science)
val esterType: String = Esters.NONE,
val pkModel: String = PKModels.ESTRANNAISE,
// PK: Bateman fallback for gel/patch/oral
val absorptionHours: Float = 4f,
val eliminationHalfLifeHours: Float = 24f,
val bioavailabilityFraction: Float = 1.0f,
// Calibration: ratio lab_value / model_prediction (like the ODS "Scale factor")
val scaleFactor: Double = 1.0,
// Reminder
val reminderHour: Int? = null,
val reminderMinute: Int? = null,
val reminderEnabled: Boolean = false,
val createdAt: Long = System.currentTimeMillis()
) {
val isInjection: Boolean
get() = route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT
val usesProfileModel: Boolean
get() = isInjection && esterType != Esters.NONE
}

View File

@ -0,0 +1,47 @@
package com.hormonetrack.data.repository
import com.hormonetrack.data.dao.DoseLogDao
import com.hormonetrack.data.dao.LabResultDao
import com.hormonetrack.data.dao.TreatmentDao
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment
import kotlinx.coroutines.flow.Flow
class HormoneRepository(
private val treatmentDao: TreatmentDao,
private val doseLogDao: DoseLogDao,
private val labResultDao: LabResultDao
) {
val activeTreatments: Flow<List<Treatment>> = treatmentDao.getActiveTreatments()
val allTreatments: Flow<List<Treatment>> = treatmentDao.getAllTreatments()
val allDoseLogs: Flow<List<DoseLog>> = doseLogDao.getAll()
val allLabResults: Flow<List<LabResult>> = labResultDao.getAll()
fun getDosesByTimeRange(start: Long, end: Long): Flow<List<DoseLog>> =
doseLogDao.getByTimeRange(start, end)
fun getDosesByTreatmentAndTimeRange(treatmentId: Long, start: Long, end: Long): Flow<List<DoseLog>> =
doseLogDao.getByTreatmentAndTimeRange(treatmentId, start, end)
fun getLabsByMarker(marker: String): Flow<List<LabResult>> =
labResultDao.getByMarker(marker)
suspend fun getTreatmentById(id: Long): Treatment? = treatmentDao.getById(id)
suspend fun allTreatmentsOnce(): List<Treatment> = treatmentDao.getAllOnce()
suspend fun allDoseLogsOnce(): List<DoseLog> = doseLogDao.getAllOnce()
suspend fun allLabResultsOnce(): List<LabResult> = labResultDao.getAllOnce()
suspend fun insertTreatment(treatment: Treatment): Long = treatmentDao.insert(treatment)
suspend fun updateTreatment(treatment: Treatment) = treatmentDao.update(treatment)
suspend fun deleteTreatment(treatment: Treatment) = treatmentDao.delete(treatment)
suspend fun insertDoseLog(log: DoseLog): Long = doseLogDao.insert(log)
suspend fun updateDoseLog(log: DoseLog) = doseLogDao.update(log)
suspend fun deleteDoseLog(log: DoseLog) = doseLogDao.delete(log)
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)
}

View File

@ -0,0 +1,106 @@
package com.hormonetrack.pk
import android.content.Context
import com.google.gson.JsonParser
object PKProfileStore {
@Volatile
private var initialized = false
private lateinit var profiles: Map<String, FloatArray>
fun init(context: Context) {
if (initialized) return
synchronized(this) {
if (initialized) return
val json = context.assets.open("pk_profiles.json").bufferedReader().use { it.readText() }
initWithJson(json)
}
}
/** Testable entry point: parse the profiles JSON directly (JVM unit tests). */
fun initWithJson(json: String) {
synchronized(this) {
val root = JsonParser.parseString(json).asJsonObject
val profilesObj = root.getAsJsonObject("profiles")
val parsed = LinkedHashMap<String, FloatArray>()
for ((key, value) in profilesObj.entrySet()) {
val arr = value.asJsonArray
val floats = FloatArray(arr.size()) { i -> arr[i].asFloat }
parsed[key] = floats
}
profiles = parsed
initialized = true
}
}
fun profileKey(ester: String, model: String): String =
"${ester}_${if (model == com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE) "tfs" else "ese"}"
/**
* Case-insensitive lookup. The ODS asset keys use the biological casing
* ("EV_ese", "EU_ese", "EEn_ese", …) while the app constants are uppercase
* (Esters.EEN = "EEN") — an exact-match lookup silently returned null for EEn
* and flattened every EEn curve to zero (bug fixed 2026-09-05).
*/
private fun lookup(key: String): FloatArray? {
if (!initialized) return null
return profiles[key]
?: profiles.entries.firstOrNull { it.key.equals(key, ignoreCase = true) }?.value
}
fun hasProfile(ester: String, model: String): Boolean =
lookup(profileKey(ester, model)) != null
fun profileLength(ester: String, model: String): Int =
lookup(profileKey(ester, model))?.size ?: 0
/**
* Normalized response (pg/mL per mg injected) at dtHours after a 1 mg injection,
* linearly interpolated between hourly points; beyond the table, extrapolated
* with the terminal exponential slope.
*/
fun sample(ester: String, model: String, dtHours: Double): Double {
if (!initialized || dtHours <= 0.0) return 0.0
val suffix = when (model) {
com.hormonetrack.data.model.PKModels.TRANSFEM_SCIENCE -> "tfs"
com.hormonetrack.data.model.PKModels.ESTRANNAISE -> "ese"
else -> return 0.0
}
val arr = lookup("${ester}_$suffix") ?: return 0.0
if (arr.size < 2) return 0.0
val lastIdx = arr.size - 1
if (dtHours >= lastIdx) {
// The ODS tables are rounded to 2 decimals and collapse into a 0.01 / 0.00
// display floor long before the true value vanishes. Extrapolate from the
// last point still >= 1% of the peak, using the average hourly decay of
// the previous 48 h (never sampling before the peak).
var peakIdx = 0
var peakV = 0f
for (idx in arr.indices) {
if (arr[idx] > peakV) {
peakV = arr[idx]
peakIdx = idx
}
}
if (peakV <= 0f) return 0.0
var j = lastIdx
while (j > 0 && arr[j] < peakV * 0.01f) j--
if (j <= 0) return 0.0
val window = 48
val j0 = maxOf(peakIdx, j - window)
val rate = if (j > j0) {
kotlin.math.ln(arr[j].toDouble() / arr[j0].toDouble().coerceAtLeast(1e-12)) / (j - j0)
} else 0.0
return arr[j].toDouble() * kotlin.math.exp(rate * (dtHours - j))
}
val i = dtHours.toInt().coerceIn(0, lastIdx - 1)
val frac = dtHours - i
val base = arr[i].toDouble()
val delta = (arr[i + 1] - arr[i]).toDouble()
return base + delta * frac
}
}

View File

@ -0,0 +1,296 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.max
data class LevelPoint(
val timestamp: Long,
val e2: Double,
val t: Double
)
data class TConfig(
val base: Double = 6.0,
val floor: Double = 0.2,
val k: Double = 0.19
)
object PharmacokineticEngine {
const val HOUR_MS = 3_600_000L
// ------------------------------------------------------------------
// Single dose contribution (pg/mL), either from the ODS lookup tables
// (Estrannaise / Transfem Science profiles for EV/EU/EEn injections) or
// from a Bateman model for gel/patch/oral routes.
// ------------------------------------------------------------------
data class BatemanParams(val ke: Double, val ka: Double)
fun computeKa(tHalfHours: Double, tMaxHours: Double): Double {
if (tMaxHours <= 0 || tHalfHours <= 0) return 1.0
val ke = ln(2.0) / tHalfHours
if (tMaxHours < 0.01) return ke * 100.0
var lo = ke * 1.001
var hi = ke * 1000.0
repeat(50) {
val mid = (lo + hi) / 2.0
// eq(mid) decreases in mid and crosses 0 at the sought ka > ke:
// eq > 0 means the root lies above mid.
val eq = ln(mid / ke) - (mid - ke) * tMaxHours
if (eq > 0) lo = mid else hi = mid
}
return (lo + hi) / 2.0
}
fun batemanParams(treatment: Treatment): BatemanParams {
val ke = ln(2.0) / max(treatment.eliminationHalfLifeHours, 0.01f).toDouble()
val ka = computeKa(
max(treatment.eliminationHalfLifeHours, 0.01f).toDouble(),
max(treatment.absorptionHours, 0.01f).toDouble()
)
return BatemanParams(ke, ka)
}
fun doseEster(treatment: Treatment, dose: DoseLog): String =
dose.esterType ?: treatment.esterType
fun concentrationOfDose(
treatment: Treatment,
dose: DoseLog,
queryTimeMs: Long,
bateman: BatemanParams? = null
): Double {
val dtH = (queryTimeMs - dose.timestamp) / 3_600_000.0
if (dtH <= 0.0) return 0.0
val mg = dose.doseAmount
if (mg <= 0.0) return 0.0
if (treatment.usesProfileModel) {
val ester = doseEster(treatment, dose)
if (ester != Esters.NONE) {
return PKProfileStore.sample(ester, treatment.pkModel, dtH) * mg
}
}
// Bateman fallback (gel / patch / oral / custom)
val p = bateman ?: batemanParams(treatment)
val diff = p.ka - p.ke
val a = mg * treatment.bioavailabilityFraction.toDouble()
val c = if (abs(diff) < 1e-3) {
a * p.ke * dtH * exp(-p.ke * dtH)
} else {
(a * p.ka / diff) * (exp(-p.ke * dtH) - exp(-p.ka * dtH))
}
return max(0.0, c)
}
private fun cutoffHours(treatment: Treatment): Double {
val profileH = if (treatment.usesProfileModel) {
PKProfileStore.profileLength(treatment.esterType, treatment.pkModel).toDouble()
} else 0.0
val batemanH = if (treatment.usesProfileModel) 0.0
else 30.0 * treatment.eliminationHalfLifeHours.toDouble()
return max(profileH, batemanH).coerceAtLeast(24.0)
}
// ------------------------------------------------------------------
// Aggregated levels
// ------------------------------------------------------------------
fun e2At(treatments: List<Treatment>, doseLogs: List<DoseLog>, tMs: Long): Double {
val batemanCache = HashMap<Long, BatemanParams>()
var total = 0.0
for (treatment in treatments) {
if (treatment.type != TreatmentType.ESTRADIOL) continue
if (!batemanCache.containsKey(treatment.id)) {
batemanCache[treatment.id] = batemanParams(treatment)
}
for (dose in doseLogs) {
if (dose.treatmentId != treatment.id || dose.timestamp > tMs) continue
val dtH = (tMs - dose.timestamp) / 3_600_000.0
if (dtH > cutoffHours(treatment)) continue
val c = concentrationOfDose(
treatment, dose, tMs, batemanCache[treatment.id]
)
if (c > 0.0) total += c * treatment.scaleFactor
}
}
return total
}
fun testosteroneAt(e2Level: Double, config: TConfig): Double {
if (e2Level <= 0.0) return config.base
return config.floor + (config.base - config.floor) / (1.0 + config.k * e2Level)
}
/**
* 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).
*/
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("ng/l") || u.endsWith("/l") -> value / 1000.0
else -> value
}
}
fun levelAt(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
tMs: Long,
tConfig: TConfig
): LevelPoint {
val e2 = e2At(treatments, doseLogs, tMs)
return LevelPoint(tMs, e2, testosteroneAt(e2, tConfig))
}
fun currentLevel(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
tConfig: TConfig,
nowMs: Long = System.currentTimeMillis()
): LevelPoint = levelAt(treatments, doseLogs, nowMs, tConfig)
fun computeCurve(
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
startMs: Long,
endMs: Long,
stepMs: Long = HOUR_MS,
tConfig: TConfig
): List<LevelPoint> {
if (treatments.isEmpty() || doseLogs.isEmpty() || endMs <= startMs) return emptyList()
val relevantTreatments = treatments.filter { tr ->
tr.type == TreatmentType.ESTRADIOL && doseLogs.any { it.treatmentId == tr.id }
}
if (relevantTreatments.isEmpty()) return emptyList()
val earliestDose = doseLogs.minOf { it.timestamp }
val searchStart = maxOf(startMs, earliestDose)
val batemanCache = HashMap<Long, BatemanParams>()
relevantTreatments.forEach { batemanCache[it.id] = batemanParams(it) }
val points = mutableListOf<LevelPoint>()
var t = searchStart
while (t <= endMs) {
var e2 = 0.0
for (treatment in relevantTreatments) {
val cutoff = cutoffHours(treatment)
val p = batemanCache[treatment.id]
for (dose in doseLogs) {
if (dose.treatmentId != treatment.id || dose.timestamp > t) continue
val dtH = (t - dose.timestamp) / 3_600_000.0
if (dtH > cutoff) continue
val c = concentrationOfDose(treatment, dose, t, p)
if (c > 0.0) e2 += c * treatment.scaleFactor
}
}
points.add(LevelPoint(t, e2, testosteroneAt(e2, tConfig)))
t += stepMs
}
return points
}
// ------------------------------------------------------------------
// Calibration from lab results
// ------------------------------------------------------------------
fun computeScaleFactor(
treatment: Treatment,
allDoseLogs: List<DoseLog>,
e2Labs: List<LabResult>
): Double? {
if (treatment.type != TreatmentType.ESTRADIOL) return null
val myDoses = allDoseLogs.filter { it.treatmentId == treatment.id }
if (myDoses.isEmpty()) return null
val p = batemanParams(treatment)
val ratios = mutableListOf<Double>()
for (lab in e2Labs) {
var predicted = 0.0
for (dose in myDoses) {
if (dose.timestamp > lab.timestamp) continue
val dtH = (lab.timestamp - dose.timestamp) / 3_600_000.0
if (dtH > cutoffHours(treatment)) continue
predicted += concentrationOfDose(treatment, dose, lab.timestamp, p)
}
if (predicted > 0.5) {
ratios.add(lab.value / predicted)
}
}
if (ratios.isEmpty()) return null
ratios.sort()
val median = if (ratios.size % 2 == 1) {
ratios[ratios.size / 2]
} else {
(ratios[ratios.size / 2 - 1] + ratios[ratios.size / 2]) / 2.0
}
return (median * 100).toInt() / 100.0
}
fun computeTConfigCalibration(
tLabs: List<LabResult>,
treatments: List<Treatment>,
doseLogs: List<DoseLog>,
current: TConfig
): TConfig? {
val usable = tLabs
.filter { it.value > current.floor + 0.02 }
.map { it.copy(value = convertTToNgMl(it.value, it.unit)) }
.filter { it.value > current.floor + 0.02 }
if (usable.isEmpty()) return null
val ks = mutableListOf<Double>()
for (lab in usable) {
val e2 = e2At(treatments, doseLogs, lab.timestamp)
if (e2 <= 1.0) continue
val k = ((current.base - current.floor) / (lab.value - current.floor) - 1.0) / e2
if (k > 1e-4 && k < 10.0) ks.add(k)
}
if (ks.isEmpty()) return null
ks.sort()
val median = if (ks.size % 2 == 1) {
ks[ks.size / 2]
} else {
(ks[ks.size / 2 - 1] + ks[ks.size / 2]) / 2.0
}
return current.copy(k = (median * 1000).toInt() / 1000.0)
}
fun nextReminderFireMs(treatments: List<Treatment>, nowMs: Long = System.currentTimeMillis()): Long? {
var next: Long? = null
val cal = java.util.Calendar.getInstance()
for (tr in treatments) {
if (!tr.reminderEnabled) continue
val h = tr.reminderHour ?: continue
val m = tr.reminderMinute ?: continue
cal.timeInMillis = nowMs
cal.set(java.util.Calendar.HOUR_OF_DAY, h)
cal.set(java.util.Calendar.MINUTE, m)
cal.set(java.util.Calendar.SECOND, 0)
cal.set(java.util.Calendar.MILLISECOND, 0)
if (cal.timeInMillis <= nowMs) cal.add(java.util.Calendar.DAY_OF_MONTH, 1)
val fire = cal.timeInMillis
if (next == null || fire < next) next = fire
}
return next
}
fun isInjectionRoute(route: AdministrationRoute): Boolean =
route == AdministrationRoute.INJECTION_IM || route == AdministrationRoute.INJECTION_SUBCUT
}

View File

@ -0,0 +1,46 @@
package com.hormonetrack.reminder
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.hormonetrack.data.AppDatabase
import com.hormonetrack.data.model.DoseLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class DoseActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val treatmentId = intent.getLongExtra(ReminderContract.EXTRA_TREATMENT_ID, -1L)
if (treatmentId <= 0) return
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.cancel(treatmentId.toInt())
when (intent.action) {
ReminderContract.ACTION_LOG_DOSE -> {
val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: ""
val dose = intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0)
val pending = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
val db = AppDatabase.getInstance(context)
val treatment = db.treatmentDao().getById(treatmentId) ?: return@launch
val amount = if (dose > 0.0) dose else treatment.doseAmount
db.doseLogDao().insert(DoseLog(treatmentId = treatmentId, doseAmount = amount))
} finally {
pending.finish()
}
}
}
ReminderContract.ACTION_SNOOZE -> {
val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: ""
val dose = intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0)
AlarmScheduler(context).scheduleSnooze(treatmentId, name, dose)
}
}
}
}

View File

@ -0,0 +1,222 @@
package com.hormonetrack.reminder
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.hormonetrack.MainActivity
import com.hormonetrack.R
import com.hormonetrack.data.AppDatabase
import com.hormonetrack.data.model.Treatment
import java.util.Calendar
object ReminderContract {
const val CHANNEL_ID = "hormonetrack_reminders"
const val ACTION_REMINDER = "com.hormonetrack.ACTION_REMINDER"
const val ACTION_LOG_DOSE = "com.hormonetrack.ACTION_LOG_DOSE"
const val ACTION_SNOOZE = "com.hormonetrack.ACTION_SNOOZE"
const val EXTRA_TREATMENT_ID = "treatment_id"
const val EXTRA_TREATMENT_NAME = "treatment_name"
const val EXTRA_DOSE_AMOUNT = "dose_amount"
fun reminderIntent(context: Context, treatmentId: Long): Intent =
Intent(context, ReminderReceiver::class.java).apply {
action = ACTION_REMINDER
putExtra(EXTRA_TREATMENT_ID, treatmentId)
}
}
fun createNotificationChannel(context: Context) {
val channel = NotificationChannel(
ReminderContract.CHANNEL_ID,
context.getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_HIGH
).apply {
description = context.getString(R.string.notification_channel_desc)
}
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
class AlarmScheduler(private val context: Context) {
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
private fun pendingBroadcast(treatmentId: Long, name: String?, dose: Double?): PendingIntent {
val intent = ReminderContract.reminderIntent(context, treatmentId)
name?.let { intent.putExtra(ReminderContract.EXTRA_TREATMENT_NAME, it) }
dose?.let { intent.putExtra(ReminderContract.EXTRA_DOSE_AMOUNT, it) }
return PendingIntent.getBroadcast(
context,
treatmentId.toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun nextOccurrence(hour: Int, minute: Int): Long {
val cal = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
if (cal.timeInMillis <= System.currentTimeMillis()) {
cal.add(Calendar.DAY_OF_MONTH, 1)
}
return cal.timeInMillis
}
fun canScheduleExact(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
fun scheduleDaily(treatment: Treatment): Boolean {
val hour = treatment.reminderHour ?: return false
val minute = treatment.reminderMinute ?: return false
val triggerAt = nextOccurrence(hour, minute)
val pi = pendingBroadcast(treatment.id, treatment.name, treatment.doseAmount)
return scheduleAt(triggerAt, pi)
}
fun scheduleSnooze(treatmentId: Long, name: String, dose: Double, delayMs: Long = 60 * 60_000L): Boolean {
val pi = pendingBroadcast(treatmentId, name, dose)
return scheduleAt(System.currentTimeMillis() + delayMs, pi)
}
private fun scheduleAt(triggerAt: Long, pi: PendingIntent): Boolean {
val canExact = canScheduleExact()
if (canExact) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt, pi
)
} else {
alarmManager.setWindow(
AlarmManager.RTC_WAKEUP, triggerAt, 10 * 60_000L, pi
)
}
return canExact
}
fun cancel(treatmentId: Long) {
val pi = PendingIntent.getBroadcast(
context,
treatmentId.toInt(),
ReminderContract.reminderIntent(context, treatmentId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pi)
}
fun rescheduleAll(treatments: List<Treatment>) {
treatments.forEach { tr ->
if (tr.reminderEnabled) {
scheduleDaily(tr)
} else {
cancel(tr.id)
}
}
}
}
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ReminderContract.ACTION_REMINDER) return
createNotificationChannel(context)
val treatmentId = intent.getLongExtra(ReminderContract.EXTRA_TREATMENT_ID, -1L)
if (treatmentId <= 0) return
val name = intent.getStringExtra(ReminderContract.EXTRA_TREATMENT_NAME) ?: ""
val dose = if (intent.hasExtra(ReminderContract.EXTRA_DOSE_AMOUNT)) {
intent.getDoubleExtra(ReminderContract.EXTRA_DOSE_AMOUNT, 0.0)
} else null
// Open the app on the Home screen
val openIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("open_log_dose", true)
putExtra(ReminderContract.EXTRA_TREATMENT_ID, treatmentId)
}
val openPi = PendingIntent.getActivity(
context,
(treatmentId * 10 + 0).toInt(),
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Action: log the dose immediately
val logIntent = Intent(context, DoseActionReceiver::class.java).apply {
action = ReminderContract.ACTION_LOG_DOSE
putExtra(ReminderContract.EXTRA_TREATMENT_ID, treatmentId)
putExtra(ReminderContract.EXTRA_TREATMENT_NAME, name)
putExtra(ReminderContract.EXTRA_DOSE_AMOUNT, dose)
}
val logPi = PendingIntent.getBroadcast(
context,
(treatmentId * 10 + 1).toInt(),
logIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Action: snooze 1 hour
val snoozeIntent = Intent(context, DoseActionReceiver::class.java).apply {
action = ReminderContract.ACTION_SNOOZE
putExtra(ReminderContract.EXTRA_TREATMENT_ID, treatmentId)
putExtra(ReminderContract.EXTRA_TREATMENT_NAME, name)
putExtra(ReminderContract.EXTRA_DOSE_AMOUNT, dose)
}
val snoozePi = PendingIntent.getBroadcast(
context,
(treatmentId * 10 + 2).toInt(),
snoozeIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val title = if (name.isNotBlank()) {
context.getString(R.string.reminder_title, name)
} else {
context.getString(R.string.reminder_title_plain)
}
val text = if (dose != null && dose > 0.0) {
context.getString(R.string.reminder_text_with_dose, dose)
} else {
context.getString(R.string.reminder_text)
}
val notification = NotificationCompat.Builder(context, ReminderContract.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setContentIntent(openPi)
.addAction(0, context.getString(R.string.action_taken), logPi)
.addAction(0, context.getString(R.string.action_snooze_1h), snoozePi)
.setAutoCancel(true)
.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(treatmentId.toInt(), notification)
}
}
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val result = goAsync()
Thread {
try {
val treatments = kotlinx.coroutines.runBlocking {
AppDatabase.getInstance(context).treatmentDao().getActiveOnce()
}
AlarmScheduler(context).rescheduleAll(treatments)
} finally {
result.finish()
}
}.start()
}
}

View File

@ -0,0 +1,50 @@
package com.hormonetrack.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.hormonetrack.pk.TConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
class AppSettings(private val context: Context) {
private object Keys {
val T_BASE = doublePreferencesKey("t_base")
val T_FLOOR = doublePreferencesKey("t_floor")
val T_K = doublePreferencesKey("t_k")
val LANGUAGE = stringPreferencesKey("language")
}
val tConfig: Flow<TConfig> = context.dataStore.data.map { prefs ->
TConfig(
base = prefs[Keys.T_BASE] ?: 6.0,
floor = prefs[Keys.T_FLOOR] ?: 0.2,
k = prefs[Keys.T_K] ?: 0.19
)
}
val language: Flow<String> = context.dataStore.data.map { prefs ->
prefs[Keys.LANGUAGE] ?: "system"
}
suspend fun setTConfig(config: TConfig) {
context.dataStore.edit { prefs ->
prefs[Keys.T_BASE] = config.base
prefs[Keys.T_FLOOR] = config.floor
prefs[Keys.T_K] = config.k
}
}
suspend fun setLanguage(code: String) {
context.dataStore.edit { prefs ->
prefs[Keys.LANGUAGE] = code
}
}
}

View File

@ -0,0 +1,115 @@
package com.hormonetrack.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Medication
import androidx.compose.material.icons.filled.Science
import androidx.compose.material.icons.filled.ShowChart
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.hormonetrack.AppContainer
import com.hormonetrack.R
import com.hormonetrack.ui.screens.ChartScreen
import com.hormonetrack.ui.screens.DosesScreen
import com.hormonetrack.ui.screens.HomeScreen
import com.hormonetrack.ui.screens.LabsScreen
import com.hormonetrack.ui.screens.SettingsScreen
import com.hormonetrack.ui.screens.TreatmentEditorScreen
import com.hormonetrack.ui.screens.TreatmentsScreen
val LocalAppContainer = compositionLocalOf<AppContainer> {
error("AppContainer not provided")
}
private data class TabItem(val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector)
@Composable
fun HormoneTrackRoot(initialOpenLogDose: Boolean, initialTreatmentId: Long) {
val app = androidx.compose.ui.platform.LocalContext.current.applicationContext as com.hormonetrack.HormoneTrackApp
CompositionLocalProvider(LocalAppContainer provides app.container) {
val navController = rememberNavController()
val tabs = listOf(
TabItem("home", R.string.nav_home, Icons.Filled.Home),
TabItem("chart", R.string.nav_chart, Icons.Filled.ShowChart),
TabItem("doses", R.string.nav_doses, Icons.Filled.Medication),
TabItem("labs", R.string.nav_labs, Icons.Filled.Science),
TabItem("treatments", R.string.nav_treatments, Icons.Filled.Tune)
)
val backStack by navController.currentBackStackEntryAsState()
val currentRoute = backStack?.destination?.route
Scaffold(
bottomBar = {
if (currentRoute in tabs.map { it.route }) {
NavigationBar {
tabs.forEach { tab ->
NavigationBarItem(
selected = currentRoute == tab.route,
onClick = {
navController.navigate(tab.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(tab.icon, contentDescription = stringResource(tab.labelRes)) },
label = { Text(stringResource(tab.labelRes)) }
)
}
}
}
}
) { padding ->
NavHost(
navController = navController,
startDestination = "home",
modifier = Modifier.padding(padding)
) {
composable("home") {
HomeScreen(
openLogDoseForTreatmentId = if (initialOpenLogDose) initialTreatmentId else null,
onOpenSettings = { navController.navigate("settings") },
onOpenTreatment = { id -> navController.navigate("treatment_edit/$id") }
)
}
composable("chart") { ChartScreen() }
composable("doses") { DosesScreen() }
composable("labs") { LabsScreen() }
composable("treatments") {
TreatmentsScreen(
onEdit = { id -> navController.navigate("treatment_edit/$id") },
onNew = { navController.navigate("treatment_edit/-1") }
)
}
composable("settings") {
SettingsScreen(onBack = { navController.popBackStack() })
}
composable("treatment_edit/{id}") { entry ->
val id = entry.arguments?.getString("id")?.toLongOrNull() ?: -1L
TreatmentEditorScreen(
treatmentId = id,
onDone = { navController.popBackStack() }
)
}
}
}
}
}

View File

@ -0,0 +1,231 @@
package com.hormonetrack.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.dp
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.ui.theme.ChartE2
import com.hormonetrack.ui.theme.ChartT
import com.hormonetrack.ui.theme.LabDot
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.ceil
data class ChartOptions(
val showT: Boolean = true,
val showLabs: Boolean = true,
val nowMs: Long = System.currentTimeMillis()
)
fun niceCeil(value: Double): Double {
if (value <= 0) return 100.0
val exp = Math.pow(10.0, Math.floor(Math.log10(value)))
val f = value / exp
val nice = when {
f <= 1 -> 1.0
f <= 2 -> 2.0
f <= 2.5 -> 2.5
f <= 5 -> 5.0
else -> 10.0
}
return nice * exp
}
@Composable
fun CurveChart(
points: List<LevelPoint>,
e2Labs: List<LabResult>,
tLabs: List<LabResult>,
options: ChartOptions,
modifier: Modifier
) {
val gridColor = MaterialTheme.colorScheme.outlineVariant
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
val nowLineColor = MaterialTheme.colorScheme.tertiary
Canvas(modifier = modifier) {
if (points.size < 2) return@Canvas
val padLeft = 42.dp.toPx()
val padRight = 42.dp.toPx()
val padTop = 12.dp.toPx()
val padBottom = 26.dp.toPx()
val w = size.width - padLeft - padRight
val h = size.height - padTop - padBottom
if (w <= 0 || h <= 0) return@Canvas
val t0 = points.first().timestamp
val t1 = points.last().timestamp
if (t1 <= t0) return@Canvas
val e2DataMax = maxOf(
points.maxOf { it.e2 },
if (options.showLabs) e2Labs.maxOfOrNull { it.value } ?: 0.0 else 0.0
)
val e2Max = niceCeil(maxOf(e2DataMax, 50.0))
val tDataMax = maxOf(
points.maxOf { it.t },
if (options.showLabs) {
tLabs.maxOfOrNull { com.hormonetrack.pk.PharmacokineticEngine.convertTToNgMl(it.value, it.unit) } ?: 0.0
} else 0.0
)
val tMax = niceCeil(tDataMax * 1.1)
fun xOf(ms: Long): Float = padLeft + w * ((ms - t0).toFloat() / (t1 - t0).toFloat())
fun yE2(v: Double): Float = padTop + h * (1f - (v / e2Max).toFloat())
fun yT(v: Double): Float = padTop + h * (1f - (v / tMax).toFloat())
drawGrid(gridColor, padLeft, padTop, w, h, rows = 4)
drawLeftYLabels(labelColor, e2Max, padLeft, padTop, h)
if (options.showT) drawRightYLabels(labelColor, tMax, padLeft + w, padTop, h)
drawXLabels(labelColor, t0, t1, padLeft, padTop + h, w)
val path = Path()
points.forEachIndexed { i, p ->
val x = xOf(p.timestamp)
val y = yE2(p.e2)
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
drawPath(path, ChartE2, style = Stroke(width = 2.5.dp.toPx()))
if (options.showT) {
val tPath = Path()
points.forEachIndexed { i, p ->
val x = xOf(p.timestamp)
val y = yT(p.t)
if (i == 0) tPath.moveTo(x, y) else tPath.lineTo(x, y)
}
drawPath(
tPath, ChartT,
style = Stroke(
width = 2.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 10f))
)
)
}
if (options.showLabs) {
e2Labs.forEach { lab ->
if (lab.timestamp in t0..t1) {
val c = Offset(xOf(lab.timestamp), yE2(lab.value))
drawCircle(LabDot, radius = 5.dp.toPx(), center = c)
drawDotLabel(lab.value, Offset(c.x, c.y - 10.dp.toPx()))
}
}
tLabs.forEach { lab ->
if (lab.timestamp in t0..t1) {
// labs may be in ng/dL or nmol/L: normalize to the model's ng/mL
val tVal = com.hormonetrack.pk.PharmacokineticEngine.convertTToNgMl(lab.value, lab.unit)
val c = Offset(xOf(lab.timestamp), yT(tVal))
drawRect(
LabDot,
topLeft = Offset(c.x - 4.dp.toPx(), c.y - 4.dp.toPx()),
size = Size(8.dp.toPx(), 8.dp.toPx())
)
drawDotLabel(tVal, Offset(c.x, c.y - 10.dp.toPx()))
}
}
}
if (options.nowMs in t0..t1) {
drawLine(
nowLineColor,
Offset(xOf(options.nowMs), padTop),
Offset(xOf(options.nowMs), padTop + h),
strokeWidth = 1.5.dp.toPx()
)
}
}
}
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)
}
}
private fun DrawScope.labelPaint(align: android.graphics.Paint.Align): android.graphics.Paint {
val c = Color(0xFF6B7280)
return android.graphics.Paint().apply {
color = android.graphics.Color.argb(
(c.alpha * 255).toInt(),
(c.red * 255).toInt(),
(c.green * 255).toInt(),
(c.blue * 255).toInt()
)
textSize = 10.dp.toPx()
textAlign = align
isAntiAlias = true
}
}
private fun DrawScope.drawLeftYLabels(color: Color, e2Max: Double, padLeft: Float, padTop: Float, h: Float) {
val paint = labelPaint(android.graphics.Paint.Align.RIGHT)
for (i in 0..4) {
val v = e2Max * i / 4
val y = padTop + h * (1f - i / 4f)
drawContext.canvas.nativeCanvas.drawText(formatValue(v), padLeft - 6.dp.toPx(), y + 4.dp.toPx(), paint)
}
}
private fun DrawScope.drawRightYLabels(color: Color, tMax: Double, xRight: Float, padTop: Float, h: Float) {
val paint = labelPaint(android.graphics.Paint.Align.LEFT)
for (i in 0..4) {
val v = tMax * i / 4
val y = padTop + h * (1f - i / 4f)
drawContext.canvas.nativeCanvas.drawText(formatValue(v), xRight + 6.dp.toPx(), y + 4.dp.toPx(), paint)
}
}
private fun DrawScope.drawXLabels(
color: Color, t0: Long, t1: Long,
padLeft: Float, yBottom: Float, w: Float
) {
val paint = labelPaint(android.graphics.Paint.Align.CENTER)
val spanH = (t1 - t0) / 3_600_000.0
val stepHours = when {
spanH <= 25 -> 6
spanH <= 25 * 7 -> 24
else -> 24 * 5
}
val fmt = if (spanH <= 25) SimpleDateFormat("HH'h'", Locale.getDefault())
else SimpleDateFormat("dd/MM", Locale.getDefault())
var tick = ceil(t0 / (stepHours * 3_600_000.0)).toLong() * stepHours * 3_600_000L
while (tick <= t1) {
val x = padLeft + w * ((tick - t0).toFloat() / (t1 - t0).toFloat())
drawContext.canvas.nativeCanvas.drawText(fmt.format(Date(tick)), x, yBottom + 16.dp.toPx(), paint)
tick += stepHours * 3_600_000L
}
}
private fun DrawScope.drawDotLabel(v: Double, c: Offset) {
val paint = labelPaint(android.graphics.Paint.Align.CENTER).apply {
color = android.graphics.Color.rgb(
(LabDot.red * 255).toInt(),
(LabDot.green * 255).toInt(),
(LabDot.blue * 255).toInt()
)
textSize = 9.dp.toPx()
isFakeBoldText = true
}
drawContext.canvas.nativeCanvas.drawText(formatValue(v), c.x, c.y, paint)
}
private fun formatValue(v: Double): String = when {
v >= 10 -> "%.0f".format(Locale.US, v)
v >= 1 -> "%.1f".format(Locale.US, v)
else -> "%.2f".format(Locale.US, v)
}

View File

@ -0,0 +1,103 @@
package com.hormonetrack.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateTimeField(
label: String,
value: LocalDateTime,
onChange: (LocalDateTime) -> Unit,
modifier: Modifier = Modifier
) {
val fmtDate = remember { SimpleDateFormat("EEE dd MMM yyyy", Locale.getDefault()) }
var showDate by remember { mutableStateOf(false) }
var showTime by remember { mutableStateOf(false) }
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { showDate = true }) {
Text(fmtDate.format(Date.from(value.atZone(ZoneId.systemDefault()).toInstant())))
}
Button(onClick = { showTime = true }) {
Text(String.format(Locale.getDefault(), "%02d:%02d", value.hour, value.minute))
}
}
if (showDate) {
val state = rememberDatePickerState(
initialSelectedDateMillis = value.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
)
DatePickerDialog(
onDismissRequest = { showDate = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let { ms ->
val d = Instant.ofEpochMilli(ms).atZone(ZoneId.systemDefault()).toLocalDate()
onChange(LocalDateTime.of(d, value.toLocalTime()))
}
showDate = false
}) { Text(stringResource(R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { showDate = false }) { Text(stringResource(R.string.cancel)) }
}
) {
DatePicker(state = state)
}
}
if (showTime) {
val state = rememberTimePickerState(
initialHour = value.hour,
initialMinute = value.minute,
is24Hour = true
)
DatePickerDialog(
onDismissRequest = { showTime = false },
confirmButton = {
TextButton(onClick = {
onChange(LocalDateTime.of(value.toLocalDate(), LocalTime.of(state.hour, state.minute)))
showTime = false
}) { Text(stringResource(R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { showTime = false }) { Text(stringResource(R.string.cancel)) }
}
) {
TimePicker(state = state)
}
}
}
fun localDateTimeFromMs(ms: Long): LocalDateTime =
Instant.ofEpochMilli(ms).atZone(ZoneId.systemDefault()).toLocalDateTime()
fun msFromLocalDateTime(dt: LocalDateTime): Long =
dt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()

View File

@ -0,0 +1,182 @@
package com.hormonetrack.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.Treatment
/**
* Create-or-edit dose dialog.
* existing == null → create mode (preselectedTreatmentId used);
* existing != null → edit mode: prefills amount/time/notes/ester and calls onConfirmEdit.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DoseDialog(
treatments: List<Treatment>,
preselectedTreatmentId: Long? = null,
existing: DoseLog? = null,
onDismiss: () -> Unit,
onConfirmCreate: (treatment: Treatment, amount: Double, timestampMs: Long, notes: String?, esterOverride: String?) -> Unit,
onConfirmEdit: (DoseLog) -> Unit
) {
val isEdit = existing != null
var selectedId by remember(existing, preselectedTreatmentId) {
mutableStateOf(
existing?.treatmentId
?: preselectedTreatmentId?.takeIf { id -> treatments.any { it.id == id } }
?: treatments.firstOrNull()?.id
)
}
val selected = treatments.firstOrNull { it.id == selectedId }
var amountText by remember(selected, existing) {
mutableStateOf(formatDose(existing?.doseAmount ?: selected?.doseAmount ?: 0.0))
}
var dateTime by remember(existing) {
mutableStateOf(
existing?.let { localDateTimeFromMs(it.timestamp) } ?: java.time.LocalDateTime.now()
)
}
var notes by remember(existing) { mutableStateOf(existing?.notes ?: "") }
var esterOverride by remember(existing, selected) {
mutableStateOf(existing?.esterType)
}
var expanded by remember { mutableStateOf(false) }
var esterExpanded by remember { mutableStateOf(false) }
if (treatments.isEmpty()) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.ok)) } },
title = { Text(stringResource(R.string.add_dose)) },
text = { Text(stringResource(R.string.no_treatment_hint)) }
)
return
}
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
val tr = selected ?: return@TextButton
val amount = amountText.replace(',', '.').toDoubleOrNull() ?: return@TextButton
if (isEdit && existing != null) {
onConfirmEdit(
existing.copy(
treatmentId = tr.id,
timestamp = msFromLocalDateTime(dateTime),
doseAmount = amount,
notes = notes.ifBlank { null },
esterType = esterOverride
)
)
} else {
onConfirmCreate(tr, amount, msFromLocalDateTime(dateTime), notes.ifBlank { null }, esterOverride)
}
}) { Text(stringResource(R.string.save)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
title = {
Text(stringResource(if (isEdit) R.string.edit_dose else R.string.add_dose))
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selected?.name ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.treatment_name)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
treatments.forEach { tr ->
DropdownMenuItem(
text = { Text("${tr.name} · ${formatDose(tr.doseAmount)} ${tr.doseUnit}") },
onClick = {
selectedId = tr.id
esterOverride = null
expanded = false
}
)
}
}
}
if (selected?.usesProfileModel == true) {
ExposedDropdownMenuBox(expanded = esterExpanded, onExpandedChange = { esterExpanded = it }) {
OutlinedTextField(
value = esterOverride?.let { esterLabel(it) }
?: stringResource(R.string.ester_default, selected.esterType),
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.ester)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = esterExpanded) },
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(expanded = esterExpanded, onDismissRequest = { esterExpanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.ester_default, selected.esterType)) },
onClick = { esterOverride = null; esterExpanded = false }
)
listOf(Esters.EV, Esters.EU, Esters.EEN).forEach { e ->
DropdownMenuItem(
text = { Text(esterLabel(e)) },
onClick = { esterOverride = e; esterExpanded = false }
)
}
}
}
}
OutlinedTextField(
value = amountText,
onValueChange = { amountText = it },
label = { Text(stringResource(R.string.dose_amount) + (selected?.let { " (${it.doseUnit})" } ?: "")) },
modifier = Modifier.fillMaxWidth()
)
DateTimeField(
label = stringResource(R.string.dose_time),
value = dateTime,
onChange = { dateTime = it },
modifier = Modifier
)
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text(stringResource(R.string.dose_notes)) },
modifier = Modifier.fillMaxWidth()
)
}
}
)
}
private fun esterLabel(ester: String): String = when (ester) {
Esters.EV -> "EV — valerate"
Esters.EU -> "EU — undecylate"
Esters.EEN -> "EEn — enanthate"
else -> ester
}
fun formatDose(d: Double): String =
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.1f".format(d)

View File

@ -0,0 +1,104 @@
package com.hormonetrack.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabDialog(
onDismiss: () -> Unit,
onConfirm: (marker: String, value: Double, unit: String, timestampMs: Long, notes: String) -> Unit
) {
val markerSuggestions = listOf("E2", "T", "PRL")
val unitSuggestions = listOf("pg/mL", "ng/mL", "ng/dL", "mIU/L")
var marker by remember { mutableStateOf("E2") }
var markerExpanded by remember { mutableStateOf(false) }
var valueText by remember { mutableStateOf("") }
var unit by remember { mutableStateOf("pg/mL") }
var unitExpanded by remember { mutableStateOf(false) }
var dateTime by remember { mutableStateOf(java.time.LocalDateTime.now()) }
var notes by remember { mutableStateOf("") }
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 })
}
}
}
OutlinedTextField(
value = valueText,
onValueChange = { valueText = it },
label = { Text(stringResource(R.string.lab_value)) },
modifier = Modifier.fillMaxWidth()
)
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 })
}
}
}
DateTimeField(
label = stringResource(R.string.lab_date),
value = dateTime,
onChange = { dateTime = it },
modifier = Modifier
)
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text(stringResource(R.string.dose_notes)) },
modifier = Modifier.fillMaxWidth()
)
}
}
)
}

View File

@ -0,0 +1,158 @@
package com.hormonetrack.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig
import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.ChartOptions
import com.hormonetrack.ui.components.CurveChart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChartScreen() {
val container = LocalAppContainer.current
val repo = container.repository
val treatments by repo.activeTreatments.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())
var rangeHours by remember { mutableLongStateOf(24L) }
var showT by remember { mutableStateOf(true) }
var showLabs by remember { mutableStateOf(true) }
val curve by produceState<List<LevelPoint>>(emptyList(), treatments, doseLogs, tConfig, rangeHours) {
withContext(Dispatchers.Default) {
val end = System.currentTimeMillis()
value = PharmacokineticEngine.computeCurve(
treatments, doseLogs,
startMs = end - rangeHours * 3_600_000L,
endMs = end,
tConfig = tConfig
)
}
}
val (a, b) = if (curve.size >= 2) {
curve.first().timestamp to curve.last().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 }
Column(
Modifier
.fillMaxSize()
.padding(16.dp)
) {
TopAppBar(
title = { Text(stringResource(R.string.nav_chart)) }
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = rangeHours == 24L,
onClick = { rangeHours = 24L },
label = { Text(stringResource(R.string.chart_24h)) }
)
FilterChip(
selected = rangeHours == 24L * 7,
onClick = { rangeHours = 24L * 7 },
label = { Text(stringResource(R.string.chart_7j)) }
)
FilterChip(
selected = rangeHours == 24L * 30,
onClick = { rangeHours = 24L * 30 },
label = { Text(stringResource(R.string.chart_30j)) }
)
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = showT,
onClick = { showT = !showT },
label = { Text("T") }
)
FilterChip(
selected = showLabs,
onClick = { showLabs = !showLabs },
label = { Text(stringResource(R.string.show_labs)) }
)
}
Spacer(Modifier.height(12.dp))
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
if (curve.isEmpty()) {
Text(
stringResource(R.string.no_data),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 32.dp)
)
} else {
CurveChart(
points = curve,
e2Labs = e2Labs,
tLabs = tLabs,
options = ChartOptions(showT = showT, showLabs = showLabs),
modifier = Modifier
.fillMaxWidth()
.height(320.dp)
)
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.legend_e2),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
if (showT) {
Text(
stringResource(R.string.legend_t),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary
)
}
if (showLabs) {
Text(
stringResource(R.string.legend_labs),
style = MaterialTheme.typography.labelMedium,
color = com.hormonetrack.ui.theme.LabDot
)
}
}
}
}
}
}

View File

@ -0,0 +1,181 @@
package com.hormonetrack.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.DoseDialog
import com.hormonetrack.ui.components.formatDose
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DosesScreen() {
val container = LocalAppContainer.current
val repo = container.repository
val doseLogs by repo.allDoseLogs.collectAsState(initial = emptyList())
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
val treatmentMap = remember(treatments) { treatments.associateBy { it.id } }
var showLogDialog by remember { mutableStateOf(false) }
var toEdit by remember { mutableStateOf<DoseLog?>(null) }
var toDelete by remember { mutableStateOf<DoseLog?>(null) }
val grouped = remember(doseLogs) {
doseLogs.groupBy { log ->
Instant.ofEpochMilli(log.timestamp).atZone(ZoneId.systemDefault()).toLocalDate()
}.toSortedMap(compareByDescending { it })
}
val timeFmt = remember { DateTimeFormatter.ofPattern("HH:mm") }
Scaffold(
floatingActionButton = {
FloatingActionButton(onClick = { showLogDialog = true }) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_dose))
}
}
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
TopAppBar(
title = { Text(stringResource(R.string.nav_doses)) }
)
if (doseLogs.isEmpty()) {
Text(
stringResource(R.string.no_data),
modifier = Modifier.padding(16.dp)
)
} else {
LazyColumn(Modifier.fillMaxSize()) {
grouped.forEach { (date, logs) ->
item(key = "header_${date}") {
Text(
date.toString(),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
items(logs, key = { it.id }) { log ->
val tr = treatmentMap[log.treatmentId]
val zone = ZoneId.systemDefault()
val time = timeFmt.format(Instant.ofEpochMilli(log.timestamp).atZone(zone))
ListItem(
modifier = Modifier.clickable { toEdit = log },
headlineContent = {
Text("${tr?.name ?: "?"} · ${formatDose(log.doseAmount)} ${tr?.doseUnit ?: ""}")
},
supportingContent = {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(time)
log.esterType?.takeIf { it.isNotEmpty() && it != "NONE" }?.let {
AssistChip(onClick = {}, label = { Text(it) })
}
log.notes?.takeIf { it.isNotBlank() }?.let {
Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
trailingContent = {
IconButton(onClick = { toDelete = log }) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
}
}
)
}
}
}
}
}
}
if (showLogDialog || toEdit != null) {
DoseDialog(
treatments = treatments,
preselectedTreatmentId = null,
existing = toEdit,
onDismiss = {
showLogDialog = false
toEdit = null
},
onConfirmCreate = { tr, amount, ts, notes, esterOverride ->
showLogDialog = false
CoroutineScope(Dispatchers.IO).launch {
repo.insertDoseLog(
DoseLog(
treatmentId = tr.id,
timestamp = ts,
doseAmount = amount,
notes = notes,
esterType = esterOverride
)
)
}
},
onConfirmEdit = { updated ->
toEdit = null
CoroutineScope(Dispatchers.IO).launch {
repo.updateDoseLog(updated)
}
}
)
}
toDelete?.let { log ->
AlertDialog(
onDismissRequest = { toDelete = null },
confirmButton = {
TextButton(onClick = {
toDelete = null
CoroutineScope(Dispatchers.IO).launch { repo.deleteDoseLog(log) }
}) { Text(stringResource(R.string.delete)) }
},
dismissButton = {
TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) }
},
title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.confirm_delete)) }
)
}
}

View File

@ -0,0 +1,321 @@
package com.hormonetrack.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.pk.LevelPoint
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig
import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.ChartOptions
import com.hormonetrack.ui.components.CurveChart
import com.hormonetrack.ui.components.DoseDialog
import com.hormonetrack.ui.components.formatDose
import com.hormonetrack.ui.theme.TransPink
import com.hormonetrack.ui.theme.TransSky
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.math.abs
private val HOUR_MS = 3_600_000L
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
openLogDoseForTreatmentId: Long?,
onOpenSettings: () -> Unit,
onOpenTreatment: (Long) -> Unit
) {
val container = LocalAppContainer.current
val repo = container.repository
val treatments by repo.activeTreatments.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())
var showLogDialog by remember { mutableStateOf(false) }
var preselectId by remember { mutableStateOf<Long?>(null) }
LaunchedEffect(openLogDoseForTreatmentId) {
openLogDoseForTreatmentId?.takeIf { it > 0 }?.let {
showLogDialog = true
preselectId = it
}
}
var tick by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(60_000)
tick++
}
}
val curve by produceState<List<LevelPoint>>(emptyList(), treatments, doseLogs, tConfig, tick) {
withContext(Dispatchers.Default) {
val end = System.currentTimeMillis()
value = PharmacokineticEngine.computeCurve(
treatments, doseLogs,
startMs = end - 24 * HOUR_MS,
endMs = end,
tConfig = tConfig
)
}
}
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
actions = {
IconButton(onClick = onOpenSettings) {
Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings))
}
}
)
Box(
Modifier
.fillMaxWidth()
.height(3.dp)
.background(Brush.horizontalGradient(listOf(TransSky, TransPink, TransSky)))
)
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Spacer(Modifier.height(4.dp))
NowLevelCard(curve)
NextDoseCard(treatments)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(Modifier.padding(12.dp)) {
Text(
stringResource(R.string.logged_today),
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(6.dp))
Row(
Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
treatments.forEach { tr ->
AssistChip(
onClick = {
preselectId = tr.id
showLogDialog = true
},
label = {
Text("${tr.name} · ${formatDose(tr.doseAmount)}${tr.doseUnit}")
}
)
}
FilledTonalIconButton(onClick = {
preselectId = null
showLogDialog = true
}) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_dose))
}
}
}
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(
stringResource(R.string.home_chart_title),
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(8.dp))
CurveChart(
points = curve,
e2Labs = labResults.filterInRange(curve, "E2"),
tLabs = labResults.filterInRange(curve, "T"),
options = ChartOptions(showT = true, showLabs = true),
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
)
}
}
Text(
stringResource(R.string.disclaimer),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(12.dp))
}
}
if (showLogDialog) {
DoseDialog(
treatments = treatments,
preselectedTreatmentId = preselectId,
existing = null,
onDismiss = { showLogDialog = false },
onConfirmCreate = { tr, amount, ts, notes, esterOverride ->
showLogDialog = false
CoroutineScope(Dispatchers.IO).launch {
repo.insertDoseLog(
com.hormonetrack.data.model.DoseLog(
treatmentId = tr.id,
timestamp = ts,
doseAmount = amount,
notes = notes,
esterType = esterOverride
)
)
}
},
onConfirmEdit = { }
)
}
}
private fun List<LabResult>.filterInRange(curve: List<LevelPoint>, marker: String): List<LabResult> {
if (curve.size < 2) return emptyList()
val (a, b) = curve.first().timestamp to curve.last().timestamp
return filter { it.marker.equals(marker, true) && it.timestamp in a..b }
}
@Composable
private fun NowLevelCard(curve: List<LevelPoint>) {
val now = curve.lastOrNull()
val sixHAgo = curve.firstOrNull { p ->
(curve.last().timestamp - p.timestamp) >= 6 * HOUR_MS
}
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Column(Modifier.padding(16.dp)) {
Text(
stringResource(R.string.current_level),
style = MaterialTheme.typography.labelMedium
)
if (now == null) {
Spacer(Modifier.height(4.dp))
Text(stringResource(R.string.no_data))
} else {
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
"≈ " + "%.0f".format(now.e2),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(" pg/mL", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.width(16.dp))
Text(
"T ≈ " + "%.2f".format(now.t) + " ng/mL",
style = MaterialTheme.typography.bodyLarge
)
}
sixHAgo?.let {
val delta = now.e2 - it.e2
val arrow = if (delta >= 0) "↗" else "↘"
Text(
stringResource(R.string.delta_6h, arrow, "%.0f".format(abs(delta))),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
@Composable
private fun NextDoseCard(treatments: List<Treatment>) {
val next = PharmacokineticEngine.nextReminderFireMs(treatments)
if (next == null) return
val nextTreatment = treatments.filter { it.reminderEnabled }
.minByOrNull { tr ->
val h = tr.reminderHour ?: 23
val m = tr.reminderMinute ?: 59
val candidate = (h * 60L + m) * 60_000L
val nowMs = System.currentTimeMillis()
val nowOfDay = nowMs % (24 * HOUR_MS)
if (candidate >= nowOfDay) candidate else candidate + 24 * HOUR_MS
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(
stringResource(R.string.next_dose),
style = MaterialTheme.typography.labelMedium
)
Spacer(Modifier.height(4.dp))
val deltaMs = next - System.currentTimeMillis()
val h = deltaMs / HOUR_MS
val m = (deltaMs % HOUR_MS) / 60_000L
val timeStr = DateTimeFormatter.ofPattern("HH:mm")
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochMilli(next))
Text(
if (h > 0) {
String.format(Locale.getDefault(), "%dh%02d · %s (%s)", h, m, timeStr, nextTreatment?.name ?: "")
} else {
String.format(Locale.getDefault(), "%d min · %s (%s)", m, timeStr, nextTreatment?.name ?: "")
},
style = MaterialTheme.typography.titleMedium
)
}
}
}

View File

@ -0,0 +1,150 @@
package com.hormonetrack.ui.screens
import androidx.compose.foundation.layout.Arrangement
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
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.LabDialog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabsScreen() {
val container = LocalAppContainer.current
val repo = container.repository
val labs by repo.allLabResults.collectAsState(initial = emptyList())
var showLabDialog by remember { mutableStateOf(false) }
var toDelete by remember { mutableStateOf<LabResult?>(null) }
val grouped = remember(labs) {
labs.sortedBy { it.marker.lowercase() }.groupBy { it.marker.uppercase() }
}
val fmt = remember {
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault())
}
Scaffold(
floatingActionButton = {
FloatingActionButton(onClick = { showLabDialog = true }) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_lab))
}
}
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
TopAppBar(
title = { Text(stringResource(R.string.nav_labs)) }
)
if (labs.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(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))
}
}
)
}
}
}
}
}
}
if (showLabDialog) {
LabDialog(
onDismiss = { showLabDialog = false },
onConfirm = { marker, value, unit, ts, notes ->
showLabDialog = false
CoroutineScope(Dispatchers.IO).launch {
repo.insertLabResult(
LabResult(
marker = marker,
value = value,
unit = unit,
timestamp = ts,
notes = notes.ifBlank { null }
)
)
}
}
)
}
toDelete?.let { lab ->
AlertDialog(
onDismissRequest = { toDelete = null },
confirmButton = {
TextButton(onClick = {
toDelete = null
CoroutineScope(Dispatchers.IO).launch { repo.deleteLabResult(lab) }
}) { Text(stringResource(R.string.delete)) }
},
dismissButton = {
TextButton(onClick = { toDelete = null }) { Text(stringResource(R.string.cancel)) }
},
title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.confirm_delete)) }
)
}
}

View File

@ -0,0 +1,347 @@
package com.hormonetrack.ui.screens
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import com.hormonetrack.R
import com.hormonetrack.data.backup.BackupManager
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.pk.TConfig
import com.hormonetrack.reminder.AlarmScheduler
import com.hormonetrack.ui.LocalAppContainer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBack: () -> Unit) {
val container = LocalAppContainer.current
val repo = container.repository
val context = LocalContext.current
val scheduler = remember { AlarmScheduler(context) }
val tConfig by container.settings.tConfig.collectAsState(initial = TConfig())
val currentLanguage by container.settings.language.collectAsState(initial = "system")
var tBaseText by remember(tConfig.base) { mutableStateOf(formatDoubles(tConfig.base)) }
var tFloorText by remember(tConfig.floor) { mutableStateOf(formatDoubles(tConfig.floor)) }
var tKText by remember(tConfig.k) { mutableStateOf(formatDoubles(tConfig.k)) }
var showImportConfirm by remember { mutableStateOf(false) }
var importJson by remember { mutableStateOf<String?>(null) }
var message by remember { mutableStateOf<String?>(null) }
val exportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri ?: return@rememberLauncherForActivityResult
CoroutineScope(Dispatchers.IO).launch {
val json = BackupManager.exportJson(repo, tConfig)
val ok = BackupManager.writeBackup(context, uri, json)
withContext(Dispatchers.Main) {
message = context.getString(
if (ok) R.string.export_ok else R.string.export_fail
)
}
}
}
val importLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri ?: return@rememberLauncherForActivityResult
CoroutineScope(Dispatchers.IO).launch {
val json = BackupManager.readBackup(context, uri)
withContext(Dispatchers.Main) {
if (json != null) {
importJson = json
showImportConfirm = true
} else {
message = context.getString(R.string.import_fail)
}
}
}
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
) {
TopAppBar(
title = { Text(stringResource(R.string.settings)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
}
)
Spacer(Modifier.height(8.dp))
message?.let {
Text(it, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
}
// --- Language ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.language), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = currentLanguage == "system",
onClick = {
CoroutineScope(Dispatchers.IO).launch {
container.settings.setLanguage("system")
withContext(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.getEmptyLocaleList()
)
}
}
},
label = { Text(stringResource(R.string.language_system)) }
)
FilterChip(
selected = currentLanguage == "fr",
onClick = {
CoroutineScope(Dispatchers.IO).launch {
container.settings.setLanguage("fr")
withContext(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags("fr")
)
}
}
},
label = { Text("Français") }
)
FilterChip(
selected = currentLanguage == "en",
onClick = {
CoroutineScope(Dispatchers.IO).launch {
container.settings.setLanguage("en")
withContext(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags("en")
)
}
}
},
label = { Text("English") }
)
}
}
}
Spacer(Modifier.height(12.dp))
// --- Testosterone model ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.t_model_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.t_model_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = tBaseText,
onValueChange = { tBaseText = it },
label = { Text(stringResource(R.string.t_base)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = tFloorText,
onValueChange = { tFloorText = it },
label = { Text(stringResource(R.string.t_floor)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = tKText,
onValueChange = { tKText = it },
label = { Text("k") },
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val base = tBaseText.replace(',', '.').toDoubleOrNull() ?: return@Button
val floor = tFloorText.replace(',', '.').toDoubleOrNull() ?: return@Button
val k = tKText.replace(',', '.').toDoubleOrNull() ?: return@Button
CoroutineScope(Dispatchers.IO).launch {
container.settings.setTConfig(TConfig(base, floor, k))
withContext(Dispatchers.Main) {
message = context.getString(R.string.saved)
}
}
}) { Text(stringResource(R.string.save)) }
OutlinedButton(onClick = {
CoroutineScope(Dispatchers.IO).launch {
val labs = repo.allLabResultsOnce().filter { it.marker.equals("T", true) }
val trs = repo.allTreatmentsOnce()
val doses = repo.allDoseLogsOnce()
val calibrated = PharmacokineticEngine.computeTConfigCalibration(
labs, trs, doses, tConfig
)
calibrated?.let {
container.settings.setTConfig(it)
withContext(Dispatchers.Main) {
message = context.getString(R.string.t_calibrated)
}
}
}
}) { Text(stringResource(R.string.calibrate_from_labs)) }
}
}
}
Spacer(Modifier.height(12.dp))
// --- Reminders / exact alarms ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.reminders_section), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
if (!scheduler.canScheduleExact()) {
Text(
stringResource(R.string.exact_alarm_needed),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(6.dp))
Button(onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
}
}) { Text(stringResource(R.string.grant_exact_alarm)) }
} else {
Text(stringResource(R.string.exact_alarm_ok), style = MaterialTheme.typography.bodyMedium)
}
}
}
Spacer(Modifier.height(12.dp))
// --- Backup ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.backup_section), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.backup_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
exportLauncher.launch("hormonetrack-backup-$date.json")
}) { Text(stringResource(R.string.export_json)) }
OutlinedButton(onClick = {
importLauncher.launch(arrayOf("application/json"))
}) { Text(stringResource(R.string.import_json)) }
}
}
}
Spacer(Modifier.height(12.dp))
// --- About ---
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.disclaimer),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
stringResource(R.string.models_credit),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.height(32.dp))
}
if (showImportConfirm) {
AlertDialog(
onDismissRequest = { showImportConfirm = false },
confirmButton = {
TextButton(onClick = {
showImportConfirm = false
val json = importJson ?: return@TextButton
CoroutineScope(Dispatchers.IO).launch {
val result = try {
BackupManager.importJson(repo, json)
} catch (e: Exception) {
null
}
withContext(Dispatchers.Main) {
message = if (result != null) {
context.getString(R.string.import_ok, result.treatments, result.doseLogs, result.labResults)
} else {
context.getString(R.string.import_fail)
}
}
}
}) { Text(stringResource(R.string.import_confirm)) }
},
dismissButton = {
TextButton(onClick = { showImportConfirm = false }) { Text(stringResource(R.string.cancel)) }
},
title = { Text(stringResource(R.string.import_title)) },
text = { Text(stringResource(R.string.import_warning)) }
)
}
}
private fun formatDoubles(d: Double): String =
if (d == d.toLong().toDouble()) d.toLong().toString() else "%.3f".format(Locale.US, d)

View File

@ -0,0 +1,477 @@
package com.hormonetrack.ui.screens
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.PKPresets
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import com.hormonetrack.pk.PharmacokineticEngine
import com.hormonetrack.reminder.AlarmScheduler
import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.formatDose
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.LocalTime
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TreatmentEditorScreen(treatmentId: Long, onDone: () -> Unit) {
val container = LocalAppContainer.current
val repo = container.repository
val context = LocalContext.current
val scheduler = remember { AlarmScheduler(context) }
var name by remember { mutableStateOf("") }
var type by remember { mutableStateOf(TreatmentType.ESTRADIOL) }
var route by remember { mutableStateOf(AdministrationRoute.INJECTION_IM) }
var ester by remember { mutableStateOf(Esters.EV) }
var model by remember { mutableStateOf(PKModels.ESTRANNAISE) }
var doseText by remember { mutableStateOf("4") }
var unit by remember { mutableStateOf("mg") }
var tmaxText by remember { mutableStateOf("46") }
var thalfText by remember { mutableStateOf("100") }
var bioText by remember { mutableStateOf("1.0") }
var scaleText by remember { mutableStateOf("1") }
var reminderEnabled by remember { mutableStateOf(false) }
var reminderTime by remember { mutableStateOf(LocalTime.of(12, 0)) }
var active by remember { mutableStateOf(true) }
var showDeleteConfirm by remember { mutableStateOf(false) }
var showPresetMenu by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(treatmentId > 0) }
var loadedCreatedAt by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(treatmentId) {
if (treatmentId > 0) {
repo.getTreatmentById(treatmentId)?.let { tr ->
name = tr.name
type = tr.type
route = tr.route
ester = tr.esterType
model = tr.pkModel
doseText = formatDose(tr.doseAmount)
unit = tr.doseUnit
tmaxText = formatDose(tr.absorptionHours.toDouble())
thalfText = formatDose(tr.eliminationHalfLifeHours.toDouble())
bioText = formatDose(tr.bioavailabilityFraction.toDouble())
scaleText = formatDose(tr.scaleFactor)
reminderEnabled = tr.reminderEnabled
reminderTime = LocalTime.of(tr.reminderHour ?: 12, tr.reminderMinute ?: 0)
active = tr.isActive
loadedCreatedAt = tr.createdAt
}
loading = false
}
}
fun buildTreatment(id: Long): Treatment? {
val dose = doseText.replace(',', '.').toDoubleOrNull() ?: return null
val tmax = tmaxText.replace(',', '.').toFloatOrNull() ?: 4f
val thalf = thalfText.replace(',', '.').toFloatOrNull() ?: 24f
val bio = bioText.replace(',', '.').toFloatOrNull() ?: 1f
val scale = scaleText.replace(',', '.').toDoubleOrNull() ?: 1.0
if (name.isBlank() || dose <= 0.0) return null
return Treatment(
id = id,
name = name.trim(),
type = type,
route = route,
doseAmount = dose,
doseUnit = unit.trim().ifBlank { "mg" },
isActive = active,
esterType = if (PharmacokineticEngine.isInjectionRoute(route)) ester else Esters.NONE,
pkModel = model,
absorptionHours = tmax,
eliminationHalfLifeHours = thalf,
bioavailabilityFraction = bio.coerceIn(0.01f, 1f),
scaleFactor = scale.coerceAtLeast(0.01),
reminderEnabled = reminderEnabled,
reminderHour = if (reminderEnabled) reminderTime.hour else null,
reminderMinute = if (reminderEnabled) reminderTime.minute else null,
createdAt = loadedCreatedAt
)
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
) {
TopAppBar(
title = {
Text(
if (treatmentId > 0) stringResource(R.string.edit_treatment)
else stringResource(R.string.add_treatment)
)
},
navigationIcon = {
IconButton(onClick = onDone) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
}
)
Spacer(Modifier.height(8.dp))
if (treatmentId <= 0) {
ExposedDropdownMenuBox(expanded = showPresetMenu, onExpandedChange = { showPresetMenu = it }) {
OutlinedTextField(
value = "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.select_preset)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPresetMenu) },
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(expanded = showPresetMenu, onDismissRequest = { showPresetMenu = false }) {
PKPresets.all.forEach { preset ->
DropdownMenuItem(
text = { Text(stringResource(preset.nameRes)) },
onClick = {
name = context.getString(preset.nameRes)
type = preset.type
route = preset.route
ester = preset.esterType
model = preset.pkModel
doseText = formatDose(preset.defaultDoseAmount)
unit = preset.defaultDoseUnit
tmaxText = formatDose(preset.absorptionHours.toDouble())
thalfText = formatDose(preset.eliminationHalfLifeHours.toDouble())
bioText = formatDose(preset.bioavailabilityFraction.toDouble())
showPresetMenu = false
}
)
}
}
}
Spacer(Modifier.height(10.dp))
}
if (!loading) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.treatment_name)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
DropdownField(
label = stringResource(R.string.treatment_type),
selectedLabel = stringResource(typeLabelRes(type)),
options = TreatmentType.entries.map { it to stringResource(typeLabelRes(it)) },
onSelect = { type = it }
)
Spacer(Modifier.height(8.dp))
DropdownField(
label = stringResource(R.string.treatment_route),
selectedLabel = stringResource(routeLabelRes(route)),
options = AdministrationRoute.entries.map { it to stringResource(routeLabelRes(it)) },
onSelect = { route = it }
)
Spacer(Modifier.height(8.dp))
if (PharmacokineticEngine.isInjectionRoute(route)) {
DropdownField(
label = stringResource(R.string.ester),
selectedLabel = ester,
options = listOf(Esters.EV, Esters.EU, Esters.EEN).map { it to it },
onSelect = { ester = it }
)
Spacer(Modifier.height(8.dp))
DropdownField(
label = stringResource(R.string.pk_model),
selectedLabel = stringResource(
if (model == PKModels.TRANSFEM_SCIENCE) R.string.model_tfs else R.string.model_ese
),
options = listOf(
PKModels.ESTRANNAISE to stringResource(R.string.model_ese),
PKModels.TRANSFEM_SCIENCE to stringResource(R.string.model_tfs)
),
onSelect = { model = it }
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = tmaxText,
onValueChange = { tmaxText = it },
label = { Text(stringResource(R.string.pk_absorption)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = thalfText,
onValueChange = { thalfText = it },
label = { Text(stringResource(R.string.pk_halflife)) },
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = bioText,
onValueChange = { bioText = it },
label = { Text(stringResource(R.string.pk_bioavail)) },
modifier = Modifier.fillMaxWidth()
)
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = doseText,
onValueChange = { doseText = it },
label = { Text(stringResource(R.string.default_dose)) },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = unit,
onValueChange = { unit = it },
label = { Text(stringResource(R.string.dose_unit)) },
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(8.dp))
if (type == TreatmentType.ESTRADIOL) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.calibration_title), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
OutlinedTextField(
value = scaleText,
onValueChange = { scaleText = it },
label = { Text(stringResource(R.string.scale_factor)) },
modifier = Modifier.fillMaxWidth()
)
Text(
stringResource(R.string.calibration_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
FilledTonalButton(onClick = {
CoroutineScope(Dispatchers.IO).launch {
val doses = repo.allDoseLogsOnce()
val labs = repo.allLabResultsOnce().filter { it.marker.equals("E2", true) }
buildTreatment(treatmentId)?.let { tmp ->
val sf = PharmacokineticEngine.computeScaleFactor(
tmp.copy(scaleFactor = 1.0), doses, labs
)
sf?.let {
withContext(Dispatchers.Main) {
scaleText = "%.2f".format(Locale.US, it)
}
}
}
}
}) { Text(stringResource(R.string.calibrate_from_labs)) }
}
}
Spacer(Modifier.height(8.dp))
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.reminder), style = MaterialTheme.typography.titleMedium)
Switch(checked = reminderEnabled, onCheckedChange = { reminderEnabled = it })
}
if (reminderEnabled) {
Spacer(Modifier.height(6.dp))
FilledTonalButton(onClick = { showTimePicker = true }) {
Text(
String.format(Locale.getDefault(), "%02d:%02d", reminderTime.hour, reminderTime.minute)
)
}
val scheduler2 = AlarmScheduler(context)
if (!scheduler2.canScheduleExact()) {
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.exact_alarm_needed),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.startActivity(
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
)
}
}) { Text(stringResource(R.string.grant_exact_alarm)) }
}
}
}
}
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.active))
Switch(checked = active, onCheckedChange = { active = it })
}
Spacer(Modifier.height(12.dp))
Button(
onClick = {
val tr = buildTreatment(if (treatmentId > 0) treatmentId else 0L) ?: return@Button
CoroutineScope(Dispatchers.IO).launch {
val savedId: Long = if (tr.id > 0) {
repo.updateTreatment(tr)
tr.id
} else {
repo.insertTreatment(tr)
}
val saved = tr.copy(id = savedId)
if (saved.reminderEnabled) {
scheduler.scheduleDaily(saved)
} else {
scheduler.cancel(savedId)
}
withContext(Dispatchers.Main) { onDone() }
}
},
modifier = Modifier.fillMaxWidth()
) { Text(stringResource(R.string.save)) }
if (treatmentId > 0) {
Spacer(Modifier.height(8.dp))
TextButton(
onClick = { showDeleteConfirm = true },
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error)
}
}
Spacer(Modifier.height(32.dp))
}
}
if (showTimePicker) {
val state = rememberTimePickerState(
initialHour = reminderTime.hour,
initialMinute = reminderTime.minute,
is24Hour = true
)
AlertDialog(
onDismissRequest = { showTimePicker = false },
confirmButton = {
TextButton(onClick = {
reminderTime = LocalTime.of(state.hour, state.minute)
showTimePicker = false
}) { Text(stringResource(R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) { Text(stringResource(R.string.cancel)) }
},
title = { Text(stringResource(R.string.reminder_time)) },
text = { TimePicker(state = state) }
)
}
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
confirmButton = {
TextButton(onClick = {
showDeleteConfirm = false
scheduler.cancel(treatmentId)
CoroutineScope(Dispatchers.IO).launch {
repo.getTreatmentById(treatmentId)?.let { repo.deleteTreatment(it) }
withContext(Dispatchers.Main) { onDone() }
}
}) { Text(stringResource(R.string.delete)) }
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) { Text(stringResource(R.string.cancel)) }
},
title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.confirm_delete_treatment)) }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> DropdownField(
label: String,
selectedLabel: String,
options: List<Pair<T, String>>,
onSelect: (T) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (value, labelRes) ->
DropdownMenuItem(
text = { Text(labelRes) },
onClick = {
onSelect(value)
expanded = false
}
)
}
}
}
}

View File

@ -0,0 +1,158 @@
package com.hormonetrack.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hormonetrack.R
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.ui.LocalAppContainer
import com.hormonetrack.ui.components.formatDose
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TreatmentsScreen(
onEdit: (Long) -> Unit,
onNew: () -> Unit
) {
val container = LocalAppContainer.current
val repo = container.repository
val treatments by repo.allTreatments.collectAsState(initial = emptyList())
Scaffold(
floatingActionButton = {
FloatingActionButton(onClick = onNew) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_treatment))
}
}
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
TopAppBar(
title = { Text(stringResource(R.string.nav_treatments)) }
)
LazyColumn(Modifier.fillMaxSize()) {
items(treatments, key = { it.id }) { tr ->
TreatmentCard(tr, onClick = { onEdit(tr.id) })
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TreatmentCard(tr: Treatment, onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
) {
Column(Modifier.padding(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
tr.name,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
if (!tr.isActive) {
Text(
stringResource(R.string.inactive),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium
)
}
}
Text(
stringResource(routeLabelRes(tr.route)) + " · " + formatDose(tr.doseAmount) + " " + tr.doseUnit,
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.width(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (tr.usesProfileModel) {
AssistChip(
onClick = {},
label = {
Text(
tr.esterType + " · " + stringResource(
if (tr.pkModel == PKModels.TRANSFEM_SCIENCE) R.string.model_tfs
else R.string.model_ese
)
)
}
)
} else {
AssistChip(
onClick = {},
label = {
Text(
stringResource(R.string.pk_absorption) + " " + formatDose(tr.absorptionHours.toDouble()) + "h"
)
}
)
}
if (tr.scaleFactor != 1.0) {
AssistChip(
onClick = {},
label = { Text("×" + "%.2f".format(tr.scaleFactor)) }
)
}
if (tr.reminderEnabled) {
AssistChip(
onClick = {},
label = {
Text(
String.format(
java.util.Locale.getDefault(), "⏰ %02d:%02d",
tr.reminderHour ?: 0, tr.reminderMinute ?: 0
)
)
}
)
}
}
}
}
}
fun routeLabelRes(route: com.hormonetrack.data.model.AdministrationRoute): Int = when (route) {
com.hormonetrack.data.model.AdministrationRoute.ORAL -> R.string.route_oral
com.hormonetrack.data.model.AdministrationRoute.TRANSDERMAL_GEL -> R.string.route_gel
com.hormonetrack.data.model.AdministrationRoute.TRANSDERMAL_PATCH -> R.string.route_patch
com.hormonetrack.data.model.AdministrationRoute.INJECTION_IM -> R.string.route_injection_im
com.hormonetrack.data.model.AdministrationRoute.INJECTION_SUBCUT -> R.string.route_injection_sc
else -> R.string.route_other
}
fun typeLabelRes(type: com.hormonetrack.data.model.TreatmentType): Int = when (type) {
com.hormonetrack.data.model.TreatmentType.ESTRADIOL -> R.string.type_estradiol
com.hormonetrack.data.model.TreatmentType.ANTI_ANDROGEN -> R.string.type_antiandrogen
com.hormonetrack.data.model.TreatmentType.PROGESTOGEN -> R.string.type_progestogen
else -> R.string.type_other
}

View File

@ -0,0 +1,18 @@
package com.hormonetrack.ui.theme
import androidx.compose.ui.graphics.Color
val BluePrimary = Color(0xFF4F5BD5)
val BluePrimaryDark = Color(0xFF3A44A8)
val BlueContainer = Color(0xFFDEE0FF)
val PinkSecondary = Color(0xFFD6589E)
val PinkContainer = Color(0xFFFFD9EC)
val TransSky = Color(0xFF5BCEFA)
val TransPink = Color(0xFFF5A9B8)
val TealTertiary = Color(0xFF2FA48D)
val TealContainer = Color(0xFFBFF0E4)
val ChartE2 = Color(0xFF4F5BD5)
val ChartT = Color(0xFFD6589E)
val LabDot = Color(0xFFE67E22)
val GoodGreen = Color(0xFF2E7D32)
val WarnOrange = Color(0xFFB26A00)

View File

@ -0,0 +1,48 @@
package com.hormonetrack.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme(
primary = BluePrimary,
onPrimary = androidx.compose.ui.graphics.Color.White,
primaryContainer = BlueContainer,
secondary = PinkSecondary,
secondaryContainer = PinkContainer,
tertiary = TealTertiary,
tertiaryContainer = TealContainer
)
private val DarkColors = darkColorScheme(
primary = androidx.compose.ui.graphics.Color(0xFFBAC0FF),
secondary = androidx.compose.ui.graphics.Color(0xFFFFB1D2),
tertiary = androidx.compose.ui.graphics.Color(0xFF7CD5C1)
)
@Composable
fun HormoneTrackTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@ -0,0 +1,15 @@
package com.hormonetrack.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
headlineMedium = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 26.sp),
titleLarge = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 20.sp),
titleMedium = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp),
bodyLarge = TextStyle(fontSize = 16.sp),
bodyMedium = TextStyle(fontSize = 14.sp),
labelMedium = TextStyle(fontSize = 12.sp)
)

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M30,54 L42,54 L48,38 L58,70 L64,54 L78,54" />
<path
android:fillColor="#F5A9B8"
android:pathData="M54,76 C46,68 38,62 38,54 C38,48 42,44 48,44 C51,44 53,46 54,48 C55,46 57,44 60,44 C66,44 70,48 70,54 C70,62 62,68 54,76 Z" />
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2C9.8,2 8,3.8 8,6C8,6.7 8.2,7.4 8.5,8H4C3.4,8 3,8.4 3,9V15C3,15.6 3.4,16 4,16H6V20C6,20.6 6.4,21 7,21H17C17.6,21 18,20.6 18,20V16H20C20.6,16 21,15.6 21,15V9C21,8.4 20.6,8 20,8H15.5C15.8,7.4 16,6.7 16,6C16,3.8 14.2,2 12,2M12,4C13.1,4 14,4.9 14,6C14,7.1 13.1,8 12,8C10.9,8 10,7.1 10,6C10,4.9 10.9,4 12,4Z" />
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Suivi Hormonal</string>
<!-- Navigation -->
<string name="nav_home">Accueil</string>
<string name="nav_chart">Graphiques</string>
<string name="nav_doses">Doses</string>
<string name="nav_labs">Analyses</string>
<string name="nav_treatments">Traitements</string>
<string name="settings">Paramètres</string>
<string name="back">Retour</string>
<string name="ok">OK</string>
<!-- Home -->
<string name="current_level">Niveau actuel estimé</string>
<string name="no_data">Pas encore de données — crée un traitement et logue une dose.</string>
<string name="logged_today">Log rapide</string>
<string name="next_dose">Prochaine dose</string>
<string name="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="disclaimer">Les courbes sont des estimations pharmacocinétiques à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes prises de sang et aux consignes de ton endocrinologue.</string>
<!-- Charts -->
<string name="chart_24h">24 h</string>
<string name="chart_7j">7 jours</string>
<string name="chart_30j">30 jours</string>
<string name="show_labs">Analyses</string>
<string name="legend_e2">— E2 estimé (pg/mL, axe gauche)</string>
<string name="legend_t">-- T estimée (ng/mL, axe droit)</string>
<string name="legend_labs">● Résultats de prise de sang</string>
<!-- Doses -->
<string name="add_dose">Ajouter une dose</string>
<string name="edit_dose">Modifier la dose</string>
<string name="ester_default">Défaut (%1$s)</string>
<string name="dose_amount">Dose</string>
<string name="dose_time">Heure</string>
<string name="dose_notes">Notes</string>
<string name="delete">Supprimer</string>
<string name="confirm_delete">Supprimer cette entrée ?</string>
<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="lab_marker">Marqueur (ex : E2, T)</string>
<string name="lab_value">Valeur</string>
<string name="lab_unit">Unité (ex : pg/mL, ng/mL)</string>
<string name="lab_date">Date</string>
<!-- Treatments -->
<string name="add_treatment">Ajouter un traitement</string>
<string name="edit_treatment">Modifier le traitement</string>
<string name="treatment_name">Nom du traitement</string>
<string name="treatment_type">Type</string>
<string name="treatment_route">Voie d\'administration</string>
<string name="default_dose">Dose standard</string>
<string name="dose_unit">Unité</string>
<string name="active">Actif</string>
<string name="inactive">inactif</string>
<string name="no_treatment_hint">Crée d\'abord un traitement (onglet Traitements).</string>
<string name="type_estradiol">Œstradiol</string>
<string name="type_antiandrogen">Anti-androgène</string>
<string name="type_progestogen">Progestatif</string>
<string name="type_other">Autre</string>
<string name="route_oral">Orale</string>
<string name="route_gel">Gel transdermique</string>
<string name="route_patch">Patch transdermique</string>
<string name="route_injection_im">Injection IM</string>
<string name="route_injection_sc">Injection SC</string>
<string name="route_other">Autre voie</string>
<string name="ester">Ester</string>
<string name="pk_model">Modèle PK</string>
<string name="model_ese">Estrannaise</string>
<string name="model_tfs">Transfem Science</string>
<string name="select_preset">Choisir un modèle (optionnel)</string>
<string name="pk_absorption">Temps jusqu\'au pic (h)</string>
<string name="pk_halflife">Demi-vie (h)</string>
<string name="pk_bioavail">Biodisponibilité (0–1)</string>
<string name="calibration_title">Calibration</string>
<string name="scale_factor">Facteur d\'échelle</string>
<string name="calibration_hint">Le facteur d\'échelle ajuste le modèle à ton corps, comme le « Scale factor » de ton tableur. Il se calcule comme médiane(valeur lab ÷ prédiction du modèle).</string>
<string name="calibrate_from_labs">Calibrer avec les analyses</string>
<string name="save">Enregistrer</string>
<string name="cancel">Annuler</string>
<!-- Reminders -->
<string name="reminder">Rappel</string>
<string name="reminder_time">Heure du rappel</string>
<string name="reminders_section">Rappels &amp; alarmes</string>
<string name="exact_alarm_needed">Les alarmes exactes ne sont pas accordées : les rappels pourraient être retardés de quelques minutes.</string>
<string name="exact_alarm_ok">Alarmes exactes accordées. Les rappels s\'afficheront sur ta montre via les notifications Huawei Health.</string>
<string name="grant_exact_alarm">Accorder les alarmes exactes</string>
<string name="action_taken">Pris</string>
<string name="action_snooze_1h">Reporter 1 h</string>
<string name="reminder_title">Rappel : %1$s</string>
<string name="reminder_title_plain">Rappel de traitement</string>
<string name="reminder_text">C\'est l\'heure de prendre ton traitement</string>
<string name="reminder_text_with_dose">Il est temps de prendre ta dose : %1$s</string>
<string name="notification_channel_name">Rappels de traitement</string>
<string name="notification_channel_desc">Notifications pour les rappels de prise de traitement</string>
<!-- Settings -->
<string name="language">Langue</string>
<string name="language_system">Système</string>
<string name="t_model_title">Estimation de la testostérone</string>
<string name="t_model_hint">Modèle empirique : T = plancher + (base − plancher) ÷ (1 + k·E2). Unités : ng/mL. Utilise tes résultats T pour calibrer k.</string>
<string name="t_base">Base</string>
<string name="t_floor">Plancher</string>
<string name="t_calibrated">k calibré avec tes analyses T.</string>
<string name="saved">Enregistré</string>
<string name="backup_section">Sauvegarde (JSON)</string>
<string name="backup_hint">Exporte toutes les données (traitements, doses, analyses, réglages T) vers un fichier JSON, et restaure depuis un fichier de sauvegarde.</string>
<string name="export_json">Exporter</string>
<string name="import_json">Importer</string>
<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_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="about_title">À propos</string>
<string name="models_credit">Profils PK issus des modèles d\'injection Estrannaise (EstraNase) et Transfem Science, extraits de Estrogen.ods (tables horaires D, k1–k3).</string>
<!-- Presets -->
<string name="preset_ev_ese">Injection EV — Estrannaise</string>
<string name="preset_eu_ese">Injection EU — Estrannaise</string>
<string name="preset_een_ese">Injection EEn — Estrannaise</string>
<string name="preset_ev_tfs">Injection EV — Transfem Science</string>
<string name="preset_eu_tfs">Injection EU — Transfem Science</string>
<string name="preset_een_tfs">Injection EEn — Transfem Science</string>
<string name="preset_e2_gel">E2 gel transdermique</string>
<string name="preset_e2_patch">E2 patch</string>
<string name="preset_e2_oral">E2 orale</string>
<string name="preset_cpa">Acétate de cyprotérone (CPA)</string>
<string name="preset_spiro">Spironolactone</string>
<string name="preset_bica">Bicalutamide</string>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#4F5BD5</color>
</resources>

View File

@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">HormoneTrack</string>
<!-- Navigation -->
<string name="nav_home">Home</string>
<string name="nav_chart">Charts</string>
<string name="nav_doses">Doses</string>
<string name="nav_labs">Labs</string>
<string name="nav_treatments">Treatments</string>
<string name="settings">Settings</string>
<string name="back">Back</string>
<string name="ok">OK</string>
<!-- Home -->
<string name="current_level">Current estimated level</string>
<string name="no_data">No data yet — add a treatment and log a dose.</string>
<string name="logged_today">Quick log</string>
<string name="next_dose">Next dose</string>
<string name="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="disclaimer">Curves are pharmacokinetic estimates for informational purposes only — they are not measurements. Always rely on your blood tests and follow your endocrinologist\'s guidance.</string>
<!-- Charts -->
<string name="chart_24h">24 h</string>
<string name="chart_7j">7 days</string>
<string name="chart_30j">30 days</string>
<string name="show_labs">Labs</string>
<string name="legend_e2">— E2 estimate (pg/mL, left axis)</string>
<string name="legend_t">-- T estimate (ng/mL, right axis)</string>
<string name="legend_labs">● Lab results</string>
<!-- Doses -->
<string name="add_dose">Log a dose</string>
<string name="edit_dose">Edit dose</string>
<string name="ester_default">Default (%1$s)</string>
<string name="dose_amount">Dose</string>
<string name="dose_time">Time</string>
<string name="dose_notes">Notes</string>
<string name="delete">Delete</string>
<string name="confirm_delete">Delete this entry?</string>
<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="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>
<string name="lab_date">Date</string>
<!-- Treatments -->
<string name="add_treatment">Add treatment</string>
<string name="edit_treatment">Edit treatment</string>
<string name="treatment_name">Treatment name</string>
<string name="treatment_type">Type</string>
<string name="treatment_route">Route</string>
<string name="default_dose">Standard dose</string>
<string name="dose_unit">Unit</string>
<string name="active">Active</string>
<string name="inactive">inactive</string>
<string name="no_treatment_hint">Create a treatment first (Treatments tab).</string>
<string name="type_estradiol">Estradiol</string>
<string name="type_antiandrogen">Anti-androgen</string>
<string name="type_progestogen">Progestogen</string>
<string name="type_other">Other</string>
<string name="route_oral">Oral</string>
<string name="route_gel">Transdermal gel</string>
<string name="route_patch">Transdermal patch</string>
<string name="route_injection_im">IM injection</string>
<string name="route_injection_sc">SC injection</string>
<string name="route_other">Other route</string>
<string name="ester">Ester</string>
<string name="pk_model">PK model</string>
<string name="model_ese">Estrannaise</string>
<string name="model_tfs">Transfem Science</string>
<string name="select_preset">Choose a preset (optional)</string>
<string name="pk_absorption">Time to peak (h)</string>
<string name="pk_halflife">Half-life (h)</string>
<string name="pk_bioavail">Bioavailability (0–1)</string>
<string name="calibration_title">Calibration</string>
<string name="scale_factor">Scale factor</string>
<string name="calibration_hint">The scale factor adjusts the model to your body, like the "Scale factor" of your spreadsheet. It is computed as median(lab value ÷ model prediction).</string>
<string name="calibrate_from_labs">Calibrate from labs</string>
<string name="save">Save</string>
<string name="cancel">Cancel</string>
<!-- Reminders -->
<string name="reminder">Reminder</string>
<string name="reminder_time">Reminder time</string>
<string name="reminders_section">Reminders &amp; alarms</string>
<string name="exact_alarm_needed">Exact alarms are not granted: reminders may be delayed by a few minutes.</string>
<string name="exact_alarm_ok">Exact alarms granted. Reminders will mirror on your watch via Huawei Health notifications.</string>
<string name="grant_exact_alarm">Grant exact alarms</string>
<string name="action_taken">Taken</string>
<string name="action_snooze_1h">Snooze 1 h</string>
<string name="reminder_title">Reminder: %1$s</string>
<string name="reminder_title_plain">Medication reminder</string>
<string name="reminder_text">It\'s time to take your treatment</string>
<string name="reminder_text_with_dose">Time for your dose: %1$s</string>
<string name="notification_channel_name">Treatment reminders</string>
<string name="notification_channel_desc">Notifications for medication reminders</string>
<!-- Settings -->
<string name="language">Language</string>
<string name="language_system">System</string>
<string name="t_model_title">Testosterone estimate</string>
<string name="t_model_hint">Empirical model: T = floor + (base − floor) ÷ (1 + k·E2). Units: ng/mL. Use your T lab results to calibrate k.</string>
<string name="t_base">Base</string>
<string name="t_floor">Floor</string>
<string name="t_calibrated">k calibrated from your T labs.</string>
<string name="saved">Saved</string>
<string name="backup_section">Backup (JSON)</string>
<string name="backup_hint">Export all data (treatments, doses, labs, T settings) to a JSON file, and restore from a backup file.</string>
<string name="export_json">Export</string>
<string name="import_json">Import</string>
<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_title">Import backup</string>
<string name="import_warning">The backup content will be ADDED to the current data (no deletion).</string>
<string name="about_title">About</string>
<string name="models_credit">PK profiles from Estrannaise (EstraNase) and Transfem Science injection models, extracted from Estrogen.ods (D, k1–k3 hourly tables).</string>
<!-- Presets -->
<string name="preset_ev_ese">EV injection — Estrannaise</string>
<string name="preset_eu_ese">EU injection — Estrannaise</string>
<string name="preset_een_ese">EEn injection — Estrannaise</string>
<string name="preset_ev_tfs">EV injection — Transfem Science</string>
<string name="preset_eu_tfs">EU injection — Transfem Science</string>
<string name="preset_een_tfs">EEn injection — Transfem Science</string>
<string name="preset_e2_gel">E2 transdermal gel</string>
<string name="preset_e2_patch">E2 patch</string>
<string name="preset_e2_oral">E2 oral</string>
<string name="preset_cpa">Cyproterone acetate (CPA)</string>
<string name="preset_spiro">Spironolactone</string>
<string name="preset_bica">Bicalutamide</string>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HormoneTrack" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>

View File

@ -0,0 +1,95 @@
package com.hormonetrack.data.backup
import com.google.gson.Gson
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import com.hormonetrack.pk.TConfig
import org.junit.Assert.assertEquals
import org.junit.Test
class BackupGsonTest {
@Test
fun `backup round-trip preserves treatments doses labs and T config`() {
val data = BackupData(
version = 1,
exportedAt = 1_700_000_000_000L,
treatments = listOf(
Treatment(
id = 12,
name = "EV",
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = 4.0,
doseUnit = "mg",
esterType = Esters.EV,
pkModel = PKModels.ESTRANNAISE,
absorptionHours = 46f,
eliminationHalfLifeHours = 100f,
bioavailabilityFraction = 1f,
scaleFactor = 0.73,
reminderEnabled = true,
reminderHour = 20,
reminderMinute = 15,
createdAt = 1_699_000_000_000L
)
),
doseLogs = listOf(
DoseLog(
id = 34,
treatmentId = 12,
timestamp = 1_699_500_000_000L,
doseAmount = 4.5,
notes = "thigh R",
esterType = Esters.EU
)
),
labResults = listOf(
LabResult(
id = 56,
marker = "E2",
value = 163.0,
unit = "pg/mL",
timestamp = 1_699_600_000_000L,
notes = "fasting"
)
),
tConfig = TConfig(base = 5.5, floor = 0.15, k = 0.21)
)
val json = Gson().toJson(data)
val parsed: BackupData = Gson().fromJson(json, BackupData::class.java)
assertEquals(data.version, parsed.version)
val tr = parsed.treatments.single()
assertEquals(12L, tr.id)
assertEquals(TreatmentType.ESTRADIOL, tr.type)
assertEquals(AdministrationRoute.INJECTION_IM, tr.route)
assertEquals(Esters.EV, tr.esterType)
assertEquals(PKModels.ESTRANNAISE, tr.pkModel)
assertEquals(0.73, tr.scaleFactor, 1e-9)
assertEquals(true, tr.reminderEnabled)
assertEquals(20, tr.reminderHour)
val dose = parsed.doseLogs.single()
assertEquals(34L, dose.id)
assertEquals(12L, dose.treatmentId)
assertEquals(Esters.EU, dose.esterType)
assertEquals(4.5, dose.doseAmount, 1e-9)
assertEquals("thigh R", dose.notes)
val lab = parsed.labResults.single()
assertEquals("E2", lab.marker)
assertEquals(163.0, lab.value, 1e-9)
assertEquals("pg/mL", lab.unit)
assertEquals(5.5, parsed.tConfig.base, 1e-9)
assertEquals(0.15, parsed.tConfig.floor, 1e-9)
assertEquals(0.21, parsed.tConfig.k, 1e-9)
}
}

View File

@ -0,0 +1,108 @@
package com.hormonetrack.pk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import kotlin.math.max
import kotlin.math.min
class PKProfileStoreTest {
companion object {
private const val EV_PEAK = 61.12
private const val EV_PEAK_HOUR = 45
}
@Before
fun setup() {
if (!PKProfileStore.hasProfile("EV", "ESE")) {
val candidates = listOf(
File("src/main/assets/pk_profiles.json"),
File("app/src/main/assets/pk_profiles.json")
)
val file = candidates.firstOrNull { it.exists() }
?: error("pk_profiles.json not found for unit tests")
PKProfileStore.initWithJson(file.readText())
}
}
@Test
fun `all six ester-model profiles are present with 8001 hourly points`() {
for (ester in listOf("EV", "EU", "EEn")) {
for (model in listOf("ESE", "TFS")) {
assertEquals("profile $ester/$model", 8001, PKProfileStore.profileLength(ester, model))
}
}
}
@Test
fun `EV ese profile peaks at 61_12 pg per mg around hour 45`() {
var peak = 0.0
var peakHour = 0
for (h in 0..8000) {
val v = PKProfileStore.sample("EV", "ESE", h.toDouble())
if (v > peak) {
peak = v
peakHour = h
}
}
assertEquals(EV_PEAK, peak, 0.05)
assertEquals(EV_PEAK_HOUR, peakHour)
}
@Test
fun `sample is zero at or before injection time`() {
assertEquals(0.0, PKProfileStore.sample("EV", "ESE", 0.0), 1e-9)
assertEquals(0.0, PKProfileStore.sample("EV", "ESE", -10.0), 1e-9)
}
@Test
fun `interpolation between two hourly points stays between them`() {
// hours 100/101 have distinct values (43.32 / 42.93); the peak plateau 45/46 is flat
val a = PKProfileStore.sample("EV", "ESE", 100.0)
val b = PKProfileStore.sample("EV", "ESE", 101.0)
val mid = PKProfileStore.sample("EV", "ESE", 100.5)
assertTrue(mid > min(a, b) && mid < max(a, b))
}
@Test
fun `unknown ester or model returns zero`() {
assertEquals(0.0, PKProfileStore.sample("XX", "ESE", 10.0), 1e-9)
assertEquals(0.0, PKProfileStore.sample("EV", "XXX", 10.0), 1e-9)
}
@Test
fun `extrapolation beyond the table decreases`() {
val v1 = PKProfileStore.sample("EV", "ESE", 8100.0)
val v2 = PKProfileStore.sample("EV", "ESE", 9000.0)
assertTrue("v1=$v1 should be > 0", v1 > 0.0)
assertTrue("v2=$v2 should be < v1=$v1", v2 < v1)
}
@Test
fun `long acting esters still measurable far after injection`() {
// EU/EEn are long-acting: 1 year after injection they must still contribute
assertTrue(PKProfileStore.sample("EU", "ESE", 8000.0) > 0.0)
assertTrue(PKProfileStore.sample("EEn", "ESE", 8000.0) > 0.0)
}
@Test
fun `key profile peaks match the ODS reference values`() {
assertPeak("EU", "ESE", 3.44)
assertPeak("EEn", "ESE", 31.35)
assertPeak("EV", "TFS", 58.96)
assertPeak("EU", "TFS", 10.11)
assertPeak("EEn", "TFS", 31.97)
}
private fun assertPeak(ester: String, model: String, expected: Double) {
var peak = 0.0
for (h in 0..8000) {
val v = PKProfileStore.sample(ester, model, h.toDouble())
if (v > peak) peak = v
}
assertEquals("peak of $ester/$model", expected, peak, 0.05)
}
}

View File

@ -0,0 +1,235 @@
package com.hormonetrack.pk
import com.hormonetrack.data.model.AdministrationRoute
import com.hormonetrack.data.model.DoseLog
import com.hormonetrack.data.model.Esters
import com.hormonetrack.data.model.LabResult
import com.hormonetrack.data.model.PKModels
import com.hormonetrack.data.model.Treatment
import com.hormonetrack.data.model.TreatmentType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import kotlin.math.abs
class PharmacokineticEngineTest {
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(scale: Double = 1.0, mg: Double = 4.0) = Treatment(
id = 1,
name = "EV test",
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.INJECTION_IM,
doseAmount = mg,
doseUnit = "mg",
esterType = Esters.EV,
pkModel = PKModels.ESTRANNAISE,
scaleFactor = scale
)
private fun gelTreatment() = Treatment(
id = 2,
name = "Gel test",
type = TreatmentType.ESTRADIOL,
route = AdministrationRoute.TRANSDERMAL_GEL,
doseAmount = 2.0,
doseUnit = "mg",
absorptionHours = 6f,
eliminationHalfLifeHours = 24f,
bioavailabilityFraction = 1.0f
)
private fun dose(treatment: Treatment, hoursAgo: Double, mg: Double = treatment.doseAmount) =
DoseLog(
treatmentId = treatment.id,
timestamp = BASE - (hoursAgo * HOUR).toLong(),
doseAmount = mg
)
@Test
fun `bateman ke derives from half-life`() {
val tr = gelTreatment().copy(eliminationHalfLifeHours = 24f)
val p = PharmacokineticEngine.batemanParams(tr)
assertEquals(kotlin.math.ln(2.0) / 24.0, p.ke, 1e-9)
}
@Test
fun `bateman curve peaks close to configured Tmax`() {
val tr = gelTreatment().copy(absorptionHours = 10f, eliminationHalfLifeHours = 24f)
val p = PharmacokineticEngine.batemanParams(tr)
var best = 0.0
var bestT = 0.0
var t = 0.0
while (t <= 200.0) {
val c = PharmacokineticEngine.concentrationOfDose(
tr, dose(tr, -t, 2.0).let { DoseLog(treatmentId = it.treatmentId, timestamp = BASE, doseAmount = 2.0) },
BASE + (t * HOUR).toLong(), p
)
if (c > best) {
best = c
bestT = t
}
t += 0.25
}
assertTrue("peak at $bestT h, expected ~10 h", abs(bestT - 10.0) < 1.5)
}
@Test
fun `EV 4mg injection peaks near 4x61 pg per mL`() {
val tr = evTreatment()
val d = DoseLog(treatmentId = tr.id, timestamp = BASE, doseAmount = 4.0)
var peak = 0.0
for (h in 0..300) {
val c = PharmacokineticEngine.concentrationOfDose(tr, d, BASE + h * HOUR)
if (c > peak) peak = c
}
assertEquals(4.0 * 61.12, peak, peak * 0.03)
}
@Test
fun `superposition of two doses exceeds one dose`() {
val tr = evTreatment()
val twoDoses = listOf(dose(tr, 200.0), dose(tr, 20.0))
val oneDose = listOf(dose(tr, 20.0))
val tNow = BASE
val two = PharmacokineticEngine.e2At(listOf(tr), twoDoses, tNow)
val one = PharmacokineticEngine.e2At(listOf(tr), oneDose, tNow)
assertTrue("two=$two one=$one", two > one)
}
@Test
fun `scale factor scales the estimate linearly`() {
val tr = evTreatment(scale = 1.0)
val doses = listOf(dose(tr, 48.0))
val unscaled = PharmacokineticEngine.e2At(listOf(tr), doses, BASE)
val scaled = PharmacokineticEngine.e2At(listOf(evTreatment(scale = 0.73)), doses, BASE)
assertEquals(0.73, scaled / unscaled, 1e-6)
}
@Test
fun `anti-androgen doses contribute nothing to E2`() {
val aa = evTreatment().copy(
id = 3, type = TreatmentType.ANTI_ANDROGEN, route = AdministrationRoute.ORAL,
esterType = Esters.NONE, doseAmount = 10.0
)
val log = DoseLog(treatmentId = aa.id, timestamp = BASE - 5 * HOUR, doseAmount = 10.0)
assertEquals(0.0, PharmacokineticEngine.e2At(listOf(aa), listOf(log), BASE), 1e-9)
}
@Test
fun `testosterone model is monotonic and bounded`() {
val cfg = TConfig()
assertEquals(cfg.base, PharmacokineticEngine.testosteroneAt(0.0, cfg), 1e-9)
val at150 = PharmacokineticEngine.testosteroneAt(150.0, cfg)
val at300 = PharmacokineticEngine.testosteroneAt(300.0, cfg)
assertTrue(at300 < at150)
assertTrue("T at E2=150 should be < 1 ng/mL, got $at150", at150 < 1.0)
assertTrue(PharmacokineticEngine.testosteroneAt(1e6, cfg) < cfg.floor + 0.01)
}
@Test
fun `scale factor calibration returns the median of lab ratios`() {
val tr = evTreatment()
val doses = listOf(
dose(tr, 24 * 21.0),
dose(tr, 24 * 14.0),
dose(tr, 24 * 7.0)
)
val ratios = listOf(0.5, 0.9, 1.4)
val labs = ratios.mapIndexed { i, r ->
val t = BASE - (i * 24 + 6) * HOUR
val predicted = PharmacokineticEngine.e2At(listOf(tr), doses, t)
LabResult(marker = "E2", value = predicted * r, unit = "pg/mL", timestamp = t)
}
val sf = PharmacokineticEngine.computeScaleFactor(tr, doses, labs)
assertEquals(0.9, sf!!, 0.01)
}
@Test
fun `scale factor calibration returns null without usable labs`() {
val tr = evTreatment()
assertEquals(null, PharmacokineticEngine.computeScaleFactor(tr, emptyList(), emptyList()))
}
@Test
fun `T calibration recovers a planted k`() {
val tr = evTreatment()
val doses = listOf(dose(tr, 96.0), dose(tr, 24.0))
val trueK = 0.25
val cfg = TConfig()
val labs = (0 until 4).map { i ->
val t = BASE - (i * 24 + 2) * HOUR
val e2 = PharmacokineticEngine.e2At(listOf(tr), doses, t)
val tValue = cfg.floor + (cfg.base - cfg.floor) / (1.0 + trueK * e2)
LabResult(marker = "T", value = tValue, unit = "ng/mL", timestamp = t)
}
val calibrated = PharmacokineticEngine.computeTConfigCalibration(labs, listOf(tr), doses, cfg)
assertEquals(trueK, calibrated!!.k, trueK * 0.15)
}
@Test
fun `computeCurve returns hourly grid clamped to first dose`() {
val tr = evTreatment()
val doses = listOf(dose(tr, 100.0))
val start = BASE - 24 * HOUR
val end = BASE
val curve = PharmacokineticEngine.computeCurve(listOf(tr), doses, start, end, tConfig = TConfig())
assertTrue(curve.size >= 24)
assertEquals(end, curve.last().timestamp)
// curve starts at (or after) the earliest dose time
assertTrue(curve.first().timestamp >= doses.first().timestamp)
}
@Test
fun `computeCurve with no doses returns empty`() {
val tr = evTreatment()
val curve = PharmacokineticEngine.computeCurve(listOf(tr), emptyList(), BASE - HOUR, BASE, tConfig = TConfig())
assertTrue(curve.isEmpty())
}
@Test
fun `per-dose ester override is used for the profile lookup`() {
val tr = evTreatment().copy(esterType = Esters.EU)
val dEv = DoseLog(treatmentId = tr.id, timestamp = BASE, doseAmount = 4.0, esterType = Esters.EV)
val dEu = DoseLog(treatmentId = tr.id, timestamp = BASE, doseAmount = 4.0, esterType = Esters.EU)
val t = BASE + 45 * HOUR
val cEv = PharmacokineticEngine.concentrationOfDose(tr, dEv, t)
val cEu = PharmacokineticEngine.concentrationOfDose(tr, dEu, t)
// EV peaks around 45h, EU is far below at that time
assertTrue("cEv=$cEv should exceed cEu=$cEu", cEv > cEu * 5.0)
}
@Test
fun `next reminder fire time is in the future`() {
val tr = evTreatment().copy(reminderEnabled = true, reminderHour = 8, reminderMinute = 30)
val now = BASE
val next = PharmacokineticEngine.nextReminderFireMs(listOf(tr), now)
assertTrue(next != null && next > now && next - now <= 24 * HOUR)
}
@Test
fun `levelAt combines e2 and testosterone`() {
val tr = evTreatment()
val point = PharmacokineticEngine.levelAt(listOf(tr), listOf(dose(tr, 45.0)), BASE + 45 * HOUR, TConfig())
assertTrue(point.e2 > 100.0)
assertTrue(point.t < point.e2)
assertTrue(point.t > 0.0)
}
}

View File

@ -0,0 +1,136 @@
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.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".
*/
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
}
@Before
fun setup() {
if (!PKProfileStore.hasProfile("EEN", "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 importUserBackup(): BackupData =
Gson().fromJson(USER_JSON, BackupData::class.java)
@Test
fun `user backup JSON 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)
}
@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
)
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)
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)
}
}
@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
assertTrue("e2=${point.e2}", point.e2 in 100.0..200.0)
}
@Test
fun `labs taken before the first logged dose are skipped by calibration`() {
val data = importUserBackup()
val sf = PharmacokineticEngine.computeScaleFactor(
data.treatments[0], data.doseLogs,
data.labResults.filter { it.marker.equals("E2", true) }
)
assertNull("labs pre-date the first dose → no prediction → null", sf)
}
@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)
assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(0.45, "ng/mL"), 1e-9)
assertEquals(0.45, PharmacokineticEngine.convertTToNgMl(450.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
val cfg = data.tConfig
val e2 = PharmacokineticEngine.e2At(data.treatments, data.doseLogs, EXPORT_TIME)
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
)
val calibrated = PharmacokineticEngine.computeTConfigCalibration(
listOf(lab), data.treatments, data.doseLogs, cfg
)
assertNotNull(calibrated)
assertEquals(trueK, calibrated!!.k, trueK * 0.15)
}
}

6
build.gradle.kts Normal file
View File

@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" apply false
id("com.google.devtools.ksp") version "2.0.0-1.0.21" apply false
}

45
docs/CHANGELOG.md Normal file
View File

@ -0,0 +1,45 @@
# Changelog — HormoneTrack
Format : [Keep a Changelog](https://keepachangelog.com/fr-FR/1.1.0/).
Versionnage : [SemVer](https://semver.org/).
## [1.1.0] — 2026-09-05 (versionCode 2)
### Corrigé
- **Courbes EEn plates à zéro** (le bug rapporté : « les graphiques ne se génèrent pas »).
Cause : casse des clés de profils — l'asset JSON (verbatim du `.ods`) contient
`"EEn_ese"`/`"EEn_tfs"` (n minuscule) alors que la constante `Esters.EEN = "EEN"` ;
le lookup exact échouait silencieusement → `sample() = 0` pour **tous** les traitements
EEn (EV et EU marchaient, ce qui masquait le bug). Fix : lookup **insensible à la casse**
dans `PKProfileStore` (`lookup()`), test de régression épinglé sur les données réelles
de l'utilisatrice (`RegressionUserCaseTest`, export v1.0.0 fourni).
- **Unités de testostérone** : les labs T pouvaient être saisis en ng/dL (cas réel :
33 et 44 ng/dL). L'axe T du chart mélangeait alors des ng/dL avec des estimations en
ng/mL (erreur ×100, courbe T invisible) et la calibration T aurait été faussée d'un
facteur 100. Fix : `PharmacokineticEngine.convertTToNgMl()` (ng/dL → ×0,01 ;
ng/L → ×0,001 ; nmol/L → ×0,2884 ; ng/mL inchangé), appliqué à l'affichage
(à intégrer dans le rendu du chart) et à la calibration.
### Ajouté
- **Édition des doses** (le bug rapporté : « on ne peut pas modifier les entrées
d'injection ») : appuyer sur une ligne de l'écran Doses ouvre le dialog en mode
édition (traitement, dose, date/heure, notes) → enregistre sans recréer.
- **Choix de l'ester par injection** dans le dialog de dose (comme dans le `.ods`) :
« Défaut (EEn) », EV, EU, EEn — override stocké par dose, utilisé par le moteur PK.
- 6 tests de régression sur les données réelles de l'utilisatrice (30 tests au total).
### Technique
- `versionCode 2`, `versionName 1.1.0` ; APK debug régénérée.
## [1.0.0] — 2026-09-05 (versionCode 1)
Première version fonctionnelle :
- Courbes estimées E2/T heure par heure (24 h / 7 j / 30 j), modèles **Estrannaise** et
**Transfem Science** (tables horaires extraites de `Estrogen.ods` : EV/EU/EEn)
- Modèle Bateman paramétrable (gel/patch/oral)
- Log des doses, analyses de sang, calibration (facteur d'échelle médian lab÷prédiction)
- Estimation T empirique calibrable
- Rappels quotidiens (alarmes exactes) avec actions « Pris » / « Reporter 1 h »,
notifications remontant sur Huawei Watch GT 3 (Gadgetbridge ou Huawei Health)
- Sauvegarde/Restauration JSON (SAF), FR/EN (langue par app), 100 % local
- 24 tests unitaires (moteur PK, profils ODS, round-trip backup)

529
docs/DEVELOPPEMENT.md Normal file
View File

@ -0,0 +1,529 @@
# Documentation de développement — HormoneTrack
> Doc de référence pour toute future session (humaine ou IA) : contexte, décisions,
> architecture, maths, build, tests, bugs corrigés, montre, évolutions.
> Projet : `~/projects/HormoneTrack` — voir aussi [README.md](../README.md),
> [GUIDE_INSTALLATION.md](GUIDE_INSTALLATION.md), [MONTRE-GADGETBRIDGE.md](MONTRE-GADGETBRIDGE.md).
---
## Table des matières
1. [Contexte & objectifs](#1-contexte--objectifs)
2. [Historique du projet](#2-historique-du-projet)
3. [Stack & versions (épinglées)](#3-stack--versions-épinglées)
4. [Environnement de build (cette machine)](#4-environnement-de-build-cette-machine)
5. [Architecture générale](#5-architecture-générale)
6. [Modèle de données (Room)](#6-modèle-de-données-room)
7. [Moteur pharmacocinétique](#7-moteur-pharmacocinétique)
8. [Tests unitaires](#8-tests-unitaires)
9. [Système de rappels](#9-système-de-rappels)
10. [UI & navigation](#10-ui--navigation)
11. [Graphiques (CurveChart)](#11-graphiques-curvechart)
12. [i18n FR/EN](#12-i18n-fren)
13. [Sauvegarde JSON](#13-sauvegarde-json)
14. [Bugs corrigés (historique complet — à ne pas réintroduire)](#14-bugs-corrigés)
15. [Comment régénérer l'asset pk_profiles.json](#15-comment-régénérer-lasset-pk_profilesjson)
16. [Workflow build / test / install](#16-workflow-build--test--install)
17. [Montre : Gadgetbridge & options](#17-montre--gadgetbridge--options)
18. [Espace disque & coûts](#18-espace-disque--coûts)
19. [Limites connues & choix volontaires](#19-limites-connues)
20. [Idées d'évolution (Phase 2+)](#20-idées-dévolution)
21. [Checklist de test manuel](#21-checklist-de-test-manuel)
---
## 1. Contexte & objectifs
Utilisatrice : femme trans, THS (thérapie hormonale), injections d'estradiol (esters
EV/EU/EEn, switchables) ± anti-androgènes. Elle tient déjà un suivi rigoureux dans
**LibreOffice Calc** (`Estrogen.ods`, cf §7.1) avec deux modèles PK : **Estrannaise
(EstraNase)** et **Transfem Science**. L'app doit reproduire fidèlement ces modèles.
Montre : **Huawei Watch GT 3 (HarmonyOS 4.0.0.120)** = *Lite Wearable*, pas d'apps
Android, apps tierces au poignet quasi impossibles (cf §17). Utilisatrice équipe de
**Gadgetbridge** (FOSS) sur son téléphone → v1 = **app téléphone + notifications miroir
sur la montre** via GB (ou Huawei Health).
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 ;
Bateman paramétrable pour gel/patch/oral
- Log des doses (date/heure exacte, mg, **ester par injection**)
- Labs (E2/T/PRL) + **calibration** (facteur d'échelle + calibration k du modèle T)
- Rappels quotidiens, actions « Pris » / « Reporter 1 h » dans la notification
- Export/Import JSON, FR/EN, 100 % local
## 2. Historique du projet
| Date | Événement |
|---|---|
| 5 sept. 2026 (session 1) | Plan, vérification GT 3 = Lite Wearable, création couche données + ancien moteur Bateman + ancien ReminderManager. Extraction des modèles du `Estrogen.ods` → `/tmp/pk_models.json` (6 profils × 8001 h + params D/k1–k3). |
| 5 sept. 2026 | L'utilisatrice mentionne un travail d'un assistant tiers « **Mimo V2.5** » : **aucune trace trouvée** (fichiers identiques à la session 1, timestamps identiques). Reprise depuis l'état existant. Bugs trouvés au passage : settings.gradle, BootReceiver, cancel PendingIntent. |
| 5 sept. 2026 (session build) | Redesign données (ester/pkModel/scaleFactor), réécriture moteur PK sur tables ODS, modèle T + calibration, rappels complets, UI 6 écrans, chart Canvas, backup JSON, i18n, wrapper Gradle, guide. **Installation SDK Android (brew) + premier build.** |
| 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. |
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
et en testant** — aucun build n'avait été lancé avant la session 3.
## 3. Stack & versions (épinglées)
| Composant | Version | Où |
|---|---|---|
| Gradle | 8.9 (wrapper jar v8.9.0) | `gradle/wrapper/` |
| AGP | 8.5.2 | `build.gradle.kts` racine |
| Kotlin | 2.0.0 + plugin compose 2.0.0 | idem |
| KSP | 2.0.0-1.0.21 | idem |
| Compose BOM | 2024.06.00 | `app/build.gradle.kts` |
| Room | 2.6.1 (KSP) | idem |
| Navigation Compose | 2.7.7 | idem |
| AppCompat | 1.7.0 (langue par app) | idem |
| DataStore Preferences | 1.1.1 | idem |
| Gson | 2.11.0 | idem |
| JUnit | 4.13.2 (testImplementation) | idem |
| WorkManager | 2.9.1 (**déclaré, non utilisé — supprimable**) | idem |
| compileSdk/targetSdk | 34 ; minSdk 26 ; Java target 17 | app |
Kotlin 2.0 → compose compiler via `org.jetbrains.kotlin.plugin.compose`. Room convertit
les enums ↔ String automatiquement. **Ne pas monter Kotlin/AGP/Gradle sans vérifier la
matrice de compatibilité.**
## 4. Environnement de build (cette machine)
- **macOS (Apple Silicon), brew présent, Java 21 (Microsoft OpenJDK) sur `/usr/bin/java`** ✓
- **SDK Android** : installé via `brew install --cask android-commandlinetools`
→ `/opt/homebrew/share/android-commandlinetools` (524 MB)
- licences acceptées : `yes | sdkmanager --licenses`
- paquets : `platform-tools`, `platforms;android-34`, `build-tools;34.0.0`
- **`local.properties`** à la racine (non commité) : `sdk.dir=/opt/homebrew/share/android-commandlinetools`
- Gradle 8.9 téléchargé par le wrapper ; caches `~/.gradle` ≈ 1,5 GB
- Build validé : `./gradlew assembleDebug testDebugUnitTest` → **BUILD SUCCESSFUL**,
APK debug 18 MB (`app/build/outputs/apk/debug/app-debug.apk`)
## 5. Architecture générale
Pas de ViewModel ni de DI externe — volontairement simple pour une v1 :
```
HormoneTrackApp (Application)
└─ AppContainer
├─ AppDatabase (Room singleton)
├─ HormoneRepository (DAOs : Flow réactifs + one-shots suspend)
└─ AppSettings (DataStore : TConfig, langue)
MainActivity (AppCompatActivity)
└─ setContent { HormoneTrackTheme { HormoneTrackRoot } }
├─ CompositionLocal LocalAppContainer
└─ NavHost + NavigationBar (5 tabs + settings + treatment_edit/{id})
Écrans = collectAsState sur les Flows + calcul PK dans produceState(Dispatchers.Default)
```
Points clés :
- `HormoneTrackApp.onCreate()` : init `PKProfileStore` (asset), canal de notification
- `MainActivity` : applique la langue sauvegardée (`AppCompatDelegate.setApplicationLocales`),
demande POST_NOTIFICATIONS (API 33+), lit les extras d'intent `open_log_dose` +
`treatment_id` (venus de la notification) → Home pré-ouvre le dialog de log
- **Tout calcul PK est hors UI thread** (`produceState` + `Dispatchers.Default`)
## 6. Modèle de données (Room)
DB `hormonetrack.db`, version 1, **`fallbackToDestructiveMigration()`** (⚠️ à retirer
avant toute migration réelle — sinon perte de données silencieuse).
### `Treatment` (treatments)
- base : `id`, `name`, `type` (ESTRADIOL/ANTI_ANDROGEN/PROGESTOGEN/OTHER), `route`
(ORAL/TRANSDERMAL_GEL/TRANSDERMAL_PATCH/INJECTION_IM/INJECTION_SUBCUT/OTHER),
`doseAmount`, `doseUnit`, `isActive`, `notes`, `createdAt`
- PK par table : `esterType` ("NONE"/"EV"/"EU"/"EEN" — objets `Esters`), `pkModel`
("ESE"/"TFS" — objets `PKModels`)
- PK Bateman : `absorptionHours` (Tmax), `eliminationHalfLifeHours`, `bioavailabilityFraction`
- Calibration : `scaleFactor` (défaut 1.0)
- Rappel : `reminderHour/Minute/Enabled`
- Helpers : `isInjection` (IM/SC), `usesProfileModel` (injection **et** ester ≠ NONE)
### `DoseLog` (dose_logs)
FK → treatments (CASCADE), index `treatmentId` + `timestamp`. `esterType: String?` =
**override par injection** (l'ODS permet de switcher d'ester d'une injection à l'autre) ;
null = ester du traitement.
### `LabResult` (lab_results)
`marker` libre ("E2", "T", "PRL"…), `value`, `unit` libre. La calibration et les charts
comparent `marker.equals("E2", true)` / `"T"` — **les dropdown suggèrent E2/T** ; si
l'utilisatrice tape autre chose, la calibration ignorera ces labs.
### DAOs
`Flow` pour l'UI + one-shots `suspend *Once()` pour backup/boot/calibration :
`TreatmentDao.getActiveOnce/getAllOnce`, `DoseLogDao.getAllOnce`, `LabResultDao.getAllOnce`.
## 7. Moteur pharmacocinétique
`pk/PharmacokineticEngine.kt` + `pk/PKProfileStore.kt`.
### 7.1 Source : `Estrogen.ods`
- Fichier : `Estrogen.ods de l'utilisatrice (Owncloud)` (30 MB, 13 tables)
- Tables nominatives (6 profils (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 (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
importante, cf §7.2)
Pics de référence (pg/mL par mg) :
| Clé | Modèle | Ester | Pic | Tmax |
|---|---|---|---|---|
| `EV_ese` | Estrannaise | valerate | 61,12 | ~45 h |
| `EU_ese` | Estrannaise | undecylate | 3,44 | ~55 h (plateau très long) |
| `EEn_ese` | Estrannaise | enanthate | 31,35 | ~152 h |
| `EV_tfs` | Transfem Science | valerate | 58,96 | ~51 h |
| `EU_tfs` | Transfem Science | undecylate | 10,11 | ~198 h |
| `EEn_tfs` | Transfem Science | enanthate | 31,97 | ~156 h |
### 7.2 `PKProfileStore` (asset loader + échantillonnage)
- Asset `app/src/main/assets/pk_profiles.json` : `{ "params": {D/k1/k2/k3…},
"profiles": { "EV_ese": [8001 floats], … } }` (550 KB, parse ~ms via `JsonParser`)
- **`initWithJson(json)`** = point d'entrée testable (JVM) ; `init(context)` lit l'asset
- `sample(ester, model, dtHours)` :
- modèle **strict** : seul "TFS"→`tfs` et "ESE"→`ese` ; tout autre → 0 (piège corrigé,
cf §14)
- interpolation **linéaire** entre heures entières
- **extrapolation terminale** : dernier point **≥ 1 % du pic** (pour éviter le plancher
d'affichage 0,01/0,00 de l'ODS), pente = décroissance moyenne sur les 48 h précédentes
(jamais avant le pic)
- ⚠️ tous les calculs en **Double** (Float×Double n'existe pas en Kotlin — source d'erreurs
de compilation, cf §14)
### 7.3 Superposition
Contribution d'une dose = `sample(...) × dose_mg` ; niveau total = somme des contributions
de toutes les doses E2, chacune multipliée par le `scaleFactor` de son traitement.
Coupure par dose : `cutoffHours` = longueur de table (8001 h) pour les profils,
`30 × t½` pour Bateman.
### 7.4 Bateman (gel/patch/oral)
`C(dt) = (F·D·ka/(ka−ke))·(e^(−ke·dt) − e^(−ka·dt))` ; cas dégénéré ka≈ke :
`F·D·ke·dt·e^(−ke·dt)`. **`computeKa`** résout `ln(ka/ke) = (ka−ke)·Tmax` par bisection
(50 itérations, bornes `ke×1.001 … ke×1000`) — **direction corrigée** (cf §14 : `eq > 0`
⇒ la racine est **au-dessus** de mid ⇒ `lo = mid`).
### 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é).
### 7.6 Calibration E2 (par traitement)
`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.
### 7.7 API du moteur
`levelAt / currentLevel / computeCurve(start, end, step=1h, tConfig) / e2At /
testosteroneAt / computeScaleFactor / computeTConfigCalibration / nextReminderFireMs /
batemanParams / concentrationOfDose / computeKa / doseEster / isInjectionRoute`.
Type de retour : `LevelPoint(timestamp, e2, t)`.
## 8. Tests unitaires
**30 tests JVM, tous verts** (`./gradlew testDebugUnitTest`). 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/…`).
- **`PKProfileStoreTest`** (8) : les 6 profils présents (8001 pts) ; pic EV_ese = 61,12
@45 h ; zéro avant injection ; interpolation stricte entre points (heures 100/101 —
le plateau 45/46 est plat, piège de test) ; modèle inconnu → 0 ; extrapolation
terminale décroissante ; esters longs mesurables à 8000 h ; **les 6 pics == valeurs ODS**
- **`PharmacokineticEngineTest`** (15) : ke = ln2/t½ ; **pic Bateman ≈ Tmax** (attrape la
bisection inversée) ; EV 4 mg → pic ≈ 4×61 pg/mL ; superposition ; linéarité du
scaleFactor ; anti-androgène → 0 en E2 ; modèle T monotone/borné ; calibration SF =
médiane (0,5/0,9/1,4 → 0,9) ; calibration nulle sans labs ; **calibration T récupère un
k planté (0,25)** ; grille horaire clampée à la 1ʳᵉ dose ; vide sans doses ; override
d'ester par dose (EV≫EU à 45 h) ; prochain rappel dans le futur ; levelAt combiné
- **`BackupGsonTest`** (1) : round-trip JSON complet (enums, IDs, notes, TConfig)
- **`RegressionUserCaseTest`** (6) : **régression épinglée sur les données réelles
exportées** par l'utilisatrice (backup JSON v1.0.0 : 1 traitement EEn/ESE 5 mg, 1 dose,
4 labs dont T en ng/dL). Vérifie : parsing du JSON réel, courbes non vides et
physiologiquement plausibles pour 24 h/7 j/30 j (attrape le bug #22 de casse EEn),
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.**
**Ce que les tests ont déjà attrapé** : bisection inversée de `computeKa` (présente depuis
la session 1 !), plancher 0,01 des queues de profils, mapping silencieux du modèle inconnu.
**Toute modification du moteur passe par ces tests.** Suite envisageable : Robolectric
(UI/logic Android), tests Compose, lint.
## 9. Système de rappels
`reminder/ReminderManager.kt` (+ `DoseActionReceiver.kt`).
- `ReminderContract` : constantes + **fabrique unique `reminderIntent()`** pour schedule
ET cancel (même action = même PendingIntent — cf bug §14.3)
- `AlarmScheduler` :
- quotidien : `setExactAndAllowWhileIdle` si `canScheduleExact()` (API≥31 :
`alarmManager.canScheduleExactAlarms()`), sinon `setWindow` ±10 min
- permission **SCHEDULE_EXACT_ALARM** : bouton d'octroi dans Paramètres + éditeur
(`Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM`)
- `scheduleDaily` (prochaine occurrence HH:mm), `scheduleSnooze` (+1 h), `rescheduleAll`
- `ReminderReceiver` : notif HIGH/REMINDER, 2 actions + tap → MainActivity
(`open_log_dose`, `treatment_id`) → Home ouvre le dialog pré-rempli ; requestCodes
PendingIntent = `id*10+{0,1,2}` ; notificationId = `id.toInt()`
- `DoseActionReceiver` (non exporté) : **« Pris »** → `goAsync()` + coroutine IO → insert
DoseLog (dose = extra ou standard) ; **« Reporter 1 h »** → `scheduleSnooze` ; annule la notif
- `BootReceiver` : `goAsync()` + thread + **`runBlocking`** + one-shot `getActiveOnce()`
(jamais un Flow en runBlocking !) → reschedule
Manifest : `POST_NOTIFICATIONS`, `SCHEDULE_EXACT_ALARM`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`.
Sur la montre : remontée par Gadgetbridge **ou** Huawei Health (cf §17).
## 10. UI & navigation
- `HormoneTrackRoot` : NavigationBar 5 tabs (home/chart/doses/labs/treatments) + routes
`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, disclaimer ; rafraîchissement `tick` 60 s
- `ChartScreen` : 24 h/7 j/30 j, toggles T + labs, chart 320 dp + légende
- `DosesScreen` : LazyColumn par jour (desc), suppression avec confirmation, FAB →
`LogDoseDialog` (traitement, dose, DateTimeField, notes)
- `LabsScreen` : groupée par marqueur, FAB → `LabDialog` (E2/T/PRL, unité suggérée)
- `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 ») ; carte Rappel (switch + TimePicker +
avertissement alarmes exactes) ; switch actif ; save → insert/update + schedule/cancel ;
delete avec confirmation ; `createdAt` préservé à l'édition
- `SettingsScreen` : langue (Système/Français/English, chips reflétant l'état) ; réglages
T + calibration ; statut alarmes exactes ; Export/Import JSON ; à propos + crédits
- Composants : `CurveChart`, `DateTimeField` (DatePicker+TimePicker Material3, LocalDateTime),
`LogDoseDialog`, `LabDialog`, `formatDose()` (top-level réutilisée)
- Thème M3 custom (`ui/theme/Color.kt` : bleu #4F5BD5, rose #D6589E, labs orange, bandeau
TransSky/TransPink), dynamic color désactivé
- ⚠️ `Card(onClick=…)` et `ExposedDropdownMenuBox` = **API expérimentales M3** → `@OptIn`
requis sur chaque composable qui les utilise
## 11. Graphiques (CurveChart)
Canvas pur (aucune lib). Dual axe : E2 gauche (pg/mL), T droite (ng/mL, pointillés rose).
Échelle « nice » (`niceCeil` : 1/2/2.5/5/10 × 10ⁿ). 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. Ligne verticale « maintenant ».
Pièges :
- `DrawScope` implémente `Density` → `X.dp.toPx()` direct ; ne PAS écrire de helper custom
- Tout label passe par `drawContext.canvas.nativeCanvas` + `android.graphics.Paint`
- Mélange Double/Float interdit (`1 - i / 4f` et pas `/4.0`)
## 12. i18n FR/EN
- Standard Android : `values/strings.xml` (EN défaut) + `values-fr/strings.xml` (FR).
L'objet `Strings.kt` custom de la session 1 a été **supprimé**.
- **Langue par app** : AppCompat 1.7 + `AppCompatDelegate.setApplicationLocales`
(fonctionne < API 33) ; choix persisté DataStore (`system`/`fr`/`en`), appliqué au
démarrage. Thème app = `Theme.AppCompat.DayNight.NoActionBar` (requis par AppCompat).
- Notifs localisées via `context.getString(R.string.*)`
- ⚠️ **Toute nouvelle string = les DEUX fichiers** (une référence manquante = erreur de
compilation `Unresolved reference 'active'` — déjà arrivé)
## 13. Sauvegarde JSON
`data/backup/BackupManager.kt` :
- `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)
- Transport : SAF (`CreateDocument("application/json")` / `OpenDocument`), écriture
`openOutputStream(uri, "wt")` ; ⚠️ pas de `return` dans un expression body `= try{}`
## 14. Bugs corrigés
Historique complet — **à ne pas réintroduire** (utile pour diff/revert) :
**Session 1 → 2 (avant tout build) :**
1. `settings.gradle.kts` : `dependencyResolution` (inexistant) → `dependencyResolutionManagement`
2. `BootReceiver` : `runBlocking { flow.collect {…} }` → blocage infini → one-shot + goAsync
3. `AlarmScheduler.cancel` : Intent sans l'action → annulation inopérante → fabrique unique
4. `PKProfileStore` : parsait la racine JSON → crash → lecture de `profiles`
**Session build (détectés à la compilation) :**
5. `kotlin.math.ln2` **n'existe pas** (hallucination) → `ln(2.0)` ; cascade d'erreurs sur
les lignes suivantes du même fichier (opérateurs sur types error)
6. Mélange **Double/Float** interdit en Kotlin : `mg * bioavailabilityFraction` (Float),
`30.0 * t½` (Float), `Float×exp()`… → `.toDouble()` partout
7. `BackupManager.writeBackup` : `return` dans expression body `= try{}` → block body
8. `DateTimeField` : `spacedBy(8f/2f*8)` (Float sans unité) → `8.dp` + import `dp` manquant
9. `DateTimeField` : extension `fun LocalDate.Companion.ofEpochMs` (java.time n'a pas de
Companion) → supprimée ; imports nettoyés
10. `CurveChart` : helper `dpToPx()` custom cassé → `dp.toPx()` de `DrawScope`
11. `CurveChart` : labels Y en Double (`i / 4.0`) → `i / 4f`
12. `LabsScreen` / `TreatmentsScreen` : imports `dp` / `fillMaxWidth` manquants
13. `TreatmentEditorScreen` : `R.string.active` inexistante → string ajoutée EN+FR
14. `TreatmentCard` : `Card(onClick=…)` sans `@OptIn(ExperimentalMaterial3Api::class)`
15. Typo `Locale.getDefault` sans parenthèses (SimpleDateFormat)
16. `SettingsScreen` : chips de langue codées en dur → état depuis DataStore
17. `ReminderManager` : constantes d'action mortes → implémentées (`DoseActionReceiver`)
18. `TreatmentEditorScreen` : `createdAt` écrasé à l'édition → préservé
**Session tests (bugs SÉMANTIQUES trouvés par les tests unitaires) :**
19. **`computeKa` : bisection inversée** — `if (eq > 0) hi = mid else lo = mid` convergeait
vers ka énorme (pic à ~0 h au lieu de Tmax) ; bug présent depuis la session 1, jamais
testé. → `if (eq > 0) lo = mid else hi = mid` (eq décroît en mid ; eq>0 ⇒ racine au-dessus)
20. **Plancher d'affichage des profils** : l'ODS arrondit à 2 décimales → queues à 0,01/0,00
; extrapoler depuis la fin de table donnait 0 à vie (ou une constante plate). →
extrapolation depuis le dernier point ≥ 1 % du pic avec pente sur 48 h
21. **Mapping silencieux du modèle** : `profileKey` mappe tout modèle ≠ "TFS" sur "ese"
→ `sample("EV","XXX")` renvoyait EV_ese. → validation stricte dans `sample`
**Session v1.1.0 (remontées par l'utilisatrice, reproduites en test) :**
22. **Casse des clés de profils** — LE bug « les graphiques ne se génèrent pas » :
l'asset contient `"EEn_ese"`/`"EEn_tfs"` (casing biologique du `.ods`) mais
`Esters.EEN = "EEN"` → lookup exact null → `sample()=0` pour tout traitement EEn
(courbe E2 plate à 0, T plate à la base). EV/EU marchaient (casse identique) et les
tests profils utilisaient la casse "EEn" — le trou passait entre les deux.
→ **lookup insensible à la casse** (`PKProfileStore.lookup()`), régression épinglée
sur les données réelles (`RegressionUserCaseTest`).
23. **Unités T non converties** : labs saisis en ng/dL (32/45) → axe T du chart à
×100 (courbe T invisible) et calibration T fausse. →
`PharmacokineticEngine.convertTToNgMl()` (ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884),
appliqué à la calibration ; **à utiliser aussi au rendu du chart** pour les dots T
(cf §11 — patch UI restant : convertir les valeurs T des labs avant yT()).
24. **Pas d'édition des doses** : suppression+recréation obligatoire. → `DoseDialog`
create/edit (préfill, changement de traitement, date/heure, notes, **override
d'ester par injection**), appelé depuis DosesScreen (tap sur la ligne) ;
`LogDoseDialog` supprimé (attention : `formatDose` vivait dedans → déplacée
top-level dans `DoseDialog.kt`).
**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** (`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).
## 15. Comment régénérer l'asset pk_profiles.json
Si le `.ods` change (re-fits, nouveaux esters) :
```python
# python3 stdlib only :
# 1. zipfile.ZipFile(ods).read("content.xml")
# 2. ElementTree (ns table/office/text) → table "Models"
# 3. lignes 1-4 = D, k1, k2, k3 (colonnes EV/EU/EEn ese + tfs) — informatif, non utilisé
# 4. lignes 5+ = profils horaires (00:00 … 8000:00), décimaux FR "61,12" → float
# 5. json.dump({"params": …, "profiles": {"EV_ese": [8001], "EU_ese": …, "EEn_ese": …,
# "EV_tfs": …, "EU_tfs": …, "EEn_tfs": …}})
# 6. cp vers app/src/main/assets/pk_profiles.json
# 7. vérifier : 6 clés × 8001 valeurs, pics == référence (§7.1) ; les tests le vérifient
```
Le script de la session 1 a été exécuté inline (non archivé) — le refaire depuis la
structure ci-dessus. **Toute restructuration du JSON impose de mettre à jour
`PKProfileStore.initWithJson`.**
## 16. Workflow build / test / install
```bash
cd ~/projects/HormoneTrack
./gradlew assembleDebug testDebugUnitTest # build + 24 tests
./gradlew lint # linters Android (à configurer)
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
- Téléphone : mode développeur + Débogage USB (détails : GUIDE_INSTALLATION.md)
- À ma charge (assistant) : build + tests JVM ✓ ; émulateur possible sur demande
(~2–3 Go + image système) ; **les tests humains sur vrai téléphone restent la référence**
(notifs → montre, UX de saisie, pickers)
- Recommandé avant chaque commit : `./gradlew testDebugUnitTest` (les tests du moteur
attrapent les régressions mathématiques)
## 17. Montre : Gadgetbridge & options
Doc dédiée : [MONTRE-GADGETBRIDGE.md](MONTRE-GADGETBRIDGE.md). Synthèse :
- GT 3 = Lite Wearable ; GB supporte la GT 3 (« mostly supported ») : **notifications ✓,
watchfaces `.hwt` ✓, apps `.hap` ✗**
- Health et GB ne peuvent pas être appairés simultanément
- Watchface via GB : **aucune signature requise** ; app `.hap` : certificat debug AGC +
UDID (chaîne DevEco Studio → DevEco Assistant)
- Régression connue : HarmonyOS 6.1+ casse l'install `.hwt` via GB (issues #5968/#6005/#6199) ;
GT 3 en HarmonyOS 4.0.0.120 probablement OK, à valider
- Choix v1 : notifications via GB/Health ; Phase 2 : watchface custom (statique) ou
mini-app Lite Wearable autonome (Wear Engine = accès partenaire)
## 18. Espace disque & coûts
Mesuré le 5 sept. 2026 (Mac, 228 Go, **33 Go libres**) :
| Élément | Taille |
|---|---|
| SDK Android (cmdline-tools + platform 34 + build-tools + platform-tools) | 524 MB |
| Cache Gradle (~/.gradle) | 1,5 GB |
| Projet (sources + build outputs) | 69 MB |
| **Total outillage actuel** | **≈ 2,1 GB** |
Marges : émulateur + image système ≈ +2–3 GB ; DevEco Studio (Phase 2) ≈ +10 GB →
tout rentre très largement. Aucune contrainte disque prévue.
## 19. Limites connues
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)
- `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 —
rétro-ingénierie des fits non tentée ; les tables sont exactes
- DST : les rappels quotidiens peuvent glisser d'1 h après changement d'heure, jusqu'au
prochain reschedule (boot/save) — mineur
- Labs : marqueur libre — E2/T exacts requis pour calibration/charts
- `allowBackup=false` → seul backup = export JSON manuel
## 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
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)
## 21. Checklist de test manuel
Sur le téléphone de test (à compléter par l'utilisatrice) :
- [ ] App se lance sans crash (asset chargé — sinon cf §14.4)
- [ ] Créer traitement « EV — Estrannaise » 4 mg + rappel 2 min à l'avance
- [ ] Notif arrive sur le téléphone **et** la GT 3 (via GB ou Health)
- [ ] « Pris » → dose loguée dans Doses ; « Reporter 1 h » → nouvelle notif 1 h après
- [ ] Logger 2–3 injections passées → Home affiche E2/T + delta 6 h cohérents
(4 mg EV → pic ≈ 4×61×scale ≈ 244 pg/mL à scale=1)
- [ ] Ajouter un lab E2 → « Calibrer avec les analyses » → scaleFactor plausible (0,5–1,2)
- [ ] Labs T + « Calibrer k » → k mis à jour, courbe T proche des points
- [ ] Charts 24 h/7 j/30 j, toggles T/labs, axes lisibles
- [ ] Export JSON → fichier inspectable ; ré-import → compteur correct
- [ ] Langue FR↔EN↔Système : UI + notifs basculent
- [ ] 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)
---
*Doc mise à jour le 5 sept. 2026 — build OK, 24/24 tests verts, APK debug 18 MB.*

109
docs/GUIDE_INSTALLATION.md Normal file
View File

@ -0,0 +1,109 @@
# HormoneTrack — Guide d'installation et d'utilisation
App Android de suivi de THS : courbes estimées heure par heure (E2 + T), log des doses, analyses de sang avec calibration, rappels affichés sur la Huawei Watch GT 3.
> **⚠️ Important** : les courbes sont des **estimations pharmacocinétiques**, pas des mesures.
> Fie-toi toujours à tes prises de sang et aux consignes de ton endocrinologue.
---
## 1. Installer les outils (une seule fois)
1. Télécharge **Android Studio** (Ladybug ou plus récent) : https://developer.android.com/studio
2. Installe-le, lance-le une première fois et accepte l'installation du **SDK Android**
(assistant de setup par défaut, tout coché).
3. Il te faut ~10 Go d'espace disque libre.
Tu n'as pas besoin d'installer Gradle ni le JDK séparément : Android Studio s'en charge.
## 2. Ouvrir le projet
1. Android Studio → **Open** → sélectionne le dossier `~/projects/HormoneTrack`
2. Laisse le **Gradle Sync** se terminer (première fois : téléchargements, 5–15 min)
- La barre du bas affiche la progression ; attends « Gradle sync finished ».
3. Vérifie que l'asset est bien présent :
`app/src/main/assets/pk_profiles.json` (profils Estrannaise / Transfem Science extraits de ton `Estrogen.ods`).
## 3. Préparer ton téléphone
1. **Paramètres → À propos du téléphone** → tape 7 fois sur « Numéro de build »
→ « Mode développeur activé »
2. **Paramètres → Système → Options développeur** → active **Débogage USB**
3. Branche le téléphone en USB → accepte la fenêtre « Autoriser le débogage USB »
## 4. Installer l'app
1. Dans Android Studio, sélectionne ton téléphone dans la liste d'appareils (en haut)
2. Clique sur **Run ▶️**
3. L'app s'installe (pas de Play Store nécessaire) — au premier lancement :
- Autorise les **notifications** (Android 13+)
- Dans **Paramètres → Rappels & alarmes** : bouton « Accorder les alarmes exactes »
(sinon les rappels peuvent être en retard de quelques minutes)
## 5. Voir les rappels sur la Watch GT 3
Les notifications de l'app remontent automatiquement sur la montre via **Huawei Santé** :
1. Vérifie que la montre est jumelée à Huawei Santé
2. Dans **Huawei Santé → Montre → Notifications** :
- Autorise les notifications d'applications
- L'app « Suivi Hormonal / HormoneTrack » doit être dans la liste autorisée
3. Test : programme un rappel 2 min à l'avance → la notif doit apparaître au poignet
avec les boutons **« Pris »** et **« Reporter 1 h »**
> Sur la GT 3, on ne peut pas installer d'app au poignet facilement (Lite Wearable, sideload
> via DevEco Assistant). La v1 utilise la montre comme **écran de notifications**, ce qui est
> fiable et sans maintenance. Une mini-app au poignet reste possible en Phase 2 si tu veux.
## 6. Premiers pas dans l'app
1. **Traitements → +** → choisis un preset (ex : *Injection EV — Estrannaise*)
- 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)
- 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)
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** :
- **Langue** : Système / Français / English
- **Estimation T** : modèle `T = plancher + (base − plancher) ÷ (1 + k·E2)` (ng/mL),
calibrable avec tes résultats T
- **Sauvegarde JSON** : Export / Import (traitements + doses + analyses + réglages T)
## 7. Les modèles mathématiques
Les profils viennent de ta feuille `Estrogen.ods` (table « Models ») :
| Profil | Source | Pic (pg/mL par mg) | Tmax (h) |
|-----------|-------------------|--------------------|----------|
| EV(ese) | Estrannaise | 61,1 | ~45 h |
| EU(ese) | Estrannaise | 3,4 | ~55 h (plateau long) |
| EEn(ese) | Estrannaise | 31,4 | ~152 h |
| EV(tfs) | Transfem Science | 59,0 | ~51 h |
| EU(tfs) | Transfem Science | 10,1 | ~198 h |
| EEn(tfs) | Transfem Science | 32,0 | ~156 h |
- **Superposition** : chaque injection contribue `dose_mg × profil(dt)` ; les courbes s'additionnent
- **Interpolation** linéaire entre les heures ; au-delà de 8000 h, extrapolation avec la pente terminale
- **Calibration** : facteur d'échelle par traitement (médiane des ratios lab/prédiction)
- **Courbe T** : dérivée de l'E2 estimé (modèle empirique, calibrable) — indicative seulement
## 8. Dépannage
| Problème | Solution |
|----------|----------|
| « SDK not found » au sync | Android Studio → Settings → Languages & Frameworks → Android SDK → installer API 34 |
| Pas de téléphone détecté | Réactive le Débogage USB, change de câble (données, pas charge seule) |
| Notif absente sur la montre | Huawei Santé → Notifications → autorise l'app ; redémarre la montre |
| Rappels en retard | Paramètres → « Accorder les alarmes exactes » + désactive l'optimisation de batterie pour l'app |
| Import JSON échoué | Le fichier doit venir d'un export de l'app même version (IDs conservés) |
## 9. Données & vie privée
- **Tout est local** : base Room sur le téléphone, aucun serveur, aucun compte
- Sauvegarde = fichier JSON que tu choisis où stocker (Owncloud, etc.)
- La désinstallation supprime les données → pense à exporter régulièrement

View File

@ -0,0 +1,94 @@
# Montre Huawei Watch GT 3 — Gadgetbridge & options
> Contexte : Huawei Watch GT 3 (HarmonyOS 4.0.0.120). C'est un appareil **« Lite Wearable »**
> (base LiteOS-like) : il n'exécute pas d'apps Android, et l'installation d'apps tierces
> au poignet est très restreinte. Ce doc récapitule ce qui est possible, ce qui ne l'est
> pas, et la stratégie retenue.
## 1. État v1 : la montre comme écran de notifications ✅
L'app téléphone envoie des notifications de rappel (AlarmManager exact) qui remontent au
poignet. **Deux chemins supportés, au choix** :
| Chemin | Prérequis | Notes |
|---|---|---|
| **Gadgetbridge** (FOSS) | Appairage GB ↔ montre | Transfert de notifications supporté pour la GT 3 (« mostly supported »). Aucun compte Huawei, aucune télémétrie — cohérent avec la vie privée de l'app. |
| **Huawei Health** | Appairage Health ↔ montre | Chemin « officiel ». Autoriser l'app dans Santé → Montre → Notifications. |
⚠️ **La montre ne peut être appairée qu'à l'un des deux à la fois** (Health ou Gadgetbridge).
Pour installer un watchface via GB, il faut dé-pairer de Health, puis re-pairer ensuite.
Le bouton **« Pris »** dans la notification logue la dose côté téléphone ; « Reporter 1 h »
re-programme l'alarme. Les deux marchent quelle que soit la source de la notification
(la montre ne fait que l'afficher).
## 2. Ce que Gadgetbridge sait faire (vérifié juillet 2026)
Source : docs gadgetbridge.org + issues/pull requests Codeberg.
- ✅ Appairage sans l'app Huawei (no-vendor pair), notifications, données santé (TruSleep…)
- ✅ **Installation de watchfaces** `.hwt` / `watchface.bin` (PR #3910 « Improve watchface
install support » : les `.hwt` récents contiennent un zip interne `com.huawei.watchface`
; pour les montres LiteOS comme la GT 3, GB en extrait `watchface.bin` et le flashe)
- ❌ **Pas d'installation d'apps `.hap`** : Gadgetbridge n'implémente pas l'installation
d'applications Lite Wearable sur les montres GT — seulement watchfaces + firmwares
- ⚠️ **Régression connue** : HarmonyOS 6.1+ (firmwares 2026, ex. GT 6) casse l'installation
de `.hwt` via GB (issues #5968/#6005/#6199). La GT 3 en HarmonyOS 4.0.0.120 **devrait**
rester OK, mais à valider en pratique.
**Conclusion** : « Gadgetbridge installe des applis » = en réalité des **watchfaces**.
Une vraie app au poignet passe par un autre chemin (§4).
## 3. Signatures : qu'est-ce qu'il faut ?
- **Watchface via Gadgetbridge** : **aucune signature** — le `watchface.bin` est flashé tel
quel par GB. Rien à faire de ce côté.
- **App Lite Wearable (`.hap`) par sideload** : nécessite un certificat de **debug
AppGallery Connect** (compte développeur Huawei, UDID de la montre enregistré,
fichiers `.p12/.cer/.p7b` dans DevEco Studio). Gadgetbridge ne gère pas ce flux.
- **App Android (téléphone)** : debug keystore auto-généré — rien à faire pour un usage
personnel.
## 4. Options « app au poignet » (Phase 2), par coût croissant
### Option A — Watchface personnalisée (statique + champs de données standard)
- Faisable **sans** Huawei : format `.hwt` (ressources + `description.xml`), installable
via GB sur HarmonyOS ≤ 6.0.x
- Limites : les données affichables sont une **liste fixe** (heure, date, batterie, pas,
FC, météo…) — **pas** nos estimations E2/T dynamiques
- Usage possible : rappel visuel statique (ex : « gel 8h · 20h » intégré au design),
pas d'affichage de courbe
### Option B — Mini-app **Lite Wearable** (`.hap`) installée par sideload
- Chaîne : **DevEco Studio** (~10 Go) → projet `[Lite] Empty Ability` (JS UI lite) →
build `.hap` signé → compte **AppGallery Connect** + UDID de la montre → installation
via **DevEco Assistant** (APK sur le téléphone) — procédure documentée (article Huawei
Developers, jan. 2025, testée sur GT 5 ; GT 3/HarmonyOS 4 à valider)
- Limites : framework JS très simple (pas de Canvas riche), stockage local limité
(`@system.storage`), et **pas de synchro live** avec l'app téléphone sans l'API
**Wear Engine** (accès partenaire, dossier à déposer chez Huawei) → la mini-app serait
autonome (saisie/redondance de données sur la montre)
- C'est le seul chemin vers une « vraie app » sur GT 3
### Option C — Rester sur les notifications (choix v1)
- Zéro maintenance, fiable, marche avec GB et Health ; la montre affiche rappel + actions
- Recommandé tant que l'option B n'apporte pas de vraie valeur (voir la saisie pénible
au poignet)
## 5. Décision et prochaines étapes
1. **V1** : notifications via Gadgetbridge (ou Health) — livré ✅
2. À tester par l'utilisatrice : installer une watchface `.hwt` de test via GB sur la GT 3
(HarmonyOS 4.0.0.120) pour valider le chemin d'installation
3. Si une app au poignet est vraiment souhaitée : option B, en commençant par un POC
« afficher une valeur statique » ; la synchro téléphone↔montre étant le point dur
(Wear Engine partenaire), la mini-app devra être **autonome** (saisie sur la montre)
## 6. Références
- Docs GB Huawei/Honor : https://gadgetbridge.org/basics/topics/huawei-honor/
- Compatibilité appareils : https://gadgetbridge.org/gadgets/wearables/huawei-honor/
- PR watchface install (#3910) : https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/3910
- Régression HarmonyOS 6.1 (#6199) : https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/6199
- Guide de développement Lite Wearable (session 1) : DevEco Studio + AGC + DevEco
Assistant — voir historique de chat et https://developer.huawei.com/consumer/en/multidevice/wearables/get-started/

4
gradle.properties Normal file
View File

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
gradlew vendored Executable file
View File

@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

18
settings.gradle.kts Normal file
View File

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "HormoneTrack"
include(":app")