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. 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 - Added the loopback-only `/exercices` desktop catalogue administration
surface with bounded search/profile/BODY ZONES filtering, detail and surface with bounded search/profile/BODY ZONES filtering, detail and
read-only equipment presentation, Core-owned creation and editing, complete 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") ?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
} ?: 4 } ?: 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 { android {
namespace = "com.labfytools.trainlog" namespace = "com.labfytools.trainlog"
compileSdk = 37 compileSdk = 37
@ -55,7 +65,7 @@ android {
targetSdk = 36 targetSdk = 36
versionCode = trainlogVersionCode versionCode = trainlogVersionCode
versionName = "0.1.3" versionName = trainlogVersionName
testInstrumentationRunner = testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner" "androidx.test.runner.AndroidJUnitRunner"
} }

View file

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

View file

@ -41,7 +41,6 @@ private val drawerIcons = mapOf(
AppSection.SESSIONS to R.drawable.ic_sessions, AppSection.SESSIONS to R.drawable.ic_sessions,
AppSection.EXERCISES to R.drawable.ic_exercises, AppSection.EXERCISES to R.drawable.ic_exercises,
AppSection.EQUIPMENT to R.drawable.ic_equipment, AppSection.EQUIPMENT to R.drawable.ic_equipment,
AppSection.STATISTICS to R.drawable.ic_statistics,
AppSection.SYNC to R.drawable.ic_sync, AppSection.SYNC to R.drawable.ic_sync,
AppSection.SETTINGS to R.drawable.ic_settings, AppSection.SETTINGS to R.drawable.ic_settings,
) )
@ -59,8 +58,7 @@ fun AndroidAppShell(
fun sectionLabel(section: AppSection): String = strings.getString(when (section) { fun sectionLabel(section: AppSection): String = strings.getString(when (section) {
AppSection.HOME -> R.string.nav_home; AppSection.SESSIONS -> R.string.nav_sessions 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.EXERCISES -> R.string.nav_exercises; AppSection.EQUIPMENT -> R.string.nav_equipment
AppSection.STATISTICS -> R.string.nav_statistics; AppSection.SYNC -> R.string.nav_sync AppSection.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings
AppSection.SETTINGS -> R.string.nav_settings
}) })
fun title(): String = strings.getString(when (route) { fun title(): String = strings.getString(when (route) {
AppRoute.Home -> R.string.nav_home; AppRoute.Sessions -> R.string.nav_sessions 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 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 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 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.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 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 { enum class AppSection {
HOME, SESSIONS, EXERCISES, HOME, SESSIONS, EXERCISES,
EQUIPMENT, STATISTICS, SYNC, SETTINGS, EQUIPMENT, SYNC, SETTINGS,
} }
sealed interface AppRoute { sealed interface AppRoute {
@ -34,9 +34,8 @@ sealed interface AppRoute {
data object Equipment : AppRoute { override val section = AppSection.EQUIPMENT } data object Equipment : AppRoute { override val section = AppSection.EQUIPMENT }
data class EquipmentDetail(val equipmentId: String) : 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 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.HOME }
data object BodyMeasurements : AppRoute { override val section = AppSection.STATISTICS } data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
data object LatestMaxima : AppRoute { override val section = AppSection.STATISTICS }
data object Sync : AppRoute { override val section = AppSection.SYNC } data object Sync : AppRoute { override val section = AppSection.SYNC }
data object Settings : AppRoute { override val section = AppSection.SETTINGS } data object Settings : AppRoute { override val section = AppSection.SETTINGS }
} }
@ -46,7 +45,6 @@ fun AppSection.rootRoute(): AppRoute = when (this) {
AppSection.SESSIONS -> AppRoute.Sessions AppSection.SESSIONS -> AppRoute.Sessions
AppSection.EXERCISES -> AppRoute.Exercises AppSection.EXERCISES -> AppRoute.Exercises
AppSection.EQUIPMENT -> AppRoute.Equipment AppSection.EQUIPMENT -> AppRoute.Equipment
AppSection.STATISTICS -> AppRoute.Statistics
AppSection.SYNC -> AppRoute.Sync AppSection.SYNC -> AppRoute.Sync
AppSection.SETTINGS -> AppRoute.Settings AppSection.SETTINGS -> AppRoute.Settings
} }

View file

@ -8,9 +8,12 @@ package com.labfytools.trainlog.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState 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( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .widthIn(min = 132.dp)
.padding(vertical = 2.dp)
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.background( .background(
if (selected) { if (selected) {
colors.surfaceAlt colors.surfaceAlt
} else { } else {
colors.surface colors.surface
} },
androidx.compose.material3.MaterialTheme.shapes.medium,
) )
.semantics { this.selected = selected } .semantics { this.selected = selected }
.clickable( .clickable(

View file

@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text 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.BodyZoneHomeState
import com.labfytools.trainlog.data.BodyZoneHomeStatus import com.labfytools.trainlog.data.BodyZoneHomeStatus
import com.labfytools.trainlog.model.ActiveSessionDraft 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.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogColors import com.labfytools.trainlog.ui.theme.TrainlogColors
@ -64,19 +61,14 @@ import com.labfytools.trainlog.R
fun HomeScreen( fun HomeScreen(
activeDraft: ActiveSessionDraft?, activeDraft: ActiveSessionDraft?,
draftError: String?, draftError: String?,
latestSession: SessionSummary?,
latestMaximum: LatestExerciseMax?,
bodyZoneOverview: BodyZoneHomeOverview, bodyZoneOverview: BodyZoneHomeOverview,
onSession: () -> Unit, onSession: () -> Unit,
onOpenExercises: (String) -> Unit, onOpenExercises: (String) -> Unit,
onOpenStatistics: () -> Unit,
onBody: () -> Unit, onBody: () -> Unit,
onOpenLatestSession: (String) -> Unit,
onSync: () -> Unit, onSync: () -> Unit,
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val strings = localizedContext() val strings = localizedContext()
val locale = presentationLocale()
TrainlogScreen(strings.getString(R.string.nav_home), scrollKey = "home") { TrainlogScreen(strings.getString(R.string.nav_home), scrollKey = "home") {
draftError?.let { TrainlogInfo(it, colors.error) } draftError?.let { TrainlogInfo(it, colors.error) }
TrainlogFrame(strings.getString(R.string.home_session)) { 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) TrainlogPrimaryAction(strings.getString(R.string.new_manual_session), strings.getString(R.string.new_manual_description), onSession)
} }
} }
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises, onOpenStatistics) BodyZoneHomeSection(bodyZoneOverview, onOpenExercises)
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)
}
TrainlogFrame(strings.getString(R.string.quick_access), active = false) { TrainlogFrame(strings.getString(R.string.quick_access), active = false) {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"), Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"),
horizontalArrangement = Arrangement.spacedBy(10.dp)) { horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@ -119,7 +101,6 @@ fun HomeScreen(
internal fun BodyZoneHomeSection( internal fun BodyZoneHomeSection(
overview: BodyZoneHomeOverview, overview: BodyZoneHomeOverview,
onOpenExercises: (String) -> Unit, onOpenExercises: (String) -> Unit,
onOpenStatistics: () -> Unit,
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val strings = localizedContext() val strings = localizedContext()
@ -159,7 +140,9 @@ internal fun BodyZoneHomeSection(
} }
selected?.let { zone -> selected?.let { zone ->
Column( 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), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
BasicText(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), style = TrainlogTypography.section.copy(color = colors.text)) 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) 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_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.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag 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.R
import com.labfytools.trainlog.data.StartProgramSessionResult import com.labfytools.trainlog.data.StartProgramSessionResult
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
@ -18,13 +29,34 @@ import com.labfytools.trainlog.model.ProgramSessionExecutionState
fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) { fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
val strings = localizedContext() val strings = localizedContext()
val programs = repository.listSyncedPrograms() val programs = repository.listSyncedPrograms()
val colors = LocalTrainlogColors.current
TrainlogScreen(strings.getString(R.string.route_programs)) { TrainlogScreen(strings.getString(R.string.route_programs)) {
if (programs.isEmpty()) TrainlogInfo(strings.getString(R.string.programs_none)) if (programs.isEmpty()) TrainlogInfo(strings.getString(R.string.programs_none))
programs.forEach { program -> 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( TrainlogInfo(
listOfNotNull( 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.startDate?.let { strings.getString(R.string.program_start_date, formatDate(it)) },
program.endDate?.let { strings.getString(R.string.program_end_date, formatDate(it)) }, program.endDate?.let { strings.getString(R.string.program_end_date, formatDate(it)) },
strings.resources.getQuantityString( strings.resources.getQuantityString(
@ -34,6 +66,7 @@ fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
), ),
).joinToString(" · "), ).joinToString(" · "),
) )
TrainlogInfo("$completed / ${program.sessionCount} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) colors.success else colors.muted)
TrainlogButton( TrainlogButton(
strings.getString(R.string.program_open), strings.getString(R.string.program_open),
{ onOpen(program.programId) }, { onOpen(program.programId) },
@ -84,22 +117,41 @@ fun ProgramDetailScreen(
program.startDate?.let { TrainlogInfo(strings.getString(R.string.program_start_date, formatDate(it))) } 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.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
program.note?.let { TrainlogInfo(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) } message?.let { TrainlogInfo(it) }
program.sessions.forEachIndexed { index, session -> program.sessions.forEachIndexed { index, session ->
var expanded by remember(session.programSessionId) { mutableStateOf(false) } var expanded by remember(session.programSessionId) { mutableStateOf(false) }
val completed = session.executionState == ProgramSessionExecutionState.COMPLETED 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) { val stateLabel = when (session.executionState) {
ProgramSessionExecutionState.TODO -> R.string.program_session_todo ProgramSessionExecutionState.TODO -> R.string.program_session_todo
ProgramSessionExecutionState.IN_PROGRESS -> R.string.program_session_in_progress ProgramSessionExecutionState.IN_PROGRESS -> R.string.program_session_in_progress
ProgramSessionExecutionState.COMPLETED -> R.string.program_session_completed 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( TrainlogInfo(
listOfNotNull( listOfNotNull(
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training), 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)) }, 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(" · "), ).joinToString(" · "),
) )
session.note?.let { TrainlogInfo(it) } 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.fillMaxHeight
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.IntrinsicSize 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.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -188,13 +190,29 @@ fun LatestMaximaScreen(repository: TrainlogRepository) {
val locale = presentationLocale() val locale = presentationLocale()
val strings = localizedContext() val strings = localizedContext()
TrainlogScreen(strings.getString(R.string.route_latest_maxima)) { 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))
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none)) maxima.forEach { max ->
maxima.forEach { max -> val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.') androidx.compose.foundation.layout.Column(
TrainlogInfo("${max.exerciseName} · $weight kg", colors.warning) Modifier.fillMaxWidth().padding(bottom = 8.dp)
TrainlogInfo("${formatDate(max.startedAt)} · " + strings.getString(R.string.equipment_value, .background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
max.equipmentDisplayName ?: strings.getString(R.string.value_none)), colors.muted) .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.lazy.items
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
@ -196,69 +197,34 @@ fun SessionScreen(
TrainlogFrame( TrainlogFrame(
title = strings.getString(R.string.session_type) title = strings.getString(R.string.session_type)
) { ) {
TrainlogAction( Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
label = val training = currentDraft.sessionType == SessionType.TRAINING
if ( TrainlogButton(
currentDraft.sessionType == label = strings.getString(R.string.training),
SessionType.TRAINING modifier = Modifier.weight(1f),
) { style = if (training) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
strings.getString(R.string.training_selected) maxLines = 2,
} else { onClick = {
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 = {
persistDraft( persistDraft(
currentDraft.copy( currentDraft.copy(sessionType = SessionType.TRAINING),
sessionType =
SessionType.TRAINING
),
null, 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), val maxTest = currentDraft.sessionType == SessionType.MAX_TEST
accent = TrainlogButton(
if ( label = strings.getString(R.string.session_max_test),
currentDraft.sessionType == modifier = Modifier.weight(1f),
SessionType.MAX_TEST style = if (maxTest) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
) { maxLines = 2,
colors.warning onClick = {
} else {
colors.muted
},
onClick = {
persistDraft( persistDraft(
currentDraft.copy( currentDraft.copy(sessionType = SessionType.MAX_TEST),
sessionType =
SessionType.MAX_TEST
),
null, null,
) )
}, },
) )
}
} }
ExercisePicker( ExercisePicker(
@ -606,7 +572,7 @@ fun SessionScreen(
colors.success colors.success
} else { } else {
colors.error colors.error
}, }
) )
} }
} }
@ -1151,54 +1117,78 @@ private fun SessionExerciseForm(
strings.getString(R.string.field_weight) strings.getString(R.string.field_weight)
} }
setRows.forEachIndexed { index, row -> setRows.forEachIndexed { index, row ->
TrainlogInfo( Column(
text = strings.getString(R.string.set_number, index + 1), Modifier
color = colors.accent, .fillMaxWidth()
) .padding(bottom = 8.dp)
SessionNumberField( .background(colors.surface, MaterialTheme.shapes.medium)
label = strings.getString(R.string.set_reps_field, index + 1), .padding(10.dp),
value = row.repsText, ) {
testTag = "session-set-$index-reps", Row(
onValueChange = { value -> Modifier.fillMaxWidth(),
val updated = setRows.replaceAt(index, row.copy(repsText = value)) horizontalArrangement = Arrangement.SpaceBetween,
setRows = updated ) {
repsText = encodeRawReps(updated) BasicText(
weightText = encodeRawWeights(updated) text = strings.getString(R.string.set_number, index + 1),
error = null modifier = Modifier.padding(vertical = 12.dp),
onFormChanged( style = TrainlogTypography.small.copy(
currentForm( color = colors.accent,
exercise, setCountText, repsText, durationText, fontWeight = FontWeight.Bold,
speedText, distanceText, selectedEquipmentId, weightText, ),
)
) )
}, TrainlogIconAction(
) icon = TrainlogIcons.DeleteOutline,
SessionNumberField( contentDescription = localizedContext().getString(R.string.a11y_delete_set),
label = strings.getString(R.string.set_load_field, index + 1, loadLabel), modifier = Modifier.testTag("delete-set-${index + 1}"),
value = row.weightText, accent = colors.error,
decimal = true, onClick = { pendingSetRemoval = index },
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,
)
) )
}, }
) Row(
TrainlogDeleteButton( Modifier.fillMaxWidth(),
contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.testTag("delete-set-${index + 1}"), ) {
onClick = { SessionNumberField(
pendingSetRemoval = index 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( TrainlogAction(
label = strings.getString(R.string.add_set_plain), label = strings.getString(R.string.add_set_plain),
@ -1608,6 +1598,7 @@ private fun SessionNumberField(
label: String, label: String,
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
testTag: String? = null, testTag: String? = null,
decimal: Boolean = false, decimal: Boolean = false,
) { ) {
@ -1623,6 +1614,7 @@ private fun SessionNumberField(
} else { } else {
KeyboardOptions(keyboardType = KeyboardType.Number) 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) { when (val route = navigation.route) {
AppRoute.Home -> HomeScreen( AppRoute.Home -> HomeScreen(
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.let { localizedRepositoryMessage(localized, it.message) } ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning?.let { localizedRepositoryMessage(localized, it) }, 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(), repository.getBodyZoneHomeOverview(),
::openManualSession, ::openManualSession,
{ zoneId -> { zoneId ->
@ -102,8 +101,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
exerciseState.searchQuery.value = "" exerciseState.searchQuery.value = ""
open(AppRoute.Exercises) open(AppRoute.Exercises)
}, },
{ open(AppRoute.Statistics) }, { open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
) )
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession, AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) }) { 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)) }) 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.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() } is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
AppRoute.Statistics -> StatisticsDashboard(repository)
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() }) AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
AppRoute.LatestMaxima -> LatestMaximaScreen(repository) AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() }) 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.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
@ -37,6 +38,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -164,7 +166,7 @@ fun TrainlogAction(
.padding(vertical = 3.dp) .padding(vertical = 3.dp)
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
.background(Color.Transparent) .background(colors.surface, MaterialTheme.shapes.medium)
.clickable(onClick = onClick) .clickable(onClick = onClick)
) { ) {
Column( Column(
@ -199,6 +201,8 @@ fun TrainlogAction(
} }
} }
enum class TrainlogButtonStyle { PRIMARY, SECONDARY, SUCCESS, DESTRUCTIVE, GHOST }
/** Compact pictogram action with a mandatory accessible name. */ /** Compact pictogram action with a mandatory accessible name. */
@Composable @Composable
fun TrainlogIconAction( fun TrainlogIconAction(
@ -230,6 +234,7 @@ fun TrainlogDeleteButton(
Button( Button(
onClick = onClick, onClick = onClick,
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp), modifier = modifier.fillMaxWidth().heightIn(min = 52.dp),
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors(containerColor = colors.error), colors = ButtonDefaults.buttonColors(containerColor = colors.error),
) { ) {
Icon(TrainlogIcons.DeleteOutline, contentDescription = contentDescription) Icon(TrainlogIcons.DeleteOutline, contentDescription = contentDescription)
@ -240,9 +245,7 @@ fun TrainlogDeleteButton(
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) { fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) { Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { TrainlogButton(label, onClick, Modifier.fillMaxWidth())
Text(label)
}
if (description.isNotBlank()) { if (description.isNotBlank()) {
BasicText( BasicText(
description, description,
@ -270,7 +273,7 @@ fun TrainlogActionTile(
Column( Column(
modifier modifier
.heightIn(min = 112.dp) .heightIn(min = 112.dp)
.background(colors.surface, RoundedCornerShape(12.dp)) .background(colors.surface, MaterialTheme.shapes.medium)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(12.dp), .padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
@ -291,13 +294,33 @@ fun TrainlogButton(
containerColor: Color? = null, containerColor: Color? = null,
maxLines: Int = 1, maxLines: Int = 1,
enabled: Boolean = true, 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( Button(
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
modifier = modifier.heightIn(min = 52.dp), modifier = modifier.heightIn(min = 52.dp),
colors = containerColor?.let { ButtonDefaults.buttonColors(containerColor = it) } shape = MaterialTheme.shapes.medium,
?: ButtonDefaults.buttonColors(), 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), contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
) { ) {
icon?.let { icon?.let {
@ -334,6 +357,7 @@ fun TrainlogInputField(
label: String, label: String,
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions = keyboardOptions: KeyboardOptions =
KeyboardOptions.Default, KeyboardOptions.Default,
testTag: String? = null, testTag: String? = null,
@ -350,8 +374,7 @@ fun TrainlogInputField(
} }
Column( Column(
modifier = modifier = modifier.padding(bottom = 10.dp)
Modifier.padding(bottom = 10.dp)
) { ) {
BasicText( BasicText(
text = label.uppercase(), text = label.uppercase(),

View file

@ -9,12 +9,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
data class TrainlogColors( data class TrainlogColors(
val background: Color, val background: Color,
@ -110,6 +113,19 @@ fun TrainlogTheme(
error = TrainlogDarkColors.error, error = TrainlogDarkColors.error,
) )
CompositionLocalProvider(LocalTrainlogColors provides TrainlogDarkColors) { 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( HomeScreen(
activeDraft = null, activeDraft = null,
draftError = null, draftError = null,
latestSession = null,
latestMaximum = null,
bodyZoneOverview = BodyZoneHomeOverview(emptyList(), emptyList(), false, false), bodyZoneOverview = BodyZoneHomeOverview(emptyList(), emptyList(), false, false),
onSession = {}, onSession = {},
onOpenExercises = {}, onOpenExercises = {},
onOpenStatistics = {},
onBody = { bodyCalls++ }, onBody = { bodyCalls++ },
onOpenLatestSession = {},
onSync = { syncCalls++ }, onSync = { syncCalls++ },
) )
} }

View file

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

View file

@ -110,12 +110,10 @@ class BodyZoneHomeSemanticsTest {
@Test fun practicalWidthMapSelectionFeedsExistingNavigationActions() { @Test fun practicalWidthMapSelectionFeedsExistingNavigationActions() {
var openedZone: String? = null var openedZone: String? = null
var statisticsOpened = false
setSection( setSection(
widthDp = 380, widthDp = 380,
fontScale = 1f, fontScale = 1f,
onOpenExercises = { openedZone = it }, onOpenExercises = { openedZone = it },
onOpenStatistics = { statisticsOpened = true },
) )
assertEquals("core", bodyZoneAtPoint("front", 180f, 236f, Offset(90f, 113.28f), allZoneIds.toSet())) assertEquals("core", bodyZoneAtPoint("front", 180f, 236f, Offset(90f, 113.28f), allZoneIds.toSet()))
@ -126,16 +124,12 @@ class BodyZoneHomeSemanticsTest {
compose.onNodeWithText("Voir les exercices").assertHasClickAction() compose.onNodeWithText("Voir les exercices").assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick) .performSemanticsAction(SemanticsActions.OnClick)
assertEquals("core", openedZone) assertEquals("core", openedZone)
compose.onNodeWithText("Voir les statistiques").assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick)
assertTrue(statisticsOpened)
} }
private fun setSection( private fun setSection(
widthDp: Int, widthDp: Int,
fontScale: Float, fontScale: Float,
onOpenExercises: (String) -> Unit = {}, onOpenExercises: (String) -> Unit = {},
onOpenStatistics: () -> Unit = {},
) { ) {
val zones = allZoneIds.map { id -> val zones = allZoneIds.map { id ->
status(id, if (id == "chest") BodyZoneHomeState.PRIORITIZE else BodyZoneHomeState.RECENT_WORK) status(id, if (id == "chest") BodyZoneHomeState.PRIORITIZE else BodyZoneHomeState.RECENT_WORK)
@ -147,7 +141,6 @@ class BodyZoneHomeSemanticsTest {
BodyZoneHomeSection( BodyZoneHomeSection(
BodyZoneHomeOverview(zones, listOf(zones.first()), true, false), BodyZoneHomeOverview(zones, listOf(zones.first()), true, false),
onOpenExercises, 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. 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`) ## Interface language (`TRAINLOG_I18N_V0_1_1`)
Trainlog version 0.1.2 is synchronized with the desktop TUI as one product 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 definitions. Web and Android derive their session state from these persisted
identities; no display-field heuristic is used. 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 The private Program execution rollout installed desktop schema v27 and the
signed nondebuggable Android versionCode 17/schema v25 update without signed nondebuggable Android versionCode 17/schema v25 update without
uninstalling or clearing application data. A strictly disposable Program was 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. TUI product version, asset names, and release title must agree.
Private update rollout may set `TRAINLOG_ANDROID_VERSION_CODE` to a positive 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 integer and `TRAINLOG_ANDROID_VERSION_NAME=0.1.2` to remain on the installed
to maintain a strictly increasing installed → backup bridge → current APK stable product line. Both are build-only overrides; the repository development
chain; it does not change any schema or exchange-format version. 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 ```bash
meson setup --reconfigure build meson setup --reconfigure build

View file

@ -70,6 +70,10 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
completed: 'Effectuée', completed: 'Effectuée',
deleted: 'Retirée', deleted: 'Retirée',
})[state] })[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"> return <article className="session-detail program-detail">
<div className="sessions-toolbar"> <div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}> <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{' '} Import {program.source_format} v{program.source_version} · modifié le{' '}
{formatDateTime(program.updated_at, dateFormat)} {formatDateTime(program.updated_at, dateFormat)}
</p> </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> </header>
<ol className="program-session-list"> <ol className="program-session-list">
{program.sessions.map((session) => <li key={session.program_session_id}> {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-remote_unknown { color: var(--yellow); }
.android-delivery-state.state-acknowledged { color: var(--green); } .android-delivery-state.state-acknowledged { color: var(--green); }
.program-list, .program-session-list { display: grid; gap: .75rem; } .program-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr)); gap: 1rem; }
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); } .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-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 { 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-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 { 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-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-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-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-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%; } .programs-calendar-page { max-width: 100%; }
.program-calendar-loading { min-height: 12rem; display: grid; place-items: center; color: var(--subtext-0); } .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); } .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); }