Add functional Android local client
This commit is contained in:
parent
aa82e0f7fa
commit
7e950baf61
35 changed files with 4170 additions and 0 deletions
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -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
5
android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.gradle/
|
||||
.idea/
|
||||
local.properties
|
||||
**/build/
|
||||
*.iml
|
||||
77
android/README.md
Normal file
77
android/README.md
Normal 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
|
||||
```
|
||||
54
android/app/build.gradle.kts
Normal file
54
android/app/build.gradle.kts
Normal 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"
|
||||
)
|
||||
}
|
||||
25
android/app/src/main/AndroidManifest.xml
Normal file
25
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
12
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
12
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
13
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
13
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
14
android/app/src/main/res/values/colors.xml
Normal file
14
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Trainlog</string>
|
||||
</resources>
|
||||
35
android/app/src/main/res/values/themes.xml
Normal file
35
android/app/src/main/res/values/themes.xml
Normal 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
4
android/build.gradle.kts
Normal 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
|
||||
}
|
||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
9
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
9
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
android/gradlew
vendored
Executable 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
82
android/gradlew.bat
vendored
Normal 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%
|
||||
21
android/settings.gradle.kts
Normal file
21
android/settings.gradle.kts
Normal 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")
|
||||
283
docs/android.md
283
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.
|
||||
<!-- 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 -->
|
||||
|
|
|
|||
217
docs/reviews/android_local_workflows.md
Normal file
217
docs/reviews/android_local_workflows.md
Normal 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.
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
Loading…
Reference in a new issue