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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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