Compare commits
3 commits
88e0eb0fb7
...
e41949557b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e41949557b | ||
|
|
d56bd3b54f | ||
|
|
cb4010dbd5 |
21 changed files with 361 additions and 188 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -4,6 +4,22 @@
|
||||||
|
|
||||||
Development opened after the stable v0.1.2 release.
|
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
|
- Added the loopback-only `/exercices` desktop catalogue administration
|
||||||
surface with bounded search/profile/BODY ZONES filtering, detail and
|
surface with bounded search/profile/BODY ZONES filtering, detail and
|
||||||
read-only equipment presentation, Core-owned creation and editing, complete
|
read-only equipment presentation, Core-owned creation and editing, complete
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,16 @@ val trainlogVersionCode =
|
||||||
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
||||||
} ?: 4
|
} ?: 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 {
|
android {
|
||||||
namespace = "com.labfytools.trainlog"
|
namespace = "com.labfytools.trainlog"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
|
|
@ -55,7 +65,7 @@ android {
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
|
|
||||||
versionCode = trainlogVersionCode
|
versionCode = trainlogVersionCode
|
||||||
versionName = "0.1.3"
|
versionName = trainlogVersionName
|
||||||
testInstrumentationRunner =
|
testInstrumentationRunner =
|
||||||
"androidx.test.runner.AndroidJUnitRunner"
|
"androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,6 @@ class DraftUiTestActivity : ComponentActivity() {
|
||||||
?: (loaded as?
|
?: (loaded as?
|
||||||
ActiveDraftLoadResult.Loaded)
|
ActiveDraftLoadResult.Loaded)
|
||||||
?.warning,
|
?.warning,
|
||||||
latestSession = null,
|
|
||||||
latestMaximum = null,
|
|
||||||
bodyZoneOverview = repository.getBodyZoneHomeOverview(),
|
bodyZoneOverview = repository.getBodyZoneHomeOverview(),
|
||||||
onSession = {
|
onSession = {
|
||||||
when (
|
when (
|
||||||
|
|
@ -102,9 +100,7 @@ class DraftUiTestActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOpenExercises = {},
|
onOpenExercises = {},
|
||||||
onOpenStatistics = {},
|
|
||||||
onBody = {},
|
onBody = {},
|
||||||
onOpenLatestSession = {},
|
|
||||||
onSync = {},
|
onSync = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ private val drawerIcons = mapOf(
|
||||||
AppSection.SESSIONS to R.drawable.ic_sessions,
|
AppSection.SESSIONS to R.drawable.ic_sessions,
|
||||||
AppSection.EXERCISES to R.drawable.ic_exercises,
|
AppSection.EXERCISES to R.drawable.ic_exercises,
|
||||||
AppSection.EQUIPMENT to R.drawable.ic_equipment,
|
AppSection.EQUIPMENT to R.drawable.ic_equipment,
|
||||||
AppSection.STATISTICS to R.drawable.ic_statistics,
|
|
||||||
AppSection.SYNC to R.drawable.ic_sync,
|
AppSection.SYNC to R.drawable.ic_sync,
|
||||||
AppSection.SETTINGS to R.drawable.ic_settings,
|
AppSection.SETTINGS to R.drawable.ic_settings,
|
||||||
)
|
)
|
||||||
|
|
@ -59,8 +58,7 @@ fun AndroidAppShell(
|
||||||
fun sectionLabel(section: AppSection): String = strings.getString(when (section) {
|
fun sectionLabel(section: AppSection): String = strings.getString(when (section) {
|
||||||
AppSection.HOME -> R.string.nav_home; AppSection.SESSIONS -> R.string.nav_sessions
|
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.EXERCISES -> R.string.nav_exercises; AppSection.EQUIPMENT -> R.string.nav_equipment
|
||||||
AppSection.STATISTICS -> R.string.nav_statistics; AppSection.SYNC -> R.string.nav_sync
|
AppSection.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings
|
||||||
AppSection.SETTINGS -> R.string.nav_settings
|
|
||||||
})
|
})
|
||||||
fun title(): String = strings.getString(when (route) {
|
fun title(): String = strings.getString(when (route) {
|
||||||
AppRoute.Home -> R.string.nav_home; AppRoute.Sessions -> R.string.nav_sessions
|
AppRoute.Home -> R.string.nav_home; AppRoute.Sessions -> R.string.nav_sessions
|
||||||
|
|
@ -71,7 +69,7 @@ fun AndroidAppShell(
|
||||||
AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail
|
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
|
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
|
AppRoute.Equipment -> R.string.nav_equipment; is AppRoute.EquipmentDetail -> R.string.route_equipment_detail
|
||||||
is AppRoute.EquipmentCreate -> R.string.route_equipment_create; AppRoute.Statistics -> R.string.nav_statistics
|
is AppRoute.EquipmentCreate -> R.string.route_equipment_create
|
||||||
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
|
AppRoute.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
|
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import androidx.lifecycle.ViewModel
|
||||||
|
|
||||||
enum class AppSection {
|
enum class AppSection {
|
||||||
HOME, SESSIONS, EXERCISES,
|
HOME, SESSIONS, EXERCISES,
|
||||||
EQUIPMENT, STATISTICS, SYNC, SETTINGS,
|
EQUIPMENT, SYNC, SETTINGS,
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface AppRoute {
|
sealed interface AppRoute {
|
||||||
|
|
@ -34,9 +34,8 @@ sealed interface AppRoute {
|
||||||
data object Equipment : AppRoute { override val section = AppSection.EQUIPMENT }
|
data object Equipment : AppRoute { override val section = AppSection.EQUIPMENT }
|
||||||
data class EquipmentDetail(val equipmentId: String) : 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 class EquipmentCreate(val caller: AppRoute) : AppRoute { override val section = caller.section }
|
||||||
data object Statistics : AppRoute { override val section = AppSection.STATISTICS }
|
data object BodyMeasurements : AppRoute { override val section = AppSection.HOME }
|
||||||
data object BodyMeasurements : AppRoute { override val section = AppSection.STATISTICS }
|
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
||||||
data object LatestMaxima : AppRoute { override val section = AppSection.STATISTICS }
|
|
||||||
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
||||||
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +45,6 @@ fun AppSection.rootRoute(): AppRoute = when (this) {
|
||||||
AppSection.SESSIONS -> AppRoute.Sessions
|
AppSection.SESSIONS -> AppRoute.Sessions
|
||||||
AppSection.EXERCISES -> AppRoute.Exercises
|
AppSection.EXERCISES -> AppRoute.Exercises
|
||||||
AppSection.EQUIPMENT -> AppRoute.Equipment
|
AppSection.EQUIPMENT -> AppRoute.Equipment
|
||||||
AppSection.STATISTICS -> AppRoute.Statistics
|
|
||||||
AppSection.SYNC -> AppRoute.Sync
|
AppSection.SYNC -> AppRoute.Sync
|
||||||
AppSection.SETTINGS -> AppRoute.Settings
|
AppSection.SETTINGS -> AppRoute.Settings
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,12 @@ package com.labfytools.trainlog.ui
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
|
@ -852,7 +855,11 @@ private fun TrainlogChoiceGroup(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
content()
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) { content() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -869,15 +876,15 @@ private fun TrainlogChoice(
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.widthIn(min = 132.dp)
|
||||||
.padding(vertical = 2.dp)
|
|
||||||
.heightIn(min = 48.dp)
|
.heightIn(min = 48.dp)
|
||||||
.background(
|
.background(
|
||||||
if (selected) {
|
if (selected) {
|
||||||
colors.surfaceAlt
|
colors.surfaceAlt
|
||||||
} else {
|
} else {
|
||||||
colors.surface
|
colors.surface
|
||||||
}
|
},
|
||||||
|
androidx.compose.material3.MaterialTheme.shapes.medium,
|
||||||
)
|
)
|
||||||
.semantics { this.selected = selected }
|
.semantics { this.selected = selected }
|
||||||
.clickable(
|
.clickable(
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
@ -52,8 +51,6 @@ import com.labfytools.trainlog.data.BodyZoneHomeOverview
|
||||||
import com.labfytools.trainlog.data.BodyZoneHomeState
|
import com.labfytools.trainlog.data.BodyZoneHomeState
|
||||||
import com.labfytools.trainlog.data.BodyZoneHomeStatus
|
import com.labfytools.trainlog.data.BodyZoneHomeStatus
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
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.model.SessionType
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
import com.labfytools.trainlog.ui.theme.TrainlogColors
|
import com.labfytools.trainlog.ui.theme.TrainlogColors
|
||||||
|
|
@ -64,19 +61,14 @@ import com.labfytools.trainlog.R
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
activeDraft: ActiveSessionDraft?,
|
activeDraft: ActiveSessionDraft?,
|
||||||
draftError: String?,
|
draftError: String?,
|
||||||
latestSession: SessionSummary?,
|
|
||||||
latestMaximum: LatestExerciseMax?,
|
|
||||||
bodyZoneOverview: BodyZoneHomeOverview,
|
bodyZoneOverview: BodyZoneHomeOverview,
|
||||||
onSession: () -> Unit,
|
onSession: () -> Unit,
|
||||||
onOpenExercises: (String) -> Unit,
|
onOpenExercises: (String) -> Unit,
|
||||||
onOpenStatistics: () -> Unit,
|
|
||||||
onBody: () -> Unit,
|
onBody: () -> Unit,
|
||||||
onOpenLatestSession: (String) -> Unit,
|
|
||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
val locale = presentationLocale()
|
|
||||||
TrainlogScreen(strings.getString(R.string.nav_home), scrollKey = "home") {
|
TrainlogScreen(strings.getString(R.string.nav_home), scrollKey = "home") {
|
||||||
draftError?.let { TrainlogInfo(it, colors.error) }
|
draftError?.let { TrainlogInfo(it, colors.error) }
|
||||||
TrainlogFrame(strings.getString(R.string.home_session)) {
|
TrainlogFrame(strings.getString(R.string.home_session)) {
|
||||||
|
|
@ -87,17 +79,7 @@ fun HomeScreen(
|
||||||
TrainlogPrimaryAction(strings.getString(R.string.new_manual_session), strings.getString(R.string.new_manual_description), onSession)
|
TrainlogPrimaryAction(strings.getString(R.string.new_manual_session), strings.getString(R.string.new_manual_description), onSession)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises, onOpenStatistics)
|
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises)
|
||||||
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) {
|
TrainlogFrame(strings.getString(R.string.quick_access), active = false) {
|
||||||
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"),
|
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
|
@ -119,7 +101,6 @@ fun HomeScreen(
|
||||||
internal fun BodyZoneHomeSection(
|
internal fun BodyZoneHomeSection(
|
||||||
overview: BodyZoneHomeOverview,
|
overview: BodyZoneHomeOverview,
|
||||||
onOpenExercises: (String) -> Unit,
|
onOpenExercises: (String) -> Unit,
|
||||||
onOpenStatistics: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
|
|
@ -159,7 +140,9 @@ internal fun BodyZoneHomeSection(
|
||||||
}
|
}
|
||||||
selected?.let { zone ->
|
selected?.let { zone ->
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().padding(top = 8.dp).background(colors.surface, RoundedCornerShape(8.dp)).padding(12.dp),
|
Modifier.fillMaxWidth().padding(top = 8.dp)
|
||||||
|
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
|
||||||
|
.padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
BasicText(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), style = TrainlogTypography.section.copy(color = colors.text))
|
BasicText(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), style = TrainlogTypography.section.copy(color = colors.text))
|
||||||
|
|
@ -179,7 +162,6 @@ internal fun BodyZoneHomeSection(
|
||||||
)
|
)
|
||||||
if (zone.availableExerciseCount == 0) TrainlogInfo(BodyZoneHomeState.UNSUPPORTED.label, colors.muted)
|
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_exercises), "", { onOpenExercises(zone.zoneId) })
|
||||||
TrainlogAction(strings.getString(R.string.view_statistics), "", onOpenStatistics)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,17 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.testTag
|
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.R
|
||||||
import com.labfytools.trainlog.data.StartProgramSessionResult
|
import com.labfytools.trainlog.data.StartProgramSessionResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
|
|
@ -18,13 +29,34 @@ import com.labfytools.trainlog.model.ProgramSessionExecutionState
|
||||||
fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
|
fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
val programs = repository.listSyncedPrograms()
|
val programs = repository.listSyncedPrograms()
|
||||||
|
val colors = LocalTrainlogColors.current
|
||||||
TrainlogScreen(strings.getString(R.string.route_programs)) {
|
TrainlogScreen(strings.getString(R.string.route_programs)) {
|
||||||
if (programs.isEmpty()) TrainlogInfo(strings.getString(R.string.programs_none))
|
if (programs.isEmpty()) TrainlogInfo(strings.getString(R.string.programs_none))
|
||||||
programs.forEach { program ->
|
programs.forEach { program ->
|
||||||
TrainlogFrame(program.title, active = program.state == "active") {
|
val detail = remember(program.programId) { repository.getSyncedProgram(program.programId) }
|
||||||
|
val completed = detail?.sessions?.count {
|
||||||
|
it.executionState == ProgramSessionExecutionState.COMPLETED
|
||||||
|
} ?: 0
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().padding(bottom = 10.dp)
|
||||||
|
.background(colors.surface, MaterialTheme.shapes.medium).padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(
|
||||||
|
program.title,
|
||||||
|
modifier = Modifier.weight(1f).padding(end = 8.dp),
|
||||||
|
color = colors.text,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
ProgramStateBadge(
|
||||||
|
if (program.state == "active") strings.getString(R.string.program_active)
|
||||||
|
else strings.getString(R.string.program_archived),
|
||||||
|
program.state == "active",
|
||||||
|
)
|
||||||
|
}
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
listOfNotNull(
|
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.startDate?.let { strings.getString(R.string.program_start_date, formatDate(it)) },
|
||||||
program.endDate?.let { strings.getString(R.string.program_end_date, formatDate(it)) },
|
program.endDate?.let { strings.getString(R.string.program_end_date, formatDate(it)) },
|
||||||
strings.resources.getQuantityString(
|
strings.resources.getQuantityString(
|
||||||
|
|
@ -34,6 +66,7 @@ fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
|
||||||
),
|
),
|
||||||
).joinToString(" · "),
|
).joinToString(" · "),
|
||||||
)
|
)
|
||||||
|
TrainlogInfo("$completed / ${program.sessionCount} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) colors.success else colors.muted)
|
||||||
TrainlogButton(
|
TrainlogButton(
|
||||||
strings.getString(R.string.program_open),
|
strings.getString(R.string.program_open),
|
||||||
{ onOpen(program.programId) },
|
{ onOpen(program.programId) },
|
||||||
|
|
@ -84,22 +117,41 @@ fun ProgramDetailScreen(
|
||||||
program.startDate?.let { TrainlogInfo(strings.getString(R.string.program_start_date, formatDate(it))) }
|
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.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
|
||||||
program.note?.let { TrainlogInfo(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) }
|
message?.let { TrainlogInfo(it) }
|
||||||
program.sessions.forEachIndexed { index, session ->
|
program.sessions.forEachIndexed { index, session ->
|
||||||
var expanded by remember(session.programSessionId) { mutableStateOf(false) }
|
var expanded by remember(session.programSessionId) { mutableStateOf(false) }
|
||||||
val completed = session.executionState == ProgramSessionExecutionState.COMPLETED
|
val completed = session.executionState == ProgramSessionExecutionState.COMPLETED
|
||||||
TrainlogFrame("${index + 1}. ${session.title}", active = completed) {
|
Column(
|
||||||
|
Modifier.fillMaxWidth().padding(bottom = 10.dp)
|
||||||
|
.background(
|
||||||
|
if (completed) LocalTrainlogColors.current.success.copy(alpha = .12f)
|
||||||
|
else LocalTrainlogColors.current.surface,
|
||||||
|
MaterialTheme.shapes.medium,
|
||||||
|
).padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
val stateLabel = when (session.executionState) {
|
val stateLabel = when (session.executionState) {
|
||||||
ProgramSessionExecutionState.TODO -> R.string.program_session_todo
|
ProgramSessionExecutionState.TODO -> R.string.program_session_todo
|
||||||
ProgramSessionExecutionState.IN_PROGRESS -> R.string.program_session_in_progress
|
ProgramSessionExecutionState.IN_PROGRESS -> R.string.program_session_in_progress
|
||||||
ProgramSessionExecutionState.COMPLETED -> R.string.program_session_completed
|
ProgramSessionExecutionState.COMPLETED -> R.string.program_session_completed
|
||||||
}
|
}
|
||||||
TrainlogInfo(strings.getString(stateLabel))
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(
|
||||||
|
"${index + 1}. ${session.title}",
|
||||||
|
modifier = Modifier.weight(1f).padding(end = 8.dp),
|
||||||
|
color = LocalTrainlogColors.current.text,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
ProgramStateBadge(strings.getString(stateLabel), completed)
|
||||||
|
}
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
|
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)) },
|
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(" · "),
|
).joinToString(" · "),
|
||||||
)
|
)
|
||||||
session.note?.let { TrainlogInfo(it) }
|
session.note?.let { TrainlogInfo(it) }
|
||||||
|
|
@ -141,3 +193,18 @@ fun ProgramDetailScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Presentation-only state badge; execution truth remains repository-owned. */
|
||||||
|
@Composable
|
||||||
|
private fun ProgramStateBadge(label: String, positive: Boolean) {
|
||||||
|
val colors = LocalTrainlogColors.current
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
modifier = Modifier.background(
|
||||||
|
(if (positive) colors.success else colors.surfaceAlt).copy(alpha = .22f),
|
||||||
|
MaterialTheme.shapes.small,
|
||||||
|
).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
color = if (positive) colors.success else colors.muted,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.height
|
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.layout.padding
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -188,13 +190,29 @@ fun LatestMaximaScreen(repository: TrainlogRepository) {
|
||||||
val locale = presentationLocale()
|
val locale = presentationLocale()
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
TrainlogScreen(strings.getString(R.string.route_latest_maxima)) {
|
TrainlogScreen(strings.getString(R.string.route_latest_maxima)) {
|
||||||
TrainlogFrame(strings.getString(R.string.max_capacities), active = maxima.isNotEmpty()) {
|
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
|
||||||
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
|
maxima.forEach { max ->
|
||||||
maxima.forEach { max ->
|
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
|
||||||
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
|
androidx.compose.foundation.layout.Column(
|
||||||
TrainlogInfo("${max.exerciseName} · $weight kg", colors.warning)
|
Modifier.fillMaxWidth().padding(bottom = 8.dp)
|
||||||
TrainlogInfo("${formatDate(max.startedAt)} · " + strings.getString(R.string.equipment_value,
|
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
|
||||||
max.equipmentDisplayName ?: strings.getString(R.string.value_none)), colors.muted)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
|
|
@ -196,69 +197,34 @@ fun SessionScreen(
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = strings.getString(R.string.session_type)
|
title = strings.getString(R.string.session_type)
|
||||||
) {
|
) {
|
||||||
TrainlogAction(
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
label =
|
val training = currentDraft.sessionType == SessionType.TRAINING
|
||||||
if (
|
TrainlogButton(
|
||||||
currentDraft.sessionType ==
|
label = strings.getString(R.string.training),
|
||||||
SessionType.TRAINING
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
style = if (training) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
|
||||||
strings.getString(R.string.training_selected)
|
maxLines = 2,
|
||||||
} else {
|
onClick = {
|
||||||
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(
|
persistDraft(
|
||||||
currentDraft.copy(
|
currentDraft.copy(sessionType = SessionType.TRAINING),
|
||||||
sessionType =
|
|
||||||
SessionType.TRAINING
|
|
||||||
),
|
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
TrainlogAction(
|
|
||||||
label =
|
|
||||||
if (
|
|
||||||
currentDraft.sessionType ==
|
|
||||||
SessionType.MAX_TEST
|
|
||||||
) {
|
|
||||||
strings.getString(R.string.max_test_selected)
|
|
||||||
} else {
|
|
||||||
strings.getString(R.string.max_test_unselected)
|
|
||||||
},
|
},
|
||||||
description =
|
)
|
||||||
strings.getString(R.string.max_session_description),
|
val maxTest = currentDraft.sessionType == SessionType.MAX_TEST
|
||||||
accent =
|
TrainlogButton(
|
||||||
if (
|
label = strings.getString(R.string.session_max_test),
|
||||||
currentDraft.sessionType ==
|
modifier = Modifier.weight(1f),
|
||||||
SessionType.MAX_TEST
|
style = if (maxTest) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
|
||||||
) {
|
maxLines = 2,
|
||||||
colors.warning
|
onClick = {
|
||||||
} else {
|
|
||||||
colors.muted
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
persistDraft(
|
persistDraft(
|
||||||
currentDraft.copy(
|
currentDraft.copy(sessionType = SessionType.MAX_TEST),
|
||||||
sessionType =
|
|
||||||
SessionType.MAX_TEST
|
|
||||||
),
|
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ExercisePicker(
|
ExercisePicker(
|
||||||
|
|
@ -606,7 +572,7 @@ fun SessionScreen(
|
||||||
colors.success
|
colors.success
|
||||||
} else {
|
} else {
|
||||||
colors.error
|
colors.error
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1151,54 +1117,78 @@ private fun SessionExerciseForm(
|
||||||
strings.getString(R.string.field_weight)
|
strings.getString(R.string.field_weight)
|
||||||
}
|
}
|
||||||
setRows.forEachIndexed { index, row ->
|
setRows.forEachIndexed { index, row ->
|
||||||
TrainlogInfo(
|
Column(
|
||||||
text = strings.getString(R.string.set_number, index + 1),
|
Modifier
|
||||||
color = colors.accent,
|
.fillMaxWidth()
|
||||||
)
|
.padding(bottom = 8.dp)
|
||||||
SessionNumberField(
|
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||||
label = strings.getString(R.string.set_reps_field, index + 1),
|
.padding(10.dp),
|
||||||
value = row.repsText,
|
) {
|
||||||
testTag = "session-set-$index-reps",
|
Row(
|
||||||
onValueChange = { value ->
|
Modifier.fillMaxWidth(),
|
||||||
val updated = setRows.replaceAt(index, row.copy(repsText = value))
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
setRows = updated
|
) {
|
||||||
repsText = encodeRawReps(updated)
|
BasicText(
|
||||||
weightText = encodeRawWeights(updated)
|
text = strings.getString(R.string.set_number, index + 1),
|
||||||
error = null
|
modifier = Modifier.padding(vertical = 12.dp),
|
||||||
onFormChanged(
|
style = TrainlogTypography.small.copy(
|
||||||
currentForm(
|
color = colors.accent,
|
||||||
exercise, setCountText, repsText, durationText,
|
fontWeight = FontWeight.Bold,
|
||||||
speedText, distanceText, selectedEquipmentId, weightText,
|
),
|
||||||
)
|
|
||||||
)
|
)
|
||||||
},
|
TrainlogIconAction(
|
||||||
)
|
icon = TrainlogIcons.DeleteOutline,
|
||||||
SessionNumberField(
|
contentDescription = localizedContext().getString(R.string.a11y_delete_set),
|
||||||
label = strings.getString(R.string.set_load_field, index + 1, loadLabel),
|
modifier = Modifier.testTag("delete-set-${index + 1}"),
|
||||||
value = row.weightText,
|
accent = colors.error,
|
||||||
decimal = true,
|
onClick = { pendingSetRemoval = index },
|
||||||
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(
|
||||||
TrainlogDeleteButton(
|
Modifier.fillMaxWidth(),
|
||||||
contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.testTag("delete-set-${index + 1}"),
|
) {
|
||||||
onClick = {
|
SessionNumberField(
|
||||||
pendingSetRemoval = index
|
label = strings.getString(R.string.field_reps),
|
||||||
},
|
value = row.repsText,
|
||||||
)
|
modifier = Modifier.weight(1f),
|
||||||
|
testTag = "session-set-$index-reps",
|
||||||
|
onValueChange = { value ->
|
||||||
|
val updated = setRows.replaceAt(index, row.copy(repsText = value))
|
||||||
|
setRows = updated
|
||||||
|
repsText = encodeRawReps(updated)
|
||||||
|
weightText = encodeRawWeights(updated)
|
||||||
|
error = null
|
||||||
|
onFormChanged(
|
||||||
|
currentForm(
|
||||||
|
exercise, setCountText, repsText, durationText,
|
||||||
|
speedText, distanceText, selectedEquipmentId, weightText,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SessionNumberField(
|
||||||
|
label = loadLabel,
|
||||||
|
value = row.weightText,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
decimal = true,
|
||||||
|
testTag = "session-set-$index-weight",
|
||||||
|
onValueChange = { value ->
|
||||||
|
val updated = setRows.replaceAt(index, row.copy(weightText = value))
|
||||||
|
setRows = updated
|
||||||
|
repsText = encodeRawReps(updated)
|
||||||
|
weightText = encodeRawWeights(updated)
|
||||||
|
error = null
|
||||||
|
onFormChanged(
|
||||||
|
currentForm(
|
||||||
|
exercise, setCountText, repsText, durationText,
|
||||||
|
speedText, distanceText, selectedEquipmentId, weightText,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label = strings.getString(R.string.add_set_plain),
|
label = strings.getString(R.string.add_set_plain),
|
||||||
|
|
@ -1608,6 +1598,7 @@ private fun SessionNumberField(
|
||||||
label: String,
|
label: String,
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
testTag: String? = null,
|
testTag: String? = null,
|
||||||
decimal: Boolean = false,
|
decimal: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
|
@ -1623,6 +1614,7 @@ private fun SessionNumberField(
|
||||||
} else {
|
} else {
|
||||||
KeyboardOptions(keyboardType = KeyboardType.Number)
|
KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||||
},
|
},
|
||||||
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,6 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
when (val route = navigation.route) {
|
when (val route = navigation.route) {
|
||||||
AppRoute.Home -> HomeScreen(
|
AppRoute.Home -> HomeScreen(
|
||||||
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.let { localizedRepositoryMessage(localized, it.message) } ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning?.let { localizedRepositoryMessage(localized, it) },
|
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(),
|
repository.getBodyZoneHomeOverview(),
|
||||||
::openManualSession,
|
::openManualSession,
|
||||||
{ zoneId ->
|
{ zoneId ->
|
||||||
|
|
@ -102,8 +101,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
exerciseState.searchQuery.value = ""
|
exerciseState.searchQuery.value = ""
|
||||||
open(AppRoute.Exercises)
|
open(AppRoute.Exercises)
|
||||||
},
|
},
|
||||||
{ open(AppRoute.Statistics) },
|
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
|
||||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
|
||||||
)
|
)
|
||||||
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
||||||
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
|
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
|
||||||
|
|
@ -141,7 +139,6 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
|
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.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
|
||||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
|
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
|
||||||
AppRoute.Statistics -> StatisticsDashboard(repository)
|
|
||||||
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
|
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
|
||||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||||
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
|
@ -37,6 +38,7 @@ import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -164,7 +166,7 @@ fun TrainlogAction(
|
||||||
.padding(vertical = 3.dp)
|
.padding(vertical = 3.dp)
|
||||||
.heightIn(min = 48.dp)
|
.heightIn(min = 48.dp)
|
||||||
.height(IntrinsicSize.Min)
|
.height(IntrinsicSize.Min)
|
||||||
.background(Color.Transparent)
|
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -199,6 +201,8 @@ fun TrainlogAction(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class TrainlogButtonStyle { PRIMARY, SECONDARY, SUCCESS, DESTRUCTIVE, GHOST }
|
||||||
|
|
||||||
/** Compact pictogram action with a mandatory accessible name. */
|
/** Compact pictogram action with a mandatory accessible name. */
|
||||||
@Composable
|
@Composable
|
||||||
fun TrainlogIconAction(
|
fun TrainlogIconAction(
|
||||||
|
|
@ -230,6 +234,7 @@ fun TrainlogDeleteButton(
|
||||||
Button(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp),
|
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = colors.error),
|
colors = ButtonDefaults.buttonColors(containerColor = colors.error),
|
||||||
) {
|
) {
|
||||||
Icon(TrainlogIcons.DeleteOutline, contentDescription = contentDescription)
|
Icon(TrainlogIcons.DeleteOutline, contentDescription = contentDescription)
|
||||||
|
|
@ -240,9 +245,7 @@ fun TrainlogDeleteButton(
|
||||||
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
|
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
|
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
|
||||||
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
|
TrainlogButton(label, onClick, Modifier.fillMaxWidth())
|
||||||
Text(label)
|
|
||||||
}
|
|
||||||
if (description.isNotBlank()) {
|
if (description.isNotBlank()) {
|
||||||
BasicText(
|
BasicText(
|
||||||
description,
|
description,
|
||||||
|
|
@ -270,7 +273,7 @@ fun TrainlogActionTile(
|
||||||
Column(
|
Column(
|
||||||
modifier
|
modifier
|
||||||
.heightIn(min = 112.dp)
|
.heightIn(min = 112.dp)
|
||||||
.background(colors.surface, RoundedCornerShape(12.dp))
|
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
|
@ -291,13 +294,33 @@ fun TrainlogButton(
|
||||||
containerColor: Color? = null,
|
containerColor: Color? = null,
|
||||||
maxLines: Int = 1,
|
maxLines: Int = 1,
|
||||||
enabled: Boolean = true,
|
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(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
modifier = modifier.heightIn(min = 52.dp),
|
modifier = modifier.heightIn(min = 52.dp),
|
||||||
colors = containerColor?.let { ButtonDefaults.buttonColors(containerColor = it) }
|
shape = MaterialTheme.shapes.medium,
|
||||||
?: ButtonDefaults.buttonColors(),
|
border = if (style == TrainlogButtonStyle.GHOST) BorderStroke(1.dp, colors.surfaceAlt) else null,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = resolvedContainer,
|
||||||
|
contentColor = resolvedContent,
|
||||||
|
disabledContainerColor = colors.surfaceAlt,
|
||||||
|
disabledContentColor = colors.muted,
|
||||||
|
),
|
||||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
|
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
icon?.let {
|
icon?.let {
|
||||||
|
|
@ -334,6 +357,7 @@ fun TrainlogInputField(
|
||||||
label: String,
|
label: String,
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
keyboardOptions: KeyboardOptions =
|
keyboardOptions: KeyboardOptions =
|
||||||
KeyboardOptions.Default,
|
KeyboardOptions.Default,
|
||||||
testTag: String? = null,
|
testTag: String? = null,
|
||||||
|
|
@ -350,8 +374,7 @@ fun TrainlogInputField(
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier = modifier.padding(bottom = 10.dp)
|
||||||
Modifier.padding(bottom = 10.dp)
|
|
||||||
) {
|
) {
|
||||||
BasicText(
|
BasicText(
|
||||||
text = label.uppercase(),
|
text = label.uppercase(),
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,15 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
data class TrainlogColors(
|
data class TrainlogColors(
|
||||||
val background: Color,
|
val background: Color,
|
||||||
|
|
@ -110,6 +113,19 @@ fun TrainlogTheme(
|
||||||
error = TrainlogDarkColors.error,
|
error = TrainlogDarkColors.error,
|
||||||
)
|
)
|
||||||
CompositionLocalProvider(LocalTrainlogColors provides TrainlogDarkColors) {
|
CompositionLocalProvider(LocalTrainlogColors provides TrainlogDarkColors) {
|
||||||
MaterialTheme(colorScheme = scheme, content = content)
|
/* INVARIANT: all Material controls share the same restrained corner
|
||||||
|
* language. Screen code may change size, but must not reintroduce the
|
||||||
|
* former pill-shaped action hierarchy. */
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = scheme,
|
||||||
|
shapes = Shapes(
|
||||||
|
extraSmall = RoundedCornerShape(6.dp),
|
||||||
|
small = RoundedCornerShape(8.dp),
|
||||||
|
medium = RoundedCornerShape(10.dp),
|
||||||
|
large = RoundedCornerShape(12.dp),
|
||||||
|
extraLarge = RoundedCornerShape(14.dp),
|
||||||
|
),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,14 +36,10 @@ class AndroidUiRedesignTest {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
activeDraft = null,
|
activeDraft = null,
|
||||||
draftError = null,
|
draftError = null,
|
||||||
latestSession = null,
|
|
||||||
latestMaximum = null,
|
|
||||||
bodyZoneOverview = BodyZoneHomeOverview(emptyList(), emptyList(), false, false),
|
bodyZoneOverview = BodyZoneHomeOverview(emptyList(), emptyList(), false, false),
|
||||||
onSession = {},
|
onSession = {},
|
||||||
onOpenExercises = {},
|
onOpenExercises = {},
|
||||||
onOpenStatistics = {},
|
|
||||||
onBody = { bodyCalls++ },
|
onBody = { bodyCalls++ },
|
||||||
onOpenLatestSession = {},
|
|
||||||
onSync = { syncCalls++ },
|
onSync = { syncCalls++ },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,13 +36,14 @@ class AppNavigationStateTest {
|
||||||
|
|
||||||
@After fun tearDown() { context.deleteDatabase(databaseName) }
|
@After fun tearDown() { context.deleteDatabase(databaseName) }
|
||||||
|
|
||||||
@Test fun sevenSectionsHaveTypedRootsAndSelectionComesFromRoute() {
|
@Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() {
|
||||||
assertEquals(7, AppSection.entries.size)
|
assertEquals(6, AppSection.entries.size)
|
||||||
|
assertFalse(AppSection.entries.any { it.name == "STATISTICS" })
|
||||||
AppSection.entries.forEach { assertEquals(it, it.rootRoute().section) }
|
AppSection.entries.forEach { assertEquals(it, it.rootRoute().section) }
|
||||||
assertEquals(AppSection.SESSIONS, AppRoute.SessionDetail("se_1").section)
|
assertEquals(AppSection.SESSIONS, AppRoute.SessionDetail("se_1").section)
|
||||||
assertEquals(AppSection.EXERCISES, AppRoute.ExerciseDetail("ex_1").section)
|
assertEquals(AppSection.EXERCISES, AppRoute.ExerciseDetail("ex_1").section)
|
||||||
assertEquals(AppSection.EQUIPMENT, AppRoute.EquipmentDetail("eq_1").section)
|
assertEquals(AppSection.EQUIPMENT, AppRoute.EquipmentDetail("eq_1").section)
|
||||||
assertEquals(AppSection.STATISTICS, AppRoute.LatestMaxima.section)
|
assertEquals(AppSection.EXERCISES, AppRoute.LatestMaxima.section)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun catalogueDetailsAndCreatorsReturnToTheirDeclaredCaller() {
|
@Test fun catalogueDetailsAndCreatorsReturnToTheirDeclaredCaller() {
|
||||||
|
|
|
||||||
|
|
@ -110,12 +110,10 @@ class BodyZoneHomeSemanticsTest {
|
||||||
|
|
||||||
@Test fun practicalWidthMapSelectionFeedsExistingNavigationActions() {
|
@Test fun practicalWidthMapSelectionFeedsExistingNavigationActions() {
|
||||||
var openedZone: String? = null
|
var openedZone: String? = null
|
||||||
var statisticsOpened = false
|
|
||||||
setSection(
|
setSection(
|
||||||
widthDp = 380,
|
widthDp = 380,
|
||||||
fontScale = 1f,
|
fontScale = 1f,
|
||||||
onOpenExercises = { openedZone = it },
|
onOpenExercises = { openedZone = it },
|
||||||
onOpenStatistics = { statisticsOpened = true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals("core", bodyZoneAtPoint("front", 180f, 236f, Offset(90f, 113.28f), allZoneIds.toSet()))
|
assertEquals("core", bodyZoneAtPoint("front", 180f, 236f, Offset(90f, 113.28f), allZoneIds.toSet()))
|
||||||
|
|
@ -126,16 +124,12 @@ class BodyZoneHomeSemanticsTest {
|
||||||
compose.onNodeWithText("Voir les exercices").assertHasClickAction()
|
compose.onNodeWithText("Voir les exercices").assertHasClickAction()
|
||||||
.performSemanticsAction(SemanticsActions.OnClick)
|
.performSemanticsAction(SemanticsActions.OnClick)
|
||||||
assertEquals("core", openedZone)
|
assertEquals("core", openedZone)
|
||||||
compose.onNodeWithText("Voir les statistiques").assertHasClickAction()
|
|
||||||
.performSemanticsAction(SemanticsActions.OnClick)
|
|
||||||
assertTrue(statisticsOpened)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setSection(
|
private fun setSection(
|
||||||
widthDp: Int,
|
widthDp: Int,
|
||||||
fontScale: Float,
|
fontScale: Float,
|
||||||
onOpenExercises: (String) -> Unit = {},
|
onOpenExercises: (String) -> Unit = {},
|
||||||
onOpenStatistics: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
val zones = allZoneIds.map { id ->
|
val zones = allZoneIds.map { id ->
|
||||||
status(id, if (id == "chest") BodyZoneHomeState.PRIORITIZE else BodyZoneHomeState.RECENT_WORK)
|
status(id, if (id == "chest") BodyZoneHomeState.PRIORITIZE else BodyZoneHomeState.RECENT_WORK)
|
||||||
|
|
@ -147,7 +141,6 @@ class BodyZoneHomeSemanticsTest {
|
||||||
BodyZoneHomeSection(
|
BodyZoneHomeSection(
|
||||||
BodyZoneHomeOverview(zones, listOf(zones.first()), true, false),
|
BodyZoneHomeOverview(zones, listOf(zones.first()), true, false),
|
||||||
onOpenExercises,
|
onOpenExercises,
|
||||||
onOpenStatistics,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,30 @@ It is a native Kotlin/Jetpack Compose application with local SQLite persistence.
|
||||||
|
|
||||||
The desktop remains the canonical long-term history and analytics store.
|
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`)
|
## Interface language (`TRAINLOG_I18N_V0_1_1`)
|
||||||
|
|
||||||
Trainlog version 0.1.2 is synchronized with the desktop TUI as one product
|
Trainlog version 0.1.2 is synchronized with the desktop TUI as one product
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,21 @@ against desktop schema v27 without transferring ownership of Program
|
||||||
definitions. Web and Android derive their session state from these persisted
|
definitions. Web and Android derive their session state from these persisted
|
||||||
identities; no display-field heuristic is used.
|
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
|
The private Program execution rollout installed desktop schema v27 and the
|
||||||
signed nondebuggable Android versionCode 17/schema v25 update without
|
signed nondebuggable Android versionCode 17/schema v25 update without
|
||||||
uninstalling or clearing application data. A strictly disposable Program was
|
uninstalling or clearing application data. A strictly disposable Program was
|
||||||
|
|
|
||||||
|
|
@ -656,9 +656,19 @@ tagged source state, and the tag, Android `versionName`, Meson project version,
|
||||||
TUI product version, asset names, and release title must agree.
|
TUI product version, asset names, and release title must agree.
|
||||||
|
|
||||||
Private update rollout may set `TRAINLOG_ANDROID_VERSION_CODE` to a positive
|
Private update rollout may set `TRAINLOG_ANDROID_VERSION_CODE` to a positive
|
||||||
integer while keeping `versionName=0.1.2`. This build-only override is used only
|
integer and `TRAINLOG_ANDROID_VERSION_NAME=0.1.2` to remain on the installed
|
||||||
to maintain a strictly increasing installed → backup bridge → current APK
|
stable product line. Both are build-only overrides; the repository development
|
||||||
chain; it does not change any schema or exchange-format version.
|
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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
meson setup --reconfigure build
|
meson setup --reconfigure build
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,10 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
|
||||||
completed: 'Effectuée',
|
completed: 'Effectuée',
|
||||||
deleted: 'Retirée',
|
deleted: 'Retirée',
|
||||||
})[state]
|
})[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">
|
return <article className="session-detail program-detail">
|
||||||
<div className="sessions-toolbar">
|
<div className="sessions-toolbar">
|
||||||
<button type="button" className="quiet-action" onClick={onBack}>
|
<button type="button" className="quiet-action" onClick={onBack}>
|
||||||
|
|
@ -99,6 +103,10 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
|
||||||
Import {program.source_format} v{program.source_version} · modifié le{' '}
|
Import {program.source_format} v{program.source_version} · modifié le{' '}
|
||||||
{formatDateTime(program.updated_at, dateFormat)}
|
{formatDateTime(program.updated_at, dateFormat)}
|
||||||
</p>
|
</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>
|
</header>
|
||||||
<ol className="program-session-list">
|
<ol className="program-session-list">
|
||||||
{program.sessions.map((session) => <li key={session.program_session_id}>
|
{program.sessions.map((session) => <li key={session.program_session_id}>
|
||||||
|
|
|
||||||
|
|
@ -195,17 +195,23 @@ 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-remote_unknown { color: var(--yellow); }
|
||||||
.android-delivery-state.state-acknowledged { color: var(--green); }
|
.android-delivery-state.state-acknowledged { color: var(--green); }
|
||||||
.program-list, .program-session-list { display: grid; gap: .75rem; }
|
.program-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr)); gap: 1rem; }
|
||||||
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); }
|
.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-row .session-row-main { grid-template-columns: minmax(13rem, 2fr) repeat(3, minmax(8rem, 1fr)); }
|
.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 { 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; }
|
.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 p { margin: .35rem 0 0; color: var(--subtext-0); }
|
.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 { 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-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-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-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-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%; }
|
.programs-calendar-page { max-width: 100%; }
|
||||||
.program-calendar-loading { min-height: 12rem; display: grid; place-items: center; color: var(--subtext-0); }
|
.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); }
|
.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