feat(android): unify capture-first mobile interface

This commit is contained in:
fy59 2026-09-20 13:23:08 +02:00
parent 88e0eb0fb7
commit cb4010dbd5
15 changed files with 276 additions and 182 deletions

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,
) )
} }
} }