Compare commits
No commits in common. "e41949557b548a51e9884310dfc5209cf57958f2" and "88e0eb0fb78d8a661e8c28fe6975d8f00cc2e14f" have entirely different histories.
e41949557b
...
88e0eb0fb7
21 changed files with 188 additions and 361 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -4,22 +4,6 @@
|
|||
|
||||
Development opened after the stable v0.1.2 release.
|
||||
|
||||
- Unified the Android capture interface around compact dark cards, action
|
||||
tiles, restrained-radius button variants, wrapping selection grids, and
|
||||
two-column performed-set entry. Home now focuses on session capture, recent
|
||||
BODY ZONES exposure, synchronization, and measurements; mobile Statistics
|
||||
navigation was removed without changing stored data or exchange contracts.
|
||||
Programs now show compact session metadata, completed progress, and explicit
|
||||
start/resume/completed states using the existing schema-v25 durable Program
|
||||
execution provenance. Web Program administration gained adaptive cards, a
|
||||
visible progress bar, and green completed-session treatment while retaining
|
||||
its revision-guarded trash confirmation and causal deletion behavior.
|
||||
Android full lint remains at the exact ten-error `main` baseline (one
|
||||
`NewApi`, one `LocalContextConfigurationRead`, and eight bilingual
|
||||
`StringFormatMatches` diagnostics); this lot adds no lint error and disables
|
||||
no rule. Private candidates may override numeric `versionName` and increasing
|
||||
`versionCode` at build time without changing development defaults.
|
||||
|
||||
- Added the loopback-only `/exercices` desktop catalogue administration
|
||||
surface with bounded search/profile/BODY ZONES filtering, detail and
|
||||
read-only equipment presentation, Core-owned creation and editing, complete
|
||||
|
|
|
|||
|
|
@ -45,16 +45,6 @@ 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
|
||||
|
|
@ -65,7 +55,7 @@ android {
|
|||
targetSdk = 36
|
||||
|
||||
versionCode = trainlogVersionCode
|
||||
versionName = trainlogVersionName
|
||||
versionName = "0.1.3"
|
||||
testInstrumentationRunner =
|
||||
"androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
?: (loaded as?
|
||||
ActiveDraftLoadResult.Loaded)
|
||||
?.warning,
|
||||
latestSession = null,
|
||||
latestMaximum = null,
|
||||
bodyZoneOverview = repository.getBodyZoneHomeOverview(),
|
||||
onSession = {
|
||||
when (
|
||||
|
|
@ -100,7 +102,9 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
}
|
||||
},
|
||||
onOpenExercises = {},
|
||||
onOpenStatistics = {},
|
||||
onBody = {},
|
||||
onOpenLatestSession = {},
|
||||
onSync = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ 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,
|
||||
)
|
||||
|
|
@ -58,7 +59,8 @@ 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.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings
|
||||
AppSection.STATISTICS -> R.string.nav_statistics; 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
|
||||
|
|
@ -69,7 +71,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
|
||||
is AppRoute.EquipmentCreate -> R.string.route_equipment_create; AppRoute.Statistics -> R.string.nav_statistics
|
||||
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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import androidx.lifecycle.ViewModel
|
|||
|
||||
enum class AppSection {
|
||||
HOME, SESSIONS, EXERCISES,
|
||||
EQUIPMENT, SYNC, SETTINGS,
|
||||
EQUIPMENT, STATISTICS, SYNC, SETTINGS,
|
||||
}
|
||||
|
||||
sealed interface AppRoute {
|
||||
|
|
@ -34,8 +34,9 @@ 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 BodyMeasurements : AppRoute { override val section = AppSection.HOME }
|
||||
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
||||
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 Sync : AppRoute { override val section = AppSection.SYNC }
|
||||
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
||||
}
|
||||
|
|
@ -45,6 +46,7 @@ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,9 @@ 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
|
||||
|
|
@ -855,11 +852,7 @@ private fun TrainlogChoiceGroup(
|
|||
),
|
||||
)
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) { content() }
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -876,15 +869,15 @@ private fun TrainlogChoice(
|
|||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.widthIn(min = 132.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.heightIn(min = 48.dp)
|
||||
.background(
|
||||
if (selected) {
|
||||
colors.surfaceAlt
|
||||
} else {
|
||||
colors.surface
|
||||
},
|
||||
androidx.compose.material3.MaterialTheme.shapes.medium,
|
||||
}
|
||||
)
|
||||
.semantics { this.selected = selected }
|
||||
.clickable(
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ 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
|
||||
|
|
@ -51,6 +52,8 @@ 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
|
||||
|
|
@ -61,14 +64,19 @@ 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)) {
|
||||
|
|
@ -79,7 +87,17 @@ fun HomeScreen(
|
|||
TrainlogPrimaryAction(strings.getString(R.string.new_manual_session), strings.getString(R.string.new_manual_description), onSession)
|
||||
}
|
||||
}
|
||||
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises)
|
||||
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)
|
||||
}
|
||||
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)) {
|
||||
|
|
@ -101,6 +119,7 @@ fun HomeScreen(
|
|||
internal fun BodyZoneHomeSection(
|
||||
overview: BodyZoneHomeOverview,
|
||||
onOpenExercises: (String) -> Unit,
|
||||
onOpenStatistics: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
|
|
@ -140,9 +159,7 @@ internal fun BodyZoneHomeSection(
|
|||
}
|
||||
selected?.let { zone ->
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp)
|
||||
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
|
||||
.padding(12.dp),
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp).background(colors.surface, RoundedCornerShape(8.dp)).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
BasicText(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), style = TrainlogTypography.section.copy(color = colors.text))
|
||||
|
|
@ -162,6 +179,7 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,17 +8,6 @@ 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
|
||||
|
|
@ -29,34 +18,13 @@ 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 ->
|
||||
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",
|
||||
)
|
||||
}
|
||||
TrainlogFrame(program.title, active = 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(
|
||||
|
|
@ -66,7 +34,6 @@ 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) },
|
||||
|
|
@ -117,41 +84,22 @@ 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
|
||||
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),
|
||||
) {
|
||||
TrainlogFrame("${index + 1}. ${session.title}", active = completed) {
|
||||
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
|
||||
}
|
||||
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(strings.getString(stateLabel))
|
||||
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) }
|
||||
|
|
@ -193,18 +141,3 @@ 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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ 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
|
||||
|
|
@ -190,29 +188,13 @@ fun LatestMaximaScreen(repository: TrainlogRepository) {
|
|||
val locale = presentationLocale()
|
||||
val strings = localizedContext()
|
||||
TrainlogScreen(strings.getString(R.string.route_latest_maxima)) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ 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
|
||||
|
|
@ -197,34 +196,69 @@ fun SessionScreen(
|
|||
TrainlogFrame(
|
||||
title = strings.getString(R.string.session_type)
|
||||
) {
|
||||
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 = {
|
||||
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 = {
|
||||
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)
|
||||
},
|
||||
)
|
||||
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 = {
|
||||
description =
|
||||
strings.getString(R.string.max_session_description),
|
||||
accent =
|
||||
if (
|
||||
currentDraft.sessionType ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
colors.warning
|
||||
} else {
|
||||
colors.muted
|
||||
},
|
||||
onClick = {
|
||||
persistDraft(
|
||||
currentDraft.copy(sessionType = SessionType.MAX_TEST),
|
||||
currentDraft.copy(
|
||||
sessionType =
|
||||
SessionType.MAX_TEST
|
||||
),
|
||||
null,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ExercisePicker(
|
||||
|
|
@ -572,7 +606,7 @@ fun SessionScreen(
|
|||
colors.success
|
||||
} else {
|
||||
colors.error
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1117,78 +1151,54 @@ private fun SessionExerciseForm(
|
|||
strings.getString(R.string.field_weight)
|
||||
}
|
||||
setRows.forEachIndexed { index, row ->
|
||||
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,
|
||||
),
|
||||
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,
|
||||
)
|
||||
)
|
||||
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 },
|
||||
},
|
||||
)
|
||||
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,
|
||||
)
|
||||
)
|
||||
}
|
||||
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,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
TrainlogDeleteButton(
|
||||
contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set),
|
||||
modifier = Modifier.testTag("delete-set-${index + 1}"),
|
||||
onClick = {
|
||||
pendingSetRemoval = index
|
||||
},
|
||||
)
|
||||
}
|
||||
TrainlogAction(
|
||||
label = strings.getString(R.string.add_set_plain),
|
||||
|
|
@ -1598,7 +1608,6 @@ private fun SessionNumberField(
|
|||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
testTag: String? = null,
|
||||
decimal: Boolean = false,
|
||||
) {
|
||||
|
|
@ -1614,7 +1623,6 @@ private fun SessionNumberField(
|
|||
} else {
|
||||
KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ 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 ->
|
||||
|
|
@ -101,7 +102,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
exerciseState.searchQuery.value = ""
|
||||
open(AppRoute.Exercises)
|
||||
},
|
||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
|
||||
{ open(AppRoute.Statistics) },
|
||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
||||
)
|
||||
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
||||
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
|
||||
|
|
@ -139,6 +141,7 @@ 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() })
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ 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
|
||||
|
|
@ -38,7 +37,6 @@ 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
|
||||
|
|
@ -166,7 +164,7 @@ fun TrainlogAction(
|
|||
.padding(vertical = 3.dp)
|
||||
.heightIn(min = 48.dp)
|
||||
.height(IntrinsicSize.Min)
|
||||
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||
.background(Color.Transparent)
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -201,8 +199,6 @@ fun TrainlogAction(
|
|||
}
|
||||
}
|
||||
|
||||
enum class TrainlogButtonStyle { PRIMARY, SECONDARY, SUCCESS, DESTRUCTIVE, GHOST }
|
||||
|
||||
/** Compact pictogram action with a mandatory accessible name. */
|
||||
@Composable
|
||||
fun TrainlogIconAction(
|
||||
|
|
@ -234,7 +230,6 @@ 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)
|
||||
|
|
@ -245,7 +240,9 @@ fun TrainlogDeleteButton(
|
|||
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
|
||||
TrainlogButton(label, onClick, Modifier.fillMaxWidth())
|
||||
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
|
||||
Text(label)
|
||||
}
|
||||
if (description.isNotBlank()) {
|
||||
BasicText(
|
||||
description,
|
||||
|
|
@ -273,7 +270,7 @@ fun TrainlogActionTile(
|
|||
Column(
|
||||
modifier
|
||||
.heightIn(min = 112.dp)
|
||||
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||
.background(colors.surface, RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
|
|
@ -294,33 +291,13 @@ 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),
|
||||
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,
|
||||
),
|
||||
colors = containerColor?.let { ButtonDefaults.buttonColors(containerColor = it) }
|
||||
?: ButtonDefaults.buttonColors(),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
icon?.let {
|
||||
|
|
@ -357,7 +334,6 @@ fun TrainlogInputField(
|
|||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
keyboardOptions: KeyboardOptions =
|
||||
KeyboardOptions.Default,
|
||||
testTag: String? = null,
|
||||
|
|
@ -374,7 +350,8 @@ fun TrainlogInputField(
|
|||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.padding(bottom = 10.dp)
|
||||
modifier =
|
||||
Modifier.padding(bottom = 10.dp)
|
||||
) {
|
||||
BasicText(
|
||||
text = label.uppercase(),
|
||||
|
|
|
|||
|
|
@ -9,15 +9,12 @@ 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,
|
||||
|
|
@ -113,19 +110,6 @@ fun TrainlogTheme(
|
|||
error = TrainlogDarkColors.error,
|
||||
)
|
||||
CompositionLocalProvider(LocalTrainlogColors provides TrainlogDarkColors) {
|
||||
/* 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,
|
||||
)
|
||||
MaterialTheme(colorScheme = scheme, content = content)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,10 +36,14 @@ 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++ },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,14 +36,13 @@ class AppNavigationStateTest {
|
|||
|
||||
@After fun tearDown() { context.deleteDatabase(databaseName) }
|
||||
|
||||
@Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() {
|
||||
assertEquals(6, AppSection.entries.size)
|
||||
assertFalse(AppSection.entries.any { it.name == "STATISTICS" })
|
||||
@Test fun sevenSectionsHaveTypedRootsAndSelectionComesFromRoute() {
|
||||
assertEquals(7, AppSection.entries.size)
|
||||
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.EXERCISES, AppRoute.LatestMaxima.section)
|
||||
assertEquals(AppSection.STATISTICS, AppRoute.LatestMaxima.section)
|
||||
}
|
||||
|
||||
@Test fun catalogueDetailsAndCreatorsReturnToTheirDeclaredCaller() {
|
||||
|
|
|
|||
|
|
@ -110,10 +110,12 @@ 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()))
|
||||
|
|
@ -124,12 +126,16 @@ 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)
|
||||
|
|
@ -141,6 +147,7 @@ class BodyZoneHomeSemanticsTest {
|
|||
BodyZoneHomeSection(
|
||||
BodyZoneHomeOverview(zones, listOf(zones.first()), true, false),
|
||||
onOpenExercises,
|
||||
onOpenStatistics,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,30 +57,6 @@ It is a native Kotlin/Jetpack Compose application with local SQLite persistence.
|
|||
|
||||
The desktop remains the canonical long-term history and analytics store.
|
||||
|
||||
## Capture-first interface
|
||||
|
||||
The Android interface uses one compact dark design system for cards, action
|
||||
tiles, buttons, selection states, and destructive actions. Controls share a
|
||||
restrained corner radius instead of screen-specific pill shapes. The Home
|
||||
screen is limited to starting or resuming capture, recent BODY ZONES exposure,
|
||||
synchronization, and body measurements. Analytics and Statistics are not
|
||||
mobile navigation destinations; the desktop remains their owner. The local
|
||||
statistics-compatible data and exchange contracts are unchanged.
|
||||
|
||||
Sessions exposes manual capture, prepared drafts, completed history, and
|
||||
Programs as structured actions. Program cards show status, dates, session
|
||||
count, and completed progress. Program detail shows each planned session as
|
||||
`todo`, `in progress`, or `completed`, with type, date, exercise count, and a
|
||||
start/resume action. Starting continues to use the schema-v25 durable singleton
|
||||
and stable Program provenance described below.
|
||||
|
||||
The active editor uses side-by-side Training/MAX selection and compact set
|
||||
cards. Repetitions and load share one row when space permits; the per-set
|
||||
delete action stays inside the card and retains its confirmation contract.
|
||||
Exercise filters use wrapping selection tiles, while catalog rows, drafts,
|
||||
exercise actions, synchronization actions, Settings actions, and latest MAX
|
||||
entries use the same card/button vocabulary.
|
||||
|
||||
## Interface language (`TRAINLOG_I18N_V0_1_1`)
|
||||
|
||||
Trainlog version 0.1.2 is synchronized with the desktop TUI as one product
|
||||
|
|
|
|||
|
|
@ -323,21 +323,6 @@ against desktop schema v27 without transferring ownership of Program
|
|||
definitions. Web and Android derive their session state from these persisted
|
||||
identities; no display-field heuristic is used.
|
||||
|
||||
Android is capture-first: its drawer no longer exposes Statistics, and Home is
|
||||
limited to capture, recent BODY ZONES exposure, synchronization, and body
|
||||
measurements. A shared compact card/button system covers Sessions, Programs,
|
||||
drafts, exercise catalog/detail/editing, synchronization, Settings, and MAX
|
||||
presentation. Program list/detail surfaces show completed counts and explicit
|
||||
todo/in-progress/completed states; the active editor uses exclusive side-by-
|
||||
side session-type controls and compact two-column set cards. These are
|
||||
presentation changes only and do not alter schemas or exchange artifacts.
|
||||
Web Program administration retains revision-guarded deletion and now presents
|
||||
adaptive cards plus an explicit completed-session progress indicator.
|
||||
The full Android lint comparison against an isolated clean `main` worktree is
|
||||
unchanged at ten pre-existing errors: one `NewApi`, one
|
||||
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
|
||||
diagnostics. This UI tranche adds no lint error and changes no lint policy.
|
||||
|
||||
The private Program execution rollout installed desktop schema v27 and the
|
||||
signed nondebuggable Android versionCode 17/schema v25 update without
|
||||
uninstalling or clearing application data. A strictly disposable Program was
|
||||
|
|
|
|||
|
|
@ -656,19 +656,9 @@ tagged source state, and the tag, Android `versionName`, Meson project version,
|
|||
TUI product version, asset names, and release title must agree.
|
||||
|
||||
Private update rollout may set `TRAINLOG_ANDROID_VERSION_CODE` to a positive
|
||||
integer and `TRAINLOG_ANDROID_VERSION_NAME=0.1.2` to remain on the installed
|
||||
stable product line. Both are build-only overrides; the repository development
|
||||
defaults remain unchanged. They are used only to maintain a strictly increasing
|
||||
installed → backup bridge → current APK chain and do not change any schema or
|
||||
exchange-format version.
|
||||
|
||||
For `TRAINLOG_UI_ANDROID_WEB_UNIFICATION_V1`, `lintDebug` was also run from a
|
||||
detached clean worktree at the exact `main` baseline and from the mission
|
||||
branch. Both runs report the same ten errors: one `NewApi` diagnostic for
|
||||
`windowLightNavigationBar`, one `LocalContextConfigurationRead`, and eight
|
||||
`StringFormatMatches` diagnostics (four bilingual call sites). Comparison by
|
||||
rule and complete diagnostic message reports zero added and zero removed
|
||||
errors. No lint rule or severity was changed.
|
||||
integer while keeping `versionName=0.1.2`. This build-only override is used only
|
||||
to maintain a strictly increasing installed → backup bridge → current APK
|
||||
chain; it does not change any schema or exchange-format version.
|
||||
|
||||
```bash
|
||||
meson setup --reconfigure build
|
||||
|
|
|
|||
|
|
@ -70,10 +70,6 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
|
|||
completed: 'Effectuée',
|
||||
deleted: 'Retirée',
|
||||
})[state]
|
||||
const completedCount = program.sessions.filter((session) =>
|
||||
session.execution_state === 'completed').length
|
||||
const progress = program.sessions.length === 0
|
||||
? 0 : Math.round((completedCount / program.sessions.length) * 100)
|
||||
return <article className="session-detail program-detail">
|
||||
<div className="sessions-toolbar">
|
||||
<button type="button" className="quiet-action" onClick={onBack}>
|
||||
|
|
@ -103,10 +99,6 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
|
|||
Import {program.source_format} v{program.source_version} · modifié le{' '}
|
||||
{formatDateTime(program.updated_at, dateFormat)}
|
||||
</p>
|
||||
<div className="program-progress" aria-label={`${completedCount} séances effectuées sur ${program.sessions.length}`}>
|
||||
<div><strong>{completedCount}/{program.sessions.length}</strong><span>{progress}% effectué</span></div>
|
||||
<progress value={completedCount} max={Math.max(program.sessions.length, 1)} />
|
||||
</div>
|
||||
</header>
|
||||
<ol className="program-session-list">
|
||||
{program.sessions.map((session) => <li key={session.program_session_id}>
|
||||
|
|
|
|||
|
|
@ -195,23 +195,17 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
|||
}
|
||||
.android-delivery-state.state-remote_unknown { color: var(--yellow); }
|
||||
.android-delivery-state.state-acknowledged { color: var(--green); }
|
||||
.program-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr)); gap: 1rem; }
|
||||
.program-session-list { display: grid; gap: .75rem; }
|
||||
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); grid-template-columns: minmax(0, 1fr) 3rem; }
|
||||
.program-list, .program-session-list { display: grid; gap: .75rem; }
|
||||
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); }
|
||||
.program-row .session-row-main { grid-template-columns: minmax(13rem, 2fr) repeat(3, minmax(8rem, 1fr)); }
|
||||
.program-session-list { padding: 0; list-style: none; }
|
||||
.program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; background: var(--base); }
|
||||
.program-session-list li:has(.state-completed) { border-color: color-mix(in srgb, var(--green) 55%, var(--surface-1)); background: color-mix(in srgb, var(--green) 7%, var(--base)); }
|
||||
.program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; }
|
||||
.program-session-list p { margin: .35rem 0 0; color: var(--subtext-0); }
|
||||
.program-execution-state { display: inline-flex; margin-left: .65rem; padding: .2rem .55rem; border-radius: 999px; font-size: .82rem; font-weight: 700; }
|
||||
.program-execution-state.state-todo { background: var(--surface-1); color: var(--subtext-1); }
|
||||
.program-execution-state.state-prepared, .program-execution-state.state-in_progress { background: color-mix(in srgb, var(--yellow) 18%, transparent); color: var(--yellow); }
|
||||
.program-execution-state.state-completed { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); }
|
||||
.program-execution-state.state-deleted { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }
|
||||
.program-progress { max-width: 28rem; display: grid; gap: .4rem; }
|
||||
.program-progress div { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
|
||||
.program-progress span { color: var(--subtext-0); font-size: var(--font-size-sm); }
|
||||
.program-progress progress { width: 100%; height: .55rem; accent-color: var(--green); }
|
||||
.programs-calendar-page { max-width: 100%; }
|
||||
.program-calendar-loading { min-height: 12rem; display: grid; place-items: center; color: var(--subtext-0); }
|
||||
.program-calendar-empty { max-width: 40rem; padding: clamp(1.25rem, 4vw, 2.5rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue