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.
|
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,16 +45,6 @@ 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
|
||||||
|
|
@ -65,7 +55,7 @@ android {
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
|
|
||||||
versionCode = trainlogVersionCode
|
versionCode = trainlogVersionCode
|
||||||
versionName = trainlogVersionName
|
versionName = "0.1.3"
|
||||||
testInstrumentationRunner =
|
testInstrumentationRunner =
|
||||||
"androidx.test.runner.AndroidJUnitRunner"
|
"androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ 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 (
|
||||||
|
|
@ -100,7 +102,9 @@ class DraftUiTestActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOpenExercises = {},
|
onOpenExercises = {},
|
||||||
|
onOpenStatistics = {},
|
||||||
onBody = {},
|
onBody = {},
|
||||||
|
onOpenLatestSession = {},
|
||||||
onSync = {},
|
onSync = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
@ -58,7 +59,8 @@ 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.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) {
|
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
|
||||||
|
|
@ -69,7 +71,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
|
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.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, SYNC, SETTINGS,
|
EQUIPMENT, STATISTICS, SYNC, SETTINGS,
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface AppRoute {
|
sealed interface AppRoute {
|
||||||
|
|
@ -34,8 +34,9 @@ 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 BodyMeasurements : AppRoute { override val section = AppSection.HOME }
|
data object Statistics : AppRoute { override val section = AppSection.STATISTICS }
|
||||||
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
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 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 }
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +46,7 @@ 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,12 +8,9 @@ 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
|
||||||
|
|
@ -855,11 +852,7 @@ private fun TrainlogChoiceGroup(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
FlowRow(
|
content()
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) { content() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -876,15 +869,15 @@ private fun TrainlogChoice(
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.widthIn(min = 132.dp)
|
.fillMaxWidth()
|
||||||
|
.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,6 +20,7 @@ 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
|
||||||
|
|
@ -51,6 +52,8 @@ 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
|
||||||
|
|
@ -61,14 +64,19 @@ 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)) {
|
||||||
|
|
@ -79,7 +87,17 @@ 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)
|
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) {
|
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)) {
|
||||||
|
|
@ -101,6 +119,7 @@ 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()
|
||||||
|
|
@ -140,9 +159,7 @@ internal fun BodyZoneHomeSection(
|
||||||
}
|
}
|
||||||
selected?.let { zone ->
|
selected?.let { zone ->
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().padding(top = 8.dp)
|
Modifier.fillMaxWidth().padding(top = 8.dp).background(colors.surface, RoundedCornerShape(8.dp)).padding(12.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))
|
||||||
|
|
@ -162,6 +179,7 @@ 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,17 +8,6 @@ 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
|
||||||
|
|
@ -29,34 +18,13 @@ 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 ->
|
||||||
val detail = remember(program.programId) { repository.getSyncedProgram(program.programId) }
|
TrainlogFrame(program.title, active = program.state == "active") {
|
||||||
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(
|
||||||
|
|
@ -66,7 +34,6 @@ 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) },
|
||||||
|
|
@ -117,41 +84,22 @@ 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
|
||||||
Column(
|
TrainlogFrame("${index + 1}. ${session.title}", active = completed) {
|
||||||
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
|
||||||
}
|
}
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
TrainlogInfo(strings.getString(stateLabel))
|
||||||
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) }
|
||||||
|
|
@ -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.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
|
||||||
|
|
@ -190,29 +188,13 @@ 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)) {
|
||||||
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
|
TrainlogFrame(strings.getString(R.string.max_capacities), active = maxima.isNotEmpty()) {
|
||||||
maxima.forEach { max ->
|
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
|
||||||
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
|
maxima.forEach { max ->
|
||||||
androidx.compose.foundation.layout.Column(
|
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
|
||||||
Modifier.fillMaxWidth().padding(bottom = 8.dp)
|
TrainlogInfo("${max.exerciseName} · $weight kg", colors.warning)
|
||||||
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
|
TrainlogInfo("${formatDate(max.startedAt)} · " + strings.getString(R.string.equipment_value,
|
||||||
.padding(12.dp),
|
max.equipmentDisplayName ?: strings.getString(R.string.value_none)), colors.muted)
|
||||||
) {
|
|
||||||
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,7 +27,6 @@ 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
|
||||||
|
|
@ -197,34 +196,69 @@ fun SessionScreen(
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = strings.getString(R.string.session_type)
|
title = strings.getString(R.string.session_type)
|
||||||
) {
|
) {
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
TrainlogAction(
|
||||||
val training = currentDraft.sessionType == SessionType.TRAINING
|
label =
|
||||||
TrainlogButton(
|
if (
|
||||||
label = strings.getString(R.string.training),
|
currentDraft.sessionType ==
|
||||||
modifier = Modifier.weight(1f),
|
SessionType.TRAINING
|
||||||
style = if (training) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
|
) {
|
||||||
maxLines = 2,
|
strings.getString(R.string.training_selected)
|
||||||
onClick = {
|
} 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(
|
persistDraft(
|
||||||
currentDraft.copy(sessionType = SessionType.TRAINING),
|
currentDraft.copy(
|
||||||
|
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 =
|
||||||
val maxTest = currentDraft.sessionType == SessionType.MAX_TEST
|
strings.getString(R.string.max_session_description),
|
||||||
TrainlogButton(
|
accent =
|
||||||
label = strings.getString(R.string.session_max_test),
|
if (
|
||||||
modifier = Modifier.weight(1f),
|
currentDraft.sessionType ==
|
||||||
style = if (maxTest) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
|
SessionType.MAX_TEST
|
||||||
maxLines = 2,
|
) {
|
||||||
onClick = {
|
colors.warning
|
||||||
|
} else {
|
||||||
|
colors.muted
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
persistDraft(
|
persistDraft(
|
||||||
currentDraft.copy(sessionType = SessionType.MAX_TEST),
|
currentDraft.copy(
|
||||||
|
sessionType =
|
||||||
|
SessionType.MAX_TEST
|
||||||
|
),
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ExercisePicker(
|
ExercisePicker(
|
||||||
|
|
@ -572,7 +606,7 @@ fun SessionScreen(
|
||||||
colors.success
|
colors.success
|
||||||
} else {
|
} else {
|
||||||
colors.error
|
colors.error
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1117,78 +1151,54 @@ private fun SessionExerciseForm(
|
||||||
strings.getString(R.string.field_weight)
|
strings.getString(R.string.field_weight)
|
||||||
}
|
}
|
||||||
setRows.forEachIndexed { index, row ->
|
setRows.forEachIndexed { index, row ->
|
||||||
Column(
|
TrainlogInfo(
|
||||||
Modifier
|
text = strings.getString(R.string.set_number, index + 1),
|
||||||
.fillMaxWidth()
|
color = colors.accent,
|
||||||
.padding(bottom = 8.dp)
|
)
|
||||||
.background(colors.surface, MaterialTheme.shapes.medium)
|
SessionNumberField(
|
||||||
.padding(10.dp),
|
label = strings.getString(R.string.set_reps_field, index + 1),
|
||||||
) {
|
value = row.repsText,
|
||||||
Row(
|
testTag = "session-set-$index-reps",
|
||||||
Modifier.fillMaxWidth(),
|
onValueChange = { value ->
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
val updated = setRows.replaceAt(index, row.copy(repsText = value))
|
||||||
) {
|
setRows = updated
|
||||||
BasicText(
|
repsText = encodeRawReps(updated)
|
||||||
text = strings.getString(R.string.set_number, index + 1),
|
weightText = encodeRawWeights(updated)
|
||||||
modifier = Modifier.padding(vertical = 12.dp),
|
error = null
|
||||||
style = TrainlogTypography.small.copy(
|
onFormChanged(
|
||||||
color = colors.accent,
|
currentForm(
|
||||||
fontWeight = FontWeight.Bold,
|
exercise, setCountText, repsText, durationText,
|
||||||
),
|
speedText, distanceText, selectedEquipmentId, weightText,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
TrainlogIconAction(
|
},
|
||||||
icon = TrainlogIcons.DeleteOutline,
|
)
|
||||||
contentDescription = localizedContext().getString(R.string.a11y_delete_set),
|
SessionNumberField(
|
||||||
modifier = Modifier.testTag("delete-set-${index + 1}"),
|
label = strings.getString(R.string.set_load_field, index + 1, loadLabel),
|
||||||
accent = colors.error,
|
value = row.weightText,
|
||||||
onClick = { pendingSetRemoval = index },
|
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(),
|
TrainlogDeleteButton(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set),
|
||||||
) {
|
modifier = Modifier.testTag("delete-set-${index + 1}"),
|
||||||
SessionNumberField(
|
onClick = {
|
||||||
label = strings.getString(R.string.field_reps),
|
pendingSetRemoval = index
|
||||||
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),
|
||||||
|
|
@ -1598,7 +1608,6 @@ 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,
|
||||||
) {
|
) {
|
||||||
|
|
@ -1614,7 +1623,6 @@ private fun SessionNumberField(
|
||||||
} else {
|
} else {
|
||||||
KeyboardOptions(keyboardType = KeyboardType.Number)
|
KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ 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 ->
|
||||||
|
|
@ -101,7 +102,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
exerciseState.searchQuery.value = ""
|
exerciseState.searchQuery.value = ""
|
||||||
open(AppRoute.Exercises)
|
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,
|
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) })
|
||||||
|
|
@ -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)) })
|
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,7 +23,6 @@ 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
|
||||||
|
|
@ -38,7 +37,6 @@ 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
|
||||||
|
|
@ -166,7 +164,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(colors.surface, MaterialTheme.shapes.medium)
|
.background(Color.Transparent)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -201,8 +199,6 @@ 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(
|
||||||
|
|
@ -234,7 +230,6 @@ 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)
|
||||||
|
|
@ -245,7 +240,9 @@ 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)) {
|
||||||
TrainlogButton(label, onClick, Modifier.fillMaxWidth())
|
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
if (description.isNotBlank()) {
|
if (description.isNotBlank()) {
|
||||||
BasicText(
|
BasicText(
|
||||||
description,
|
description,
|
||||||
|
|
@ -273,7 +270,7 @@ fun TrainlogActionTile(
|
||||||
Column(
|
Column(
|
||||||
modifier
|
modifier
|
||||||
.heightIn(min = 112.dp)
|
.heightIn(min = 112.dp)
|
||||||
.background(colors.surface, MaterialTheme.shapes.medium)
|
.background(colors.surface, RoundedCornerShape(12.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
|
@ -294,33 +291,13 @@ 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),
|
||||||
shape = MaterialTheme.shapes.medium,
|
colors = containerColor?.let { ButtonDefaults.buttonColors(containerColor = it) }
|
||||||
border = if (style == TrainlogButtonStyle.GHOST) BorderStroke(1.dp, colors.surfaceAlt) else null,
|
?: ButtonDefaults.buttonColors(),
|
||||||
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 {
|
||||||
|
|
@ -357,7 +334,6 @@ 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,
|
||||||
|
|
@ -374,7 +350,8 @@ fun TrainlogInputField(
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.padding(bottom = 10.dp)
|
modifier =
|
||||||
|
Modifier.padding(bottom = 10.dp)
|
||||||
) {
|
) {
|
||||||
BasicText(
|
BasicText(
|
||||||
text = label.uppercase(),
|
text = label.uppercase(),
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,12 @@ 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,
|
||||||
|
|
@ -113,19 +110,6 @@ fun TrainlogTheme(
|
||||||
error = TrainlogDarkColors.error,
|
error = TrainlogDarkColors.error,
|
||||||
)
|
)
|
||||||
CompositionLocalProvider(LocalTrainlogColors provides TrainlogDarkColors) {
|
CompositionLocalProvider(LocalTrainlogColors provides TrainlogDarkColors) {
|
||||||
/* INVARIANT: all Material controls share the same restrained corner
|
MaterialTheme(colorScheme = scheme, content = content)
|
||||||
* 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,10 +36,14 @@ 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,14 +36,13 @@ class AppNavigationStateTest {
|
||||||
|
|
||||||
@After fun tearDown() { context.deleteDatabase(databaseName) }
|
@After fun tearDown() { context.deleteDatabase(databaseName) }
|
||||||
|
|
||||||
@Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() {
|
@Test fun sevenSectionsHaveTypedRootsAndSelectionComesFromRoute() {
|
||||||
assertEquals(6, AppSection.entries.size)
|
assertEquals(7, 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.EXERCISES, AppRoute.LatestMaxima.section)
|
assertEquals(AppSection.STATISTICS, AppRoute.LatestMaxima.section)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun catalogueDetailsAndCreatorsReturnToTheirDeclaredCaller() {
|
@Test fun catalogueDetailsAndCreatorsReturnToTheirDeclaredCaller() {
|
||||||
|
|
|
||||||
|
|
@ -110,10 +110,12 @@ 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()))
|
||||||
|
|
@ -124,12 +126,16 @@ 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)
|
||||||
|
|
@ -141,6 +147,7 @@ class BodyZoneHomeSemanticsTest {
|
||||||
BodyZoneHomeSection(
|
BodyZoneHomeSection(
|
||||||
BodyZoneHomeOverview(zones, listOf(zones.first()), true, false),
|
BodyZoneHomeOverview(zones, listOf(zones.first()), true, false),
|
||||||
onOpenExercises,
|
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.
|
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,21 +323,6 @@ 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,19 +656,9 @@ 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 and `TRAINLOG_ANDROID_VERSION_NAME=0.1.2` to remain on the installed
|
integer while keeping `versionName=0.1.2`. This build-only override is used only
|
||||||
stable product line. Both are build-only overrides; the repository development
|
to maintain a strictly increasing installed → backup bridge → current APK
|
||||||
defaults remain unchanged. They are used only to maintain a strictly increasing
|
chain; it does not change any schema or exchange-format version.
|
||||||
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,10 +70,6 @@ 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}>
|
||||||
|
|
@ -103,10 +99,6 @@ 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,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-remote_unknown { color: var(--yellow); }
|
||||||
.android-delivery-state.state-acknowledged { color: var(--green); }
|
.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-list, .program-session-list { display: grid; gap: .75rem; }
|
||||||
.program-session-list { display: grid; gap: .75rem; }
|
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); }
|
||||||
.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; background: 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 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