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;
|
- made continuous TUI duration entry explicitly minute-based;
|
||||||
- preserved frozen session JSON v1 unchanged.
|
- preserved frozen session JSON v1 unchanged.
|
||||||
<!-- TRAINLOG_PROFILE_AWARE_CHANGELOG _END -->
|
<!-- 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
|
Initial development uses fictitious records. Test/development data is removed
|
||||||
before normal production use starts.
|
before normal production use starts.
|
||||||
<!-- TRAINLOG_ANDROID_PROFILE_CURSOR _END -->
|
<!-- 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 revert continuous activities to performed sets.
|
||||||
Do not modify JSON v1 to accommodate continuous metrics.
|
Do not modify JSON v1 to accommodate continuous metrics.
|
||||||
<!-- TRAINLOG_PROFILE_AWARE_ROADMAP_FINAL _END -->
|
<!-- 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