Add functional Android local client

This commit is contained in:
fy59 2026-09-06 14:29:59 +02:00
parent aa82e0f7fa
commit 7e950baf61
35 changed files with 4170 additions and 0 deletions

View file

@ -170,3 +170,18 @@ Next:
- made continuous TUI duration entry explicitly minute-based;
- preserved frozen session JSON v1 unchanged.
<!-- TRAINLOG_PROFILE_AWARE_CHANGELOG _END -->
<!-- TRAINLOG_ANDROID_LOCAL_CHANGELOG -->
### 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.
<!-- TRAINLOG_ANDROID_LOCAL_CHANGELOG _END -->

5
android/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.gradle/
.idea/
local.properties
**/build/
*.iml

View file

77
android/README.md Normal file
View file

@ -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
```

View file

@ -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"
)
}

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Trainlog">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action
android:name="android.intent.action.MAIN" />
<category
android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -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
)
}
}
}
}

View file

@ -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,
)

View file

@ -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
}
}

View file

@ -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<SessionSetDraft> = emptyList(),
val continuousDurationSeconds: Int = 0,
val speedKmh: Double? = null,
val distanceKm: Double? = null,
)
data class SessionDraft(
val exercises: List<SessionExerciseDraft>,
)
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<SessionSetDraft> = emptyList(),
val continuousDurationSeconds: Int = 0,
val speedKmh: Double? = null,
val distanceKm: Double? = null,
)
data class SessionDetail(
val summary: SessionSummary,
val exercises: List<SessionExerciseDetail>,
)

View file

@ -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<String?>(
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
}
}

View file

@ -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<String?>(
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)
}
}
}

View file

@ -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)
}

View file

@ -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."
)
}
}
}

View file

@ -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"
}

View file

@ -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")
}
}
}

View file

@ -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<String?>(
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
},
)
}
}

View file

@ -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,
),
)
}

View file

@ -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,
)
}

View file

@ -0,0 +1,12 @@
<?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:fillColor="#94E2D5"
android:pathData="M24,20H84V34H61V88H47V34H24Z" />
</vector>

View file

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

View file

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

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="trainlog_background">#1E1E2E</color>
<color name="trainlog_surface">#181825</color>
<color name="trainlog_surface_alt">#313244</color>
<color name="trainlog_text">#CDD6F4</color>
<color name="trainlog_accent">#94E2D5</color>
<color name="trainlog_success">#A6E3A1</color>
<color name="trainlog_warning">#F9E2AF</color>
<color name="trainlog_error">#F38BA8</color>
<color name="trainlog_muted">#89B4FA</color>
<color name="trainlog_graph">#F5C2E7</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Trainlog</string>
</resources>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style
name="Theme.Trainlog"
parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">
monospace
</item>
<item name="android:windowBackground">
@color/trainlog_background
</item>
<item name="android:statusBarColor">
@color/trainlog_background
</item>
<item name="android:navigationBarColor">
@color/trainlog_background
</item>
<item name="android:windowLightStatusBar">
false
</item>
<item name="android:windowLightNavigationBar">
false
</item>
<item name="android:colorAccent">
@color/trainlog_accent
</item>
</style>
</resources>

4
android/build.gradle.kts Normal file
View file

@ -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
}

View file

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

Binary file not shown.

View file

@ -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

248
android/gradlew vendored Executable file
View file

@ -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/<unknown>/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" "$@"

82
android/gradlew.bat vendored Normal file
View file

@ -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%

View file

@ -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")

View file

@ -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.
<!-- TRAINLOG_ANDROID_PROFILE_CURSOR _END -->
<!-- TRAINLOG_ANDROID_SCAFFOLD_IMPLEMENTED -->
## 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
```
<!-- TRAINLOG_ANDROID_SCAFFOLD_IMPLEMENTED _END -->
<!-- TRAINLOG_ANDROID_LOCAL_CATALOG -->
## 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
```
<!-- TRAINLOG_ANDROID_LOCAL_CATALOG _END -->
<!-- TRAINLOG_ANDROID_SESSION_RECORDING -->
## 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
```
<!-- TRAINLOG_ANDROID_SESSION_RECORDING _END -->
<!-- TRAINLOG_ANDROID_SESSION_HISTORY -->
## 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
```
<!-- TRAINLOG_ANDROID_SESSION_HISTORY _END -->
<!-- TRAINLOG_ANDROID_BODY_PERSISTENCE -->
## 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
```
<!-- TRAINLOG_ANDROID_BODY_PERSISTENCE _END -->
<!-- TRAINLOG_ANDROID_LOCAL_WORKFLOWS_PASS -->
## 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
```
<!-- TRAINLOG_ANDROID_LOCAL_WORKFLOWS_PASS _END -->

View file

@ -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.

View file

@ -349,3 +349,30 @@ Next Android slice:
Do not revert continuous activities to performed sets.
Do not modify JSON v1 to accommodate continuous metrics.
<!-- TRAINLOG_PROFILE_AWARE_ROADMAP_FINAL _END -->
<!-- TRAINLOG_ANDROID_LOCAL_CHECKPOINT -->
## 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
```
<!-- TRAINLOG_ANDROID_LOCAL_CHECKPOINT _END -->