diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0429bc2..e587d0b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -170,3 +170,18 @@ Next:
- made continuous TUI duration entry explicitly minute-based;
- preserved frozen session JSON v1 unchanged.
+
+
+### Android local client
+
+- added native Kotlin/Jetpack Compose Android client;
+- matched Trainlog TUI visual language;
+- added minimal themed `T` launcher icon;
+- added exercise catalog and profile-aware exercise creation;
+- added inline exercise creation from session recording;
+- added persistent local session recording;
+- preserved SETS versus CONTINUOUS persistence semantics;
+- added local session history and detail views;
+- added persistent body measurement recording;
+- validated the application on a real Samsung device through ADB.
+
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..ebc00fe
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,5 @@
+.gradle/
+.idea/
+local.properties
+**/build/
+*.iml
diff --git a/android/.gitkeep b/android/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/android/README.md b/android/README.md
new file mode 100644
index 0000000..47ffb0f
--- /dev/null
+++ b/android/README.md
@@ -0,0 +1,77 @@
+# Trainlog Android
+
+## Current status
+
+```text
+ANDROID_SCAFFOLD=IMPLEMENTED
+ANDROID_THEME_PARITY=IMPLEMENTED
+ANDROID_HOME=IMPLEMENTED
+ANDROID_SESSION_SHELL=IMPLEMENTED
+ANDROID_INLINE_EXERCISE_ROUTE=IMPLEMENTED
+ANDROID_EXERCISE_SHELL=IMPLEMENTED
+ANDROID_BODY_SHELL=IMPLEMENTED
+
+LOCAL_PERSISTENCE=NEXT
+MTP_SYNC=AFTER_LOCAL_WORKFLOW
+```
+
+## Visual contract
+
+The application follows the same Trainlog language as the TUI:
+
+```text
+dark background
+monospace typography
+cyan/teal accent
+yellow active frame
+green success
+red error
+blue muted/navigation
+magenta graph role
+```
+
+The launcher icon is only a themed `T`.
+
+## Navigation
+
+```text
+Accueil
+├── Enregistrer une séance
+│ └── Créer un nouvel exercice
+│ └── retour séance
+├── Enregistrer un exercice
+└── Enregistrer des mensurations
+```
+
+## Exercise contract
+
+Android must consume the same model as the desktop application:
+
+```text
+recording_mode
+tracking_mode
+data_fields
+```
+
+No exercise input form may be inferred from its name.
+
+## Toolchain
+
+```text
+AGP 9.4.0
+Gradle 9.6
+Kotlin 2.3.21
+Compose BOM 2026.08.00
+compileSdk 37
+targetSdk 36
+minSdk 26
+JDK 17
+```
+
+A Gradle wrapper is intentionally not committed by the scaffold script unless
+it can be generated locally. From this directory:
+
+```bash
+gradle wrapper --gradle-version 9.6.0
+./gradlew assembleDebug
+```
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..59a3038
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,54 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+android {
+ namespace = "com.labfytools.trainlog"
+ compileSdk = 37
+
+ defaultConfig {
+ applicationId = "com.labfytools.trainlog"
+ minSdk = 26
+ targetSdk = 36
+
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ val composeBom =
+ platform("androidx.compose:compose-bom:2026.08.00")
+
+ implementation(composeBom)
+
+ implementation(
+ "androidx.activity:activity-compose:1.13.0"
+ )
+
+ implementation(
+ "androidx.compose.foundation:foundation"
+ )
+
+ implementation(
+ "androidx.compose.ui:ui"
+ )
+
+ implementation(
+ "androidx.compose.ui:ui-tooling-preview"
+ )
+
+ debugImplementation(
+ "androidx.compose.ui:ui-tooling"
+ )
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..2f85ef1
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt b/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt
new file mode 100644
index 0000000..1890355
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt
@@ -0,0 +1,29 @@
+package com.labfytools.trainlog
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import com.labfytools.trainlog.data.TrainlogRepository
+import com.labfytools.trainlog.ui.TrainlogApp
+import com.labfytools.trainlog.ui.theme.TrainlogTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(
+ savedInstanceState: Bundle?
+ ) {
+ super.onCreate(savedInstanceState)
+
+ val repository =
+ TrainlogRepository(
+ applicationContext
+ )
+
+ setContent {
+ TrainlogTheme {
+ TrainlogApp(
+ repository = repository
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/BodyModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/BodyModels.kt
new file mode 100644
index 0000000..48a0a37
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/model/BodyModels.kt
@@ -0,0 +1,66 @@
+package com.labfytools.trainlog.model
+
+data class BodyObservationDraft(
+ val bodyWeightKg: Double? = null,
+ val neckCm: Double? = null,
+ val shouldersCm: Double? = null,
+ val chestCm: Double? = null,
+ val waistCm: Double? = null,
+ val hipsCm: Double? = null,
+ val leftArmCm: Double? = null,
+ val rightArmCm: Double? = null,
+ val leftForearmCm: Double? = null,
+ val rightForearmCm: Double? = null,
+ val leftThighCm: Double? = null,
+ val rightThighCm: Double? = null,
+ val leftCalfCm: Double? = null,
+ val rightCalfCm: Double? = null,
+) {
+ fun hasAnyMetric(): Boolean =
+ listOf(
+ bodyWeightKg,
+ neckCm,
+ shouldersCm,
+ chestCm,
+ waistCm,
+ hipsCm,
+ leftArmCm,
+ rightArmCm,
+ leftForearmCm,
+ rightForearmCm,
+ leftThighCm,
+ rightThighCm,
+ leftCalfCm,
+ rightCalfCm,
+ ).any {
+ it != null
+ }
+
+ fun allValuesPositive(): Boolean =
+ listOf(
+ bodyWeightKg,
+ neckCm,
+ shouldersCm,
+ chestCm,
+ waistCm,
+ hipsCm,
+ leftArmCm,
+ rightArmCm,
+ leftForearmCm,
+ rightForearmCm,
+ leftThighCm,
+ rightThighCm,
+ leftCalfCm,
+ rightCalfCm,
+ ).filterNotNull()
+ .all {
+ it > 0.0
+ }
+}
+
+data class BodyObservationSummary(
+ val observationId: String,
+ val observedAt: String,
+ val bodyWeightKg: Double?,
+ val metricCount: Int,
+)
diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/ExerciseProfile.kt b/android/app/src/main/java/com/labfytools/trainlog/model/ExerciseProfile.kt
new file mode 100644
index 0000000..9f5d581
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/model/ExerciseProfile.kt
@@ -0,0 +1,72 @@
+package com.labfytools.trainlog.model
+
+enum class RecordingMode(
+ val wireValue: String,
+) {
+ SETS("sets"),
+ CONTINUOUS("continuous"),
+}
+
+enum class TrackingMode(
+ val wireValue: String,
+) {
+ REPS("reps"),
+ DURATION("duration"),
+}
+
+object ExerciseDataFields {
+ const val NONE: Int = 0
+ const val SPEED_KMH: Int = 1
+ const val DISTANCE_KM: Int = 2
+ const val KNOWN_MASK: Int =
+ SPEED_KMH or DISTANCE_KM
+}
+
+data class ExerciseProfile(
+ val exerciseId: String,
+ val name: String,
+ val normalizedName: String,
+ val recordingMode: RecordingMode,
+ val trackingMode: TrackingMode,
+ val dataFields: Int,
+)
+
+data class NewExerciseProfile(
+ val name: String,
+ val recordingMode: RecordingMode,
+ val trackingMode: TrackingMode,
+ val dataFields: Int,
+) {
+ fun validate(): Boolean {
+ if (name.isBlank()) {
+ return false
+ }
+
+ if (
+ dataFields and
+ ExerciseDataFields.KNOWN_MASK.inv() != 0
+ ) {
+ return false
+ }
+
+ if (
+ recordingMode ==
+ RecordingMode.CONTINUOUS &&
+ trackingMode !=
+ TrackingMode.DURATION
+ ) {
+ return false
+ }
+
+ if (
+ recordingMode ==
+ RecordingMode.SETS &&
+ dataFields !=
+ ExerciseDataFields.NONE
+ ) {
+ return false
+ }
+
+ return true
+ }
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt
new file mode 100644
index 0000000..2b6a7b2
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt
@@ -0,0 +1,40 @@
+package com.labfytools.trainlog.model
+
+data class SessionSetDraft(
+ val reps: Int = 0,
+ val durationSeconds: Int = 0,
+)
+
+data class SessionExerciseDraft(
+ val exercise: ExerciseProfile,
+ val sets: List = emptyList(),
+ val continuousDurationSeconds: Int = 0,
+ val speedKmh: Double? = null,
+ val distanceKm: Double? = null,
+)
+
+data class SessionDraft(
+ val exercises: List,
+)
+
+data class SessionSummary(
+ val sessionId: String,
+ val startedAt: String,
+ val exerciseCount: Int,
+)
+
+data class SessionExerciseDetail(
+ val exerciseName: String,
+ val recordingMode: RecordingMode,
+ val trackingMode: TrackingMode,
+ val dataFields: Int,
+ val sets: List = emptyList(),
+ val continuousDurationSeconds: Int = 0,
+ val speedKmh: Double? = null,
+ val distanceKm: Double? = null,
+)
+
+data class SessionDetail(
+ val summary: SessionSummary,
+ val exercises: List,
+)
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt
new file mode 100644
index 0000000..93b1be2
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt
@@ -0,0 +1,601 @@
+package com.labfytools.trainlog.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusManager
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.unit.dp
+import com.labfytools.trainlog.data.SaveBodyObservationResult
+import com.labfytools.trainlog.data.TrainlogRepository
+import com.labfytools.trainlog.model.BodyObservationDraft
+import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
+import com.labfytools.trainlog.ui.theme.TrainlogTypography
+
+@Composable
+fun BodyScreen(
+ repository: TrainlogRepository,
+ onBack: () -> Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ val focusManager =
+ LocalFocusManager.current
+
+ val keyboardController =
+ LocalSoftwareKeyboardController.current
+
+ var bodyWeight by
+ remember {
+ mutableStateOf("")
+ }
+
+ var neck by
+ remember {
+ mutableStateOf("")
+ }
+
+ var shoulders by
+ remember {
+ mutableStateOf("")
+ }
+
+ var chest by
+ remember {
+ mutableStateOf("")
+ }
+
+ var waist by
+ remember {
+ mutableStateOf("")
+ }
+
+ var hips by
+ remember {
+ mutableStateOf("")
+ }
+
+ var leftArm by
+ remember {
+ mutableStateOf("")
+ }
+
+ var rightArm by
+ remember {
+ mutableStateOf("")
+ }
+
+ var leftForearm by
+ remember {
+ mutableStateOf("")
+ }
+
+ var rightForearm by
+ remember {
+ mutableStateOf("")
+ }
+
+ var leftThigh by
+ remember {
+ mutableStateOf("")
+ }
+
+ var rightThigh by
+ remember {
+ mutableStateOf("")
+ }
+
+ var leftCalf by
+ remember {
+ mutableStateOf("")
+ }
+
+ var rightCalf by
+ remember {
+ mutableStateOf("")
+ }
+
+ var message by
+ remember {
+ mutableStateOf(
+ null
+ )
+ }
+
+ var revision by
+ remember {
+ mutableIntStateOf(0)
+ }
+
+ val recent =
+ remember(revision) {
+ repository
+ .listBodyObservations(
+ limit = 5
+ )
+ }
+
+ fun clearForm() {
+ bodyWeight = ""
+ neck = ""
+ shoulders = ""
+ chest = ""
+ waist = ""
+ hips = ""
+ leftArm = ""
+ rightArm = ""
+ leftForearm = ""
+ rightForearm = ""
+ leftThigh = ""
+ rightThigh = ""
+ leftCalf = ""
+ rightCalf = ""
+ }
+
+ TrainlogScreen(
+ subtitle = "M E N S U R A T I O N S"
+ ) {
+ TrainlogAction(
+ label = "< Retour",
+ description =
+ "Revenir à l'accueil.",
+ onClick = onBack,
+ accent = colors.muted,
+ )
+
+ TrainlogFrame(
+ title = "GENERAL"
+ ) {
+ BodyMetricField(
+ label = "Poids",
+ unit = "kg",
+ value = bodyWeight,
+ onValueChange = {
+ bodyWeight = it
+ message = null
+ },
+ )
+
+ BodyMetricField(
+ label = "Cou",
+ unit = "cm",
+ value = neck,
+ onValueChange = {
+ neck = it
+ message = null
+ },
+ )
+
+ BodyMetricField(
+ label = "Épaules",
+ unit = "cm",
+ value = shoulders,
+ onValueChange = {
+ shoulders = it
+ message = null
+ },
+ )
+
+ BodyMetricField(
+ label = "Poitrine",
+ unit = "cm",
+ value = chest,
+ onValueChange = {
+ chest = it
+ message = null
+ },
+ )
+
+ BodyMetricField(
+ label = "Tour de taille",
+ unit = "cm",
+ value = waist,
+ onValueChange = {
+ waist = it
+ message = null
+ },
+ )
+
+ BodyMetricField(
+ label = "Hanches",
+ unit = "cm",
+ value = hips,
+ onValueChange = {
+ hips = it
+ message = null
+ },
+ )
+ }
+
+ TrainlogFrame(
+ title = "MEMBRES"
+ ) {
+ BodyMetricField(
+ label = "Bras gauche",
+ unit = "cm",
+ value = leftArm,
+ onValueChange = {
+ leftArm = it
+ message = null
+ },
+ )
+
+ BodyMetricField(
+ label = "Bras droit",
+ unit = "cm",
+ value = rightArm,
+ onValueChange = {
+ rightArm = it
+ message = null
+ },
+ )
+
+ BodyMetricField(
+ label = "Avant-bras gauche",
+ unit = "cm",
+ value = leftForearm,
+ onValueChange = {
+ leftForearm = it
+ message = null
+ },
+ )
+
+ BodyMetricField(
+ label = "Avant-bras droit",
+ unit = "cm",
+ value = rightForearm,
+ onValueChange = {
+ rightForearm = it
+ message = null
+ },
+ )
+
+ BodyMetricField(
+ label = "Cuisse gauche",
+ unit = "cm",
+ value = leftThigh,
+ onValueChange = {
+ leftThigh = it
+ message = null
+ },
+ )
+
+ BodyMetricField(
+ label = "Cuisse droite",
+ unit = "cm",
+ value = rightThigh,
+ onValueChange = {
+ rightThigh = it
+ message = null
+ },
+ )
+
+ BodyMetricField(
+ label = "Mollet gauche",
+ unit = "cm",
+ value = leftCalf,
+ onValueChange = {
+ leftCalf = it
+ message = null
+ },
+ )
+
+ BodyMetricField(
+ label = "Mollet droit",
+ unit = "cm",
+ value = rightCalf,
+ onValueChange = {
+ rightCalf = it
+ message = null
+ },
+ )
+ }
+
+ TrainlogFrame(
+ title = "ENREGISTREMENT"
+ ) {
+ TrainlogAction(
+ label =
+ "Enregistrer les mensurations",
+ description =
+ "Les champs vides sont ignorés.",
+ accent =
+ colors.success,
+ onClick = {
+ focusManager.clearFocus(
+ force = true
+ )
+
+ keyboardController?.hide()
+
+ val values =
+ listOf(
+ bodyWeight,
+ neck,
+ shoulders,
+ chest,
+ waist,
+ hips,
+ leftArm,
+ rightArm,
+ leftForearm,
+ rightForearm,
+ leftThigh,
+ rightThigh,
+ leftCalf,
+ rightCalf,
+ )
+
+ val parsed =
+ values.map {
+ parseOptionalMetric(
+ it
+ )
+ }
+
+ if (
+ parsed.any {
+ it is MetricParse.Invalid
+ }
+ ) {
+ message =
+ "Une valeur est invalide."
+ } else {
+ val doubles =
+ parsed.map {
+ when (it) {
+ is MetricParse.Value ->
+ it.value
+
+ MetricParse.Empty ->
+ null
+
+ MetricParse.Invalid ->
+ null
+ }
+ }
+
+ val draft =
+ BodyObservationDraft(
+ bodyWeightKg =
+ doubles[0],
+ neckCm =
+ doubles[1],
+ shouldersCm =
+ doubles[2],
+ chestCm =
+ doubles[3],
+ waistCm =
+ doubles[4],
+ hipsCm =
+ doubles[5],
+ leftArmCm =
+ doubles[6],
+ rightArmCm =
+ doubles[7],
+ leftForearmCm =
+ doubles[8],
+ rightForearmCm =
+ doubles[9],
+ leftThighCm =
+ doubles[10],
+ rightThighCm =
+ doubles[11],
+ leftCalfCm =
+ doubles[12],
+ rightCalfCm =
+ doubles[13],
+ )
+
+ when (
+ repository
+ .saveBodyObservation(
+ draft
+ )
+ ) {
+ is SaveBodyObservationResult.Saved -> {
+ clearForm()
+ revision += 1
+ message =
+ "Mensurations enregistrées."
+ }
+
+ SaveBodyObservationResult.Invalid -> {
+ message =
+ "Ajoutez au moins une mesure positive."
+ }
+
+ SaveBodyObservationResult.DatabaseError -> {
+ message =
+ "Erreur base locale."
+ }
+ }
+ }
+ },
+ )
+
+ if (
+ message != null
+ ) {
+ TrainlogInfo(
+ text =
+ message.orEmpty(),
+ color =
+ if (
+ message ==
+ "Mensurations enregistrées."
+ ) {
+ colors.success
+ } else {
+ colors.error
+ },
+ )
+ }
+ }
+
+ TrainlogFrame(
+ title = "DERNIERS RELEVES",
+ active =
+ recent.isNotEmpty(),
+ ) {
+ if (recent.isEmpty()) {
+ TrainlogInfo(
+ "Aucun relevé enregistré."
+ )
+ } else {
+ recent.forEach {
+ item ->
+
+ TrainlogInfo(
+ text =
+ buildString {
+ append(
+ formatStartedAt(
+ item.observedAt
+ )
+ )
+
+ append(
+ " · ${item.metricCount} mesure(s)"
+ )
+
+ item.bodyWeightKg
+ ?.let {
+ append(
+ " · %.1f kg"
+ .format(it)
+ )
+ }
+ },
+ color =
+ colors.text,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun BodyMetricField(
+ label: String,
+ unit: String,
+ value: String,
+ onValueChange: (String) -> Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ Column(
+ modifier =
+ Modifier.padding(
+ bottom = 11.dp
+ )
+ ) {
+ BasicText(
+ text =
+ "$label ($unit)"
+ .uppercase(),
+ modifier =
+ Modifier.padding(
+ bottom = 5.dp
+ ),
+ style =
+ TrainlogTypography.small
+ .copy(
+ color =
+ colors.muted,
+ ),
+ )
+
+ BasicTextField(
+ value = value,
+ onValueChange =
+ onValueChange,
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ keyboardType =
+ KeyboardType.Decimal,
+ imeAction =
+ ImeAction.Next,
+ ),
+ cursorBrush =
+ SolidColor(
+ colors.accent
+ ),
+ textStyle =
+ TrainlogTypography.normal
+ .copy(
+ color =
+ colors.text,
+ ),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .border(
+ width = 1.dp,
+ color =
+ colors.surfaceAlt,
+ )
+ .background(
+ colors.surface
+ )
+ .padding(12.dp),
+ )
+ }
+}
+
+private sealed interface MetricParse {
+ data object Empty :
+ MetricParse
+
+ data object Invalid :
+ MetricParse
+
+ data class Value(
+ val value: Double,
+ ) : MetricParse
+}
+
+private fun parseOptionalMetric(
+ text: String,
+): MetricParse {
+ if (text.isBlank()) {
+ return MetricParse.Empty
+ }
+
+ val value =
+ text.trim()
+ .replace(
+ ',',
+ '.'
+ )
+ .toDoubleOrNull()
+ ?: return MetricParse.Invalid
+
+ return if (value > 0.0) {
+ MetricParse.Value(
+ value
+ )
+ } else {
+ MetricParse.Invalid
+ }
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt
new file mode 100644
index 0000000..da6d63a
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt
@@ -0,0 +1,487 @@
+package com.labfytools.trainlog.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.text.BasicTextField
+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.graphics.SolidColor
+import androidx.compose.ui.unit.dp
+import com.labfytools.trainlog.data.CreateExerciseResult
+import com.labfytools.trainlog.data.TrainlogRepository
+import com.labfytools.trainlog.model.ExerciseDataFields
+import com.labfytools.trainlog.model.NewExerciseProfile
+import com.labfytools.trainlog.model.RecordingMode
+import com.labfytools.trainlog.model.TrackingMode
+import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
+import com.labfytools.trainlog.ui.theme.TrainlogTypography
+
+@Composable
+fun ExerciseScreen(
+ repository: TrainlogRepository,
+ inline: Boolean,
+ onBack: () -> Unit,
+ onSaved: () -> Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ var name by
+ remember {
+ mutableStateOf("")
+ }
+
+ var recordingMode by
+ remember {
+ mutableStateOf(
+ RecordingMode.SETS
+ )
+ }
+
+ var trackingMode by
+ remember {
+ mutableStateOf(
+ TrackingMode.REPS
+ )
+ }
+
+ var speed by
+ remember {
+ mutableStateOf(false)
+ }
+
+ var distance by
+ remember {
+ mutableStateOf(false)
+ }
+
+ var message by
+ remember {
+ mutableStateOf(
+ null
+ )
+ }
+
+ TrainlogScreen(
+ subtitle = "E X E R C I C E"
+ ) {
+ TrainlogAction(
+ label =
+ if (inline) {
+ "< Retour à la séance"
+ } else {
+ "< Retour"
+ },
+ description =
+ if (inline) {
+ "Retourner dans la séance en cours."
+ } else {
+ "Revenir à l'accueil."
+ },
+ onClick = onBack,
+ accent = colors.muted,
+ )
+
+ TrainlogFrame(
+ title = "NOUVEL EXERCICE"
+ ) {
+ TrainlogField(
+ label = "Nom",
+ value = name,
+ onValueChange = {
+ name = it
+ message = null
+ },
+ )
+
+ TrainlogChoiceGroup(
+ label = "Organisation",
+ ) {
+ TrainlogChoice(
+ label = "Séries",
+ selected =
+ recordingMode ==
+ RecordingMode.SETS,
+ onClick = {
+ recordingMode =
+ RecordingMode.SETS
+
+ speed = false
+ distance = false
+ message = null
+ },
+ )
+
+ TrainlogChoice(
+ label = "Continu",
+ selected =
+ recordingMode ==
+ RecordingMode.CONTINUOUS,
+ onClick = {
+ recordingMode =
+ RecordingMode.CONTINUOUS
+
+ trackingMode =
+ TrackingMode.DURATION
+
+ message = null
+ },
+ )
+ }
+
+ TrainlogChoiceGroup(
+ label = "Mesure principale",
+ ) {
+ if (
+ recordingMode ==
+ RecordingMode.SETS
+ ) {
+ TrainlogChoice(
+ label =
+ "Répétitions",
+ selected =
+ trackingMode ==
+ TrackingMode.REPS,
+ onClick = {
+ trackingMode =
+ TrackingMode.REPS
+
+ message = null
+ },
+ )
+ }
+
+ TrainlogChoice(
+ label = "Durée",
+ selected =
+ trackingMode ==
+ TrackingMode.DURATION,
+ onClick = {
+ trackingMode =
+ TrackingMode.DURATION
+
+ message = null
+ },
+ )
+ }
+
+ if (
+ recordingMode ==
+ RecordingMode.CONTINUOUS
+ ) {
+ TrainlogChoiceGroup(
+ label =
+ "Données complémentaires",
+ ) {
+ TrainlogChoice(
+ label = "Vitesse",
+ selected = speed,
+ onClick = {
+ speed = !speed
+ message = null
+ },
+ )
+
+ TrainlogChoice(
+ label = "Distance",
+ selected = distance,
+ onClick = {
+ distance =
+ !distance
+
+ message = null
+ },
+ )
+ }
+ }
+
+ val fields =
+ if (
+ recordingMode ==
+ RecordingMode.CONTINUOUS
+ ) {
+ (
+ if (speed) {
+ ExerciseDataFields
+ .SPEED_KMH
+ } else {
+ ExerciseDataFields
+ .NONE
+ }
+ ) or
+ (
+ if (distance) {
+ ExerciseDataFields
+ .DISTANCE_KM
+ } else {
+ ExerciseDataFields
+ .NONE
+ }
+ )
+ } else {
+ ExerciseDataFields.NONE
+ }
+
+ TrainlogInfo(
+ text =
+ profilePreview(
+ recordingMode,
+ trackingMode,
+ fields,
+ ),
+ color = colors.accent,
+ )
+
+ TrainlogAction(
+ label =
+ if (inline) {
+ "Créer et revenir à la séance"
+ } else {
+ "Enregistrer l'exercice"
+ },
+ description =
+ "Ajouter ce profil au catalogue local.",
+ accent =
+ colors.success,
+ onClick = {
+ when (
+ repository.createExercise(
+ NewExerciseProfile(
+ name = name,
+ recordingMode =
+ recordingMode,
+ trackingMode =
+ trackingMode,
+ dataFields =
+ fields,
+ )
+ )
+ ) {
+ is CreateExerciseResult.Created -> {
+ message = null
+ onSaved()
+ }
+
+ CreateExerciseResult.Conflict -> {
+ message =
+ "Un exercice portant ce nom existe déjà."
+ }
+
+ CreateExerciseResult.Invalid -> {
+ message =
+ "Profil ou nom invalide."
+ }
+ }
+ },
+ )
+
+ if (message != null) {
+ TrainlogInfo(
+ text =
+ message.orEmpty(),
+ color = colors.error,
+ )
+ }
+ }
+
+ TrainlogFrame(
+ title = "CONTRAT",
+ active = false,
+ ) {
+ TrainlogInfo(
+ "CONTINUOUS force DURATION."
+ )
+
+ TrainlogInfo(
+ "Aucune règle ne dépend du nom."
+ )
+ }
+ }
+}
+
+@Composable
+private fun TrainlogField(
+ label: String,
+ value: String,
+ onValueChange: (String) -> Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ Column(
+ modifier =
+ Modifier.padding(
+ bottom = 14.dp
+ )
+ ) {
+ BasicText(
+ text = label.uppercase(),
+ modifier =
+ Modifier.padding(
+ bottom = 6.dp
+ ),
+ style =
+ TrainlogTypography.small.copy(
+ color = colors.muted,
+ ),
+ )
+
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ singleLine = true,
+ cursorBrush =
+ SolidColor(
+ colors.accent
+ ),
+ textStyle =
+ TrainlogTypography.normal
+ .copy(
+ color = colors.text
+ ),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .border(
+ width = 1.dp,
+ color =
+ colors.surfaceAlt,
+ )
+ .background(
+ colors.surface
+ )
+ .padding(12.dp),
+ )
+ }
+}
+
+@Composable
+private fun TrainlogChoiceGroup(
+ label: String,
+ content: @Composable () -> Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ Column(
+ modifier =
+ Modifier.padding(
+ bottom = 14.dp
+ )
+ ) {
+ BasicText(
+ text = label.uppercase(),
+ modifier =
+ Modifier.padding(
+ bottom = 5.dp
+ ),
+ style =
+ TrainlogTypography.small.copy(
+ color = colors.muted,
+ ),
+ )
+
+ content()
+ }
+}
+
+@Composable
+private fun TrainlogChoice(
+ label: String,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(
+ vertical = 3.dp
+ )
+ .border(
+ width = 1.dp,
+ color =
+ if (selected) {
+ colors.warning
+ } else {
+ colors.surfaceAlt
+ },
+ )
+ .background(
+ if (selected) {
+ colors.surfaceAlt
+ } else {
+ colors.surface
+ }
+ )
+ .clickable(
+ onClick = onClick
+ )
+ .padding(11.dp)
+ ) {
+ BasicText(
+ text =
+ if (selected) {
+ "[X] $label"
+ } else {
+ "[ ] $label"
+ },
+ style =
+ TrainlogTypography.normal.copy(
+ color =
+ if (selected) {
+ colors.warning
+ } else {
+ colors.text
+ },
+ ),
+ )
+ }
+}
+
+private fun profilePreview(
+ recordingMode: RecordingMode,
+ trackingMode: TrackingMode,
+ dataFields: Int,
+): String {
+ val extras =
+ buildList {
+ if (
+ dataFields and
+ ExerciseDataFields.SPEED_KMH != 0
+ ) {
+ add("VITESSE")
+ }
+
+ if (
+ dataFields and
+ ExerciseDataFields.DISTANCE_KM != 0
+ ) {
+ add("DISTANCE")
+ }
+ }
+
+ return buildString {
+ append(recordingMode.name)
+ append(" + ")
+ append(trackingMode.name)
+
+ for (extra in extras) {
+ append(" + ")
+ append(extra)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt
new file mode 100644
index 0000000..7f1be59
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt
@@ -0,0 +1,74 @@
+package com.labfytools.trainlog.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import com.labfytools.trainlog.data.TrainlogRepository
+import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
+
+@Composable
+fun HistoryScreen(
+ repository: TrainlogRepository,
+ onBack: () -> Unit,
+ onOpenSession: (String) -> Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ val sessions =
+ remember {
+ repository.listSessions()
+ }
+
+ TrainlogScreen(
+ subtitle = "H I S T O R I Q U E"
+ ) {
+ TrainlogAction(
+ label = "< Retour",
+ description =
+ "Revenir à l'accueil.",
+ onClick = onBack,
+ accent = colors.muted,
+ )
+
+ TrainlogFrame(
+ title = "SEANCES",
+ active =
+ sessions.isNotEmpty(),
+ ) {
+ if (sessions.isEmpty()) {
+ TrainlogInfo(
+ "Aucune séance enregistrée."
+ )
+ } else {
+ sessions.forEach {
+ session ->
+
+ TrainlogAction(
+ label =
+ formatStartedAt(
+ session.startedAt
+ ),
+ description =
+ "${session.exerciseCount} exercice(s)",
+ onClick = {
+ onOpenSession(
+ session.sessionId
+ )
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+internal fun formatStartedAt(
+ value: String,
+): String {
+ return value
+ .replace(
+ 'T',
+ ' '
+ )
+ .take(16)
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt
new file mode 100644
index 0000000..2de7c76
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt
@@ -0,0 +1,68 @@
+package com.labfytools.trainlog.ui
+
+import androidx.compose.runtime.Composable
+
+@Composable
+fun HomeScreen(
+ onSession: () -> Unit,
+ onExercise: () -> Unit,
+ onBody: () -> Unit,
+ onHistory: () -> Unit,
+) {
+ TrainlogScreen(
+ subtitle = "A C C U E I L"
+ ) {
+ TrainlogFrame(
+ title = "ENREGISTREMENT"
+ ) {
+ TrainlogAction(
+ label =
+ "Enregistrer une séance",
+ description =
+ "Saisir un entraînement et ses exercices.",
+ onClick = onSession,
+ )
+
+ TrainlogAction(
+ label =
+ "Enregistrer un exercice",
+ description =
+ "Créer une entrée dans le catalogue Trainlog.",
+ onClick = onExercise,
+ )
+
+ TrainlogAction(
+ label =
+ "Enregistrer des mensurations",
+ description =
+ "Ajouter un relevé corporel.",
+ onClick = onBody,
+ )
+ }
+
+ TrainlogFrame(
+ title = "CONSULTATION"
+ ) {
+ TrainlogAction(
+ label =
+ "Historique des séances",
+ description =
+ "Consulter les séances enregistrées et leur détail.",
+ onClick = onHistory,
+ )
+ }
+
+ TrainlogFrame(
+ title = "STATUT",
+ active = false,
+ ) {
+ TrainlogInfo(
+ "Stockage Android local actif."
+ )
+
+ TrainlogInfo(
+ "Synchronisation MTP : après les workflows locaux."
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt
new file mode 100644
index 0000000..6e750b0
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt
@@ -0,0 +1,186 @@
+package com.labfytools.trainlog.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import com.labfytools.trainlog.data.TrainlogRepository
+import com.labfytools.trainlog.model.ExerciseDataFields
+import com.labfytools.trainlog.model.RecordingMode
+import com.labfytools.trainlog.model.SessionExerciseDetail
+import com.labfytools.trainlog.model.TrackingMode
+import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
+
+@Composable
+fun SessionDetailScreen(
+ repository: TrainlogRepository,
+ sessionId: String?,
+ onBack: () -> Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ val detail =
+ remember(sessionId) {
+ sessionId?.let {
+ repository.getSessionDetail(
+ it
+ )
+ }
+ }
+
+ TrainlogScreen(
+ subtitle = "D E T A I L S E A N C E"
+ ) {
+ TrainlogAction(
+ label =
+ "< Retour à l'historique",
+ description =
+ "Revenir à la liste des séances.",
+ onClick = onBack,
+ accent = colors.muted,
+ )
+
+ if (detail == null) {
+ TrainlogFrame(
+ title = "ERREUR"
+ ) {
+ TrainlogInfo(
+ text =
+ "Séance introuvable.",
+ color =
+ colors.error,
+ )
+ }
+
+ return@TrainlogScreen
+ }
+
+ TrainlogFrame(
+ title = "SEANCE"
+ ) {
+ TrainlogInfo(
+ formatStartedAt(
+ detail.summary.startedAt
+ )
+ )
+
+ TrainlogInfo(
+ "${detail.summary.exerciseCount} exercice(s)"
+ )
+ }
+
+ detail.exercises
+ .forEachIndexed {
+ index,
+ exercise ->
+
+ TrainlogFrame(
+ title =
+ "${index + 1}. ${exercise.exerciseName}"
+ ) {
+ if (
+ exercise.recordingMode ==
+ RecordingMode.CONTINUOUS
+ ) {
+ ContinuousDetail(
+ exercise
+ )
+ } else {
+ SetsDetail(
+ exercise
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SetsDetail(
+ exercise: SessionExerciseDetail,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ TrainlogInfo(
+ text =
+ if (
+ exercise.trackingMode ==
+ TrackingMode.REPS
+ ) {
+ "Mode : séries · répétitions"
+ } else {
+ "Mode : séries · durée"
+ },
+ color = colors.accent,
+ )
+
+ exercise.sets
+ .forEachIndexed {
+ index,
+ set ->
+
+ TrainlogInfo(
+ if (
+ exercise.trackingMode ==
+ TrackingMode.REPS
+ ) {
+ "Série ${index + 1} : ${set.reps} reps"
+ } else {
+ "Série ${index + 1} : ${formatDuration(set.durationSeconds)}"
+ }
+ )
+ }
+}
+
+@Composable
+private fun ContinuousDetail(
+ exercise: SessionExerciseDetail,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ TrainlogInfo(
+ text =
+ "Mode : continu",
+ color = colors.accent,
+ )
+
+ TrainlogInfo(
+ "Durée : ${formatDuration(exercise.continuousDurationSeconds)}"
+ )
+
+ if (
+ exercise.dataFields and
+ ExerciseDataFields.SPEED_KMH != 0
+ ) {
+ TrainlogInfo(
+ "Vitesse : %.1f km/h".format(
+ exercise.speedKmh ?: 0.0
+ )
+ )
+ }
+
+ if (
+ exercise.dataFields and
+ ExerciseDataFields.DISTANCE_KM != 0
+ ) {
+ TrainlogInfo(
+ "Distance : %.2f km".format(
+ exercise.distanceKm ?: 0.0
+ )
+ )
+ }
+}
+
+private fun formatDuration(
+ seconds: Int,
+): String {
+ if (
+ seconds > 0 &&
+ seconds % 60 == 0
+ ) {
+ return "${seconds / 60} min"
+ }
+
+ return "$seconds s"
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt
new file mode 100644
index 0000000..bcd0e8a
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt
@@ -0,0 +1,879 @@
+package com.labfytools.trainlog.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.unit.dp
+import com.labfytools.trainlog.data.SaveSessionResult
+import com.labfytools.trainlog.data.TrainlogRepository
+import com.labfytools.trainlog.model.ExerciseDataFields
+import com.labfytools.trainlog.model.ExerciseProfile
+import com.labfytools.trainlog.model.RecordingMode
+import com.labfytools.trainlog.model.SessionDraft
+import com.labfytools.trainlog.model.SessionExerciseDraft
+import com.labfytools.trainlog.model.SessionSetDraft
+import com.labfytools.trainlog.model.TrackingMode
+import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
+import com.labfytools.trainlog.ui.theme.TrainlogTypography
+
+@Composable
+fun SessionScreen(
+ repository: TrainlogRepository,
+ catalogRevision: Int,
+ onBack: () -> Unit,
+ onCreateExercise: () -> Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ val exercises =
+ remember(
+ catalogRevision
+ ) {
+ repository.listExercises()
+ }
+
+ var selectedExercise by
+ remember(
+ catalogRevision
+ ) {
+ mutableStateOf<
+ ExerciseProfile?
+ >(null)
+ }
+
+ var draftExercises by
+ remember {
+ mutableStateOf(
+ emptyList<
+ SessionExerciseDraft
+ >()
+ )
+ }
+
+ var sessionRevision by
+ remember {
+ mutableIntStateOf(0)
+ }
+
+ var message by
+ remember {
+ mutableStateOf<
+ String?
+ >(null)
+ }
+
+ TrainlogScreen(
+ subtitle = "S E A N C E"
+ ) {
+ TrainlogAction(
+ label = "< Retour",
+ description =
+ "Revenir à l'accueil.",
+ onClick = onBack,
+ accent = colors.muted,
+ )
+
+ TrainlogFrame(
+ title = "SEANCE EN COURS"
+ ) {
+ if (
+ draftExercises.isEmpty()
+ ) {
+ TrainlogInfo(
+ "Aucun exercice ajouté."
+ )
+ } else {
+ draftExercises
+ .forEachIndexed {
+ index,
+ draft ->
+
+ TrainlogInfo(
+ text =
+ "${index + 1}. " +
+ draftSummary(
+ draft
+ ),
+ color =
+ colors.text,
+ )
+ }
+ }
+ }
+
+ TrainlogFrame(
+ title = "CATALOGUE",
+ active =
+ exercises.isNotEmpty(),
+ ) {
+ if (
+ exercises.isEmpty()
+ ) {
+ TrainlogInfo(
+ "Aucun exercice."
+ )
+ } else {
+ exercises.forEach {
+ exercise ->
+
+ val alreadyAdded =
+ draftExercises.any {
+ it.exercise.exerciseId ==
+ exercise.exerciseId
+ }
+
+ CatalogChoice(
+ exercise =
+ exercise,
+ selected =
+ selectedExercise
+ ?.exerciseId ==
+ exercise.exerciseId,
+ disabled =
+ alreadyAdded,
+ onClick = {
+ if (
+ !alreadyAdded
+ ) {
+ selectedExercise =
+ exercise
+
+ message = null
+ }
+ },
+ )
+ }
+ }
+ }
+
+ if (
+ selectedExercise != null
+ ) {
+ SessionExerciseForm(
+ key =
+ selectedExercise!!
+ .exerciseId,
+ exercise =
+ selectedExercise!!,
+ onCancel = {
+ selectedExercise =
+ null
+ },
+ onAdd = {
+ draft ->
+ draftExercises =
+ draftExercises +
+ draft
+
+ selectedExercise =
+ null
+
+ sessionRevision += 1
+
+ message =
+ "Exercice ajouté à la séance."
+ },
+ )
+ }
+
+ TrainlogFrame(
+ title = "EXERCICES"
+ ) {
+ TrainlogAction(
+ label =
+ "Créer un nouvel exercice",
+ description =
+ "Créer l'exercice sans quitter la saisie de séance.",
+ onClick =
+ onCreateExercise,
+ accent =
+ colors.success,
+ )
+ }
+
+ TrainlogFrame(
+ title = "ENREGISTREMENT",
+ active =
+ draftExercises.isNotEmpty(),
+ ) {
+ TrainlogAction(
+ label =
+ "Enregistrer la séance",
+ description =
+ "${draftExercises.size} exercice(s) dans la séance.",
+ accent =
+ colors.success,
+ onClick = {
+ when (
+ val result =
+ repository
+ .saveSession(
+ SessionDraft(
+ exercises =
+ draftExercises
+ )
+ )
+ ) {
+ is SaveSessionResult.Saved -> {
+ draftExercises =
+ emptyList()
+
+ selectedExercise =
+ null
+
+ sessionRevision += 1
+
+ message =
+ "Séance enregistrée."
+ }
+
+ SaveSessionResult.Invalid -> {
+ message =
+ "Séance invalide."
+ }
+
+ SaveSessionResult.DatabaseError -> {
+ message =
+ "Erreur base locale."
+ }
+ }
+ },
+ )
+
+ if (
+ message != null
+ ) {
+ TrainlogInfo(
+ text =
+ message.orEmpty(),
+ color =
+ if (
+ message ==
+ "Séance enregistrée." ||
+ message ==
+ "Exercice ajouté à la séance."
+ ) {
+ colors.success
+ } else {
+ colors.error
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun CatalogChoice(
+ exercise: ExerciseProfile,
+ selected: Boolean,
+ disabled: Boolean,
+ onClick: () -> Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ val profile =
+ exerciseProfileLabel(
+ exercise
+ )
+
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(
+ vertical = 3.dp
+ )
+ .border(
+ width = 1.dp,
+ color =
+ when {
+ selected ->
+ colors.warning
+
+ disabled ->
+ colors.muted
+
+ else ->
+ colors.surfaceAlt
+ },
+ )
+ .background(
+ if (selected) {
+ colors.surfaceAlt
+ } else {
+ colors.surface
+ }
+ )
+ .clickable(
+ enabled =
+ !disabled,
+ onClick =
+ onClick,
+ )
+ .padding(11.dp)
+ ) {
+ BasicText(
+ text =
+ (
+ if (disabled) {
+ "[✓] "
+ } else if (selected) {
+ "[>] "
+ } else {
+ "[ ] "
+ }
+ ) +
+ exercise.name +
+ " [$profile]",
+ style =
+ TrainlogTypography.normal
+ .copy(
+ color =
+ if (disabled) {
+ colors.muted
+ } else if (
+ selected
+ ) {
+ colors.warning
+ } else {
+ colors.text
+ },
+ ),
+ )
+ }
+}
+
+@Composable
+private fun SessionExerciseForm(
+ key: String,
+ exercise: ExerciseProfile,
+ onCancel: () -> Unit,
+ onAdd:
+ (SessionExerciseDraft) ->
+ Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ var setCountText by
+ remember(key) {
+ mutableStateOf("3")
+ }
+
+ var repsText by
+ remember(key) {
+ mutableStateOf("10")
+ }
+
+ var durationText by
+ remember(key) {
+ mutableStateOf("30")
+ }
+
+ var speedText by
+ remember(key) {
+ mutableStateOf("")
+ }
+
+ var distanceText by
+ remember(key) {
+ mutableStateOf("")
+ }
+
+ var error by
+ remember(key) {
+ mutableStateOf<
+ String?
+ >(null)
+ }
+
+ TrainlogFrame(
+ title =
+ "SAISIE — ${exercise.name}"
+ ) {
+ TrainlogInfo(
+ text =
+ exerciseProfileLabel(
+ exercise
+ ),
+ color = colors.accent,
+ )
+
+ if (
+ exercise.recordingMode ==
+ RecordingMode.SETS
+ ) {
+ SessionNumberField(
+ label =
+ "Nombre de séries",
+ value =
+ setCountText,
+ onValueChange = {
+ setCountText = it
+ error = null
+ },
+ )
+
+ if (
+ exercise.trackingMode ==
+ TrackingMode.REPS
+ ) {
+ SessionNumberField(
+ label =
+ "Répétitions par série",
+ value =
+ repsText,
+ onValueChange = {
+ repsText = it
+ error = null
+ },
+ )
+ } else {
+ SessionNumberField(
+ label =
+ "Durée par série (secondes)",
+ value =
+ durationText,
+ onValueChange = {
+ durationText = it
+ error = null
+ },
+ )
+ }
+ } else {
+ SessionNumberField(
+ label =
+ "Durée (minutes)",
+ value =
+ durationText,
+ onValueChange = {
+ durationText = it
+ error = null
+ },
+ )
+
+ if (
+ exercise.dataFields and
+ ExerciseDataFields
+ .SPEED_KMH != 0
+ ) {
+ SessionNumberField(
+ label =
+ "Vitesse km/h",
+ value =
+ speedText,
+ onValueChange = {
+ speedText = it
+ error = null
+ },
+ )
+ }
+
+ if (
+ exercise.dataFields and
+ ExerciseDataFields
+ .DISTANCE_KM != 0
+ ) {
+ SessionNumberField(
+ label =
+ "Distance km",
+ value =
+ distanceText,
+ onValueChange = {
+ distanceText = it
+ error = null
+ },
+ )
+ }
+ }
+
+ TrainlogAction(
+ label =
+ "Ajouter à la séance",
+ description =
+ "Ajouter cette saisie au brouillon.",
+ accent =
+ colors.success,
+ onClick = {
+ val draft =
+ buildSessionExerciseDraft(
+ exercise =
+ exercise,
+ setCountText =
+ setCountText,
+ repsText =
+ repsText,
+ durationText =
+ durationText,
+ speedText =
+ speedText,
+ distanceText =
+ distanceText,
+ )
+
+ if (draft == null) {
+ error =
+ "Valeurs invalides."
+ } else {
+ onAdd(draft)
+ }
+ },
+ )
+
+ TrainlogAction(
+ label =
+ "Annuler la saisie",
+ description =
+ "Revenir au catalogue.",
+ accent =
+ colors.muted,
+ onClick =
+ onCancel,
+ )
+
+ if (
+ error != null
+ ) {
+ TrainlogInfo(
+ text =
+ error.orEmpty(),
+ color = colors.error,
+ )
+ }
+ }
+}
+
+@Composable
+private fun SessionNumberField(
+ label: String,
+ value: String,
+ onValueChange: (String) -> Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ Column(
+ modifier =
+ Modifier.padding(
+ bottom = 12.dp
+ )
+ ) {
+ BasicText(
+ text =
+ label.uppercase(),
+ modifier =
+ Modifier.padding(
+ bottom = 5.dp
+ ),
+ style =
+ TrainlogTypography.small
+ .copy(
+ color =
+ colors.muted,
+ ),
+ )
+
+ BasicTextField(
+ value = value,
+ onValueChange =
+ onValueChange,
+ singleLine = true,
+ cursorBrush =
+ SolidColor(
+ colors.accent
+ ),
+ textStyle =
+ TrainlogTypography.normal
+ .copy(
+ color =
+ colors.text,
+ ),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .border(
+ width = 1.dp,
+ color =
+ colors.surfaceAlt,
+ )
+ .background(
+ colors.surface
+ )
+ .padding(12.dp),
+ )
+ }
+}
+
+private fun buildSessionExerciseDraft(
+ exercise: ExerciseProfile,
+ setCountText: String,
+ repsText: String,
+ durationText: String,
+ speedText: String,
+ distanceText: String,
+): SessionExerciseDraft? {
+ return if (
+ exercise.recordingMode ==
+ RecordingMode.CONTINUOUS
+ ) {
+ val minutes =
+ durationText
+ .toIntOrNull()
+
+ if (
+ minutes == null ||
+ minutes <= 0 ||
+ minutes > 1440
+ ) {
+ null
+ } else {
+ val wantsSpeed =
+ exercise.dataFields and
+ ExerciseDataFields
+ .SPEED_KMH != 0
+
+ val wantsDistance =
+ exercise.dataFields and
+ ExerciseDataFields
+ .DISTANCE_KM != 0
+
+ val speed =
+ if (wantsSpeed) {
+ speedText
+ .replace(
+ ',',
+ '.'
+ )
+ .toDoubleOrNull()
+ } else {
+ null
+ }
+
+ val distance =
+ if (
+ wantsDistance
+ ) {
+ distanceText
+ .replace(
+ ',',
+ '.'
+ )
+ .toDoubleOrNull()
+ } else {
+ null
+ }
+
+ if (
+ (
+ wantsSpeed &&
+ (
+ speed == null ||
+ speed <= 0.0
+ )
+ ) ||
+ (
+ wantsDistance &&
+ (
+ distance == null ||
+ distance <= 0.0
+ )
+ )
+ ) {
+ null
+ } else {
+ SessionExerciseDraft(
+ exercise =
+ exercise,
+ continuousDurationSeconds =
+ minutes * 60,
+ speedKmh =
+ speed,
+ distanceKm =
+ distance,
+ )
+ }
+ }
+ } else {
+ val count =
+ setCountText
+ .toIntOrNull()
+
+ if (
+ count == null ||
+ count <= 0 ||
+ count > 64
+ ) {
+ null
+ } else if (
+ exercise.trackingMode ==
+ TrackingMode.REPS
+ ) {
+ val reps =
+ repsText
+ .toIntOrNull()
+
+ if (
+ reps == null ||
+ reps < 0 ||
+ reps > 10000
+ ) {
+ null
+ } else {
+ SessionExerciseDraft(
+ exercise =
+ exercise,
+ sets =
+ List(count) {
+ SessionSetDraft(
+ reps = reps
+ )
+ },
+ )
+ }
+ } else {
+ val seconds =
+ durationText
+ .toIntOrNull()
+
+ if (
+ seconds == null ||
+ seconds <= 0 ||
+ seconds > 86400
+ ) {
+ null
+ } else {
+ SessionExerciseDraft(
+ exercise =
+ exercise,
+ sets =
+ List(count) {
+ SessionSetDraft(
+ durationSeconds =
+ seconds
+ )
+ },
+ )
+ }
+ }
+ }
+}
+
+private fun draftSummary(
+ draft:
+ SessionExerciseDraft,
+): String {
+ val exercise =
+ draft.exercise
+
+ return if (
+ exercise.recordingMode ==
+ RecordingMode.CONTINUOUS
+ ) {
+ buildString {
+ append(
+ exercise.name
+ )
+
+ append(" · ")
+
+ append(
+ draft
+ .continuousDurationSeconds /
+ 60
+ )
+
+ append(" min")
+
+ draft.speedKmh
+ ?.let {
+ append(
+ " · %.1f km/h"
+ .format(it)
+ )
+ }
+
+ draft.distanceKm
+ ?.let {
+ append(
+ " · %.2f km"
+ .format(it)
+ )
+ }
+ }
+ } else {
+ val metric =
+ if (
+ exercise.trackingMode ==
+ TrackingMode.REPS
+ ) {
+ "${draft.sets.firstOrNull()?.reps ?: 0} reps"
+ } else {
+ "${draft.sets.firstOrNull()?.durationSeconds ?: 0} s"
+ }
+
+ "${exercise.name} · ${draft.sets.size} × $metric"
+ }
+}
+
+private fun exerciseProfileLabel(
+ exercise: ExerciseProfile,
+): String {
+ return buildString {
+ append(
+ if (
+ exercise.recordingMode ==
+ RecordingMode.CONTINUOUS
+ ) {
+ "CONTINU"
+ } else {
+ "SERIES"
+ }
+ )
+
+ append(" · ")
+
+ append(
+ if (
+ exercise.trackingMode ==
+ TrackingMode.REPS
+ ) {
+ "REPS"
+ } else {
+ "DUREE"
+ }
+ )
+
+ if (
+ exercise.dataFields and
+ ExerciseDataFields
+ .SPEED_KMH != 0
+ ) {
+ append(" · VITESSE")
+ }
+
+ if (
+ exercise.dataFields and
+ ExerciseDataFields
+ .DISTANCE_KM != 0
+ ) {
+ append(" · DISTANCE")
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt
new file mode 100644
index 0000000..cd57cab
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt
@@ -0,0 +1,167 @@
+package com.labfytools.trainlog.ui
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import com.labfytools.trainlog.data.TrainlogRepository
+
+private enum class TrainlogScreenId {
+ HOME,
+ SESSION,
+ EXERCISE,
+ BODY,
+ HISTORY,
+ SESSION_DETAIL,
+}
+
+@Composable
+fun TrainlogApp(
+ repository: TrainlogRepository,
+) {
+ var screen by
+ remember {
+ mutableStateOf(
+ TrainlogScreenId.HOME
+ )
+ }
+
+ var exerciseReturnTarget by
+ remember {
+ mutableStateOf(
+ TrainlogScreenId.HOME
+ )
+ }
+
+ var catalogRevision by
+ remember {
+ mutableIntStateOf(0)
+ }
+
+ var selectedSessionId by
+ remember {
+ mutableStateOf(
+ null
+ )
+ }
+
+ if (
+ screen !=
+ TrainlogScreenId.HOME
+ ) {
+ BackHandler {
+ screen =
+ when (screen) {
+ TrainlogScreenId.EXERCISE ->
+ exerciseReturnTarget
+
+ TrainlogScreenId.SESSION_DETAIL ->
+ TrainlogScreenId.HISTORY
+
+ else ->
+ TrainlogScreenId.HOME
+ }
+ }
+ }
+
+ when (screen) {
+ TrainlogScreenId.HOME ->
+ HomeScreen(
+ onSession = {
+ screen =
+ TrainlogScreenId.SESSION
+ },
+ onExercise = {
+ exerciseReturnTarget =
+ TrainlogScreenId.HOME
+
+ screen =
+ TrainlogScreenId.EXERCISE
+ },
+ onBody = {
+ screen =
+ TrainlogScreenId.BODY
+ },
+ onHistory = {
+ screen =
+ TrainlogScreenId.HISTORY
+ },
+ )
+
+ TrainlogScreenId.SESSION ->
+ SessionScreen(
+ repository = repository,
+ catalogRevision =
+ catalogRevision,
+ onBack = {
+ screen =
+ TrainlogScreenId.HOME
+ },
+ onCreateExercise = {
+ exerciseReturnTarget =
+ TrainlogScreenId.SESSION
+
+ screen =
+ TrainlogScreenId.EXERCISE
+ },
+ )
+
+ TrainlogScreenId.EXERCISE ->
+ ExerciseScreen(
+ repository = repository,
+ inline =
+ exerciseReturnTarget ==
+ TrainlogScreenId.SESSION,
+ onBack = {
+ screen =
+ exerciseReturnTarget
+ },
+ onSaved = {
+ catalogRevision += 1
+
+ screen =
+ exerciseReturnTarget
+ },
+ )
+
+ TrainlogScreenId.BODY ->
+ BodyScreen(
+ repository = repository,
+ onBack = {
+ screen =
+ TrainlogScreenId.HOME
+ },
+ )
+
+ TrainlogScreenId.HISTORY ->
+ HistoryScreen(
+ repository = repository,
+ onBack = {
+ screen =
+ TrainlogScreenId.HOME
+ },
+ onOpenSession = {
+ sessionId ->
+ selectedSessionId =
+ sessionId
+
+ screen =
+ TrainlogScreenId.SESSION_DETAIL
+ },
+ )
+
+ TrainlogScreenId.SESSION_DETAIL ->
+ SessionDetailScreen(
+ repository = repository,
+ sessionId =
+ selectedSessionId,
+ onBack = {
+ screen =
+ TrainlogScreenId.HISTORY
+ },
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt
new file mode 100644
index 0000000..d840bda
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt
@@ -0,0 +1,261 @@
+package com.labfytools.trainlog.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
+import com.labfytools.trainlog.ui.theme.TrainlogTypography
+
+private val FullAsciiBanner =
+ """
+TTTTT RRRR AAA IIIII N N L OOO GGG
+ T R R A A I NN N L O O G
+ T RRRR AAAAA I N N N L O O G GG
+ T R R A A I N NN L O O G G
+ T R R A A IIIII N N LLLLL OOO GGG
+""".trimIndent()
+
+@Composable
+fun TrainlogScreen(
+ subtitle: String,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ Column(
+ modifier =
+ Modifier
+ .background(colors.background)
+ .statusBarsPadding()
+ .navigationBarsPadding()
+ .imePadding()
+ .verticalScroll(
+ rememberScrollState()
+ )
+ .padding(
+ PaddingValues(
+ horizontal = 16.dp,
+ vertical = 18.dp,
+ )
+ ),
+ ) {
+ TrainlogBanner(
+ subtitle = subtitle
+ )
+
+ content()
+ }
+}
+
+@Composable
+private fun TrainlogBanner(
+ subtitle: String
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ BoxWithConstraints(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(
+ bottom = 18.dp
+ )
+ ) {
+ val wide =
+ maxWidth >= 560.dp
+
+ Column(
+ modifier =
+ Modifier.fillMaxWidth(),
+ horizontalAlignment =
+ Alignment.CenterHorizontally,
+ ) {
+ BasicText(
+ text =
+ if (wide) {
+ FullAsciiBanner
+ } else {
+ "T R A I N L O G"
+ },
+ style =
+ TrainlogTypography.banner.copy(
+ color = colors.accent,
+ fontWeight = FontWeight.Bold,
+ fontSize =
+ if (wide) {
+ 16.sp
+ } else {
+ 24.sp
+ },
+ ),
+ )
+
+ BasicText(
+ text = ":: $subtitle ::",
+ modifier =
+ Modifier.padding(
+ top = 6.dp
+ ),
+ style =
+ TrainlogTypography.normal.copy(
+ color = colors.muted,
+ ),
+ )
+ }
+ }
+}
+
+@Composable
+fun TrainlogFrame(
+ title: String,
+ modifier: Modifier = Modifier,
+ active: Boolean = true,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ val borderColor =
+ if (active) {
+ colors.warning
+ } else {
+ colors.muted
+ }
+
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(
+ bottom = 14.dp
+ )
+ .border(
+ width = 1.dp,
+ color = borderColor,
+ )
+ .padding(
+ horizontal = 14.dp,
+ vertical = 12.dp,
+ )
+ ) {
+ BasicText(
+ text = title.uppercase(),
+ modifier =
+ Modifier.padding(
+ bottom = 12.dp
+ ),
+ style =
+ TrainlogTypography.normal.copy(
+ color = borderColor,
+ fontWeight = FontWeight.Bold,
+ ),
+ )
+
+ content()
+ }
+}
+
+@Composable
+fun TrainlogAction(
+ label: String,
+ description: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ accent: Color? = null,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ val actualAccent =
+ accent ?: colors.accent
+
+ Box(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(
+ vertical = 5.dp
+ )
+ .border(
+ width = 1.dp,
+ color = colors.surfaceAlt,
+ )
+ .background(
+ colors.surface
+ )
+ .clickable(
+ onClick = onClick
+ )
+ .padding(
+ horizontal = 14.dp,
+ vertical = 14.dp,
+ )
+ ) {
+ Column {
+ BasicText(
+ text = label,
+ style =
+ TrainlogTypography.normal.copy(
+ color = actualAccent,
+ fontWeight = FontWeight.Bold,
+ ),
+ )
+
+ BasicText(
+ text = description,
+ modifier =
+ Modifier.padding(
+ top = 4.dp
+ ),
+ style =
+ TrainlogTypography.small.copy(
+ color = colors.text,
+ ),
+ )
+ }
+ }
+}
+
+@Composable
+fun TrainlogInfo(
+ text: String,
+ color: Color? = null,
+) {
+ val colors =
+ LocalTrainlogColors.current
+
+ BasicText(
+ text = text,
+ modifier =
+ Modifier.padding(
+ vertical = 4.dp
+ ),
+ style =
+ TrainlogTypography.normal.copy(
+ color =
+ color ?: colors.text,
+ ),
+ )
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/theme/TrainlogTheme.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/theme/TrainlogTheme.kt
new file mode 100644
index 0000000..3061704
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/theme/TrainlogTheme.kt
@@ -0,0 +1,78 @@
+package com.labfytools.trainlog.ui.theme
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.sp
+
+data class TrainlogColors(
+ val background: Color,
+ val surface: Color,
+ val surfaceAlt: Color,
+ val text: Color,
+ val accent: Color,
+ val success: Color,
+ val warning: Color,
+ val error: Color,
+ val muted: Color,
+ val graph: Color,
+)
+
+private val TrainlogDarkColors =
+ TrainlogColors(
+ background = Color(0xFF1E1E2E),
+ surface = Color(0xFF181825),
+ surfaceAlt = Color(0xFF313244),
+ text = Color(0xFFCDD6F4),
+ accent = Color(0xFF94E2D5),
+ success = Color(0xFFA6E3A1),
+ warning = Color(0xFFF9E2AF),
+ error = Color(0xFFF38BA8),
+ muted = Color(0xFF89B4FA),
+ graph = Color(0xFFF5C2E7),
+ )
+
+val LocalTrainlogColors =
+ staticCompositionLocalOf {
+ TrainlogDarkColors
+ }
+
+object TrainlogTypography {
+ val normal =
+ TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 16.sp,
+ )
+
+ val small =
+ TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 13.sp,
+ )
+
+ val title =
+ TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 20.sp,
+ )
+
+ val banner =
+ TextStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 24.sp,
+ )
+}
+
+@Composable
+fun TrainlogTheme(
+ content: @Composable () -> Unit
+) {
+ CompositionLocalProvider(
+ LocalTrainlogColors provides
+ TrainlogDarkColors,
+ content = content,
+ )
+}
diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..e7d45b9
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..36e19c1
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..36e19c1
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..759971c
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,14 @@
+
+
+ #1E1E2E
+ #181825
+ #313244
+
+ #CDD6F4
+ #94E2D5
+ #A6E3A1
+ #F9E2AF
+ #F38BA8
+ #89B4FA
+ #F5C2E7
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..31da2ce
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Trainlog
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..3efca3a
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,35 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..579d878
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+ id("com.android.application") version "9.4.0" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..d83de41
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+android.useAndroidX=true
+kotlin.code.style=official
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..eddabd2
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..eb84db6
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..379a658
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 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
+#
+
+##############################################################################
+#
+# gradlew 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 gradlew
+#
+# 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//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\n' "$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
+
+
+
+# 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" )
+
+ 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='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_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" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# 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" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..8508ef6
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,82 @@
+@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 gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+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="-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
+
+"%COMSPEC%" /c exit 1
+
+: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
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..e4d26b6
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,21 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(
+ RepositoriesMode.FAIL_ON_PROJECT_REPOS
+ )
+
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "TrainlogAndroid"
+include(":app")
diff --git a/docs/android.md b/docs/android.md
index 76a4c95..60f013b 100644
--- a/docs/android.md
+++ b/docs/android.md
@@ -347,3 +347,286 @@ The app must never infer an input form from an exercise display name.
Initial development uses fictitious records. Test/development data is removed
before normal production use starts.
+
+
+## Android scaffold implementation
+
+The Android project now lives in:
+
+```text
+android/
+```
+
+It is a native Kotlin + Jetpack Compose application with a custom Trainlog
+visual layer rather than default Material presentation.
+
+Implemented scaffold navigation:
+
+```text
+Accueil
+├── Enregistrer une séance
+│ ├── Ajouter depuis le catalogue
+│ └── Créer un nouvel exercice
+│ └── returns to session flow
+├── Enregistrer un exercice
+└── Enregistrer des mensurations
+```
+
+The launcher icon is a minimal themed `T`.
+
+The UI reuses the Trainlog visual roles from the TUI:
+
+```text
+background
+accent/cyan
+success/green
+warning/yellow
+error/red
+muted/blue
+graph/magenta
+```
+
+Android forms will be driven by:
+
+```text
+recording_mode
+tracking_mode
+data_fields
+```
+
+The scaffold intentionally does not implement persistence or MTP yet.
+
+Next:
+
+```text
+ANDROID_LOCAL_MODEL_AND_PERSISTENCE=NEXT
+ANDROID_SESSION_FORM=AFTER
+ANDROID_MTP_SYNC=AFTER_LOCAL_WORKFLOW
+```
+
+
+
+## Android local catalog checkpoint
+
+The Android application now has a persistent local exercise catalog.
+
+Implemented:
+
+```text
+Android SQLite exercise database
+profile-aware exercise model
+standalone exercise creation
+inline exercise creation from session flow
+catalog survives application restart
+session screen refreshes after inline creation
+```
+
+Android uses the same semantic axes as desktop:
+
+```text
+recording_mode
+tracking_mode
+data_fields
+```
+
+Known supplemental fields remain:
+
+```text
+SPEED_KMH
+DISTANCE_KM
+```
+
+Continuous creation forces duration tracking. Set-based creation keeps
+supplemental continuous fields disabled.
+
+The local Android schema is intentionally independent from the desktop SQLite
+schema. Synchronization later exchanges versioned domain data rather than
+copying SQLite database files.
+
+Next:
+
+```text
+ANDROID_SESSION_RECORDING=NEXT
+ANDROID_BODY_PERSISTENCE=AFTER
+MTP_SYNC=AFTER_LOCAL_WORKFLOWS
+```
+
+
+
+## Android session recording checkpoint
+
+Android can now build and persist real local sessions.
+
+Flow:
+
+```text
+Session
+→ choose catalog exercise
+→ profile-aware entry form
+→ add exercise to session draft
+→ repeat for additional exercises
+→ save session
+```
+
+Profile-aware forms:
+
+```text
+SETS + REPS
+ set count
+ repetitions per set
+
+SETS + DURATION
+ set count
+ duration per set
+
+CONTINUOUS + DURATION
+ duration minutes
+ configured speed/distance fields
+```
+
+Persistence mirrors the domain split:
+
+```text
+sessions
+session_exercises
+performed_sets
+continuous_activity
+```
+
+Continuous exercises do not create fake performed sets.
+
+The Android local database version is now 2.
+
+Next:
+
+```text
+ANDROID_SESSION_HISTORY=NEXT
+ANDROID_BODY_PERSISTENCE=AFTER
+MTP_SYNC=AFTER_LOCAL_WORKFLOWS
+```
+
+
+
+## Android session history checkpoint
+
+Android now exposes persisted local sessions through:
+
+```text
+Accueil
+→ Consultation
+→ Historique des séances
+→ Détail séance
+```
+
+Detail rendering remains profile-aware:
+
+```text
+SETS + REPS
+ one line per performed set with reps
+
+SETS + DURATION
+ one line per performed set with duration
+
+CONTINUOUS
+ duration
+ configured speed
+ configured distance
+```
+
+The history reader uses the persisted session snapshot metadata rather than
+inferring behavior from exercise names.
+
+Next:
+
+```text
+ANDROID_BODY_PERSISTENCE=NEXT
+ANDROID_LOCAL_WORKFLOWS_THEN_MTP
+```
+
+
+
+## Android body measurement checkpoint
+
+The Android body workflow is now persistent and uses the same measurement set
+as the TUI.
+
+Fields:
+
+```text
+weight
+neck
+shoulders
+chest
+waist
+hips
+left/right arm
+left/right forearm
+left/right thigh
+left/right calf
+```
+
+Rules:
+
+```text
+empty field = measurement not taken
+at least one positive metric required
+comma or dot accepted for decimal entry
+```
+
+Android SQLite schema version:
+
+```text
+3
+```
+
+The body screen also shows the five most recent observations.
+
+At this point the three primary Android recording workflows are locally
+functional:
+
+```text
+session recording
+exercise creation
+body measurement recording
+```
+
+Next:
+
+```text
+ANDROID_LOCAL_POLISH_AND_VALIDATION=NEXT
+MTP_SYNC=AFTER_LOCAL_CHECKPOINT
+```
+
+
+
+## Local Android workflows — validated
+
+```text
+ANDROID_SCAFFOLD=PASS
+ANDROID_THEME_PARITY=PASS
+ANDROID_EXERCISE_CREATE=PASS
+ANDROID_INLINE_EXERCISE_CREATE=PASS
+ANDROID_SESSION_RECORDING=PASS
+ANDROID_SESSION_HISTORY=PASS
+ANDROID_BODY_RECORDING=PASS
+ANDROID_LOCAL_WORKFLOWS=PASS
+```
+
+The application is now locally usable for its three primary recording flows:
+
+```text
+session
+exercise
+body measurements
+```
+
+Session and history rendering are profile-aware.
+
+The Android-local SQLite database is not a synchronization format.
+
+Next:
+
+```text
+ANDROID_MTP_SYNC=NEXT
+```
+
diff --git a/docs/reviews/android_local_workflows.md b/docs/reviews/android_local_workflows.md
new file mode 100644
index 0000000..ebeed41
--- /dev/null
+++ b/docs/reviews/android_local_workflows.md
@@ -0,0 +1,217 @@
+# Android local workflows checkpoint
+
+## Status
+
+```text
+ANDROID_SCAFFOLD=PASS
+ANDROID_THEME_PARITY=PASS
+ANDROID_SYSTEM_BARS=PASS
+ANDROID_EXERCISE_CREATE=PASS
+ANDROID_INLINE_EXERCISE_CREATE=PASS
+ANDROID_SESSION_RECORDING=PASS
+ANDROID_SESSION_HISTORY=PASS
+ANDROID_SESSION_DETAIL=PASS
+ANDROID_BODY_RECORDING=PASS
+ANDROID_LOCAL_WORKFLOWS=PASS
+
+ANDROID_MTP_SYNC=NEXT
+```
+
+## Visual contract
+
+Android and TUI share the same Trainlog visual language:
+
+```text
+dark background
+monospace typography
+cyan/teal accent
+yellow active/focus frame
+green success
+red error
+blue muted/navigation
+magenta graph role
+```
+
+The Android launcher icon is intentionally only:
+
+```text
+T
+```
+
+using Trainlog theme colors.
+
+## Home structure
+
+```text
+ENREGISTREMENT
+├── Enregistrer une séance
+├── Enregistrer un exercice
+└── Enregistrer des mensurations
+
+CONSULTATION
+└── Historique des séances
+```
+
+## Exercise creation
+
+Android uses the same exercise-profile model as desktop:
+
+```text
+recording_mode
+tracking_mode
+data_fields
+```
+
+Supported profile combinations:
+
+```text
+SETS + REPS
+SETS + DURATION
+CONTINUOUS + DURATION
+```
+
+Known supplemental fields:
+
+```text
+SPEED_KMH
+DISTANCE_KM
+```
+
+Behavior is never inferred from exercise names.
+
+Exercise creation is available:
+
+```text
+standalone
+inline from session recording
+```
+
+Inline creation returns directly to the session workflow.
+
+## Session recording
+
+The local Android session flow is:
+
+```text
+choose catalog exercise
+→ profile-aware form
+→ add to session draft
+→ repeat
+→ save session
+```
+
+Persistence is split semantically:
+
+```text
+SET-based exercise
+ performed_sets
+
+CONTINUOUS exercise
+ continuous_activity
+```
+
+Continuous activities never create fake performed sets.
+
+## Session history
+
+Persisted sessions are visible in Android history.
+
+Detail rendering remains profile-aware:
+
+```text
+SETS + REPS
+ per-set reps
+
+SETS + DURATION
+ per-set durations
+
+CONTINUOUS
+ duration
+ configured speed
+ configured distance
+```
+
+## Body measurements
+
+Android supports the same body measurement fields as the TUI:
+
+```text
+weight
+neck
+shoulders
+chest
+waist
+hips
+left/right arm
+left/right forearm
+left/right thigh
+left/right calf
+```
+
+Rules:
+
+```text
+empty = not measured
+comma or dot accepted
+at least one positive metric required
+```
+
+Recent observations are displayed in the body screen.
+
+## Android local database
+
+Current Android-local schema version:
+
+```text
+3
+```
+
+Current local tables include:
+
+```text
+exercises
+sessions
+session_exercises
+performed_sets
+continuous_activity
+body_observations
+```
+
+The Android SQLite database is intentionally independent from the desktop
+SQLite database.
+
+Synchronization must exchange versioned Trainlog domain data.
+
+Do not synchronize or copy SQLite database files.
+
+## Manual validation
+
+Validated on a real Samsung device through ADB:
+
+```text
+APK build/install/launch
+exercise creation
+catalog persistence across restart
+session recording
+performed-set persistence
+session history/detail
+body observation persistence
+```
+
+Example validated session:
+
+```text
+Pompe
+SETS + REPS
+5 sets × 10 reps
+```
+
+## Next cursor
+
+```text
+ANDROID_MTP_SYNC=NEXT
+```
+
+The next slice should connect the already-validated direct MTP transport design
+to Android/desktop exchange without weakening the frozen Trainlog JSON v1
+contract.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 0c3369d..028c84b 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -349,3 +349,30 @@ Next Android slice:
Do not revert continuous activities to performed sets.
Do not modify JSON v1 to accommodate continuous metrics.
+
+
+## Android local checkpoint
+
+```text
+ANDROID_PROJECT=PASS
+ANDROID_THEME=PASS
+ANDROID_EXERCISE_CATALOG=PASS
+ANDROID_SESSION_RECORDING=PASS
+ANDROID_SESSION_HISTORY=PASS
+ANDROID_BODY_RECORDING=PASS
+
+ANDROID_MTP_SYNC=NEXT
+```
+
+The next implementation cursor is synchronization between the Android client
+and the desktop TUI over the existing direct-MTP transport architecture.
+
+Constraints remain:
+
+```text
+no GVFS/FUSE dependency
+no SQLite-file synchronization
+TRAINLOG_FORMAT_V1 remains frozen
+profile-aware data must not be forced into v1
+```
+