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:
commit
8f7a235c93
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal 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
123
README.md
Normal 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
101
app/build.gradle.kts
Normal 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
2
app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||
43
app/src/main/AndroidManifest.xml
Normal file
43
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
48048
app/src/main/assets/pk_profiles.json
Normal file
48048
app/src/main/assets/pk_profiles.json
Normal file
File diff suppressed because it is too large
Load Diff
32
app/src/main/java/com/hormonetrack/HormoneTrackApp.kt
Normal file
32
app/src/main/java/com/hormonetrack/HormoneTrackApp.kt
Normal 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)
|
||||
}
|
||||
65
app/src/main/java/com/hormonetrack/MainActivity.kt
Normal file
65
app/src/main/java/com/hormonetrack/MainActivity.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
42
app/src/main/java/com/hormonetrack/data/AppDatabase.kt
Normal file
42
app/src/main/java/com/hormonetrack/data/AppDatabase.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
35
app/src/main/java/com/hormonetrack/data/dao/DoseLogDao.kt
Normal file
35
app/src/main/java/com/hormonetrack/data/dao/DoseLogDao.kt
Normal 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)
|
||||
}
|
||||
29
app/src/main/java/com/hormonetrack/data/dao/LabResultDao.kt
Normal file
29
app/src/main/java/com/hormonetrack/data/dao/LabResultDao.kt
Normal 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)
|
||||
}
|
||||
35
app/src/main/java/com/hormonetrack/data/dao/TreatmentDao.kt
Normal file
35
app/src/main/java/com/hormonetrack/data/dao/TreatmentDao.kt
Normal 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)
|
||||
}
|
||||
29
app/src/main/java/com/hormonetrack/data/model/DoseLog.kt
Normal file
29
app/src/main/java/com/hormonetrack/data/model/DoseLog.kt
Normal 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
|
||||
)
|
||||
14
app/src/main/java/com/hormonetrack/data/model/LabResult.kt
Normal file
14
app/src/main/java/com/hormonetrack/data/model/LabResult.kt
Normal 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
|
||||
)
|
||||
134
app/src/main/java/com/hormonetrack/data/model/PKPresets.kt
Normal file
134
app/src/main/java/com/hormonetrack/data/model/PKPresets.kt
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
69
app/src/main/java/com/hormonetrack/data/model/Treatment.kt
Normal file
69
app/src/main/java/com/hormonetrack/data/model/Treatment.kt
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
106
app/src/main/java/com/hormonetrack/pk/PKProfileStore.kt
Normal file
106
app/src/main/java/com/hormonetrack/pk/PKProfileStore.kt
Normal 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
|
||||
}
|
||||
}
|
||||
296
app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt
Normal file
296
app/src/main/java/com/hormonetrack/pk/PharmacokineticEngine.kt
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
222
app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt
Normal file
222
app/src/main/java/com/hormonetrack/reminder/ReminderManager.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
50
app/src/main/java/com/hormonetrack/settings/AppSettings.kt
Normal file
50
app/src/main/java/com/hormonetrack/settings/AppSettings.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
115
app/src/main/java/com/hormonetrack/ui/HormoneTrackRoot.kt
Normal file
115
app/src/main/java/com/hormonetrack/ui/HormoneTrackRoot.kt
Normal 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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
231
app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt
Normal file
231
app/src/main/java/com/hormonetrack/ui/components/CurveChart.kt
Normal 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)
|
||||
}
|
||||
@ -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()
|
||||
182
app/src/main/java/com/hormonetrack/ui/components/DoseDialog.kt
Normal file
182
app/src/main/java/com/hormonetrack/ui/components/DoseDialog.kt
Normal 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)
|
||||
104
app/src/main/java/com/hormonetrack/ui/components/LabDialog.kt
Normal file
104
app/src/main/java/com/hormonetrack/ui/components/LabDialog.kt
Normal 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
158
app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt
Normal file
158
app/src/main/java/com/hormonetrack/ui/screens/ChartScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
181
app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt
Normal file
181
app/src/main/java/com/hormonetrack/ui/screens/DosesScreen.kt
Normal 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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
321
app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt
Normal file
321
app/src/main/java/com/hormonetrack/ui/screens/HomeScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
150
app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt
Normal file
150
app/src/main/java/com/hormonetrack/ui/screens/LabsScreen.kt
Normal 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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
347
app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt
Normal file
347
app/src/main/java/com/hormonetrack/ui/screens/SettingsScreen.kt
Normal 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)
|
||||
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
18
app/src/main/java/com/hormonetrack/ui/theme/Color.kt
Normal file
18
app/src/main/java/com/hormonetrack/ui/theme/Color.kt
Normal 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)
|
||||
48
app/src/main/java/com/hormonetrack/ui/theme/Theme.kt
Normal file
48
app/src/main/java/com/hormonetrack/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
15
app/src/main/java/com/hormonetrack/ui/theme/Type.kt
Normal file
15
app/src/main/java/com/hormonetrack/ui/theme/Type.kt
Normal 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)
|
||||
)
|
||||
16
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
16
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_notification.xml
Normal file
10
app/src/main/res/drawable/ic_notification.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
147
app/src/main/res/values-fr/strings.xml
Normal file
147
app/src/main/res/values-fr/strings.xml
Normal 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 & 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>
|
||||
4
app/src/main/res/values/colors.xml
Normal file
4
app/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#4F5BD5</color>
|
||||
</resources>
|
||||
147
app/src/main/res/values/strings.xml
Normal file
147
app/src/main/res/values/strings.xml
Normal 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 & 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>
|
||||
7
app/src/main/res/values/themes.xml
Normal file
7
app/src/main/res/values/themes.xml
Normal 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>
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
108
app/src/test/java/com/hormonetrack/pk/PKProfileStoreTest.kt
Normal file
108
app/src/test/java/com/hormonetrack/pk/PKProfileStoreTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
136
app/src/test/java/com/hormonetrack/pk/RegressionUserCaseTest.kt
Normal file
136
app/src/test/java/com/hormonetrack/pk/RegressionUserCaseTest.kt
Normal 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
6
build.gradle.kts
Normal 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
45
docs/CHANGELOG.md
Normal 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
529
docs/DEVELOPPEMENT.md
Normal 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
109
docs/GUIDE_INSTALLATION.md
Normal 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
|
||||
94
docs/MONTRE-GADGETBRIDGE.md
Normal file
94
docs/MONTRE-GADGETBRIDGE.md
Normal 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
4
gradle.properties
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
252
gradlew
vendored
Executable 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
94
gradlew.bat
vendored
Normal 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
18
settings.gradle.kts
Normal 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")
|
||||
Loading…
x
Reference in New Issue
Block a user