refactor(android): compact drafts and body measurement screens

This commit is contained in:
fy59 2026-09-20 14:26:34 +02:00
parent 53baa7ad24
commit f27ef5af5f
8 changed files with 357 additions and 190 deletions

View file

@ -19,6 +19,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@ -32,6 +33,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.RoundedCornerShape
import com.labfytools.trainlog.R
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import kotlinx.coroutines.launch
@ -105,7 +107,16 @@ fun AndroidAppShell(
onOpenSection(section)
scope.launch { drawerState.close() }
},
modifier = Modifier.padding(horizontal = 12.dp),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
shape = RoundedCornerShape(10.dp),
colors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = colors.surfaceAlt,
selectedIconColor = colors.accent,
selectedTextColor = colors.text,
unselectedContainerColor = colors.mantle,
unselectedIconColor = colors.muted,
unselectedTextColor = colors.muted,
),
)
}
}

View file

@ -8,6 +8,12 @@ package com.labfytools.trainlog.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@ -89,148 +95,74 @@ fun BodyScreen(
TrainlogFrame(
title = strings.getString(R.string.general)
) {
BodyMetricField(
label = strings.getString(R.string.weight),
unit = "kg",
value = bodyWeight,
onValueChange = {
bodyWeight = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.neck),
unit = "cm",
value = neck,
onValueChange = {
neck = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.shoulders),
unit = "cm",
value = shoulders,
onValueChange = {
shoulders = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.chest),
unit = "cm",
value = chest,
onValueChange = {
chest = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.waist),
unit = "cm",
value = waist,
onValueChange = {
waist = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.hips),
unit = "cm",
value = hips,
onValueChange = {
hips = it
message = null
},
BodyMetricGrid(
listOf(
BodyMetricItem(strings.getString(R.string.weight), "kg", bodyWeight) {
bodyWeight = it
message = null
},
BodyMetricItem(strings.getString(R.string.neck), "cm", neck) {
neck = it
message = null
},
BodyMetricItem(strings.getString(R.string.shoulders), "cm", shoulders) {
shoulders = it
message = null
},
BodyMetricItem(strings.getString(R.string.chest), "cm", chest) {
chest = it
message = null
},
BodyMetricItem(strings.getString(R.string.waist), "cm", waist) {
waist = it
message = null
},
BodyMetricItem(strings.getString(R.string.hips), "cm", hips) {
hips = it
message = null
},
),
)
}
TrainlogFrame(
title = strings.getString(R.string.limbs)
) {
BodyMetricField(
label = strings.getString(R.string.left_arm),
unit = "cm",
value = leftArm,
onValueChange = {
leftArm = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.right_arm),
unit = "cm",
value = rightArm,
onValueChange = {
rightArm = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.left_forearm),
unit = "cm",
value = leftForearm,
onValueChange = {
leftForearm = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.right_forearm),
unit = "cm",
value = rightForearm,
onValueChange = {
rightForearm = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.left_thigh),
unit = "cm",
value = leftThigh,
onValueChange = {
leftThigh = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.right_thigh),
unit = "cm",
value = rightThigh,
onValueChange = {
rightThigh = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.left_calf),
unit = "cm",
value = leftCalf,
onValueChange = {
leftCalf = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.right_calf),
unit = "cm",
value = rightCalf,
onValueChange = {
rightCalf = it
message = null
},
BodyMetricGrid(
listOf(
BodyMetricItem(strings.getString(R.string.left_arm), "cm", leftArm) {
leftArm = it
message = null
},
BodyMetricItem(strings.getString(R.string.right_arm), "cm", rightArm) {
rightArm = it
message = null
},
BodyMetricItem(strings.getString(R.string.left_forearm), "cm", leftForearm) {
leftForearm = it
message = null
},
BodyMetricItem(strings.getString(R.string.right_forearm), "cm", rightForearm) {
rightForearm = it
message = null
},
BodyMetricItem(strings.getString(R.string.left_thigh), "cm", leftThigh) {
leftThigh = it
message = null
},
BodyMetricItem(strings.getString(R.string.right_thigh), "cm", rightThigh) {
rightThigh = it
message = null
},
BodyMetricItem(strings.getString(R.string.left_calf), "cm", leftCalf) {
leftCalf = it
message = null
},
BodyMetricItem(strings.getString(R.string.right_calf), "cm", rightCalf) {
rightCalf = it
message = null
},
),
)
}
@ -426,12 +358,14 @@ private fun BodyMetricField(
unit: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
TrainlogInputField(
label = "$label ($unit)",
label = label,
value = value,
onValueChange =
onValueChange,
modifier = modifier,
keyboardOptions =
KeyboardOptions(
keyboardType =
@ -439,9 +373,42 @@ private fun BodyMetricField(
imeAction =
ImeAction.Next,
),
suffix = unit,
compact = true,
)
}
private data class BodyMetricItem(
val label: String,
val unit: String,
val value: String,
val onValueChange: (String) -> Unit,
)
/** Two columns retain scanability; very narrow accessibility layouts stack. */
@Composable
private fun ColumnScope.BodyMetricGrid(items: List<BodyMetricItem>) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
val columns = if (maxWidth < 330.dp) 1 else 2
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
items.chunked(columns).forEach { rowItems ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
rowItems.forEach { item ->
BodyMetricField(
item.label,
item.unit,
item.value,
item.onValueChange,
Modifier.weight(1f),
)
}
repeat(columns - rowItems.size) { androidx.compose.foundation.layout.Spacer(Modifier.weight(1f)) }
}
}
}
}
}
private sealed interface MetricParse {
data object Empty :
MetricParse

View file

@ -12,6 +12,7 @@ import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
import java.time.OffsetDateTime
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Composable
@ -133,7 +134,8 @@ internal fun formatStartedAt(
internal fun formatDate(value: String): String {
val locale = presentationLocale()
val pattern = uiString(R.string.date_pattern)
return runCatching {
OffsetDateTime.parse(value).format(DateTimeFormatter.ofPattern(pattern, locale))
}.getOrElse { value.take(10) }
val formatter = DateTimeFormatter.ofPattern(pattern, locale)
return runCatching { LocalDate.parse(value.take(10)).format(formatter) }
.recoverCatching { OffsetDateTime.parse(value).format(formatter) }
.getOrElse { value.take(10) }
}

View file

@ -14,6 +14,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
@ -21,7 +22,9 @@ 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.foundation.layout.widthIn
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
@ -38,6 +41,8 @@ import com.labfytools.trainlog.data.AndroidBackupService
import com.labfytools.trainlog.data.AndroidBackupResult
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.ui.theme.TrainlogTypography
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
@ -95,21 +100,14 @@ fun AiSessionDraftsScreen(
message?.let { TrainlogInfo(it) }
if (drafts.isEmpty() && preparations.isEmpty()) TrainlogInfo(strings.getString(R.string.draft_none))
preparations.forEach { preparation ->
TrainlogFrame(preparation.title, active = true) {
TrainlogInfo(listOfNotNull(
strings.getString(R.string.manual_preparation),
preparation.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, preparation.entries.size, preparation.entries.size),
).joinToString(" · "))
preparation.notes?.let { TrainlogInfo(it) }
preparation.entries.forEach { entry ->
val plan = checkNotNull(entry.plan)
val metric = plan.reps?.let { strings.resources.getQuantityString(R.plurals.repetition_count, it, it) }
?: plan.durationSeconds?.let { strings.resources.getQuantityString(R.plurals.seconds_count, it, it) }.orEmpty()
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
TrainlogInfo(strings.getString(R.string.plan_draft_summary, entry.exercise.name, plan.sets, metric, weight, plan.restSeconds))
}
TrainlogPrimaryAction(strings.getString(R.string.start), strings.getString(R.string.start_preparation_description)) {
DraftSummaryCard(
title = preparation.title,
provenance = strings.getString(R.string.manual_preparation),
plannedFor = preparation.plannedFor,
notes = preparation.notes,
entries = preparation.entries,
startDescription = strings.getString(R.string.start_preparation_description),
onStart = {
when (val result = repository.startPreparedSession(preparation.deliveryId)) {
StartAiSessionDraftResult.Started -> { onPendingChanged(); onStarted() }
StartAiSessionDraftResult.ExistingActiveDraft ->
@ -117,24 +115,18 @@ fun AiSessionDraftsScreen(
StartAiSessionDraftResult.NotPending -> { message = strings.getString(R.string.draft_missing); revision++; onPendingChanged() }
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
}
}
}
},
)
}
drafts.forEach { draft ->
TrainlogFrame(draft.title ?: strings.getString(R.string.session_proposal), active = true) {
TrainlogInfo(listOfNotNull(
draft.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, draft.entries.size, draft.entries.size),
).joinToString(" · "))
draft.notes?.let { TrainlogInfo(it) }
draft.entries.forEach { entry ->
val plan = checkNotNull(entry.plan)
val metric = plan.reps?.let { strings.resources.getQuantityString(R.plurals.repetition_count, it, it) }
?: plan.durationSeconds?.let { strings.resources.getQuantityString(R.plurals.seconds_count, it, it) }.orEmpty()
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
TrainlogInfo(strings.getString(R.string.plan_draft_summary, entry.exercise.name, plan.sets, metric, weight, plan.restSeconds))
}
TrainlogPrimaryAction(strings.getString(R.string.start), strings.getString(R.string.start_proposal_description)) {
DraftSummaryCard(
title = draft.title ?: strings.getString(R.string.session_proposal),
provenance = strings.getString(R.string.session_proposal),
plannedFor = draft.plannedFor,
notes = draft.notes,
entries = draft.entries,
startDescription = strings.getString(R.string.start_proposal_description),
onStart = {
when (val result = repository.startAiSessionDraft(draft.draftId)) {
StartAiSessionDraftResult.Started -> {
onPendingChanged()
@ -149,15 +141,24 @@ fun AiSessionDraftsScreen(
}
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
}
}
TrainlogDeleteButton(
contentDescription = localizedContext().getString(R.string.a11y_delete_draft),
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
onClick = {
pendingDeletion = draft.draftId to (draft.title ?: strings.getString(R.string.session_proposal))
},
)
}
},
footer = {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TrainlogDeleteButton(
contentDescription = localizedContext().getString(R.string.a11y_delete_draft),
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
fullWidth = false,
onClick = {
pendingDeletion = draft.draftId to
(draft.title ?: strings.getString(R.string.session_proposal))
},
)
}
},
)
}
}
pendingDeletion?.let { (draftId, title) ->
@ -183,6 +184,138 @@ fun AiSessionDraftsScreen(
}
}
/** Compact presentation of an inert preparation; starting remains caller-owned. */
@Composable
private fun DraftSummaryCard(
title: String,
provenance: String,
plannedFor: String?,
notes: String?,
entries: List<SessionExerciseDraft>,
startDescription: String,
onStart: () -> Unit,
footer: (@Composable () -> Unit)? = null,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
TrainlogCompactCard {
androidx.compose.material3.Text(
title,
color = colors.text,
style = TrainlogTypography.section,
fontWeight = FontWeight.Bold,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
TrainlogMetaBadge(provenance, Modifier.weight(1f))
TrainlogMetaBadge(
strings.resources.getQuantityString(
R.plurals.exercise_count,
entries.size,
entries.size,
),
)
}
plannedFor?.let {
androidx.compose.material3.Text(
strings.getString(R.string.planned_for, formatDate(it)),
color = colors.muted,
style = TrainlogTypography.small,
)
}
notes?.takeIf { it.isNotBlank() }?.let {
androidx.compose.material3.Text(
it,
color = colors.muted,
style = TrainlogTypography.small,
maxLines = 2,
)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
entries.forEach { DraftExerciseRow(it) }
}
Row(
Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrainlogButton(
strings.getString(R.string.start),
onStart,
Modifier.widthIn(min = 128.dp),
style = TrainlogButtonStyle.SUCCESS,
)
androidx.compose.material3.Text(
startDescription,
modifier = Modifier.weight(1f),
color = colors.muted,
style = TrainlogTypography.small,
maxLines = 2,
)
}
footer?.invoke()
}
}
}
@Composable
private fun DraftExerciseRow(entry: SessionExerciseDraft) {
val colors = LocalTrainlogColors.current
val plan = checkNotNull(entry.plan)
Column(
Modifier.fillMaxWidth()
.background(colors.background, androidx.compose.material3.MaterialTheme.shapes.small)
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
androidx.compose.material3.Text(
entry.exercise.name,
color = colors.text,
style = TrainlogTypography.small,
fontWeight = FontWeight.SemiBold,
)
androidx.compose.material3.Text(
draftPlanSummary(plan.sets, plan.reps, plan.durationSeconds, plan.weightKg, plan.restSeconds),
color = colors.muted,
style = TrainlogTypography.small,
)
}
}
@Composable
private fun draftPlanSummary(
sets: Int,
reps: Int?,
durationSeconds: Int?,
weightKg: Double?,
restSeconds: Int,
): String {
val strings = localizedContext()
val metric = when {
reps != null -> "$sets × $reps"
durationSeconds != null && sets > 0 -> "$sets × ${humanDuration(durationSeconds)}"
durationSeconds != null -> humanDuration(durationSeconds)
else -> "—"
}
return buildList {
add(metric)
weightKg?.let { add("${it.toString().trimEnd('0').trimEnd('.')} kg") }
if (restSeconds > 0) add(strings.getString(R.string.draft_rest_short, restSeconds))
}.joinToString(" · ")
}
@Composable
private fun humanDuration(seconds: Int): String {
val strings = localizedContext()
return if (seconds % 60 == 0) {
val minutes = seconds / 60
strings.resources.getQuantityString(R.plurals.duration_minutes_value, minutes, minutes)
} else {
strings.resources.getQuantityString(R.plurals.duration_seconds_value, seconds, seconds)
}
}
@Composable
fun LatestMaximaScreen(repository: TrainlogRepository) {
val colors = LocalTrainlogColors.current

View file

@ -201,6 +201,44 @@ fun TrainlogAction(
}
}
/** Presentation-only surface for dense object summaries and repeated rows. */
@Composable
fun TrainlogCompactCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val colors = LocalTrainlogColors.current
val interaction = if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)
Column(
modifier
.fillMaxWidth()
.background(colors.surface, MaterialTheme.shapes.medium)
.then(interaction)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
content = content,
)
}
/** Small non-interactive metadata token; business state remains caller-owned. */
@Composable
fun TrainlogMetaBadge(
text: String,
modifier: Modifier = Modifier,
accent: Color? = null,
) {
val colors = LocalTrainlogColors.current
Text(
text = text,
modifier = modifier.background(colors.surfaceAlt, MaterialTheme.shapes.small)
.padding(horizontal = 8.dp, vertical = 4.dp),
color = accent ?: colors.text,
style = TrainlogTypography.small,
maxLines = 1,
)
}
enum class TrainlogButtonStyle { PRIMARY, SECONDARY, SUCCESS, DESTRUCTIVE, GHOST }
/** Compact pictogram action with a mandatory accessible name. */
@ -229,11 +267,12 @@ fun TrainlogDeleteButton(
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
fullWidth: Boolean = true,
) {
val colors = LocalTrainlogColors.current
Button(
onClick = onClick,
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp),
modifier = (if (fullWidth) modifier.fillMaxWidth() else modifier).heightIn(min = 48.dp),
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors(containerColor = colors.error),
) {
@ -364,6 +403,8 @@ fun TrainlogInputField(
singleLine: Boolean = true,
minLines: Int = 1,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
suffix: String? = null,
compact: Boolean = false,
) {
val colors =
LocalTrainlogColors.current
@ -418,7 +459,7 @@ fun TrainlogInputField(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.heightIn(min = if (compact) 44.dp else 48.dp)
.onFocusChanged {
focused = it.isFocused
}
@ -429,11 +470,22 @@ fun TrainlogInputField(
Modifier.testTag(testTag)
}
)
.background(colors.surface)
.background(colors.surface, MaterialTheme.shapes.medium)
.padding(
horizontal = 12.dp,
vertical = 10.dp,
horizontal = if (compact) 10.dp else 12.dp,
vertical = if (compact) 8.dp else 10.dp,
),
decorationBox = { innerTextField ->
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(Modifier.weight(1f)) { innerTextField() }
suffix?.let {
BasicText(it, style = TrainlogTypography.small.copy(color = colors.muted))
}
}
},
)
Box(

View file

@ -334,7 +334,8 @@
<plurals name="repetition_count"><item quantity="one">%d repetition</item><item quantity="other">%d repetitions</item></plurals>
<string name="start">Start</string><string name="start_proposal_description">Copy this proposal into the current session.</string>
<string name="manual_preparation">Manual preparation</string>
<string name="start_preparation_description">Start this preparation without replacing an active session.</string>
<string name="start_preparation_description">Creates a protected active session.</string>
<string name="draft_rest_short">rest %1$d s</string>
<string name="draft_already_active">A session is already in progress. The imported draft was preserved.</string>
<string name="delete_draft_detail">Draft “%1$s” will be deleted.\nThis action cannot be undone.</string>
<string name="training">Training</string><string name="resume_current_session">Resume current session</string>

View file

@ -334,7 +334,8 @@
<plurals name="repetition_count"><item quantity="one">%d répétition</item><item quantity="other">%d répétitions</item></plurals>
<string name="start">Démarrer</string><string name="start_proposal_description">Copier cette proposition dans la séance en cours.</string>
<string name="manual_preparation">Préparation manuelle</string>
<string name="start_preparation_description">Démarrer cette préparation sans écraser une séance en cours.</string>
<string name="start_preparation_description">Crée une séance en cours protégée.</string>
<string name="draft_rest_short">repos %1$d s</string>
<string name="draft_already_active">Une séance est déjà en cours. Le brouillon importé a été conservé.</string>
<string name="delete_draft_detail">Le brouillon « %1$s » sera supprimé.\nCette action est irréversible.</string>
<string name="training">Entraînement</string><string name="resume_current_session">Reprendre la séance en cours</string>

View file

@ -139,8 +139,8 @@ class AiSessionDraftUiWiringTest {
} }
compose.onNodeWithText("Dos + biceps").assertIsDisplayed()
compose.onNodeWithText("Tractions supination · 3 × 10 répétitions · repos 90 s")
.assertIsDisplayed()
compose.onNodeWithText("Tractions supination").assertIsDisplayed()
compose.onNodeWithText("3 × 10 · repos 90 s").assertIsDisplayed()
compose.onNodeWithText("Accent sur le dos et les biceps.").assertIsDisplayed()
}