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