diff --git a/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt b/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt index f66a1c2..69fb1da 100644 --- a/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt +++ b/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt @@ -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 = {}, diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/BodyZoneHomeOverview.kt b/android/app/src/main/java/com/labfytools/trainlog/data/BodyZoneHomeOverview.kt new file mode 100644 index 0000000..fb54ac5 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/BodyZoneHomeOverview.kt @@ -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, +) + +data class BodyZoneHomeOverview( + val zones: List, + val recommendations: List, + val hasTrainingHistory: Boolean, + val hasInvalidHistoryTimestamp: Boolean, +) diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 54e7dc6..7c633b2 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -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() + 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() + while (seen.add(current)) current = canonicalByAlias[current] ?: return current + return current + } + + val relations = mutableMapOf>>() + 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() } + 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? = null, + var lastSecondary: Pair? = null, + var primary7: Int = 0, + var secondary7: Int = 0, + var primary30: Int = 0, + var secondary30: Int = 0, + val sessions30: MutableSet = 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) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt index 59f9bf5..d490ab9 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt @@ -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, 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, + 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, + val accessibilityBounds: Map, +) { + 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) + +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? { + 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 { + 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" +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index 7f62b38..75f8669 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -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) }) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZoneHomeOverviewRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZoneHomeOverviewRepositoryTest.kt new file mode 100644 index 0000000..59a7425 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZoneHomeOverviewRepositoryTest.kt @@ -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 = 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(row, it)) } + secondary.forEach { db.execSQL("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) VALUES(?,?,'secondary')", arrayOf(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(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(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(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" + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomePresentationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomePresentationTest.kt new file mode 100644 index 0000000..99a49f1 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomePresentationTest.kt @@ -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é")) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomeSemanticsTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomeSemanticsTest.kt new file mode 100644 index 0000000..69b14da --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomeSemanticsTest.kt @@ -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", + ) + } +} diff --git a/docs/android.md b/docs/android.md index ca5f76e..e782295 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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 diff --git a/docs/current_state.md b/docs/current_state.md index af24a1f..e025a35 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 9e4f433..23cc108 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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: diff --git a/docs/tests.md b/docs/tests.md index 981b5e3..8ed7e7c 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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