Compare commits

..

3 commits

21 changed files with 361 additions and 188 deletions

View file

@ -4,6 +4,22 @@
Development opened after the stable v0.1.2 release.
- Unified the Android capture interface around compact dark cards, action
tiles, restrained-radius button variants, wrapping selection grids, and
two-column performed-set entry. Home now focuses on session capture, recent
BODY ZONES exposure, synchronization, and measurements; mobile Statistics
navigation was removed without changing stored data or exchange contracts.
Programs now show compact session metadata, completed progress, and explicit
start/resume/completed states using the existing schema-v25 durable Program
execution provenance. Web Program administration gained adaptive cards, a
visible progress bar, and green completed-session treatment while retaining
its revision-guarded trash confirmation and causal deletion behavior.
Android full lint remains at the exact ten-error `main` baseline (one
`NewApi`, one `LocalContextConfigurationRead`, and eight bilingual
`StringFormatMatches` diagnostics); this lot adds no lint error and disables
no rule. Private candidates may override numeric `versionName` and increasing
`versionCode` at build time without changing development defaults.
- Added the loopback-only `/exercices` desktop catalogue administration
surface with bounded search/profile/BODY ZONES filtering, detail and
read-only equipment presentation, Core-owned creation and editing, complete

View file

@ -45,6 +45,16 @@ val trainlogVersionCode =
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
} ?: 4
/* WHY: private in-place candidates can remain on the installed stable product
* line while development continues on the next version. CONTRACT: the build-
* only override accepts a numeric semantic version and leaves the repository
* default untouched. */
val trainlogVersionName =
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_NAME").orNull?.let { raw ->
raw.takeIf { it.matches(Regex("[0-9]+\\.[0-9]+\\.[0-9]+")) }
?: throw GradleException("TRAINLOG_ANDROID_VERSION_NAME must be numeric x.y.z")
} ?: "0.1.3"
android {
namespace = "com.labfytools.trainlog"
compileSdk = 37
@ -55,7 +65,7 @@ android {
targetSdk = 36
versionCode = trainlogVersionCode
versionName = "0.1.3"
versionName = trainlogVersionName
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
}

View file

@ -82,8 +82,6 @@ class DraftUiTestActivity : ComponentActivity() {
?: (loaded as?
ActiveDraftLoadResult.Loaded)
?.warning,
latestSession = null,
latestMaximum = null,
bodyZoneOverview = repository.getBodyZoneHomeOverview(),
onSession = {
when (
@ -102,9 +100,7 @@ class DraftUiTestActivity : ComponentActivity() {
}
},
onOpenExercises = {},
onOpenStatistics = {},
onBody = {},
onOpenLatestSession = {},
onSync = {},
)
}

View file

@ -41,7 +41,6 @@ private val drawerIcons = mapOf(
AppSection.SESSIONS to R.drawable.ic_sessions,
AppSection.EXERCISES to R.drawable.ic_exercises,
AppSection.EQUIPMENT to R.drawable.ic_equipment,
AppSection.STATISTICS to R.drawable.ic_statistics,
AppSection.SYNC to R.drawable.ic_sync,
AppSection.SETTINGS to R.drawable.ic_settings,
)
@ -59,8 +58,7 @@ fun AndroidAppShell(
fun sectionLabel(section: AppSection): String = strings.getString(when (section) {
AppSection.HOME -> R.string.nav_home; AppSection.SESSIONS -> R.string.nav_sessions
AppSection.EXERCISES -> R.string.nav_exercises; AppSection.EQUIPMENT -> R.string.nav_equipment
AppSection.STATISTICS -> R.string.nav_statistics; AppSection.SYNC -> R.string.nav_sync
AppSection.SETTINGS -> R.string.nav_settings
AppSection.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings
})
fun title(): String = strings.getString(when (route) {
AppRoute.Home -> R.string.nav_home; AppRoute.Sessions -> R.string.nav_sessions
@ -71,7 +69,7 @@ fun AndroidAppShell(
AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail
is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create
AppRoute.Equipment -> R.string.nav_equipment; is AppRoute.EquipmentDetail -> R.string.route_equipment_detail
is AppRoute.EquipmentCreate -> R.string.route_equipment_create; AppRoute.Statistics -> R.string.nav_statistics
is AppRoute.EquipmentCreate -> R.string.route_equipment_create
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
})

View file

@ -12,7 +12,7 @@ import androidx.lifecycle.ViewModel
enum class AppSection {
HOME, SESSIONS, EXERCISES,
EQUIPMENT, STATISTICS, SYNC, SETTINGS,
EQUIPMENT, SYNC, SETTINGS,
}
sealed interface AppRoute {
@ -34,9 +34,8 @@ sealed interface AppRoute {
data object Equipment : AppRoute { override val section = AppSection.EQUIPMENT }
data class EquipmentDetail(val equipmentId: String) : AppRoute { override val section = AppSection.EQUIPMENT }
data class EquipmentCreate(val caller: AppRoute) : AppRoute { override val section = caller.section }
data object Statistics : AppRoute { override val section = AppSection.STATISTICS }
data object BodyMeasurements : AppRoute { override val section = AppSection.STATISTICS }
data object LatestMaxima : AppRoute { override val section = AppSection.STATISTICS }
data object BodyMeasurements : AppRoute { override val section = AppSection.HOME }
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
data object Sync : AppRoute { override val section = AppSection.SYNC }
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
}
@ -46,7 +45,6 @@ fun AppSection.rootRoute(): AppRoute = when (this) {
AppSection.SESSIONS -> AppRoute.Sessions
AppSection.EXERCISES -> AppRoute.Exercises
AppSection.EQUIPMENT -> AppRoute.Equipment
AppSection.STATISTICS -> AppRoute.Statistics
AppSection.SYNC -> AppRoute.Sync
AppSection.SETTINGS -> AppRoute.Settings
}

View file

@ -8,9 +8,12 @@ package com.labfytools.trainlog.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@ -852,7 +855,11 @@ private fun TrainlogChoiceGroup(
),
)
content()
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { content() }
}
}
@ -869,15 +876,15 @@ private fun TrainlogChoice(
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.widthIn(min = 132.dp)
.heightIn(min = 48.dp)
.background(
if (selected) {
colors.surfaceAlt
} else {
colors.surface
}
},
androidx.compose.material3.MaterialTheme.shapes.medium,
)
.semantics { this.selected = selected }
.clickable(

View file

@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
@ -52,8 +51,6 @@ import com.labfytools.trainlog.data.BodyZoneHomeOverview
import com.labfytools.trainlog.data.BodyZoneHomeState
import com.labfytools.trainlog.data.BodyZoneHomeStatus
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.LatestExerciseMax
import com.labfytools.trainlog.model.SessionSummary
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogColors
@ -64,19 +61,14 @@ import com.labfytools.trainlog.R
fun HomeScreen(
activeDraft: ActiveSessionDraft?,
draftError: String?,
latestSession: SessionSummary?,
latestMaximum: LatestExerciseMax?,
bodyZoneOverview: BodyZoneHomeOverview,
onSession: () -> Unit,
onOpenExercises: (String) -> Unit,
onOpenStatistics: () -> Unit,
onBody: () -> Unit,
onOpenLatestSession: (String) -> Unit,
onSync: () -> Unit,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
val locale = presentationLocale()
TrainlogScreen(strings.getString(R.string.nav_home), scrollKey = "home") {
draftError?.let { TrainlogInfo(it, colors.error) }
TrainlogFrame(strings.getString(R.string.home_session)) {
@ -87,17 +79,7 @@ fun HomeScreen(
TrainlogPrimaryAction(strings.getString(R.string.new_manual_session), strings.getString(R.string.new_manual_description), onSession)
}
}
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises, onOpenStatistics)
latestSession?.let { session ->
TrainlogFrame(strings.getString(R.string.latest_session)) {
TrainlogAction(formatStartedAt(session.startedAt), sessionTypeLabel(session.sessionType) + " · " +
strings.resources.getQuantityString(R.plurals.exercise_count, session.exerciseCount, session.exerciseCount), { onOpenLatestSession(session.sessionId) })
}
}
latestMaximum?.let { maximum ->
val weight = "%.2f".format(locale, maximum.maxWeightKg).trimEnd('0').trimEnd(',', '.')
TrainlogInfo(strings.getString(R.string.latest_max_value, maximum.exerciseName, weight, formatDate(maximum.startedAt)), colors.warning)
}
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises)
TrainlogFrame(strings.getString(R.string.quick_access), active = false) {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"),
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@ -119,7 +101,6 @@ fun HomeScreen(
internal fun BodyZoneHomeSection(
overview: BodyZoneHomeOverview,
onOpenExercises: (String) -> Unit,
onOpenStatistics: () -> Unit,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
@ -159,7 +140,9 @@ internal fun BodyZoneHomeSection(
}
selected?.let { zone ->
Column(
Modifier.fillMaxWidth().padding(top = 8.dp).background(colors.surface, RoundedCornerShape(8.dp)).padding(12.dp),
Modifier.fillMaxWidth().padding(top = 8.dp)
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
BasicText(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), style = TrainlogTypography.section.copy(color = colors.text))
@ -179,7 +162,6 @@ internal fun BodyZoneHomeSection(
)
if (zone.availableExerciseCount == 0) TrainlogInfo(BodyZoneHomeState.UNSUPPORTED.label, colors.muted)
TrainlogAction(strings.getString(R.string.view_exercises), "", { onOpenExercises(zone.zoneId) })
TrainlogAction(strings.getString(R.string.view_statistics), "", onOpenStatistics)
}
}
}

View file

@ -8,6 +8,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.StartProgramSessionResult
import com.labfytools.trainlog.data.TrainlogRepository
@ -18,13 +29,34 @@ import com.labfytools.trainlog.model.ProgramSessionExecutionState
fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
val strings = localizedContext()
val programs = repository.listSyncedPrograms()
val colors = LocalTrainlogColors.current
TrainlogScreen(strings.getString(R.string.route_programs)) {
if (programs.isEmpty()) TrainlogInfo(strings.getString(R.string.programs_none))
programs.forEach { program ->
TrainlogFrame(program.title, active = program.state == "active") {
val detail = remember(program.programId) { repository.getSyncedProgram(program.programId) }
val completed = detail?.sessions?.count {
it.executionState == ProgramSessionExecutionState.COMPLETED
} ?: 0
Column(
Modifier.fillMaxWidth().padding(bottom = 10.dp)
.background(colors.surface, MaterialTheme.shapes.medium).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
program.title,
modifier = Modifier.weight(1f).padding(end = 8.dp),
color = colors.text,
fontWeight = FontWeight.Bold,
)
ProgramStateBadge(
if (program.state == "active") strings.getString(R.string.program_active)
else strings.getString(R.string.program_archived),
program.state == "active",
)
}
TrainlogInfo(
listOfNotNull(
strings.getString(if (program.state == "active") R.string.program_active else R.string.program_archived),
program.startDate?.let { strings.getString(R.string.program_start_date, formatDate(it)) },
program.endDate?.let { strings.getString(R.string.program_end_date, formatDate(it)) },
strings.resources.getQuantityString(
@ -34,6 +66,7 @@ fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
),
).joinToString(" · "),
)
TrainlogInfo("$completed / ${program.sessionCount} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) colors.success else colors.muted)
TrainlogButton(
strings.getString(R.string.program_open),
{ onOpen(program.programId) },
@ -84,22 +117,41 @@ fun ProgramDetailScreen(
program.startDate?.let { TrainlogInfo(strings.getString(R.string.program_start_date, formatDate(it))) }
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
program.note?.let { TrainlogInfo(it) }
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
TrainlogInfo("$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted)
}
message?.let { TrainlogInfo(it) }
program.sessions.forEachIndexed { index, session ->
var expanded by remember(session.programSessionId) { mutableStateOf(false) }
val completed = session.executionState == ProgramSessionExecutionState.COMPLETED
TrainlogFrame("${index + 1}. ${session.title}", active = completed) {
Column(
Modifier.fillMaxWidth().padding(bottom = 10.dp)
.background(
if (completed) LocalTrainlogColors.current.success.copy(alpha = .12f)
else LocalTrainlogColors.current.surface,
MaterialTheme.shapes.medium,
).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
val stateLabel = when (session.executionState) {
ProgramSessionExecutionState.TODO -> R.string.program_session_todo
ProgramSessionExecutionState.IN_PROGRESS -> R.string.program_session_in_progress
ProgramSessionExecutionState.COMPLETED -> R.string.program_session_completed
}
TrainlogInfo(strings.getString(stateLabel))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
"${index + 1}. ${session.title}",
modifier = Modifier.weight(1f).padding(end = 8.dp),
color = LocalTrainlogColors.current.text,
fontWeight = FontWeight.Bold,
)
ProgramStateBadge(strings.getString(stateLabel), completed)
}
TrainlogInfo(
listOfNotNull(
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
).joinToString(" · "),
)
session.note?.let { TrainlogInfo(it) }
@ -141,3 +193,18 @@ fun ProgramDetailScreen(
}
}
}
/** Presentation-only state badge; execution truth remains repository-owned. */
@Composable
private fun ProgramStateBadge(label: String, positive: Boolean) {
val colors = LocalTrainlogColors.current
Text(
label,
modifier = Modifier.background(
(if (positive) colors.success else colors.surfaceAlt).copy(alpha = .22f),
MaterialTheme.shapes.small,
).padding(horizontal = 8.dp, vertical = 4.dp),
color = if (positive) colors.success else colors.muted,
style = MaterialTheme.typography.labelMedium,
)
}

View file

@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
@ -188,13 +190,29 @@ fun LatestMaximaScreen(repository: TrainlogRepository) {
val locale = presentationLocale()
val strings = localizedContext()
TrainlogScreen(strings.getString(R.string.route_latest_maxima)) {
TrainlogFrame(strings.getString(R.string.max_capacities), active = maxima.isNotEmpty()) {
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
maxima.forEach { max ->
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
TrainlogInfo("${max.exerciseName} · $weight kg", colors.warning)
TrainlogInfo("${formatDate(max.startedAt)} · " + strings.getString(R.string.equipment_value,
max.equipmentDisplayName ?: strings.getString(R.string.value_none)), colors.muted)
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
maxima.forEach { max ->
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
androidx.compose.foundation.layout.Column(
Modifier.fillMaxWidth().padding(bottom = 8.dp)
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
.padding(12.dp),
) {
androidx.compose.material3.Text(
max.exerciseName,
color = colors.text,
style = com.labfytools.trainlog.ui.theme.TrainlogTypography.section,
)
androidx.compose.foundation.layout.Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
androidx.compose.material3.Text("$weight kg", color = colors.warning)
androidx.compose.material3.Text(formatDate(max.startedAt), color = colors.muted)
}
max.equipmentDisplayName?.let {
TrainlogInfo(strings.getString(R.string.equipment_value, it), colors.muted)
}
}
}
}

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
@ -196,69 +197,34 @@ fun SessionScreen(
TrainlogFrame(
title = strings.getString(R.string.session_type)
) {
TrainlogAction(
label =
if (
currentDraft.sessionType ==
SessionType.TRAINING
) {
strings.getString(R.string.training_selected)
} else {
strings.getString(R.string.training_unselected)
},
description =
strings.getString(R.string.normal_session_description),
accent =
if (
currentDraft.sessionType ==
SessionType.TRAINING
) {
colors.success
} else {
colors.muted
},
onClick = {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val training = currentDraft.sessionType == SessionType.TRAINING
TrainlogButton(
label = strings.getString(R.string.training),
modifier = Modifier.weight(1f),
style = if (training) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
maxLines = 2,
onClick = {
persistDraft(
currentDraft.copy(
sessionType =
SessionType.TRAINING
),
currentDraft.copy(sessionType = SessionType.TRAINING),
null,
)
},
)
TrainlogAction(
label =
if (
currentDraft.sessionType ==
SessionType.MAX_TEST
) {
strings.getString(R.string.max_test_selected)
} else {
strings.getString(R.string.max_test_unselected)
},
description =
strings.getString(R.string.max_session_description),
accent =
if (
currentDraft.sessionType ==
SessionType.MAX_TEST
) {
colors.warning
} else {
colors.muted
},
onClick = {
)
val maxTest = currentDraft.sessionType == SessionType.MAX_TEST
TrainlogButton(
label = strings.getString(R.string.session_max_test),
modifier = Modifier.weight(1f),
style = if (maxTest) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
maxLines = 2,
onClick = {
persistDraft(
currentDraft.copy(
sessionType =
SessionType.MAX_TEST
),
currentDraft.copy(sessionType = SessionType.MAX_TEST),
null,
)
},
)
},
)
}
}
ExercisePicker(
@ -606,7 +572,7 @@ fun SessionScreen(
colors.success
} else {
colors.error
},
}
)
}
}
@ -1151,54 +1117,78 @@ private fun SessionExerciseForm(
strings.getString(R.string.field_weight)
}
setRows.forEachIndexed { index, row ->
TrainlogInfo(
text = strings.getString(R.string.set_number, index + 1),
color = colors.accent,
)
SessionNumberField(
label = strings.getString(R.string.set_reps_field, index + 1),
value = row.repsText,
testTag = "session-set-$index-reps",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(repsText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
Column(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
.background(colors.surface, MaterialTheme.shapes.medium)
.padding(10.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
BasicText(
text = strings.getString(R.string.set_number, index + 1),
modifier = Modifier.padding(vertical = 12.dp),
style = TrainlogTypography.small.copy(
color = colors.accent,
fontWeight = FontWeight.Bold,
),
)
},
)
SessionNumberField(
label = strings.getString(R.string.set_load_field, index + 1, loadLabel),
value = row.weightText,
decimal = true,
testTag = "session-set-$index-weight",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(weightText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
TrainlogIconAction(
icon = TrainlogIcons.DeleteOutline,
contentDescription = localizedContext().getString(R.string.a11y_delete_set),
modifier = Modifier.testTag("delete-set-${index + 1}"),
accent = colors.error,
onClick = { pendingSetRemoval = index },
)
},
)
TrainlogDeleteButton(
contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set),
modifier = Modifier.testTag("delete-set-${index + 1}"),
onClick = {
pendingSetRemoval = index
},
)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SessionNumberField(
label = strings.getString(R.string.field_reps),
value = row.repsText,
modifier = Modifier.weight(1f),
testTag = "session-set-$index-reps",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(repsText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
)
},
)
SessionNumberField(
label = loadLabel,
value = row.weightText,
modifier = Modifier.weight(1f),
decimal = true,
testTag = "session-set-$index-weight",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(weightText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
)
},
)
}
}
}
TrainlogAction(
label = strings.getString(R.string.add_set_plain),
@ -1608,6 +1598,7 @@ private fun SessionNumberField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
testTag: String? = null,
decimal: Boolean = false,
) {
@ -1623,6 +1614,7 @@ private fun SessionNumberField(
} else {
KeyboardOptions(keyboardType = KeyboardType.Number)
},
modifier = modifier,
)
}

View file

@ -93,7 +93,6 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
when (val route = navigation.route) {
AppRoute.Home -> HomeScreen(
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.let { localizedRepositoryMessage(localized, it.message) } ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning?.let { localizedRepositoryMessage(localized, it) },
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
repository.getBodyZoneHomeOverview(),
::openManualSession,
{ zoneId ->
@ -102,8 +101,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
exerciseState.searchQuery.value = ""
open(AppRoute.Exercises)
},
{ open(AppRoute.Statistics) },
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
)
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
@ -141,7 +139,6 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
AppRoute.Statistics -> StatisticsDashboard(repository)
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
@ -37,6 +38,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -164,7 +166,7 @@ fun TrainlogAction(
.padding(vertical = 3.dp)
.heightIn(min = 48.dp)
.height(IntrinsicSize.Min)
.background(Color.Transparent)
.background(colors.surface, MaterialTheme.shapes.medium)
.clickable(onClick = onClick)
) {
Column(
@ -199,6 +201,8 @@ fun TrainlogAction(
}
}
enum class TrainlogButtonStyle { PRIMARY, SECONDARY, SUCCESS, DESTRUCTIVE, GHOST }
/** Compact pictogram action with a mandatory accessible name. */
@Composable
fun TrainlogIconAction(
@ -230,6 +234,7 @@ fun TrainlogDeleteButton(
Button(
onClick = onClick,
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp),
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors(containerColor = colors.error),
) {
Icon(TrainlogIcons.DeleteOutline, contentDescription = contentDescription)
@ -240,9 +245,7 @@ fun TrainlogDeleteButton(
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
val colors = LocalTrainlogColors.current
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
Text(label)
}
TrainlogButton(label, onClick, Modifier.fillMaxWidth())
if (description.isNotBlank()) {
BasicText(
description,
@ -270,7 +273,7 @@ fun TrainlogActionTile(
Column(
modifier
.heightIn(min = 112.dp)
.background(colors.surface, RoundedCornerShape(12.dp))
.background(colors.surface, MaterialTheme.shapes.medium)
.clickable(onClick = onClick)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
@ -291,13 +294,33 @@ fun TrainlogButton(
containerColor: Color? = null,
maxLines: Int = 1,
enabled: Boolean = true,
style: TrainlogButtonStyle = TrainlogButtonStyle.PRIMARY,
) {
val colors = LocalTrainlogColors.current
val resolvedContainer = containerColor ?: when (style) {
TrainlogButtonStyle.PRIMARY -> colors.accent
TrainlogButtonStyle.SECONDARY -> colors.surfaceAlt
TrainlogButtonStyle.SUCCESS -> colors.success
TrainlogButtonStyle.DESTRUCTIVE -> colors.error
TrainlogButtonStyle.GHOST -> Color.Transparent
}
val resolvedContent = when (style) {
TrainlogButtonStyle.PRIMARY, TrainlogButtonStyle.SUCCESS,
TrainlogButtonStyle.DESTRUCTIVE -> colors.crust
TrainlogButtonStyle.SECONDARY, TrainlogButtonStyle.GHOST -> colors.text
}
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 52.dp),
colors = containerColor?.let { ButtonDefaults.buttonColors(containerColor = it) }
?: ButtonDefaults.buttonColors(),
shape = MaterialTheme.shapes.medium,
border = if (style == TrainlogButtonStyle.GHOST) BorderStroke(1.dp, colors.surfaceAlt) else null,
colors = ButtonDefaults.buttonColors(
containerColor = resolvedContainer,
contentColor = resolvedContent,
disabledContainerColor = colors.surfaceAlt,
disabledContentColor = colors.muted,
),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
) {
icon?.let {
@ -334,6 +357,7 @@ fun TrainlogInputField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions =
KeyboardOptions.Default,
testTag: String? = null,
@ -350,8 +374,7 @@ fun TrainlogInputField(
}
Column(
modifier =
Modifier.padding(bottom = 10.dp)
modifier = modifier.padding(bottom = 10.dp)
) {
BasicText(
text = label.uppercase(),

View file

@ -9,12 +9,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
data class TrainlogColors(
val background: Color,
@ -110,6 +113,19 @@ fun TrainlogTheme(
error = TrainlogDarkColors.error,
)
CompositionLocalProvider(LocalTrainlogColors provides TrainlogDarkColors) {
MaterialTheme(colorScheme = scheme, content = content)
/* INVARIANT: all Material controls share the same restrained corner
* language. Screen code may change size, but must not reintroduce the
* former pill-shaped action hierarchy. */
MaterialTheme(
colorScheme = scheme,
shapes = Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(10.dp),
large = RoundedCornerShape(12.dp),
extraLarge = RoundedCornerShape(14.dp),
),
content = content,
)
}
}

View file

@ -36,14 +36,10 @@ class AndroidUiRedesignTest {
HomeScreen(
activeDraft = null,
draftError = null,
latestSession = null,
latestMaximum = null,
bodyZoneOverview = BodyZoneHomeOverview(emptyList(), emptyList(), false, false),
onSession = {},
onOpenExercises = {},
onOpenStatistics = {},
onBody = { bodyCalls++ },
onOpenLatestSession = {},
onSync = { syncCalls++ },
)
}

View file

@ -36,13 +36,14 @@ class AppNavigationStateTest {
@After fun tearDown() { context.deleteDatabase(databaseName) }
@Test fun sevenSectionsHaveTypedRootsAndSelectionComesFromRoute() {
assertEquals(7, AppSection.entries.size)
@Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() {
assertEquals(6, AppSection.entries.size)
assertFalse(AppSection.entries.any { it.name == "STATISTICS" })
AppSection.entries.forEach { assertEquals(it, it.rootRoute().section) }
assertEquals(AppSection.SESSIONS, AppRoute.SessionDetail("se_1").section)
assertEquals(AppSection.EXERCISES, AppRoute.ExerciseDetail("ex_1").section)
assertEquals(AppSection.EQUIPMENT, AppRoute.EquipmentDetail("eq_1").section)
assertEquals(AppSection.STATISTICS, AppRoute.LatestMaxima.section)
assertEquals(AppSection.EXERCISES, AppRoute.LatestMaxima.section)
}
@Test fun catalogueDetailsAndCreatorsReturnToTheirDeclaredCaller() {

View file

@ -110,12 +110,10 @@ class BodyZoneHomeSemanticsTest {
@Test fun practicalWidthMapSelectionFeedsExistingNavigationActions() {
var openedZone: String? = null
var statisticsOpened = false
setSection(
widthDp = 380,
fontScale = 1f,
onOpenExercises = { openedZone = it },
onOpenStatistics = { statisticsOpened = true },
)
assertEquals("core", bodyZoneAtPoint("front", 180f, 236f, Offset(90f, 113.28f), allZoneIds.toSet()))
@ -126,16 +124,12 @@ class BodyZoneHomeSemanticsTest {
compose.onNodeWithText("Voir les exercices").assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick)
assertEquals("core", openedZone)
compose.onNodeWithText("Voir les statistiques").assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick)
assertTrue(statisticsOpened)
}
private fun setSection(
widthDp: Int,
fontScale: Float,
onOpenExercises: (String) -> Unit = {},
onOpenStatistics: () -> Unit = {},
) {
val zones = allZoneIds.map { id ->
status(id, if (id == "chest") BodyZoneHomeState.PRIORITIZE else BodyZoneHomeState.RECENT_WORK)
@ -147,7 +141,6 @@ class BodyZoneHomeSemanticsTest {
BodyZoneHomeSection(
BodyZoneHomeOverview(zones, listOf(zones.first()), true, false),
onOpenExercises,
onOpenStatistics,
)
}
}

View file

@ -57,6 +57,30 @@ It is a native Kotlin/Jetpack Compose application with local SQLite persistence.
The desktop remains the canonical long-term history and analytics store.
## Capture-first interface
The Android interface uses one compact dark design system for cards, action
tiles, buttons, selection states, and destructive actions. Controls share a
restrained corner radius instead of screen-specific pill shapes. The Home
screen is limited to starting or resuming capture, recent BODY ZONES exposure,
synchronization, and body measurements. Analytics and Statistics are not
mobile navigation destinations; the desktop remains their owner. The local
statistics-compatible data and exchange contracts are unchanged.
Sessions exposes manual capture, prepared drafts, completed history, and
Programs as structured actions. Program cards show status, dates, session
count, and completed progress. Program detail shows each planned session as
`todo`, `in progress`, or `completed`, with type, date, exercise count, and a
start/resume action. Starting continues to use the schema-v25 durable singleton
and stable Program provenance described below.
The active editor uses side-by-side Training/MAX selection and compact set
cards. Repetitions and load share one row when space permits; the per-set
delete action stays inside the card and retains its confirmation contract.
Exercise filters use wrapping selection tiles, while catalog rows, drafts,
exercise actions, synchronization actions, Settings actions, and latest MAX
entries use the same card/button vocabulary.
## Interface language (`TRAINLOG_I18N_V0_1_1`)
Trainlog version 0.1.2 is synchronized with the desktop TUI as one product

View file

@ -323,6 +323,21 @@ against desktop schema v27 without transferring ownership of Program
definitions. Web and Android derive their session state from these persisted
identities; no display-field heuristic is used.
Android is capture-first: its drawer no longer exposes Statistics, and Home is
limited to capture, recent BODY ZONES exposure, synchronization, and body
measurements. A shared compact card/button system covers Sessions, Programs,
drafts, exercise catalog/detail/editing, synchronization, Settings, and MAX
presentation. Program list/detail surfaces show completed counts and explicit
todo/in-progress/completed states; the active editor uses exclusive side-by-
side session-type controls and compact two-column set cards. These are
presentation changes only and do not alter schemas or exchange artifacts.
Web Program administration retains revision-guarded deletion and now presents
adaptive cards plus an explicit completed-session progress indicator.
The full Android lint comparison against an isolated clean `main` worktree is
unchanged at ten pre-existing errors: one `NewApi`, one
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
diagnostics. This UI tranche adds no lint error and changes no lint policy.
The private Program execution rollout installed desktop schema v27 and the
signed nondebuggable Android versionCode 17/schema v25 update without
uninstalling or clearing application data. A strictly disposable Program was

View file

@ -656,9 +656,19 @@ tagged source state, and the tag, Android `versionName`, Meson project version,
TUI product version, asset names, and release title must agree.
Private update rollout may set `TRAINLOG_ANDROID_VERSION_CODE` to a positive
integer while keeping `versionName=0.1.2`. This build-only override is used only
to maintain a strictly increasing installed → backup bridge → current APK
chain; it does not change any schema or exchange-format version.
integer and `TRAINLOG_ANDROID_VERSION_NAME=0.1.2` to remain on the installed
stable product line. Both are build-only overrides; the repository development
defaults remain unchanged. They are used only to maintain a strictly increasing
installed → backup bridge → current APK chain and do not change any schema or
exchange-format version.
For `TRAINLOG_UI_ANDROID_WEB_UNIFICATION_V1`, `lintDebug` was also run from a
detached clean worktree at the exact `main` baseline and from the mission
branch. Both runs report the same ten errors: one `NewApi` diagnostic for
`windowLightNavigationBar`, one `LocalContextConfigurationRead`, and eight
`StringFormatMatches` diagnostics (four bilingual call sites). Comparison by
rule and complete diagnostic message reports zero added and zero removed
errors. No lint rule or severity was changed.
```bash
meson setup --reconfigure build

View file

@ -70,6 +70,10 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
completed: 'Effectuée',
deleted: 'Retirée',
})[state]
const completedCount = program.sessions.filter((session) =>
session.execution_state === 'completed').length
const progress = program.sessions.length === 0
? 0 : Math.round((completedCount / program.sessions.length) * 100)
return <article className="session-detail program-detail">
<div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>
@ -99,6 +103,10 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
Import {program.source_format} v{program.source_version} · modifié le{' '}
{formatDateTime(program.updated_at, dateFormat)}
</p>
<div className="program-progress" aria-label={`${completedCount} séances effectuées sur ${program.sessions.length}`}>
<div><strong>{completedCount}/{program.sessions.length}</strong><span>{progress}% effectué</span></div>
<progress value={completedCount} max={Math.max(program.sessions.length, 1)} />
</div>
</header>
<ol className="program-session-list">
{program.sessions.map((session) => <li key={session.program_session_id}>

View file

@ -195,17 +195,23 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
}
.android-delivery-state.state-remote_unknown { color: var(--yellow); }
.android-delivery-state.state-acknowledged { color: var(--green); }
.program-list, .program-session-list { display: grid; gap: .75rem; }
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); }
.program-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr)); gap: 1rem; }
.program-session-list { display: grid; gap: .75rem; }
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); grid-template-columns: minmax(0, 1fr) 3rem; }
.program-row .session-row-main { grid-template-columns: minmax(13rem, 2fr) repeat(3, minmax(8rem, 1fr)); }
.program-session-list { padding: 0; list-style: none; }
.program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; }
.program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; background: var(--base); }
.program-session-list li:has(.state-completed) { border-color: color-mix(in srgb, var(--green) 55%, var(--surface-1)); background: color-mix(in srgb, var(--green) 7%, var(--base)); }
.program-session-list p { margin: .35rem 0 0; color: var(--subtext-0); }
.program-execution-state { display: inline-flex; margin-left: .65rem; padding: .2rem .55rem; border-radius: 999px; font-size: .82rem; font-weight: 700; }
.program-execution-state.state-todo { background: var(--surface-1); color: var(--subtext-1); }
.program-execution-state.state-prepared, .program-execution-state.state-in_progress { background: color-mix(in srgb, var(--yellow) 18%, transparent); color: var(--yellow); }
.program-execution-state.state-completed { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); }
.program-execution-state.state-deleted { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }
.program-progress { max-width: 28rem; display: grid; gap: .4rem; }
.program-progress div { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
.program-progress span { color: var(--subtext-0); font-size: var(--font-size-sm); }
.program-progress progress { width: 100%; height: .55rem; accent-color: var(--green); }
.programs-calendar-page { max-width: 100%; }
.program-calendar-loading { min-height: 12rem; display: grid; place-items: center; color: var(--subtext-0); }
.program-calendar-empty { max-width: 40rem; padding: clamp(1.25rem, 4vw, 2.5rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }