From cb4010dbd5a14bed1295bb611d020cdd5848c0dc Mon Sep 17 00:00:00 2001 From: fy59 Date: Sun, 20 Sep 2026 13:23:08 +0200 Subject: [PATCH] feat(android): unify capture-first mobile interface --- android/app/build.gradle.kts | 12 +- .../trainlog/ui/DraftUiTestActivity.kt | 4 - .../labfytools/trainlog/ui/AndroidAppShell.kt | 6 +- .../labfytools/trainlog/ui/AppNavigation.kt | 8 +- .../labfytools/trainlog/ui/ExerciseScreen.kt | 15 +- .../com/labfytools/trainlog/ui/HomeScreen.kt | 26 +-- .../labfytools/trainlog/ui/ProgramScreens.kt | 75 ++++++- .../labfytools/trainlog/ui/SectionScreens.kt | 32 ++- .../labfytools/trainlog/ui/SessionScreen.kt | 198 +++++++++--------- .../com/labfytools/trainlog/ui/TrainlogApp.kt | 5 +- .../trainlog/ui/TrainlogComponents.kt | 41 +++- .../trainlog/ui/theme/TrainlogTheme.kt | 18 +- .../trainlog/ui/AndroidUiRedesignTest.kt | 4 - .../trainlog/ui/AppNavigationStateTest.kt | 7 +- .../trainlog/ui/BodyZoneHomeSemanticsTest.kt | 7 - 15 files changed, 276 insertions(+), 182 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2f00680..7dcc289 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" } diff --git a/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt b/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt index b51e79e..6b82aa5 100644 --- a/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt +++ b/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt @@ -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 = {}, ) } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt index 128afb2..0c3783e 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt @@ -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 }) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt index 14d1282..e6b9238 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt @@ -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 } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt index 0327f53..7188779 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt index fce2934..399abfa 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt @@ -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) } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/ProgramScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/ProgramScreens.kt index 5d80c6d..480db80 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/ProgramScreens.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/ProgramScreens.kt @@ -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, + ) +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt index 7fd3992..813b27c 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt @@ -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) + } } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index e1263e3..56abb4b 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -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, ) } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index 217f95d..8636fc4 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -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() }) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt index ead21cd..f14f78d 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt @@ -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(), diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/theme/TrainlogTheme.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/theme/TrainlogTheme.kt index a623186..dbf2899 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/theme/TrainlogTheme.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/theme/TrainlogTheme.kt @@ -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, + ) } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/AndroidUiRedesignTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/AndroidUiRedesignTest.kt index 3233956..878abda 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/AndroidUiRedesignTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/AndroidUiRedesignTest.kt @@ -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++ }, ) } diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/AppNavigationStateTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/AppNavigationStateTest.kt index 6d7f7c4..b0b81ae 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/AppNavigationStateTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/AppNavigationStateTest.kt @@ -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() { diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomeSemanticsTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomeSemanticsTest.kt index f40ce86..6b704e1 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomeSemanticsTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomeSemanticsTest.kt @@ -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, ) } }