diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt index a8d118922..5766291d4 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/PreferencesManager.kt @@ -74,8 +74,31 @@ data class JustLiftDefaults( val weightPerCableKg: Float = 20f, val weightChangePerRep: Float = 0f, val eccentricLoadPercentage: Int = 100, - val echoLevelValue: Int = 2 -) + val echoLevelValue: Int = 2, + val stallDetectionEnabled: Boolean = true // Stall detection auto-stop toggle +) { + fun getEccentricLoad(): com.devil.phoenixproject.domain.model.EccentricLoad { + return com.devil.phoenixproject.domain.model.EccentricLoad.entries.find { it.percentage == eccentricLoadPercentage } + ?: com.devil.phoenixproject.domain.model.EccentricLoad.LOAD_100 + } + + fun getEchoLevel(): com.devil.phoenixproject.domain.model.EchoLevel { + return com.devil.phoenixproject.domain.model.EchoLevel.entries.find { it.levelValue == echoLevelValue } + ?: com.devil.phoenixproject.domain.model.EchoLevel.HARDER + } + + fun toWorkoutType(): com.devil.phoenixproject.domain.model.WorkoutType { + return when (workoutModeId) { + 0 -> com.devil.phoenixproject.domain.model.WorkoutType.Program(com.devil.phoenixproject.domain.model.ProgramMode.OldSchool) + 2 -> com.devil.phoenixproject.domain.model.WorkoutType.Program(com.devil.phoenixproject.domain.model.ProgramMode.Pump) + 3 -> com.devil.phoenixproject.domain.model.WorkoutType.Program(com.devil.phoenixproject.domain.model.ProgramMode.TUT) + 4 -> com.devil.phoenixproject.domain.model.WorkoutType.Program(com.devil.phoenixproject.domain.model.ProgramMode.TUTBeast) + 6 -> com.devil.phoenixproject.domain.model.WorkoutType.Program(com.devil.phoenixproject.domain.model.ProgramMode.EccentricOnly) + 10 -> com.devil.phoenixproject.domain.model.WorkoutType.Echo(getEchoLevel(), getEccentricLoad()) + else -> com.devil.phoenixproject.domain.model.WorkoutType.Program(com.devil.phoenixproject.domain.model.ProgramMode.OldSchool) + } + } +} /** * Preferences Manager interface diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt index 19c8bfaf3..b871a0f91 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt @@ -224,7 +224,8 @@ data class WorkoutParameters( val selectedExerciseId: String? = null, val isAMRAP: Boolean = false, // AMRAP (As Many Reps As Possible) - disables auto-stop val lastUsedWeightKg: Float? = null, // Last used weight for this exercise (for quick preset) - val prWeightKg: Float? = null // Personal record weight for this exercise (for quick preset) + val prWeightKg: Float? = null, // Personal record weight for this exercise (for quick preset) + val stallDetectionEnabled: Boolean = true // Enable stall detection auto-stop for Just Lift/AMRAP modes ) /** diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Routine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Routine.kt index a39c27da1..ea615d4b7 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Routine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Routine.kt @@ -48,6 +48,8 @@ data class RoutineExercise( val isAMRAP: Boolean = false, // Per Set Rest Time toggle - when true, each set has its own rest time; when false, single rest time applies to all sets val perSetRestTime: Boolean = false, + // Stall detection toggle - when true, auto-stops set if user hesitates too long (applies to AMRAP/Just Lift modes) + val stallDetectionEnabled: Boolean = true, // Superset configuration val supersetGroupId: String? = null, // Exercises with same ID are in same superset val supersetOrder: Int = 0, // Order within the superset diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt index 6e9a82661..570acd595 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt @@ -108,6 +108,7 @@ fun ExerciseEditBottomSheet( val perSetRestTime by viewModel.perSetRestTime.collectAsState() val eccentricLoad by viewModel.eccentricLoad.collectAsState() val echoLevel by viewModel.echoLevel.collectAsState() + val stallDetectionEnabled by viewModel.stallDetectionEnabled.collectAsState() // Fetch current PR for selected mode var currentPR by remember { mutableStateOf(null) } @@ -394,6 +395,44 @@ fun ExerciseEditBottomSheet( } } + // Stall Detection toggle - show when any set is AMRAP + val hasAMRAPSets = sets.any { it.reps == null } + if (hasAMRAPSets) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.surfaceVariant), + shadowElevation = 2.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.small), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Stall Detection", + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (stallDetectionEnabled) FontWeight.Bold else FontWeight.Normal, + color = if (stallDetectionEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Auto-stop set when movement pauses for 5 seconds", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = stallDetectionEnabled, + onCheckedChange = viewModel::onStallDetectionEnabledChange + ) + } + } + } + // Sets Configuration SetsConfiguration( sets = sets, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt index 6b4188dd2..d3a76af8c 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt @@ -77,6 +77,7 @@ fun JustLiftScreen( var weightChangePerRep by remember { mutableStateOf(0) } // Progression/Regression value var eccentricLoad by remember { mutableStateOf(EccentricLoad.LOAD_100) } var echoLevel by remember { mutableStateOf(EchoLevel.HARDER) } + var stallDetectionEnabled by remember { mutableStateOf(true) } var defaultsLoaded by remember { mutableStateOf(false) } // Load saved Just Lift defaults on screen init @@ -102,7 +103,10 @@ fun JustLiftScreen( eccentricLoad = defaults.getEccentricLoad() echoLevel = defaults.getEchoLevel() - Logger.d("Loaded Just Lift defaults: modeId=${defaults.workoutModeId}, weight=${defaults.weightPerCableKg}kg, progression=${defaults.weightChangePerRep}") + // Restore stall detection setting + stallDetectionEnabled = defaults.stallDetectionEnabled + + Logger.d("Loaded Just Lift defaults: modeId=${defaults.workoutModeId}, weight=${defaults.weightPerCableKg}kg, progression=${defaults.weightChangePerRep}, stallDetection=$stallDetectionEnabled") } defaultsLoaded = true } @@ -150,7 +154,7 @@ fun JustLiftScreen( } // Update parameters whenever user changes them - LaunchedEffect(selectedMode, weightPerCable, weightChangePerRep) { + LaunchedEffect(selectedMode, weightPerCable, weightChangePerRep, stallDetectionEnabled) { val weightChangeKg = if (weightUnit == WeightUnit.LB) { weightChangePerRep / 2.20462f } else { @@ -162,7 +166,8 @@ fun JustLiftScreen( weightPerCableKg = weightPerCable, progressionRegressionKg = weightChangeKg, isJustLift = true, - useAutoStart = true // Enable auto-start for Just Lift + useAutoStart = true, // Enable auto-start for Just Lift + stallDetectionEnabled = stallDetectionEnabled ) viewModel.updateWorkoutParameters(updatedParameters) } @@ -251,6 +256,41 @@ fun JustLiftScreen( } } + // Stall Detection Toggle Card + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.medium, vertical = Spacing.small), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Stall Detection", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + "Auto-stop set when movement pauses for 5 seconds", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = stallDetectionEnabled, + onCheckedChange = { stallDetectionEnabled = it } + ) + } + } + // Mode-specific options - OLD SCHOOL & PUMP val isOldSchoolOrPump = selectedMode is WorkoutMode.OldSchool || selectedMode is WorkoutMode.Pump if (isOldSchoolOrPump) { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ExerciseConfigViewModel.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ExerciseConfigViewModel.kt index 09df790da..09b50b1ae 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ExerciseConfigViewModel.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ExerciseConfigViewModel.kt @@ -75,6 +75,9 @@ class ExerciseConfigViewModel constructor() : ViewModel() { private val _echoLevel = MutableStateFlow(EchoLevel.HARDER) val echoLevel: StateFlow = _echoLevel.asStateFlow() + private val _stallDetectionEnabled = MutableStateFlow(true) + val stallDetectionEnabled: StateFlow = _stallDetectionEnabled.asStateFlow() + init { } @@ -166,6 +169,7 @@ class ExerciseConfigViewModel constructor() : ViewModel() { _perSetRestTime.value = exercise.perSetRestTime _eccentricLoad.value = exercise.eccentricLoad _echoLevel.value = exercise.echoLevel + _stallDetectionEnabled.value = exercise.stallDetectionEnabled _initialized.value = true } @@ -213,6 +217,10 @@ class ExerciseConfigViewModel constructor() : ViewModel() { } } + fun onStallDetectionEnabledChange(enabled: Boolean) { + _stallDetectionEnabled.value = enabled + } + fun updateReps(setId: String, reps: Int?) { _sets.value = _sets.value.map { set -> if (set.id == setId) set.copy(reps = reps) else set @@ -297,7 +305,8 @@ class ExerciseConfigViewModel constructor() : ViewModel() { _sets.value.firstOrNull()?.duration ?: 30 // Default to 30 seconds if not set } else null, perSetRestTime = _perSetRestTime.value, - isAMRAP = isAMRAP + isAMRAP = isAMRAP, + stallDetectionEnabled = _stallDetectionEnabled.value ) logDebug("Updated exercise to save:") diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt index a866d4bce..cab4b0c20 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt @@ -740,10 +740,10 @@ class MainViewModel constructor( // Update rep ranges for position bar ROM visualization _repRanges.value = repCounter.getRepRanges() - // Just Lift Auto-Stop (danger zone detection) - // Note: AMRAP mode explicitly disables auto-stop + // Just Lift / AMRAP Auto-Stop (stall detection) + // Stall detection is now toggleable via stallDetectionEnabled flag val params = _workoutParameters.value - if (params.isJustLift && !params.isAMRAP) { + if ((params.isJustLift || params.isAMRAP) && params.stallDetectionEnabled) { checkAutoStop(metric) } @@ -1139,10 +1139,11 @@ class MainViewModel constructor( stopAtTop = stopAtTop.value, warmupReps = _workoutParameters.value.warmupReps, isAMRAP = firstSetReps == null, // This SET is AMRAP if its reps is null - selectedExerciseId = firstExercise.exercise.id + selectedExerciseId = firstExercise.exercise.id, + stallDetectionEnabled = firstExercise.stallDetectionEnabled ) - Logger.d { "Created WorkoutParameters: isAMRAP=${params.isAMRAP}, isJustLift=${params.isJustLift}" } + Logger.d { "Created WorkoutParameters: isAMRAP=${params.isAMRAP}, isJustLift=${params.isJustLift}, stallDetection=${params.stallDetectionEnabled}" } updateWorkoutParameters(params) } @@ -2319,7 +2320,8 @@ class MainViewModel constructor( _workoutParameters.value = _workoutParameters.value.copy( reps = targetReps ?: 0, weightPerCableKg = setWeight, - isAMRAP = targetReps == null + isAMRAP = targetReps == null, + stallDetectionEnabled = currentExercise.stallDetectionEnabled ) repCounter.resetCountsOnly() @@ -2368,7 +2370,8 @@ class MainViewModel constructor( workoutType = nextExercise.workoutType, progressionRegressionKg = nextExercise.progressionKg, selectedExerciseId = nextExercise.exercise.id, - isAMRAP = nextSetReps == null + isAMRAP = nextSetReps == null, + stallDetectionEnabled = nextExercise.stallDetectionEnabled ) repCounter.resetCountsOnly() @@ -2397,7 +2400,8 @@ class MainViewModel constructor( workoutType = nextExercise.workoutType, progressionRegressionKg = nextExercise.progressionKg, selectedExerciseId = nextExercise.exercise.id, - isAMRAP = nextSetReps == null + isAMRAP = nextSetReps == null, + stallDetectionEnabled = nextExercise.stallDetectionEnabled ) repCounter.resetCountsOnly() @@ -2420,7 +2424,8 @@ class MainViewModel constructor( _workoutParameters.value = _workoutParameters.value.copy( reps = targetReps ?: 0, weightPerCableKg = setWeight, - isAMRAP = targetReps == null + isAMRAP = targetReps == null, + stallDetectionEnabled = currentExercise.stallDetectionEnabled ) repCounter.resetCountsOnly() @@ -2445,7 +2450,8 @@ class MainViewModel constructor( workoutType = nextExercise.workoutType, progressionRegressionKg = nextExercise.progressionKg, selectedExerciseId = nextExercise.exercise.id, - isAMRAP = nextSetReps == null + isAMRAP = nextSetReps == null, + stallDetectionEnabled = nextExercise.stallDetectionEnabled ) repCounter.reset() @@ -2505,7 +2511,8 @@ data class JustLiftDefaults( val weightChangePerRep: Int, // In display units (kg or lbs based on user preference) val workoutModeId: Int, // 0=OldSchool, 1=Pump, 2=Echo val eccentricLoadPercentage: Int = 100, - val echoLevelValue: Int = 1 // 0=Hard, 1=Harder, 2=Hardest, 3=Epic + val echoLevelValue: Int = 1, // 0=Hard, 1=Harder, 2=Hardest, 3=Epic + val stallDetectionEnabled: Boolean = true // Stall detection auto-stop toggle ) { /** * Convert stored mode ID to WorkoutType