feat(stats): add global dashboard and body focus home
This commit is contained in:
parent
766e6498a7
commit
42b12b8e1e
12 changed files with 1234 additions and 2 deletions
|
|
@ -79,6 +79,7 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
?.warning,
|
||||
latestSession = null,
|
||||
latestMaximum = null,
|
||||
bodyZoneOverview = repository.getBodyZoneHomeOverview(),
|
||||
onSession = {
|
||||
when (
|
||||
val result =
|
||||
|
|
@ -95,6 +96,8 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
}
|
||||
}
|
||||
},
|
||||
onOpenExercises = {},
|
||||
onOpenStatistics = {},
|
||||
onBody = {},
|
||||
onOpenLatestSession = {},
|
||||
onSync = {},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
enum class BodyZoneHomeState(val label: String) {
|
||||
PRIORITIZE("À privilégier"),
|
||||
LITTLE_RECENT_WORK("Peu travaillé récemment"),
|
||||
RECENT_WORK("Travaillé récemment"),
|
||||
HIGH_RECENT_EXPOSURE("Forte exposition récente"),
|
||||
INSUFFICIENT_DATA("Données insuffisantes"),
|
||||
UNSUPPORTED("Aucun exercice résolu disponible"),
|
||||
}
|
||||
|
||||
data class BodyZoneHomeStatus(
|
||||
val zoneId: String,
|
||||
val displayName: String,
|
||||
val lastPrimaryExposure: String?,
|
||||
val lastSecondaryExposure: String?,
|
||||
val primaryExposureAgeSeconds: Long?,
|
||||
val secondaryExposureAgeSeconds: Long?,
|
||||
val primaryWork7Days: Int,
|
||||
val secondaryWork7Days: Int,
|
||||
val primaryWork30Days: Int,
|
||||
val secondaryWork30Days: Int,
|
||||
/** Distinct completed sessions with qualifying primary or secondary work in 30 days. */
|
||||
val recentSessionCount: Int,
|
||||
val availableExerciseCount: Int,
|
||||
val state: BodyZoneHomeState,
|
||||
val reasons: List<String>,
|
||||
)
|
||||
|
||||
data class BodyZoneHomeOverview(
|
||||
val zones: List<BodyZoneHomeStatus>,
|
||||
val recommendations: List<BodyZoneHomeStatus>,
|
||||
val hasTrainingHistory: Boolean,
|
||||
val hasInvalidHistoryTimestamp: Boolean,
|
||||
)
|
||||
|
|
@ -469,6 +469,181 @@ class TrainlogRepository(
|
|||
)
|
||||
}
|
||||
|
||||
fun getBodyZoneHomeOverview(): BodyZoneHomeOverview = getBodyZoneHomeOverview(
|
||||
checkNotNull(TrainlogTimestamp.parse(OffsetDateTime.now().toString())),
|
||||
)
|
||||
|
||||
/**
|
||||
* WHY: Home guidance must describe recorded exposure, never infer recovery,
|
||||
* fatigue, readiness, or a session prescription.
|
||||
* CONTRACT: only completed occurrence-owned performed sets, continuous
|
||||
* activity, and explicit MAX results qualify. Windows are inclusive rolling
|
||||
* instants parsed by [TrainlogTimestamp]; SQL date and lexical comparisons
|
||||
* are deliberately forbidden. Parent BODY ZONES and draft/target data never
|
||||
* enter this read model.
|
||||
*/
|
||||
internal fun getBodyZoneHomeOverview(now: TrainlogTimestampKey): BodyZoneHomeOverview {
|
||||
val db = database.readableDatabase
|
||||
val focusZoneIds = setOf("chest", "back", "shoulders", "arms", "core", "glutes", "thighs", "calves")
|
||||
val childZones = bodyZones.zones.filter { it.zoneId in focusZoneIds }
|
||||
check(childZones.size == focusZoneIds.size && childZones.none { it.kind == BodyZoneKind.GROUP }) {
|
||||
"BODY_FOCUS_HOME_V1 doit référencer exactement huit zones anatomiques enfant"
|
||||
}
|
||||
val childIds = childZones.mapTo(mutableSetOf()) { it.zoneId }
|
||||
val canonicalByAlias = mutableMapOf<String, String>()
|
||||
db.rawQuery(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases", null,
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) canonicalByAlias[cursor.getString(0)] = cursor.getString(1)
|
||||
}
|
||||
fun canonical(id: String): String {
|
||||
var current = id
|
||||
val seen = mutableSetOf<String>()
|
||||
while (seen.add(current)) current = canonicalByAlias[current] ?: return current
|
||||
return current
|
||||
}
|
||||
|
||||
val relations = mutableMapOf<String, Pair<String?, List<String>>>()
|
||||
db.rawQuery(
|
||||
"""SELECT e.exercise_id,ebz.zone_id,ebz.role
|
||||
FROM exercises e JOIN exercise_body_zones ebz ON ebz.exercise_row_id=e.id
|
||||
ORDER BY e.exercise_id,ebz.role,ebz.zone_id""".trimIndent(), null,
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = canonical(cursor.getString(0))
|
||||
val zone = cursor.getString(1).takeIf { it in childIds } ?: continue
|
||||
val old = relations[id] ?: (null to emptyList())
|
||||
relations[id] = if (cursor.getString(2) == "primary") zone to old.second
|
||||
else old.first to (old.second + zone).distinct()
|
||||
}
|
||||
}
|
||||
|
||||
/* There is no soft-active flag in schema v12: a present exercise row
|
||||
* which is not a retired alias source is the active canonical row. A
|
||||
* direct child-zone relation is the minimum resolved classification
|
||||
* needed by the existing Catalogue filter. */
|
||||
val availability = childIds.associateWith { mutableSetOf<String>() }
|
||||
db.rawQuery("SELECT exercise_id FROM exercises ORDER BY exercise_id", null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val stored = cursor.getString(0)
|
||||
val id = canonical(stored)
|
||||
if (stored != id) continue
|
||||
val zones = relations[id] ?: continue
|
||||
zones.first?.let { availability.getValue(it).add(id) }
|
||||
zones.second.forEach { availability.getValue(it).add(id) }
|
||||
}
|
||||
}
|
||||
|
||||
data class MutableExposure(
|
||||
var lastPrimary: Pair<TrainlogTimestampKey, String>? = null,
|
||||
var lastSecondary: Pair<TrainlogTimestampKey, String>? = null,
|
||||
var primary7: Int = 0,
|
||||
var secondary7: Int = 0,
|
||||
var primary30: Int = 0,
|
||||
var secondary30: Int = 0,
|
||||
val sessions30: MutableSet<String> = mutableSetOf(),
|
||||
)
|
||||
val exposure = childIds.associateWith { MutableExposure() }
|
||||
val cutoff7 = TrainlogTimestampKey(now.utcSecond - 7L * 86400L, now.fraction)
|
||||
val cutoff30 = TrainlogTimestampKey(now.utcSecond - 30L * 86400L, now.fraction)
|
||||
var hasActualHistory = false
|
||||
var invalidTimestamp = false
|
||||
db.rawQuery(
|
||||
"""SELECT s.session_id,s.started_at,e.exercise_id,
|
||||
(SELECT COUNT(*) FROM performed_sets ps WHERE ps.session_exercise_row_id=se.id),
|
||||
EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id),
|
||||
EXISTS(SELECT 1 FROM max_results mr WHERE mr.session_exercise_row_id=se.id)
|
||||
FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id
|
||||
JOIN exercises e ON e.id=se.exercise_row_id
|
||||
WHERE EXISTS(SELECT 1 FROM performed_sets ps WHERE ps.session_exercise_row_id=se.id)
|
||||
OR EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id)
|
||||
OR EXISTS(SELECT 1 FROM max_results mr WHERE mr.session_exercise_row_id=se.id)
|
||||
ORDER BY s.session_id,se.entry_id""".trimIndent(), null,
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
hasActualHistory = true
|
||||
val timestampText = cursor.getString(1)
|
||||
val time = TrainlogTimestamp.parse(timestampText)
|
||||
if (time == null) { invalidTimestamp = true; continue }
|
||||
if (time > now) continue
|
||||
val zones = relations[canonical(cursor.getString(2))] ?: continue
|
||||
val work = cursor.getInt(3) + cursor.getInt(4) + cursor.getInt(5)
|
||||
fun add(zoneId: String, primary: Boolean) {
|
||||
val item = exposure.getValue(zoneId)
|
||||
if (primary) {
|
||||
if (item.lastPrimary == null || time > item.lastPrimary!!.first) item.lastPrimary = time to timestampText
|
||||
if (time >= cutoff7) item.primary7 += work
|
||||
if (time >= cutoff30) item.primary30 += work
|
||||
} else {
|
||||
if (item.lastSecondary == null || time > item.lastSecondary!!.first) item.lastSecondary = time to timestampText
|
||||
if (time >= cutoff7) item.secondary7 += work
|
||||
if (time >= cutoff30) item.secondary30 += work
|
||||
}
|
||||
if (time >= cutoff30) item.sessions30 += cursor.getString(0)
|
||||
}
|
||||
zones.first?.let { add(it, true) }
|
||||
zones.second.forEach { add(it, false) }
|
||||
}
|
||||
}
|
||||
|
||||
val supported = childZones.filter { availability.getValue(it.zoneId).isNotEmpty() }
|
||||
/* FROZEN V1 rank: absent primary first; then longest elapsed primary
|
||||
* exposure; then lower primary work at 7d and 30d; then weaker
|
||||
* secondary modifiers (7d, 30d, longest elapsed); finally stable ID. */
|
||||
val ranked = supported.sortedWith { left, right ->
|
||||
val a = exposure.getValue(left.zoneId)
|
||||
val b = exposure.getValue(right.zoneId)
|
||||
compareValues(a.lastPrimary != null, b.lastPrimary != null).takeIf { it != 0 }
|
||||
?: compareValues(a.lastPrimary?.first, b.lastPrimary?.first).takeIf { it != 0 }
|
||||
?: compareValues(a.primary7, b.primary7).takeIf { it != 0 }
|
||||
?: compareValues(a.primary30, b.primary30).takeIf { it != 0 }
|
||||
?: compareValues(a.secondary7, b.secondary7).takeIf { it != 0 }
|
||||
?: compareValues(a.secondary30, b.secondary30).takeIf { it != 0 }
|
||||
?: compareValues(a.lastSecondary?.first, b.lastSecondary?.first).takeIf { it != 0 }
|
||||
?: left.zoneId.compareTo(right.zoneId)
|
||||
}.take(3).map { it.zoneId }.toSet()
|
||||
|
||||
val statuses = childZones.map { zone ->
|
||||
val item = exposure.getValue(zone.zoneId)
|
||||
val available = availability.getValue(zone.zoneId).size
|
||||
val state = when {
|
||||
available == 0 -> BodyZoneHomeState.UNSUPPORTED
|
||||
invalidTimestamp -> BodyZoneHomeState.INSUFFICIENT_DATA
|
||||
zone.zoneId in ranked -> BodyZoneHomeState.PRIORITIZE
|
||||
item.primary7 >= 4 -> BodyZoneHomeState.HIGH_RECENT_EXPOSURE
|
||||
item.primary7 > 0 -> BodyZoneHomeState.RECENT_WORK
|
||||
else -> BodyZoneHomeState.LITTLE_RECENT_WORK
|
||||
}
|
||||
val reasons = when (state) {
|
||||
BodyZoneHomeState.UNSUPPORTED -> listOf(BodyZoneHomeState.UNSUPPORTED.label)
|
||||
BodyZoneHomeState.INSUFFICIENT_DATA -> listOf("Au moins un horodatage historique est invalide.")
|
||||
BodyZoneHomeState.PRIORITIZE -> listOf(
|
||||
if (item.lastPrimary == null) "Aucune exposition primaire enregistrée."
|
||||
else "Exposition primaire moins récente ou moins représentée.",
|
||||
"Exposition secondaire comptée comme modificateur plus faible.",
|
||||
)
|
||||
BodyZoneHomeState.LITTLE_RECENT_WORK -> listOf("Aucun travail primaire enregistré sur les 7 derniers jours.")
|
||||
BodyZoneHomeState.RECENT_WORK -> listOf("Travail primaire enregistré sur les 7 derniers jours.")
|
||||
BodyZoneHomeState.HIGH_RECENT_EXPOSURE -> listOf("Plusieurs faits de travail primaire récents sont enregistrés.")
|
||||
}
|
||||
BodyZoneHomeStatus(
|
||||
zone.zoneId, zone.displayName,
|
||||
item.lastPrimary?.second, item.lastSecondary?.second,
|
||||
item.lastPrimary?.first?.let { now.utcSecond - it.utcSecond },
|
||||
item.lastSecondary?.first?.let { now.utcSecond - it.utcSecond },
|
||||
item.primary7, item.secondary7, item.primary30, item.secondary30,
|
||||
item.sessions30.size, available, state, reasons,
|
||||
)
|
||||
}
|
||||
val byId = statuses.associateBy { it.zoneId }
|
||||
return BodyZoneHomeOverview(
|
||||
zones = statuses,
|
||||
recommendations = ranked.mapNotNull(byId::get),
|
||||
hasTrainingHistory = hasActualHistory,
|
||||
hasInvalidHistoryTimestamp = invalidTimestamp,
|
||||
)
|
||||
}
|
||||
|
||||
fun getExerciseKnowledge(exerciseId: String): ExerciseKnowledge? =
|
||||
trainingKnowledge.getExerciseKnowledge(exerciseId)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,56 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.asAndroidPath
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.labfytools.trainlog.data.BodyZoneHomeOverview
|
||||
import com.labfytools.trainlog.data.BodyZoneHomeState
|
||||
import com.labfytools.trainlog.data.BodyZoneHomeStatus
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.LatestExerciseMax
|
||||
import com.labfytools.trainlog.model.SessionSummary
|
||||
import com.labfytools.trainlog.model.SessionType
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
|
|
@ -13,7 +58,10 @@ fun HomeScreen(
|
|||
draftError: String?,
|
||||
latestSession: SessionSummary?,
|
||||
latestMaximum: LatestExerciseMax?,
|
||||
bodyZoneOverview: BodyZoneHomeOverview,
|
||||
onSession: () -> Unit,
|
||||
onOpenExercises: (String) -> Unit,
|
||||
onOpenStatistics: () -> Unit,
|
||||
onBody: () -> Unit,
|
||||
onOpenLatestSession: (String) -> Unit,
|
||||
onSync: () -> Unit,
|
||||
|
|
@ -29,6 +77,7 @@ fun HomeScreen(
|
|||
TrainlogPrimaryAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
|
||||
}
|
||||
}
|
||||
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises, onOpenStatistics)
|
||||
latestSession?.let { session ->
|
||||
TrainlogFrame("Dernière séance") {
|
||||
TrainlogAction(formatStartedAt(session.startedAt), "${sessionTypeLabel(session.sessionType)} · ${session.exerciseCount} exercice(s)", { onOpenLatestSession(session.sessionId) })
|
||||
|
|
@ -44,3 +93,415 @@ fun HomeScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CONTRACT: this section reports conservative recorded exposure only. Its
|
||||
* wording and actions must not imply recovery/readiness/fatigue or expose the
|
||||
* hidden session generator.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BodyZoneHomeSection(
|
||||
overview: BodyZoneHomeOverview,
|
||||
onOpenExercises: (String) -> Unit,
|
||||
onOpenStatistics: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
var showExposureInfo by remember { mutableStateOf(false) }
|
||||
var selectedId by remember(overview.zones) {
|
||||
mutableStateOf(overview.recommendations.firstOrNull()?.zoneId ?: overview.zones.firstOrNull()?.zoneId)
|
||||
}
|
||||
val selected = overview.zones.firstOrNull { it.zoneId == selectedId }
|
||||
TrainlogFrame("Zones à travailler", active = overview.recommendations.isNotEmpty()) {
|
||||
TextButton(onClick = { showExposureInfo = true }) {
|
||||
Text("Exposition récente ⓘ")
|
||||
}
|
||||
if (!overview.hasTrainingHistory) {
|
||||
TrainlogInfo("Pas encore d’historique d’entraînement.")
|
||||
}
|
||||
if (overview.hasInvalidHistoryTimestamp) {
|
||||
TrainlogInfo("Données insuffisantes : un horodatage historique ne peut pas être interprété.", colors.warning)
|
||||
}
|
||||
BodyZoneMap(overview.zones, selectedId) { selectedId = it }
|
||||
selected?.let { zone ->
|
||||
BasicText(
|
||||
"Sélection : ${zone.displayName}",
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
style = TrainlogTypography.normal.copy(color = colors.lavender),
|
||||
)
|
||||
}
|
||||
TrainlogInfo("Priorités", colors.accent)
|
||||
overview.recommendations.take(3).forEachIndexed { index, zone ->
|
||||
TrainlogAction(
|
||||
"${index + 1}. ${zone.displayName} · ${homeStateLabel(zone)}",
|
||||
recommendationReason(zone),
|
||||
{ selectedId = zone.zoneId },
|
||||
)
|
||||
}
|
||||
if (overview.recommendations.isEmpty()) {
|
||||
TrainlogInfo("Aucune zone disposant d’un exercice résolu n’est disponible.")
|
||||
}
|
||||
selected?.let { zone ->
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp).background(colors.surface, RoundedCornerShape(8.dp)).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
BasicText(zone.displayName, style = TrainlogTypography.section.copy(color = colors.text))
|
||||
BasicText(homeStateLabel(zone), style = TrainlogTypography.small.copy(color = stateColor(zone.state, colors)))
|
||||
BasicText(
|
||||
"Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}",
|
||||
style = TrainlogTypography.small.copy(color = colors.text),
|
||||
)
|
||||
BasicText(
|
||||
exposureCountLabel(zone.primaryWork7Days, "principale") + " sur 7 j · " +
|
||||
exposureCountLabel(zone.secondaryWork7Days, "secondaire") + " sur 7 j",
|
||||
style = TrainlogTypography.small.copy(color = colors.text),
|
||||
)
|
||||
BasicText(
|
||||
availableExerciseLabel(zone.availableExerciseCount),
|
||||
style = TrainlogTypography.small.copy(color = colors.text),
|
||||
)
|
||||
if (zone.availableExerciseCount == 0) TrainlogInfo(BodyZoneHomeState.UNSUPPORTED.label, colors.muted)
|
||||
TrainlogAction("Voir les exercices", "", { onOpenExercises(zone.zoneId) })
|
||||
TrainlogAction("Voir les statistiques", "", onOpenStatistics)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showExposureInfo) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExposureInfo = false },
|
||||
title = { Text("Exposition récente") },
|
||||
text = {
|
||||
Text("Ces couleurs décrivent le travail enregistré récemment. Elles ne mesurent pas la récupération physiologique.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showExposureInfo = false }) { Text("Compris") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BodyZoneMap(zones: List<BodyZoneHomeStatus>, selectedId: String?, select: (String) -> Unit) {
|
||||
val byId = zones.associateBy { it.zoneId }
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
BodyMapPanel(BodyView.FRONT, byId, selectedId, select, Modifier.weight(1f))
|
||||
BodyMapPanel(BodyView.BACK, byId, selectedId, select, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BodyMapPanel(
|
||||
view: BodyView,
|
||||
zones: Map<String, BodyZoneHomeStatus>,
|
||||
selectedId: String?,
|
||||
select: (String) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val visibleZones = view.zoneIds.mapNotNull(zones::get)
|
||||
Column(modifier.semantics { contentDescription = "Carte corporelle, ${view.label}" }) {
|
||||
BasicText(view.label, style = TrainlogTypography.small.copy(color = colors.muted))
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(236.dp)) {
|
||||
val panelWidth = maxWidth
|
||||
val panelHeight = maxHeight
|
||||
Canvas(
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.zIndex(1f)
|
||||
.testTag("body-map-${view.testName}")
|
||||
.pointerInput(view, visibleZones) {
|
||||
detectTapGestures { tap ->
|
||||
bodyZoneAtPoint(
|
||||
viewName = view.testName,
|
||||
width = size.width.toFloat(),
|
||||
height = size.height.toFloat(),
|
||||
point = tap,
|
||||
visibleZoneIds = visibleZones.mapTo(mutableSetOf()) { it.zoneId },
|
||||
)?.let(select)
|
||||
}
|
||||
},
|
||||
) {
|
||||
drawOval(
|
||||
color = colors.surfaceAlt,
|
||||
topLeft = Offset(size.width * .405f, size.height * .015f),
|
||||
size = Size(size.width * .19f, size.height * .135f),
|
||||
)
|
||||
drawPath(neckPath(size.width, size.height), colors.surfaceAlt)
|
||||
bodyRegions(view, size.width, size.height).forEach { region ->
|
||||
val zone = zones[region.zoneId] ?: return@forEach
|
||||
val chosen = selectedId == region.zoneId
|
||||
region.paths.forEach { path ->
|
||||
drawPath(path, stateColor(zone.state, colors).copy(alpha = .62f))
|
||||
if (chosen) {
|
||||
drawPath(
|
||||
path,
|
||||
color = colors.lavender.copy(alpha = .34f),
|
||||
style = Stroke(width = 8f),
|
||||
)
|
||||
}
|
||||
drawPath(
|
||||
path,
|
||||
color = if (chosen) colors.lavender else colors.crust.copy(alpha = .72f),
|
||||
style = Stroke(width = if (chosen) 4.5f else 1.25f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accessibility controls deliberately have no pointer modifier: direct
|
||||
// touch is resolved against the Path geometry above, while TalkBack gets
|
||||
// one explicit, comfortably sized Button target per visible zone.
|
||||
visibleZones.forEach { zone ->
|
||||
val target = view.accessibilityBounds.getValue(zone.zoneId)
|
||||
val chosen = selectedId == zone.zoneId
|
||||
Box(
|
||||
Modifier
|
||||
.offset(panelWidth * target.left, panelHeight * target.top)
|
||||
.size(
|
||||
width = maxOf(panelWidth * target.width, 48.dp),
|
||||
height = maxOf(panelHeight * target.height, 48.dp),
|
||||
)
|
||||
.testTag("body-zone-${view.testName}-${zone.zoneId}")
|
||||
.semantics {
|
||||
role = Role.Button
|
||||
selected = chosen
|
||||
contentDescription = buildString {
|
||||
append(view.label)
|
||||
append(", ")
|
||||
append(zone.displayName)
|
||||
append(", ")
|
||||
append(homeStateLabel(zone))
|
||||
append(if (chosen) ", sélectionnée" else ", non sélectionnée")
|
||||
}
|
||||
onClick(label = "Sélectionner ${zone.displayName}") {
|
||||
select(zone.zoneId)
|
||||
true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class BodyView(
|
||||
val label: String,
|
||||
val testName: String,
|
||||
val zoneIds: List<String>,
|
||||
val accessibilityBounds: Map<String, Rect>,
|
||||
) {
|
||||
FRONT(
|
||||
label = "Face",
|
||||
testName = "front",
|
||||
zoneIds = listOf("shoulders", "chest", "arms", "core", "thighs", "calves"),
|
||||
accessibilityBounds = mapOf(
|
||||
"shoulders" to Rect(.18f, .14f, .82f, .32f),
|
||||
"chest" to Rect(.27f, .24f, .73f, .40f),
|
||||
"arms" to Rect(.02f, .30f, .32f, .59f),
|
||||
"core" to Rect(.31f, .39f, .69f, .59f),
|
||||
"thighs" to Rect(.25f, .58f, .75f, .81f),
|
||||
"calves" to Rect(.27f, .79f, .73f, .99f),
|
||||
),
|
||||
),
|
||||
BACK(
|
||||
label = "Dos",
|
||||
testName = "back",
|
||||
zoneIds = listOf("shoulders", "back", "arms", "glutes", "thighs", "calves"),
|
||||
accessibilityBounds = mapOf(
|
||||
"shoulders" to Rect(.18f, .14f, .82f, .32f),
|
||||
"back" to Rect(.29f, .26f, .71f, .53f),
|
||||
"arms" to Rect(.68f, .30f, .98f, .59f),
|
||||
"glutes" to Rect(.29f, .52f, .71f, .67f),
|
||||
"thighs" to Rect(.25f, .65f, .75f, .83f),
|
||||
"calves" to Rect(.27f, .81f, .73f, .99f),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
private data class BodyRegion(val zoneId: String, val paths: List<Path>)
|
||||
|
||||
private fun Path.containsPoint(point: Offset): Boolean {
|
||||
val approximation = asAndroidPath().approximate(.5f)
|
||||
val pointCount = approximation.size / 3
|
||||
if (pointCount < 3) return false
|
||||
var inside = false
|
||||
var previous = pointCount - 1
|
||||
for (current in 0 until pointCount) {
|
||||
val currentX = approximation[current * 3 + 1]
|
||||
val currentY = approximation[current * 3 + 2]
|
||||
val previousX = approximation[previous * 3 + 1]
|
||||
val previousY = approximation[previous * 3 + 2]
|
||||
if ((currentY > point.y) != (previousY > point.y)) {
|
||||
val crossingX = (previousX - currentX) * (point.y - currentY) /
|
||||
(previousY - currentY) + currentX
|
||||
if (point.x < crossingX) inside = !inside
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
internal fun bodyZoneAtPoint(
|
||||
viewName: String,
|
||||
width: Float,
|
||||
height: Float,
|
||||
point: Offset,
|
||||
visibleZoneIds: Set<String>,
|
||||
): String? {
|
||||
val view = BodyView.entries.firstOrNull { it.testName == viewName } ?: return null
|
||||
return bodyRegions(view, width, height)
|
||||
.lastOrNull { it.zoneId in visibleZoneIds && it.paths.any { path -> path.containsPoint(point) } }
|
||||
?.zoneId
|
||||
}
|
||||
|
||||
/**
|
||||
* CONTRACT: these stylized shapes represent only Trainlog BODY ZONES UX. They
|
||||
* are not medical anatomy and never communicate recovery, fatigue or readiness.
|
||||
* Every coordinate is normalized; drawing and hit testing must both call this
|
||||
* function with the same Canvas size so their Path geometry remains identical.
|
||||
*/
|
||||
private fun bodyRegions(view: BodyView, width: Float, height: Float): List<BodyRegion> {
|
||||
fun path(build: Path.(Float, Float) -> Unit) = Path().apply { build(width, height) }
|
||||
fun paired(zoneId: String, left: Path, right: Path) = BodyRegion(zoneId, listOf(left, right))
|
||||
|
||||
val shoulders = paired(
|
||||
"shoulders",
|
||||
path { w, h ->
|
||||
moveTo(.48f * w, .175f * h); cubicTo(.40f * w, .17f * h, .30f * w, .165f * h, .22f * w, .215f * h)
|
||||
cubicTo(.18f * w, .24f * h, .18f * w, .285f * h, .23f * w, .30f * h)
|
||||
cubicTo(.30f * w, .275f * h, .38f * w, .25f * h, .48f * w, .245f * h); close()
|
||||
},
|
||||
path { w, h ->
|
||||
moveTo(.52f * w, .175f * h); cubicTo(.60f * w, .17f * h, .70f * w, .165f * h, .78f * w, .215f * h)
|
||||
cubicTo(.82f * w, .24f * h, .82f * w, .285f * h, .77f * w, .30f * h)
|
||||
cubicTo(.70f * w, .275f * h, .62f * w, .25f * h, .52f * w, .245f * h); close()
|
||||
},
|
||||
)
|
||||
val arms = paired(
|
||||
"arms",
|
||||
path { w, h ->
|
||||
moveTo(.215f * w, .255f * h); cubicTo(.16f * w, .285f * h, .145f * w, .365f * h, .13f * w, .43f * h)
|
||||
lineTo(.105f * w, .56f * h); cubicTo(.12f * w, .59f * h, .17f * w, .595f * h, .19f * w, .555f * h)
|
||||
lineTo(.255f * w, .305f * h); close()
|
||||
},
|
||||
path { w, h ->
|
||||
moveTo(.785f * w, .255f * h); cubicTo(.84f * w, .285f * h, .855f * w, .365f * h, .87f * w, .43f * h)
|
||||
lineTo(.895f * w, .56f * h); cubicTo(.88f * w, .59f * h, .83f * w, .595f * h, .81f * w, .555f * h)
|
||||
lineTo(.745f * w, .305f * h); close()
|
||||
},
|
||||
)
|
||||
val thighs = paired(
|
||||
"thighs",
|
||||
path { w, h ->
|
||||
moveTo(.35f * w, .565f * h); cubicTo(.31f * w, .63f * h, .30f * w, .71f * h, .315f * w, .805f * h)
|
||||
cubicTo(.34f * w, .83f * h, .405f * w, .825f * h, .435f * w, .795f * h)
|
||||
lineTo(.475f * w, .57f * h); close()
|
||||
},
|
||||
path { w, h ->
|
||||
moveTo(.65f * w, .565f * h); cubicTo(.69f * w, .63f * h, .70f * w, .71f * h, .685f * w, .805f * h)
|
||||
cubicTo(.66f * w, .83f * h, .595f * w, .825f * h, .565f * w, .795f * h)
|
||||
lineTo(.525f * w, .57f * h); close()
|
||||
},
|
||||
)
|
||||
val calves = paired(
|
||||
"calves",
|
||||
path { w, h ->
|
||||
moveTo(.315f * w, .805f * h); cubicTo(.285f * w, .86f * h, .315f * w, .91f * h, .335f * w, .98f * h)
|
||||
lineTo(.405f * w, .98f * h); cubicTo(.41f * w, .91f * h, .45f * w, .855f * h, .435f * w, .795f * h); close()
|
||||
},
|
||||
path { w, h ->
|
||||
moveTo(.685f * w, .805f * h); cubicTo(.715f * w, .86f * h, .685f * w, .91f * h, .665f * w, .98f * h)
|
||||
lineTo(.595f * w, .98f * h); cubicTo(.59f * w, .91f * h, .55f * w, .855f * h, .565f * w, .795f * h); close()
|
||||
},
|
||||
)
|
||||
|
||||
return if (view == BodyView.FRONT) {
|
||||
val chest = paired(
|
||||
"chest",
|
||||
path { w, h ->
|
||||
moveTo(.27f * w, .285f * h); cubicTo(.32f * w, .245f * h, .40f * w, .24f * h, .49f * w, .265f * h)
|
||||
lineTo(.49f * w, .38f * h); cubicTo(.42f * w, .395f * h, .34f * w, .38f * h, .29f * w, .345f * h); close()
|
||||
},
|
||||
path { w, h ->
|
||||
moveTo(.73f * w, .285f * h); cubicTo(.68f * w, .245f * h, .60f * w, .24f * h, .51f * w, .265f * h)
|
||||
lineTo(.51f * w, .38f * h); cubicTo(.58f * w, .395f * h, .66f * w, .38f * h, .71f * w, .345f * h); close()
|
||||
},
|
||||
)
|
||||
val core = BodyRegion("core", listOf(path { w, h ->
|
||||
moveTo(.30f * w, .36f * h); cubicTo(.35f * w, .40f * h, .38f * w, .43f * h, .37f * w, .55f * h)
|
||||
cubicTo(.41f * w, .58f * h, .59f * w, .58f * h, .63f * w, .55f * h)
|
||||
cubicTo(.62f * w, .43f * h, .65f * w, .40f * h, .70f * w, .36f * h)
|
||||
cubicTo(.60f * w, .39f * h, .40f * w, .39f * h, .30f * w, .36f * h); close()
|
||||
}))
|
||||
listOf(shoulders, chest, arms, core, thighs, calves)
|
||||
} else {
|
||||
val back = BodyRegion("back", listOf(path { w, h ->
|
||||
moveTo(.28f * w, .285f * h); cubicTo(.35f * w, .25f * h, .43f * w, .245f * h, .50f * w, .265f * h)
|
||||
cubicTo(.57f * w, .245f * h, .65f * w, .25f * h, .72f * w, .285f * h)
|
||||
cubicTo(.68f * w, .38f * h, .64f * w, .47f * h, .62f * w, .55f * h)
|
||||
cubicTo(.56f * w, .57f * h, .44f * w, .57f * h, .38f * w, .55f * h)
|
||||
cubicTo(.36f * w, .47f * h, .32f * w, .38f * h, .28f * w, .285f * h); close()
|
||||
}))
|
||||
val glutes = paired(
|
||||
"glutes",
|
||||
path { w, h ->
|
||||
moveTo(.37f * w, .535f * h); cubicTo(.31f * w, .57f * h, .32f * w, .64f * h, .39f * w, .665f * h)
|
||||
cubicTo(.43f * w, .675f * h, .475f * w, .645f * h, .49f * w, .595f * h); lineTo(.49f * w, .55f * h); close()
|
||||
},
|
||||
path { w, h ->
|
||||
moveTo(.63f * w, .535f * h); cubicTo(.69f * w, .57f * h, .68f * w, .64f * h, .61f * w, .665f * h)
|
||||
cubicTo(.57f * w, .675f * h, .525f * w, .645f * h, .51f * w, .595f * h); lineTo(.51f * w, .55f * h); close()
|
||||
},
|
||||
)
|
||||
listOf(shoulders, back, arms, glutes, thighs, calves)
|
||||
}
|
||||
}
|
||||
|
||||
private fun neckPath(width: Float, height: Float) = Path().apply {
|
||||
moveTo(.445f * width, .13f * height)
|
||||
lineTo(.43f * width, .19f * height)
|
||||
cubicTo(.46f * width, .205f * height, .54f * width, .205f * height, .57f * width, .19f * height)
|
||||
lineTo(.555f * width, .13f * height)
|
||||
close()
|
||||
}
|
||||
|
||||
private fun stateColor(state: BodyZoneHomeState, colors: TrainlogColors): Color {
|
||||
return when (state) {
|
||||
BodyZoneHomeState.PRIORITIZE -> colors.lavender
|
||||
BodyZoneHomeState.LITTLE_RECENT_WORK -> colors.info
|
||||
BodyZoneHomeState.RECENT_WORK -> colors.success
|
||||
BodyZoneHomeState.HIGH_RECENT_EXPOSURE -> colors.notice
|
||||
BodyZoneHomeState.INSUFFICIENT_DATA -> colors.warning
|
||||
BodyZoneHomeState.UNSUPPORTED -> colors.muted
|
||||
}
|
||||
}
|
||||
|
||||
internal fun recommendationReason(zone: BodyZoneHomeStatus): String = when {
|
||||
zone.lastPrimaryExposure == null -> "Aucune exposition principale enregistrée"
|
||||
zone.primaryWork7Days == 0 -> "Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}"
|
||||
else -> exposureCountLabel(zone.primaryWork7Days, "principale") + " sur 7 j"
|
||||
}
|
||||
|
||||
internal fun homeStateLabel(zone: BodyZoneHomeStatus): String =
|
||||
if (zone.state == BodyZoneHomeState.PRIORITIZE && zone.primaryWork7Days > 0) {
|
||||
"Priorité relative"
|
||||
} else {
|
||||
zone.state.label
|
||||
}
|
||||
|
||||
internal fun exposureCountLabel(count: Int, adjective: String): String =
|
||||
"$count ${if (count == 1) "exposition" else "expositions"} " +
|
||||
if (count == 1) adjective else "${adjective}s"
|
||||
|
||||
private fun availableExerciseLabel(count: Int): String =
|
||||
"$count ${if (count == 1) "exercice disponible" else "exercices disponibles"}"
|
||||
|
||||
internal fun ageLabel(seconds: Long?): String = when {
|
||||
seconds == null -> "jamais enregistrée"
|
||||
seconds < 86400L -> "aujourd’hui"
|
||||
seconds < 2L * 86400L -> "il y a 1 jour"
|
||||
else -> "il y a ${seconds / 86400L} jours"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,15 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
AppRoute.Home -> HomeScreen(
|
||||
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning,
|
||||
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
|
||||
repository.getBodyZoneHomeOverview(),
|
||||
::openManualSession,
|
||||
{ zoneId ->
|
||||
exerciseState.filterZoneId.value = zoneId
|
||||
exerciseState.unclassifiedFilter.value = false
|
||||
exerciseState.searchQuery.value = ""
|
||||
open(AppRoute.Exercises)
|
||||
},
|
||||
{ open(AppRoute.Statistics) },
|
||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
||||
)
|
||||
AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, ::openManualSession, { open(AppRoute.CompletedSessions) })
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class BodyZoneHomeOverviewRepositoryTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private val databaseName = "body-focus-${UUID.randomUUID()}.db"
|
||||
private val repository = TrainlogRepository(context, databaseName)
|
||||
|
||||
@After fun close() { repository.close(); context.deleteDatabase(databaseName) }
|
||||
|
||||
@Test fun suppliedRankingSecondaryModifierAndUnsupportedZone() {
|
||||
editDb { db ->
|
||||
clearMappings(db)
|
||||
exercise(db, CHEST, "Pectoraux test", "chest")
|
||||
exercise(db, BACK, "Dos test", "back", listOf("shoulders"))
|
||||
exercise(db, SHOULDERS, "Épaules test", "shoulders")
|
||||
exercise(db, THIGHS, "Cuisses test", "thighs")
|
||||
work(db, "chest-old", "2026-09-01T12:00:00Z", CHEST)
|
||||
work(db, "shoulders-old", "2026-09-03T12:00:00Z", SHOULDERS)
|
||||
work(db, "thighs-recent", "2026-09-08T12:00:00Z", THIGHS)
|
||||
work(db, "back-recent", "2026-09-10T12:00:00Z", BACK)
|
||||
}
|
||||
|
||||
val result = repository.getBodyZoneHomeOverview(now())
|
||||
assertEquals(listOf("chest", "shoulders", "thighs"), result.recommendations.map { it.zoneId })
|
||||
assertTrue(result.recommendations.take(2).map { it.zoneId }.containsAll(listOf("chest", "shoulders")))
|
||||
assertFalse(result.recommendations.any { it.zoneId == "back" || it.zoneId == "calves" })
|
||||
val shoulders = result.zones.single { it.zoneId == "shoulders" }
|
||||
assertEquals(0, shoulders.primaryWork7Days)
|
||||
assertEquals(1, shoulders.secondaryWork7Days)
|
||||
assertEquals("2026-09-03T12:00:00Z", shoulders.lastPrimaryExposure)
|
||||
assertEquals("2026-09-10T12:00:00Z", shoulders.lastSecondaryExposure)
|
||||
assertEquals(BodyZoneHomeState.UNSUPPORTED, result.zones.single { it.zoneId == "calves" }.state)
|
||||
assertEquals(listOf("chest", "back", "shoulders", "arms", "core", "glutes", "thighs", "calves"), result.zones.map { it.zoneId })
|
||||
}
|
||||
|
||||
@Test fun aliasHistoryCountsOnceAndParentRelationsNeverEnterOverview() {
|
||||
editDb { db ->
|
||||
clearMappings(db)
|
||||
exercise(db, CANONICAL, "Canonique", "chest")
|
||||
exercise(db, RETIRED, "Ancien", null)
|
||||
db.execSQL(
|
||||
"INSERT INTO exercise_aliases(source_exercise_id,canonical_exercise_id) VALUES(?,?)",
|
||||
arrayOf(RETIRED, CANONICAL),
|
||||
)
|
||||
// A corrupt grouping relation is impossible through production APIs;
|
||||
// this fixture proves the read boundary still cannot aggregate it.
|
||||
val canonicalRow = rowId(db, CANONICAL)
|
||||
db.execSQL("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) VALUES(?,'upper_body','secondary')", arrayOf(canonicalRow))
|
||||
work(db, "retired-work", "2026-09-10T12:00:00Z", RETIRED)
|
||||
}
|
||||
|
||||
val result = repository.getBodyZoneHomeOverview(now())
|
||||
val chest = result.zones.single { it.zoneId == "chest" }
|
||||
assertEquals(1, chest.primaryWork7Days)
|
||||
assertEquals(1, chest.primaryWork30Days)
|
||||
assertEquals(1, chest.recentSessionCount)
|
||||
assertEquals(1, chest.availableExerciseCount)
|
||||
assertFalse(result.zones.any { it.zoneId == "upper_body" || it.zoneId == "lower_body" || it.zoneId == "full_body" })
|
||||
}
|
||||
|
||||
@Test fun emptyHistoryIsHonestAndOnlySupportedZonesAreSuggested() {
|
||||
editDb { db ->
|
||||
clearMappings(db)
|
||||
exercise(db, CHEST, "Pectoraux test", "chest")
|
||||
exercise(db, BACK, "Dos test", "back")
|
||||
}
|
||||
val result = repository.getBodyZoneHomeOverview(now())
|
||||
assertFalse(result.hasTrainingHistory)
|
||||
assertEquals(listOf("back", "chest"), result.recommendations.map { it.zoneId })
|
||||
assertTrue(result.recommendations.all { it.availableExerciseCount > 0 })
|
||||
assertNull(result.zones.single { it.zoneId == "chest" }.lastPrimaryExposure)
|
||||
assertEquals(BodyZoneHomeState.UNSUPPORTED, result.zones.single { it.zoneId == "calves" }.state)
|
||||
}
|
||||
|
||||
@Test fun exactRollingInstantsAndTargetsOrDraftsDoNotQualify() {
|
||||
editDb { db ->
|
||||
clearMappings(db)
|
||||
exercise(db, CHEST, "Pectoraux test", "chest")
|
||||
work(db, "boundary", "2026-09-04T14:00:00+02:00", CHEST)
|
||||
work(db, "outside", "2026-09-04T11:59:59.999999999Z", CHEST)
|
||||
continuousWork(db, "continuous", "2026-09-10T09:00:00Z", CHEST)
|
||||
maximumWork(db, "maximum", "2026-09-10T10:00:00Z", CHEST)
|
||||
val session = session(db, "target", "2026-09-11T11:00:00Z")
|
||||
db.execSQL(
|
||||
"""INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id,target_sets)
|
||||
VALUES(?,?,0,'sets','reps',0,'target-only',9)""".trimIndent(),
|
||||
arrayOf(session, rowId(db, CHEST)),
|
||||
)
|
||||
}
|
||||
val result = repository.getBodyZoneHomeOverview(now())
|
||||
val chest = result.zones.single { it.zoneId == "chest" }
|
||||
assertEquals(3, chest.primaryWork7Days)
|
||||
assertEquals(4, chest.primaryWork30Days)
|
||||
assertEquals(4, chest.recentSessionCount)
|
||||
}
|
||||
|
||||
private fun now() = TrainlogTimestamp.parse("2026-09-11T12:00:00Z")!!
|
||||
|
||||
private fun editDb(block: (SQLiteDatabase) -> Unit) {
|
||||
repository.listExercises()
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
db.execSQL("PRAGMA foreign_keys=ON")
|
||||
block(db)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearMappings(db: SQLiteDatabase) {
|
||||
db.execSQL("DELETE FROM exercise_body_zones")
|
||||
db.execSQL("DELETE FROM exercise_aliases")
|
||||
}
|
||||
|
||||
private fun exercise(db: SQLiteDatabase, id: String, name: String, primary: String?, secondary: List<String> = emptyList()) {
|
||||
db.execSQL(
|
||||
"INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) VALUES(?,?,?,'sets','reps',0)",
|
||||
arrayOf(id, name, name.lowercase()),
|
||||
)
|
||||
val row = rowId(db, id)
|
||||
primary?.let { db.execSQL("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) VALUES(?,?,'primary')", arrayOf<Any>(row, it)) }
|
||||
secondary.forEach { db.execSQL("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) VALUES(?,?,'secondary')", arrayOf<Any>(row, it)) }
|
||||
}
|
||||
|
||||
private fun work(db: SQLiteDatabase, id: String, at: String, exerciseId: String) {
|
||||
val session = session(db, id, at)
|
||||
db.execSQL(
|
||||
"""INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id)
|
||||
VALUES(?,?,0,'sets','reps',0,?)""".trimIndent(),
|
||||
arrayOf<Any>(session, rowId(db, exerciseId), "entry-$id"),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO performed_sets(session_exercise_row_id,position,reps) SELECT id,0,8 FROM session_exercises WHERE entry_id=?",
|
||||
arrayOf("entry-$id"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun continuousWork(db: SQLiteDatabase, id: String, at: String, exerciseId: String) {
|
||||
val session = session(db, id, at)
|
||||
db.execSQL(
|
||||
"""INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id)
|
||||
VALUES(?,?,0,'continuous','duration',0,?)""".trimIndent(),
|
||||
arrayOf<Any>(session, rowId(db, exerciseId), "entry-$id"),
|
||||
)
|
||||
db.execSQL("INSERT INTO continuous_activity(session_exercise_row_id,duration_seconds) SELECT id,600 FROM session_exercises WHERE entry_id=?", arrayOf("entry-$id"))
|
||||
}
|
||||
|
||||
private fun maximumWork(db: SQLiteDatabase, id: String, at: String, exerciseId: String) {
|
||||
val session = session(db, id, at)
|
||||
db.execSQL(
|
||||
"""INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id)
|
||||
VALUES(?,?,0,'sets','reps',0,?)""".trimIndent(),
|
||||
arrayOf<Any>(session, rowId(db, exerciseId), "entry-$id"),
|
||||
)
|
||||
db.execSQL("INSERT INTO max_results(session_exercise_row_id,max_weight_kg) SELECT id,100.0 FROM session_exercises WHERE entry_id=?", arrayOf("entry-$id"))
|
||||
}
|
||||
|
||||
private fun session(db: SQLiteDatabase, id: String, at: String): Long {
|
||||
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,'training')", arrayOf("session-$id", at))
|
||||
return db.rawQuery("SELECT id FROM sessions WHERE session_id=?", arrayOf("session-$id")).use { it.moveToFirst(); it.getLong(0) }
|
||||
}
|
||||
|
||||
private fun rowId(db: SQLiteDatabase, id: String): Long =
|
||||
db.rawQuery("SELECT id FROM exercises WHERE exercise_id=?", arrayOf(id)).use { it.moveToFirst(); it.getLong(0) }
|
||||
|
||||
companion object {
|
||||
private const val CHEST = "ex_10000000-0000-4000-8000-000000000001"
|
||||
private const val BACK = "ex_10000000-0000-4000-8000-000000000002"
|
||||
private const val SHOULDERS = "ex_10000000-0000-4000-8000-000000000003"
|
||||
private const val THIGHS = "ex_10000000-0000-4000-8000-000000000004"
|
||||
private const val CANONICAL = "ex_10000000-0000-4000-8000-000000000005"
|
||||
private const val RETIRED = "ex_10000000-0000-4000-8000-000000000006"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import com.labfytools.trainlog.data.BodyZoneHomeState
|
||||
import com.labfytools.trainlog.data.BodyZoneHomeStatus
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BodyZoneHomePresentationTest {
|
||||
@Test fun detailWordingIsExposureOnlyAndIncludesEvidence() {
|
||||
val status = BodyZoneHomeStatus(
|
||||
zoneId = "chest",
|
||||
displayName = "Pectoraux",
|
||||
lastPrimaryExposure = "2026-09-01T12:00:00Z",
|
||||
lastSecondaryExposure = null,
|
||||
primaryExposureAgeSeconds = 10 * 86400L,
|
||||
secondaryExposureAgeSeconds = null,
|
||||
primaryWork7Days = 0,
|
||||
secondaryWork7Days = 1,
|
||||
primaryWork30Days = 2,
|
||||
secondaryWork30Days = 3,
|
||||
recentSessionCount = 2,
|
||||
availableExerciseCount = 4,
|
||||
state = BodyZoneHomeState.PRIORITIZE,
|
||||
reasons = listOf("Exposition primaire moins récente."),
|
||||
)
|
||||
val text = recommendationReason(status)
|
||||
assertEquals("Dernier travail principal : il y a 10 jours", text)
|
||||
assertEquals("jamais enregistrée", ageLabel(null))
|
||||
assertEquals("aujourd’hui", ageLabel(60))
|
||||
assertEquals("il y a 10 jours", ageLabel(10 * 86400L))
|
||||
listOf("récupération", "fatigue", "prêt", "séance recommandée").forEach {
|
||||
assertTrue(it !in text.lowercase())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun exposureLabelsUseNaturalFrenchSingularAndPlural() {
|
||||
assertEquals("1 exposition principale", exposureCountLabel(1, "principale"))
|
||||
assertEquals("2 expositions principales", exposureCountLabel(2, "principale"))
|
||||
assertEquals("1 exposition secondaire", exposureCountLabel(1, "secondaire"))
|
||||
assertEquals("3 expositions secondaires", exposureCountLabel(3, "secondaire"))
|
||||
}
|
||||
|
||||
@Test fun recentPrioritizedExposureIsPresentedAsRelativePriority() {
|
||||
val status = BodyZoneHomeStatus(
|
||||
zoneId = "chest",
|
||||
displayName = "Pectoraux",
|
||||
lastPrimaryExposure = "2026-09-10T12:00:00Z",
|
||||
lastSecondaryExposure = null,
|
||||
primaryExposureAgeSeconds = 86400L,
|
||||
secondaryExposureAgeSeconds = null,
|
||||
primaryWork7Days = 2,
|
||||
secondaryWork7Days = 1,
|
||||
primaryWork30Days = 3,
|
||||
secondaryWork30Days = 1,
|
||||
recentSessionCount = 2,
|
||||
availableExerciseCount = 4,
|
||||
state = BodyZoneHomeState.PRIORITIZE,
|
||||
reasons = emptyList(),
|
||||
)
|
||||
|
||||
assertEquals("Priorité relative", homeStateLabel(status))
|
||||
assertEquals("2 expositions principales sur 7 j", recommendationReason(status))
|
||||
assertFalse(recommendationReason(status).contains("fait(s)"))
|
||||
assertFalse(recommendationReason(status).contains("pondéré"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.assertHasClickAction
|
||||
import androidx.compose.ui.test.assertIsNotSelected
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.click
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onChildren
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.data.BodyZoneHomeOverview
|
||||
import com.labfytools.trainlog.data.BodyZoneHomeState
|
||||
import com.labfytools.trainlog.data.BodyZoneHomeStatus
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class BodyZoneHomeSemanticsTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test fun compactLargeFontMapExposesEveryViewSpecificZoneAndChangesSelection() {
|
||||
setSection(widthDp = 288, fontScale = 1.6f)
|
||||
|
||||
compose.onNodeWithContentDescription("Carte corporelle, Face").assertExists()
|
||||
compose.onNodeWithContentDescription("Carte corporelle, Dos").assertExists()
|
||||
expectedFrontZones.forEach { (id, name) ->
|
||||
val state = if (id == "chest") "Priorité relative" else "Travaillé récemment"
|
||||
val selection = if (id == "chest") "sélectionnée" else "non sélectionnée"
|
||||
compose.onNodeWithContentDescription("Face, $name, $state, $selection")
|
||||
.assertExists()
|
||||
.assertHasClickAction()
|
||||
}
|
||||
expectedBackZones.forEach { (_, name) ->
|
||||
compose.onNodeWithContentDescription("Dos, $name, Travaillé récemment, non sélectionnée")
|
||||
.assertExists()
|
||||
.assertHasClickAction()
|
||||
}
|
||||
|
||||
compose.onNodeWithContentDescription("Face, Pectoraux, Priorité relative, sélectionnée").assertIsSelected()
|
||||
compose.onNodeWithText("Sélection : Pectoraux").assertExists()
|
||||
compose.onNodeWithContentDescription("Dos, Fessiers, Travaillé récemment, non sélectionnée")
|
||||
.assertIsNotSelected()
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.onNodeWithContentDescription("Dos, Fessiers, Travaillé récemment, sélectionnée")
|
||||
.assertIsSelected()
|
||||
compose.onNodeWithText("Sélection : Fessiers").assertExists()
|
||||
compose.onNodeWithText("Fessiers").assertTextEquals("Fessiers")
|
||||
|
||||
// Anatomical labels are not rendered over either vector: each tagged
|
||||
// Canvas is a leaf and textual state/detail remains below the maps.
|
||||
compose.onNodeWithTag("body-map-front", useUnmergedTree = true).onChildren().assertCountEquals(0)
|
||||
compose.onNodeWithTag("body-map-back", useUnmergedTree = true).onChildren().assertCountEquals(0)
|
||||
compose.onNodeWithText("1. Pectoraux · Priorité relative").assertExists()
|
||||
compose.onNodeWithText("1 exposition principale sur 7 j").assertExists()
|
||||
listOf("fait(s) primaire(s)", "fait(s) secondaire(s)", "secondaire pondéré plus faiblement").forEach {
|
||||
compose.onAllNodesWithText(it, substring = true).assertCountEquals(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun compactExposureInfoOpensAccessibleExplanation() {
|
||||
setSection(widthDp = 380, fontScale = 1f)
|
||||
|
||||
compose.onNodeWithText("Exposition récente ⓘ")
|
||||
.assertHasClickAction()
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.onNodeWithText(
|
||||
"Ces couleurs décrivent le travail enregistré récemment. Elles ne mesurent pas la récupération physiologique.",
|
||||
).assertExists()
|
||||
compose.onNodeWithText("Compris").assertHasClickAction()
|
||||
}
|
||||
|
||||
@Test fun wideLayoutKeepsBothMapsSelectionAndCompactPriorities() {
|
||||
setSection(widthDp = 720, fontScale = 1f)
|
||||
|
||||
compose.onNodeWithTag("body-map-front").assertExists()
|
||||
compose.onNodeWithTag("body-map-back").assertExists()
|
||||
compose.onNodeWithText("Priorités").assertExists()
|
||||
compose.onNodeWithText("1. Pectoraux · Priorité relative")
|
||||
.assertHasClickAction()
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.onNodeWithText("Sélection : Pectoraux").assertExists()
|
||||
}
|
||||
|
||||
@Test fun practicalWidthMapSelectionFeedsExistingNavigationActions() {
|
||||
var openedZone: String? = null
|
||||
var statisticsOpened = false
|
||||
setSection(
|
||||
widthDp = 380,
|
||||
fontScale = 1f,
|
||||
onOpenExercises = { openedZone = it },
|
||||
onOpenStatistics = { statisticsOpened = true },
|
||||
)
|
||||
|
||||
assertEquals("core", bodyZoneAtPoint("front", 180f, 236f, Offset(90f, 113.28f), allZoneIds.toSet()))
|
||||
compose.onNodeWithTag("body-map-front")
|
||||
.performTouchInput { click(percentOffset(.5f, .48f)) }
|
||||
compose.onNodeWithContentDescription("Face, Abdominaux / tronc, Travaillé récemment, sélectionnée")
|
||||
.assertIsSelected()
|
||||
compose.onNodeWithText("Voir les exercices").assertHasClickAction()
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
assertEquals("core", openedZone)
|
||||
compose.onNodeWithText("Voir les statistiques").assertHasClickAction()
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
assertTrue(statisticsOpened)
|
||||
}
|
||||
|
||||
private fun setSection(
|
||||
widthDp: Int,
|
||||
fontScale: Float,
|
||||
onOpenExercises: (String) -> Unit = {},
|
||||
onOpenStatistics: () -> Unit = {},
|
||||
) {
|
||||
val zones = allZoneIds.map { id ->
|
||||
status(id, if (id == "chest") BodyZoneHomeState.PRIORITIZE else BodyZoneHomeState.RECENT_WORK)
|
||||
}
|
||||
compose.setContent {
|
||||
TrainlogTheme {
|
||||
CompositionLocalProvider(LocalDensity provides Density(1f, fontScale)) {
|
||||
Box(Modifier.width(widthDp.dp)) {
|
||||
BodyZoneHomeSection(
|
||||
BodyZoneHomeOverview(zones, listOf(zones.first()), true, false),
|
||||
onOpenExercises,
|
||||
onOpenStatistics,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun status(id: String, state: BodyZoneHomeState) = BodyZoneHomeStatus(
|
||||
zoneId = id,
|
||||
displayName = mapOf(
|
||||
"chest" to "Pectoraux", "back" to "Dos", "shoulders" to "Épaules", "arms" to "Bras",
|
||||
"core" to "Abdominaux / tronc", "glutes" to "Fessiers", "thighs" to "Cuisses", "calves" to "Mollets",
|
||||
).getValue(id),
|
||||
lastPrimaryExposure = "2026-09-10T12:00:00Z",
|
||||
lastSecondaryExposure = null,
|
||||
primaryExposureAgeSeconds = 86400L,
|
||||
secondaryExposureAgeSeconds = null,
|
||||
primaryWork7Days = 1,
|
||||
secondaryWork7Days = 0,
|
||||
primaryWork30Days = 1,
|
||||
secondaryWork30Days = 0,
|
||||
recentSessionCount = 1,
|
||||
availableExerciseCount = 1,
|
||||
state = state,
|
||||
reasons = listOf(state.label),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val allZoneIds = listOf("chest", "back", "shoulders", "arms", "core", "glutes", "thighs", "calves")
|
||||
val expectedFrontZones = listOf(
|
||||
"shoulders" to "Épaules",
|
||||
"chest" to "Pectoraux",
|
||||
"arms" to "Bras",
|
||||
"core" to "Abdominaux / tronc",
|
||||
"thighs" to "Cuisses",
|
||||
"calves" to "Mollets",
|
||||
)
|
||||
val expectedBackZones = listOf(
|
||||
"shoulders" to "Épaules",
|
||||
"back" to "Dos",
|
||||
"arms" to "Bras",
|
||||
"glutes" to "Fessiers",
|
||||
"thighs" to "Cuisses",
|
||||
"calves" to "Mollets",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ the dashboard remains available and reports that invalid history was ignored.
|
|||
The section roots are:
|
||||
|
||||
```text
|
||||
Accueil durable-draft resume, generation/manual capture, concise local links
|
||||
Accueil durable-draft resume, manual capture, recent BODY ZONES exposure, concise local links
|
||||
Séances current session, programme a session, manual entry, completed sessions
|
||||
Exercices catalogue, detail, create, and contextual edit
|
||||
Équipements catalogue, detail, and custom-equipment creation
|
||||
|
|
@ -144,7 +144,8 @@ targets.
|
|||
|
||||
## Session generator V1
|
||||
|
||||
Home exposes **Générer une séance**. The generator uses the shared frozen
|
||||
The V1 generator remains implemented but hidden from normal UI; Home exposes no
|
||||
generator action. The generator uses the shared frozen
|
||||
policy for all 11 selectable BODY ZONES, four goals, policy duration presets
|
||||
and custom bounds. A generated preview is read-only until acceptance and
|
||||
explains target dose, rest, equipment, observed-load source or absence,
|
||||
|
|
@ -157,6 +158,52 @@ non-mutating `existing_active_draft` conflict. Empty results cannot be accepted;
|
|||
nonempty partial results may be accepted and edited normally. Final completion
|
||||
continues to require actual captured work.
|
||||
|
||||
## Body focus Home V1
|
||||
|
||||
Home displays **Zones à travailler** immediately after the active-session or
|
||||
manual-session action. This is a read-only description of recent recorded
|
||||
training exposure. It does not estimate recovery, readiness or fatigue and it
|
||||
does not prescribe or generate a session.
|
||||
|
||||
The production `getBodyZoneHomeOverview(now)` read model processes exactly the
|
||||
eight meaningful child zones `chest`, `back`, `shoulders`, `arms`, `core`,
|
||||
`glutes`, `thighs` and `calves`. Grouping zones `upper_body`, `lower_body` and
|
||||
`full_body` are never overlays and never contribute duplicate exposure. Only
|
||||
completed occurrence-owned `performed_sets`, `continuous_activity` and
|
||||
`max_results` are evidence; targets, empty occurrences and active drafts are
|
||||
excluded. Timestamps use the canonical parser and inclusive rolling instant
|
||||
boundaries, never SQLite date functions or lexical ordering. Alias history is
|
||||
resolved to the canonical exercise identity and counted once.
|
||||
|
||||
Each zone reports the last primary and secondary exposure, primary/secondary
|
||||
actual-work counts for 7 and 30 days, distinct exposed-session count for 30
|
||||
days, active canonical exercise availability, a textual state and reasons.
|
||||
Primary exposure is the dominant signal. Secondary exposure is only a weaker
|
||||
modifier and can never stand in for missing primary exposure.
|
||||
|
||||
Eligible zones have at least one present canonical exercise with a direct
|
||||
resolved child-zone relation. Unsupported zones remain visible with **Aucun
|
||||
exercice résolu disponible** but are never recommended. The top three (or
|
||||
fewer) use this exact stable ordering: no primary exposure first; oldest last
|
||||
primary exposure; lower primary work over 7 days; lower primary work over 30
|
||||
days; lower secondary work over 7 days; lower secondary work over 30 days;
|
||||
oldest last secondary exposure; stable `zone_id`. Empty history says **Pas
|
||||
encore d’historique d’entraînement.** and ranks only supported zones.
|
||||
|
||||
The compact side-by-side Compose **Face**/**Dos** maps use independent filled,
|
||||
anatomically contoured cubic `Path` regions for shoulders, chest/back, arms,
|
||||
core/glutes, thighs and calves. The canvas contains no zone-name text and is
|
||||
only a stylized BODY ZONES selector, never medical anatomy or a recovery model.
|
||||
Direct taps are resolved against the same normalized paths used to draw each
|
||||
region. Selection changes both fill and a high-contrast outline. Separate
|
||||
transparent accessibility controls follow the region bounds, provide at least
|
||||
48 dp targets, use the Button role, and announce view, zone, textual exposure
|
||||
state and selected state; color is never the sole signal. Zone details remain
|
||||
below the maps and show last-primary age, 7-day primary/secondary work and
|
||||
available exercise count. **Voir les exercices** opens the existing Catalogue
|
||||
with its body-zone filter, while **Voir les statistiques** opens the existing
|
||||
Statistics landing route.
|
||||
|
||||
Load editing offers compact choices for automatic V1 qualification, a
|
||||
user-selected `%MAX`, or no numeric target; direct manual kg remains available.
|
||||
`%MAX` accepts an integer 1..100 and uses exactly
|
||||
|
|
|
|||
|
|
@ -130,6 +130,8 @@ Implemented:
|
|||
- native Kotlin/Compose application;
|
||||
- local SQLite database v11, with non-destructive v3 -> v11 migration;
|
||||
- one durable active-session draft, Home resume and raw-form restoration;
|
||||
- `BODY_FOCUS_HOME_V1`: a compact accessible Home front/back BODY ZONES map,
|
||||
textual top-three exposure ranking and Catalogue/Statistics drill-through;
|
||||
- explicit confirmed discard and atomic completed-save/draft-clear;
|
||||
- exercise creation;
|
||||
- stable-ID exercise rename/editing with referenced-profile protection;
|
||||
|
|
@ -149,6 +151,18 @@ Implemented:
|
|||
- Android-triggered synchronization request;
|
||||
- synchronization receipt handling.
|
||||
|
||||
The Home BODY ZONES overview is exposure-only and read-only. It uses only
|
||||
actual performed sets, continuous activities and explicit MAX results from
|
||||
completed history, with canonical parsed-instant 7/30-day windows. Plans,
|
||||
targets and drafts never qualify. Exactly the eight child anatomical zones are
|
||||
processed; grouping zones cannot double count. Primary exposure dominates the
|
||||
stable ranking, while secondary exposure is a weaker tie modifier. Alias
|
||||
history resolves once to the canonical exercise. Unsupported zones are shown
|
||||
as **Aucun exercice résolu disponible** and excluded from suggestions; empty
|
||||
history is stated explicitly and suggests supported zones only. The existing
|
||||
session generator remains hidden from normal UI, and the overview makes no
|
||||
recovery, readiness, fatigue or prescription claim.
|
||||
|
||||
The Android catalog exposes **Modifier** for every existing exercise. A rename
|
||||
updates `name` and `normalized_name` in the original row, never creates an ID,
|
||||
and remains valid for completed session and active-draft references. A profile
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ The current product baseline includes:
|
|||
- native Android capture client;
|
||||
- exercise catalog;
|
||||
- canonical hierarchical body zones, primary/secondary relations and filters;
|
||||
- Android `BODY_FOCUS_HOME_V1` recent-exposure map and deterministic supported-zone ranking;
|
||||
- profile-aware set and continuous activity;
|
||||
- heterogeneous repetition sets;
|
||||
- session history and editing;
|
||||
|
|
@ -84,6 +85,17 @@ metadata without duplicates. `ANDROID_BANNER_PARITY_V1` is a presentation-only
|
|||
completed checkpoint: all Android screens share the Notcurses-derived compact
|
||||
header; it does not reorder the roadmap below.
|
||||
|
||||
`BODY_FOCUS_HOME_V1` is a completed Android Home presentation/query tranche.
|
||||
It adds no schema, sync or catalog semantics. The overview uses completed
|
||||
actual work only, canonical exact-instant windows and exactly eight anatomical
|
||||
child zones. Its stable order is: absent primary, oldest primary, lower primary
|
||||
7d then 30d, weaker lower secondary 7d then 30d, oldest secondary, then stable
|
||||
zone ID. Only zones backed by a current canonical exercise with a resolved
|
||||
direct relation are eligible; unsupported zones stay visible but cannot be
|
||||
recommended. It is descriptive exposure guidance, not medical recovery,
|
||||
fatigue/readiness analysis or a session prescription. Session Generator V1
|
||||
remains hidden.
|
||||
|
||||
## Product boundary
|
||||
|
||||
`ANDROID_SESSION_DRAFT_V1` is an implemented P0 capture-reliability correction:
|
||||
|
|
|
|||
|
|
@ -300,6 +300,36 @@ Android repository host tests additionally cover exercise editing:
|
|||
search+filter, unclassified rows, companion replay/update/conflict and
|
||||
zone-safe exercise-identity merging.
|
||||
|
||||
`BodyZoneHomeOverviewRepositoryTest` exercises the production
|
||||
`getBodyZoneHomeOverview(now)` query with a caller-supplied deterministic
|
||||
instant. It covers the settled chest/back/shoulders/thighs example,
|
||||
secondary-only exposure remaining weaker than primary, stable ranking,
|
||||
unsupported calves exclusion, parent-zone non-participation, retired alias A
|
||||
to canonical B counted once, active/resolved availability, honest empty
|
||||
history, inclusive exact 7/30-day instant boundaries, and target-only exclusion.
|
||||
`BodyZoneHomePresentationTest` covers concise evidence and last-primary age
|
||||
wording without recovery/fatigue/prescription language.
|
||||
`BodyZoneHomeSemanticsTest` renders the production section at 288 dp content
|
||||
width (the constrained 320 dp-screen case) with 1.6x font scale and at a
|
||||
practical 380 dp content width. It verifies every view-specific Face/Dos zone
|
||||
and textual state, Button actions, selected/not-selected transitions, leaf
|
||||
canvases with no anatomical text children, exact normalized-path containment,
|
||||
a real Canvas tap selecting core, and both existing-route callbacks. The Home
|
||||
implementation keeps side-by-side maps with separate region-aligned, at-least
|
||||
48 dp semantic controls; 320/360/393/412 dp and TalkBack visual checks remain
|
||||
explicit device/emulator validation.
|
||||
|
||||
Focused host command:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk \
|
||||
./gradlew :app:testDebugUnitTest \
|
||||
--tests com.labfytools.trainlog.data.BodyZoneHomeOverviewRepositoryTest \
|
||||
--tests com.labfytools.trainlog.ui.BodyZoneHomePresentationTest \
|
||||
--tests com.labfytools.trainlog.ui.BodyZoneHomeSemanticsTest
|
||||
```
|
||||
|
||||
## 7. Hardware MTP validation
|
||||
|
||||
Hardware probes and real synchronization are separate from the normal automated
|
||||
|
|
|
|||
Loading…
Reference in a new issue