Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ExerciseVideoEntity?>(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?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Comment thread
9thLevelSoftware marked this conversation as resolved.
Loading