refactor(android): compact drafts and body measurement screens
This commit is contained in:
parent
53baa7ad24
commit
f27ef5af5f
8 changed files with 357 additions and 190 deletions
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue