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.ModalDrawerSheet
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
|
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
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.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import com.labfytools.trainlog.R
|
import com.labfytools.trainlog.R
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -105,7 +107,16 @@ fun AndroidAppShell(
|
||||||
onOpenSection(section)
|
onOpenSection(section)
|
||||||
scope.launch { drawerState.close() }
|
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.background
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
|
@ -89,148 +95,74 @@ fun BodyScreen(
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = strings.getString(R.string.general)
|
title = strings.getString(R.string.general)
|
||||||
) {
|
) {
|
||||||
BodyMetricField(
|
BodyMetricGrid(
|
||||||
label = strings.getString(R.string.weight),
|
listOf(
|
||||||
unit = "kg",
|
BodyMetricItem(strings.getString(R.string.weight), "kg", bodyWeight) {
|
||||||
value = bodyWeight,
|
bodyWeight = it
|
||||||
onValueChange = {
|
message = null
|
||||||
bodyWeight = it
|
},
|
||||||
message = null
|
BodyMetricItem(strings.getString(R.string.neck), "cm", neck) {
|
||||||
},
|
neck = it
|
||||||
)
|
message = null
|
||||||
|
},
|
||||||
BodyMetricField(
|
BodyMetricItem(strings.getString(R.string.shoulders), "cm", shoulders) {
|
||||||
label = strings.getString(R.string.neck),
|
shoulders = it
|
||||||
unit = "cm",
|
message = null
|
||||||
value = neck,
|
},
|
||||||
onValueChange = {
|
BodyMetricItem(strings.getString(R.string.chest), "cm", chest) {
|
||||||
neck = it
|
chest = it
|
||||||
message = null
|
message = null
|
||||||
},
|
},
|
||||||
)
|
BodyMetricItem(strings.getString(R.string.waist), "cm", waist) {
|
||||||
|
waist = it
|
||||||
BodyMetricField(
|
message = null
|
||||||
label = strings.getString(R.string.shoulders),
|
},
|
||||||
unit = "cm",
|
BodyMetricItem(strings.getString(R.string.hips), "cm", hips) {
|
||||||
value = shoulders,
|
hips = it
|
||||||
onValueChange = {
|
message = null
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = strings.getString(R.string.limbs)
|
title = strings.getString(R.string.limbs)
|
||||||
) {
|
) {
|
||||||
BodyMetricField(
|
BodyMetricGrid(
|
||||||
label = strings.getString(R.string.left_arm),
|
listOf(
|
||||||
unit = "cm",
|
BodyMetricItem(strings.getString(R.string.left_arm), "cm", leftArm) {
|
||||||
value = leftArm,
|
leftArm = it
|
||||||
onValueChange = {
|
message = null
|
||||||
leftArm = it
|
},
|
||||||
message = null
|
BodyMetricItem(strings.getString(R.string.right_arm), "cm", rightArm) {
|
||||||
},
|
rightArm = it
|
||||||
)
|
message = null
|
||||||
|
},
|
||||||
BodyMetricField(
|
BodyMetricItem(strings.getString(R.string.left_forearm), "cm", leftForearm) {
|
||||||
label = strings.getString(R.string.right_arm),
|
leftForearm = it
|
||||||
unit = "cm",
|
message = null
|
||||||
value = rightArm,
|
},
|
||||||
onValueChange = {
|
BodyMetricItem(strings.getString(R.string.right_forearm), "cm", rightForearm) {
|
||||||
rightArm = it
|
rightForearm = it
|
||||||
message = null
|
message = null
|
||||||
},
|
},
|
||||||
)
|
BodyMetricItem(strings.getString(R.string.left_thigh), "cm", leftThigh) {
|
||||||
|
leftThigh = it
|
||||||
BodyMetricField(
|
message = null
|
||||||
label = strings.getString(R.string.left_forearm),
|
},
|
||||||
unit = "cm",
|
BodyMetricItem(strings.getString(R.string.right_thigh), "cm", rightThigh) {
|
||||||
value = leftForearm,
|
rightThigh = it
|
||||||
onValueChange = {
|
message = null
|
||||||
leftForearm = it
|
},
|
||||||
message = null
|
BodyMetricItem(strings.getString(R.string.left_calf), "cm", leftCalf) {
|
||||||
},
|
leftCalf = it
|
||||||
)
|
message = null
|
||||||
|
},
|
||||||
BodyMetricField(
|
BodyMetricItem(strings.getString(R.string.right_calf), "cm", rightCalf) {
|
||||||
label = strings.getString(R.string.right_forearm),
|
rightCalf = it
|
||||||
unit = "cm",
|
message = null
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -426,12 +358,14 @@ private fun BodyMetricField(
|
||||||
unit: String,
|
unit: String,
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
TrainlogInputField(
|
TrainlogInputField(
|
||||||
label = "$label ($unit)",
|
label = label,
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange =
|
onValueChange =
|
||||||
onValueChange,
|
onValueChange,
|
||||||
|
modifier = modifier,
|
||||||
keyboardOptions =
|
keyboardOptions =
|
||||||
KeyboardOptions(
|
KeyboardOptions(
|
||||||
keyboardType =
|
keyboardType =
|
||||||
|
|
@ -439,9 +373,42 @@ private fun BodyMetricField(
|
||||||
imeAction =
|
imeAction =
|
||||||
ImeAction.Next,
|
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 {
|
private sealed interface MetricParse {
|
||||||
data object Empty :
|
data object Empty :
|
||||||
MetricParse
|
MetricParse
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import com.labfytools.trainlog.model.SessionType
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
import com.labfytools.trainlog.R
|
import com.labfytools.trainlog.R
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -133,7 +134,8 @@ internal fun formatStartedAt(
|
||||||
internal fun formatDate(value: String): String {
|
internal fun formatDate(value: String): String {
|
||||||
val locale = presentationLocale()
|
val locale = presentationLocale()
|
||||||
val pattern = uiString(R.string.date_pattern)
|
val pattern = uiString(R.string.date_pattern)
|
||||||
return runCatching {
|
val formatter = DateTimeFormatter.ofPattern(pattern, locale)
|
||||||
OffsetDateTime.parse(value).format(DateTimeFormatter.ofPattern(pattern, locale))
|
return runCatching { LocalDate.parse(value.take(10)).format(formatter) }
|
||||||
}.getOrElse { value.take(10) }
|
.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.runtime.setValue
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
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.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.AndroidBackupResult
|
||||||
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
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.ui.theme.LocalTrainlogColors
|
||||||
import com.labfytools.trainlog.R
|
import com.labfytools.trainlog.R
|
||||||
|
|
||||||
|
|
@ -95,21 +100,14 @@ fun AiSessionDraftsScreen(
|
||||||
message?.let { TrainlogInfo(it) }
|
message?.let { TrainlogInfo(it) }
|
||||||
if (drafts.isEmpty() && preparations.isEmpty()) TrainlogInfo(strings.getString(R.string.draft_none))
|
if (drafts.isEmpty() && preparations.isEmpty()) TrainlogInfo(strings.getString(R.string.draft_none))
|
||||||
preparations.forEach { preparation ->
|
preparations.forEach { preparation ->
|
||||||
TrainlogFrame(preparation.title, active = true) {
|
DraftSummaryCard(
|
||||||
TrainlogInfo(listOfNotNull(
|
title = preparation.title,
|
||||||
strings.getString(R.string.manual_preparation),
|
provenance = strings.getString(R.string.manual_preparation),
|
||||||
preparation.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
plannedFor = preparation.plannedFor,
|
||||||
strings.resources.getQuantityString(R.plurals.exercise_count, preparation.entries.size, preparation.entries.size),
|
notes = preparation.notes,
|
||||||
).joinToString(" · "))
|
entries = preparation.entries,
|
||||||
preparation.notes?.let { TrainlogInfo(it) }
|
startDescription = strings.getString(R.string.start_preparation_description),
|
||||||
preparation.entries.forEach { entry ->
|
onStart = {
|
||||||
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)) {
|
|
||||||
when (val result = repository.startPreparedSession(preparation.deliveryId)) {
|
when (val result = repository.startPreparedSession(preparation.deliveryId)) {
|
||||||
StartAiSessionDraftResult.Started -> { onPendingChanged(); onStarted() }
|
StartAiSessionDraftResult.Started -> { onPendingChanged(); onStarted() }
|
||||||
StartAiSessionDraftResult.ExistingActiveDraft ->
|
StartAiSessionDraftResult.ExistingActiveDraft ->
|
||||||
|
|
@ -117,24 +115,18 @@ fun AiSessionDraftsScreen(
|
||||||
StartAiSessionDraftResult.NotPending -> { message = strings.getString(R.string.draft_missing); revision++; onPendingChanged() }
|
StartAiSessionDraftResult.NotPending -> { message = strings.getString(R.string.draft_missing); revision++; onPendingChanged() }
|
||||||
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
|
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
drafts.forEach { draft ->
|
drafts.forEach { draft ->
|
||||||
TrainlogFrame(draft.title ?: strings.getString(R.string.session_proposal), active = true) {
|
DraftSummaryCard(
|
||||||
TrainlogInfo(listOfNotNull(
|
title = draft.title ?: strings.getString(R.string.session_proposal),
|
||||||
draft.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
provenance = strings.getString(R.string.session_proposal),
|
||||||
strings.resources.getQuantityString(R.plurals.exercise_count, draft.entries.size, draft.entries.size),
|
plannedFor = draft.plannedFor,
|
||||||
).joinToString(" · "))
|
notes = draft.notes,
|
||||||
draft.notes?.let { TrainlogInfo(it) }
|
entries = draft.entries,
|
||||||
draft.entries.forEach { entry ->
|
startDescription = strings.getString(R.string.start_proposal_description),
|
||||||
val plan = checkNotNull(entry.plan)
|
onStart = {
|
||||||
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)) {
|
|
||||||
when (val result = repository.startAiSessionDraft(draft.draftId)) {
|
when (val result = repository.startAiSessionDraft(draft.draftId)) {
|
||||||
StartAiSessionDraftResult.Started -> {
|
StartAiSessionDraftResult.Started -> {
|
||||||
onPendingChanged()
|
onPendingChanged()
|
||||||
|
|
@ -149,15 +141,24 @@ fun AiSessionDraftsScreen(
|
||||||
}
|
}
|
||||||
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
|
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
TrainlogDeleteButton(
|
footer = {
|
||||||
contentDescription = localizedContext().getString(R.string.a11y_delete_draft),
|
Row(
|
||||||
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
|
Modifier.fillMaxWidth(),
|
||||||
onClick = {
|
horizontalArrangement = Arrangement.End,
|
||||||
pendingDeletion = draft.draftId to (draft.title ?: strings.getString(R.string.session_proposal))
|
) {
|
||||||
},
|
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) ->
|
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
|
@Composable
|
||||||
fun LatestMaximaScreen(repository: TrainlogRepository) {
|
fun LatestMaximaScreen(repository: TrainlogRepository) {
|
||||||
val colors = LocalTrainlogColors.current
|
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 }
|
enum class TrainlogButtonStyle { PRIMARY, SECONDARY, SUCCESS, DESTRUCTIVE, GHOST }
|
||||||
|
|
||||||
/** Compact pictogram action with a mandatory accessible name. */
|
/** Compact pictogram action with a mandatory accessible name. */
|
||||||
|
|
@ -229,11 +267,12 @@ fun TrainlogDeleteButton(
|
||||||
contentDescription: String,
|
contentDescription: String,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
fullWidth: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
Button(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp),
|
modifier = (if (fullWidth) modifier.fillMaxWidth() else modifier).heightIn(min = 48.dp),
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = colors.error),
|
colors = ButtonDefaults.buttonColors(containerColor = colors.error),
|
||||||
) {
|
) {
|
||||||
|
|
@ -364,6 +403,8 @@ fun TrainlogInputField(
|
||||||
singleLine: Boolean = true,
|
singleLine: Boolean = true,
|
||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||||
|
suffix: String? = null,
|
||||||
|
compact: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
|
|
@ -418,7 +459,7 @@ fun TrainlogInputField(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 48.dp)
|
.heightIn(min = if (compact) 44.dp else 48.dp)
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
focused = it.isFocused
|
focused = it.isFocused
|
||||||
}
|
}
|
||||||
|
|
@ -429,11 +470,22 @@ fun TrainlogInputField(
|
||||||
Modifier.testTag(testTag)
|
Modifier.testTag(testTag)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.background(colors.surface)
|
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||||
.padding(
|
.padding(
|
||||||
horizontal = 12.dp,
|
horizontal = if (compact) 10.dp else 12.dp,
|
||||||
vertical = 10.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(
|
Box(
|
||||||
|
|
|
||||||
|
|
@ -334,7 +334,8 @@
|
||||||
<plurals name="repetition_count"><item quantity="one">%d repetition</item><item quantity="other">%d repetitions</item></plurals>
|
<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="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="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="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="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>
|
<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>
|
<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="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="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="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="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>
|
<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("Dos + biceps").assertIsDisplayed()
|
||||||
compose.onNodeWithText("Tractions supination · 3 × 10 répétitions · repos 90 s")
|
compose.onNodeWithText("Tractions supination").assertIsDisplayed()
|
||||||
.assertIsDisplayed()
|
compose.onNodeWithText("3 × 10 · repos 90 s").assertIsDisplayed()
|
||||||
compose.onNodeWithText("Accent sur le dos et les biceps.").assertIsDisplayed()
|
compose.onNodeWithText("Accent sur le dos et les biceps.").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue