diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt index 34f4525a6..3f32be7eb 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.devil.phoenixproject.data.repository.ExerciseRepository +import com.devil.phoenixproject.data.repository.ExerciseVideoEntity import com.devil.phoenixproject.domain.model.* import com.devil.phoenixproject.domain.model.BiomechanicsRepResult import com.devil.phoenixproject.presentation.components.AnimatedRepCounter @@ -31,6 +33,7 @@ import com.devil.phoenixproject.presentation.components.EnhancedCablePositionBar import com.devil.phoenixproject.presentation.components.ExpandedForceCurve import com.devil.phoenixproject.presentation.components.ForceCurveMiniGraph import com.devil.phoenixproject.presentation.components.StableRepProgress +import com.devil.phoenixproject.presentation.components.VideoPlayer import com.devil.phoenixproject.presentation.util.LocalWindowSizeClass import com.devil.phoenixproject.presentation.util.ResponsiveDimensions import com.devil.phoenixproject.presentation.util.WindowWidthSizeClass @@ -50,7 +53,7 @@ import vitruvianprojectphoenix.shared.generated.resources.* * * Slots: * - Top Bar: Connection Status (Left), Phase/Mode (Center), Stop Button (Right) - * - Center: Horizontal Pager (Metrics | Stats) + * - Center: Horizontal Pager (Metrics | Video | Stats) * - Bottom Bar: Weight/Reps controls & Navigation */ @OptIn(ExperimentalFoundationApi::class) @@ -63,9 +66,11 @@ fun WorkoutHud( repRanges: com.devil.phoenixproject.domain.usecase.RepRanges?, // Full qualified to avoid conflict if any weightUnit: WeightUnit, connectionState: ConnectionState, + exerciseRepository: ExerciseRepository, loadedRoutine: Routine?, currentExerciseIndex: Int, currentSetIndex: Int, + enableVideoPlayback: Boolean, onStopWorkout: () -> Unit, formatWeight: (Float, WeightUnit) -> String, onUpdateParameters: (WorkoutParameters) -> Unit, @@ -86,7 +91,7 @@ fun WorkoutHud( ) { // Determine if we're in Echo mode val isEchoMode = workoutParameters.isEchoMode - val pagerState = rememberPagerState(pageCount = { 2 }) + val pagerState = rememberPagerState(pageCount = { 3 }) val topBarModeLabel = if (isCurrentExerciseBodyweight) "Bodyweight" else workoutParameters.programMode.displayName val currentRoutineExercise = loadedRoutine?.exercises?.getOrNull(currentExerciseIndex) @@ -175,7 +180,15 @@ fun WorkoutHud( ) } - 1 -> StatsPage( + 1 -> InstructionPage( + loadedRoutine = loadedRoutine, + currentExerciseIndex = currentExerciseIndex, + selectedExerciseId = workoutParameters.selectedExerciseId, + exerciseRepository = exerciseRepository, + enableVideoPlayback = enableVideoPlayback, + ) + + 2 -> StatsPage( metric = metric, weightUnit = weightUnit, formatWeight = formatWeight, @@ -695,6 +708,110 @@ private fun ExecutionPage( } } +@Composable +private fun InstructionPage( + loadedRoutine: Routine?, + currentExerciseIndex: Int, + selectedExerciseId: String?, + exerciseRepository: ExerciseRepository, + enableVideoPlayback: Boolean, +) { + val currentExercise = loadedRoutine?.exercises?.getOrNull(currentExerciseIndex) + val exerciseId = currentExercise?.exercise?.id ?: selectedExerciseId + var videoEntity by remember(currentExerciseIndex, exerciseId) { mutableStateOf(null) } + var isLoading by remember(currentExerciseIndex, exerciseId) { mutableStateOf(true) } + + LaunchedEffect(currentExerciseIndex, exerciseId) { + isLoading = true + videoEntity = null + if (exerciseId != null) { + try { + videoEntity = exerciseRepository.getVideos(exerciseId).firstOrNull() + } catch (e: Exception) { + co.touchlab.kermit.Logger.e("WorkoutHud") { "Failed to load video for $exerciseId: ${e.message}" } + } + } + isLoading = false + } + + Box( + modifier = Modifier.fillMaxSize().padding(16.dp), + contentAlignment = Alignment.Center, + ) { + when { + !enableVideoPlayback -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.VideocamOff, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + Text( + "Video Playback Disabled", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "Enable in Settings", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + + isLoading -> CircularProgressIndicator() + + videoEntity != null -> Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + currentExercise?.exercise?.name?.let { name -> + Text( + name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 12.dp), + ) + } + VideoPlayer( + videoUrl = videoEntity?.videoUrl, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(MaterialTheme.shapes.small), + ) + } + + else -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.VideoLibrary, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + Text( + "No Video Available", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + currentExercise?.exercise?.name?.let { name -> + Text( + name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + } + } +} + @Composable private fun StatsPage( metric: WorkoutMetric?, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt index 044f9b767..e97cae428 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt @@ -323,9 +323,11 @@ fun WorkoutTab( repRanges = repRanges, weightUnit = weightUnit, connectionState = connectionState, + exerciseRepository = exerciseRepository, loadedRoutine = loadedRoutine, currentExerciseIndex = currentExerciseIndex, currentSetIndex = currentSetIndex, + enableVideoPlayback = enableVideoPlayback, onStopWorkout = onStopWorkout, formatWeight = formatWeight, onUpdateParameters = onUpdateParameters, diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/WorkoutHudVideoWiringTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/WorkoutHudVideoWiringTest.kt new file mode 100644 index 000000000..c4cb97195 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/WorkoutHudVideoWiringTest.kt @@ -0,0 +1,69 @@ +package com.devil.phoenixproject.presentation + +import com.devil.phoenixproject.testutil.readProjectFile +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** Source-level guard for issue #643: active-workout HUD keeps its video page. */ +class WorkoutHudVideoWiringTest { + + @Test + fun activeHudKeepsVideoPageBetweenMetricsAndStats() { + val src = readWorkoutHudSource() + + assertTrue( + src.contains("exerciseRepository: ExerciseRepository") && + src.contains("enableVideoPlayback: Boolean"), + "WorkoutHud must accept video dependencies instead of dropping the preference/repository.", + ) + assertTrue( + src.contains("rememberPagerState(pageCount = { 3 })"), + "WorkoutHud must expose Metrics, Video, and Stats pages.", + ) + assertTrue( + src.contains("1 -> InstructionPage(") && src.contains("2 -> StatsPage("), + "WorkoutHud must keep the video page between metrics and stats.", + ) + assertTrue( + src.contains("Video Playback Disabled") && src.contains("VideoPlayer("), + "InstructionPage must render the disabled state and exercise video player.", + ) + assertTrue( + src.contains("selectedExerciseId = workoutParameters.selectedExerciseId") && + src.contains("currentExercise?.exercise?.id ?: selectedExerciseId"), + "InstructionPage must load a selected exercise video when an active workout has no routine.", + ) + assertTrue( + src.contains("var videoEntity by remember(currentExerciseIndex, exerciseId)") && + src.contains("var isLoading by remember(currentExerciseIndex, exerciseId)"), + "InstructionPage state must reset when either the routine position or exercise changes.", + ) + } + + @Test + fun workoutTabForwardsVideoSettingsIntoActiveHud() { + val src = readWorkoutTabSource() + val hudCall = src.substringAfter("WorkoutHud(", missingDelimiterValue = "") + .substringBefore("RepQualityIndicator", missingDelimiterValue = "") + assertTrue(hudCall.isNotEmpty(), "WorkoutTab must call WorkoutHud for connected active workouts.") + + assertTrue( + hudCall.contains("exerciseRepository = exerciseRepository,") && + hudCall.contains("enableVideoPlayback = enableVideoPlayback,"), + "WorkoutTab must forward ExerciseRepository and enableVideoPlayback into WorkoutHud.", + ) + } + + private fun readWorkoutHudSource(): String { + val src = readProjectFile("src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutHud.kt") + assertNotNull(src, "Could not locate WorkoutHud.kt") + return src + } + + private fun readWorkoutTabSource(): String { + val src = readProjectFile("src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/WorkoutTab.kt") + assertNotNull(src, "Could not locate WorkoutTab.kt") + return src + } +}