diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt index 10784eda9..2335d1189 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt @@ -641,7 +641,7 @@ class SchemaParityTest { // ==================== HELPERS ==================== companion object { - private const val EXPECTED_SCHEMA_VERSION = 43L + private const val EXPECTED_SCHEMA_VERSION = 44L private val CANONICAL_UUID_REGEX = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") /** diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/MigrationManagerTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/MigrationManagerTest.kt index cc60044fc..b297a22d7 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/MigrationManagerTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/migration/MigrationManagerTest.kt @@ -965,6 +965,8 @@ class MigrationManagerTest { prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = 1, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, stopAtTop = 0, repCountTiming = "TOP", setEchoLevels = "", diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightCompletedSetRepositoryTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightCompletedSetRepositoryTest.kt index d33e2adc4..3276691c1 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightCompletedSetRepositoryTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightCompletedSetRepositoryTest.kt @@ -145,6 +145,8 @@ class SqlDelightCompletedSetRepositoryTest { prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = 1L, + dropSetEnabled = 0L, + dropSetMinWeightKg = 0.0, stopAtTop = 0L, repCountTiming = "TOP", setEchoLevels = "", diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepositoryTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepositoryTest.kt index a25a7cec9..38c33d5cb 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepositoryTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepositoryTest.kt @@ -736,6 +736,8 @@ class SqlDelightSyncRepositoryTest { prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = 1, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, stopAtTop = 0, repCountTiming = "TOP", setEchoLevels = "", @@ -778,7 +780,7 @@ class SqlDelightSyncRepositoryTest { } @Test - fun `mergePortalRoutines preserves local scalingBasis across portal pull`() = runTest { + fun `mergePortalRoutines preserves local scalingBasis and drop set fields across portal pull`() = runTest { database.vitruvianDatabaseQueries.insertRoutine( id = "routine-scaling-basis", name = "Scaling Basis", @@ -805,7 +807,7 @@ class SqlDelightSyncRepositoryTest { mode = "OldSchool", eccentricLoad = 100, echoLevel = 1, - progressionKg = 0.0, + progressionKg = -8.0, restSeconds = 90, duration = null, setRestSeconds = "[]", @@ -818,6 +820,8 @@ class SqlDelightSyncRepositoryTest { prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = 1, + dropSetEnabled = 1, + dropSetMinWeightKg = 17.5, stopAtTop = 0, repCountTiming = "TOP", setEchoLevels = "", @@ -857,6 +861,9 @@ class SqlDelightSyncRepositoryTest { .executeAsList() .single() assertEquals("ESTIMATED_1RM", exercise.scalingBasis) + assertEquals(1L, exercise.dropSetEnabled) + assertEquals(17.5, exercise.dropSetMinWeightKg) + assertEquals(-8.0, exercise.progressionKg) } @Test diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightTrainingCycleRepositoryTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightTrainingCycleRepositoryTest.kt index 1ed31beff..441b21059 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightTrainingCycleRepositoryTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightTrainingCycleRepositoryTest.kt @@ -273,6 +273,8 @@ class SqlDelightTrainingCycleRepositoryTest { prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = 1L, + dropSetEnabled = 0L, + dropSetMinWeightKg = 0.0, stopAtTop = 0L, repCountTiming = "TOP", setEchoLevels = "", diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ExerciseConfigViewModelTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ExerciseConfigViewModelTest.kt index 84ec0188d..384c1730b 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ExerciseConfigViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/presentation/viewmodel/ExerciseConfigViewModelTest.kt @@ -56,6 +56,40 @@ class ExerciseConfigViewModelTest { assertEquals(SetMode.DURATION, viewModel.setMode.value) } + @Test + fun `initialize clears stale drop set mode outside Old School`() = runTest { + val viewModel = ExerciseConfigViewModel() + val exercise = RoutineExercise( + id = "rex-drop-set-pump", + exercise = Exercise( + id = "cable-row-1", + name = "Cable Row", + muscleGroup = "Back", + muscleGroups = "Back", + equipment = "CABLE", + ), + orderIndex = 0, + setReps = listOf(10), + weightPerCableKg = 30f, + progressionKg = -2f, + programMode = ProgramMode.Pump, + dropSetEnabled = true, + dropSetMinWeightKg = 15f, + ) + + viewModel.initialize( + exercise = exercise, + unit = WeightUnit.KG, + toDisplay = { value, _ -> value }, + toKg = { value, _ -> value }, + ) + + assertFalse(viewModel.dropSetEnabled.value) + var saved: RoutineExercise? = null + viewModel.onSave { saved = it } + assertFalse(saved?.dropSetEnabled ?: true) + } + @Test fun `bodyweight exercise hides cable-only configuration toggles`() { val bodyweightSets = listOf(SetConfiguration(setNumber = 1, reps = 10)) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt index f8cc59202..2d18a30b9 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt @@ -1017,6 +1017,8 @@ internal val manifestTables: List = listOf( setWeightsPercentOfPR TEXT, scalingBasis TEXT, stallDetectionEnabled INTEGER NOT NULL DEFAULT 1, + dropSetEnabled INTEGER NOT NULL DEFAULT 0, + dropSetMinWeightKg REAL NOT NULL DEFAULT 0.0, stopAtTop INTEGER NOT NULL DEFAULT 0, repCountTiming TEXT NOT NULL DEFAULT 'TOP', setEchoLevels TEXT NOT NULL DEFAULT '', @@ -1374,6 +1376,9 @@ internal val manifestColumns: List = listOf( SchemaHealOperation("RoutineExercise", "stallDetectionEnabled", "ALTER TABLE RoutineExercise ADD COLUMN stallDetectionEnabled INTEGER NOT NULL DEFAULT 1"), SchemaHealOperation("RoutineExercise", "stopAtTop", "ALTER TABLE RoutineExercise ADD COLUMN stopAtTop INTEGER NOT NULL DEFAULT 0"), SchemaHealOperation("RoutineExercise", "repCountTiming", "ALTER TABLE RoutineExercise ADD COLUMN repCountTiming TEXT NOT NULL DEFAULT 'TOP'"), + // Migration 43: drop-set mode for Old School (issue #673) + SchemaHealOperation("RoutineExercise", "dropSetEnabled", "ALTER TABLE RoutineExercise ADD COLUMN dropSetEnabled INTEGER NOT NULL DEFAULT 0"), + SchemaHealOperation("RoutineExercise", "dropSetMinWeightKg", "ALTER TABLE RoutineExercise ADD COLUMN dropSetMinWeightKg REAL NOT NULL DEFAULT 0.0"), // ── UserProfile (4 columns) ───────────────────────────────────────── 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 c478daff4..27f62d5d2 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 @@ -38,6 +38,8 @@ data class SingleExerciseDefaults( val isAMRAP: Boolean, val perSetRestTime: Boolean, val defaultRackItemIds: List = emptyList(), + val dropSetEnabled: Boolean = false, + val dropSetMinWeightKg: Float = 0f, ) { fun getEccentricLoad(): com.devil.phoenixproject.domain.model.EccentricLoad { // Handle legacy 125% -> fall back to 120% diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileWorkoutDefaultsMapper.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileWorkoutDefaultsMapper.kt index 48a8804a0..42cf47218 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileWorkoutDefaultsMapper.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/preferences/ProfileWorkoutDefaultsMapper.kt @@ -17,6 +17,8 @@ internal fun SingleExerciseDefaults.toDocument() = SingleExerciseDefaultsDocumen isAMRAP = isAMRAP, perSetRestTime = perSetRestTime, defaultRackItemIds = defaultRackItemIds, + dropSetEnabled = dropSetEnabled, + dropSetMinWeightKg = dropSetMinWeightKg, ) internal fun SingleExerciseDefaultsDocument.toLegacySingleExerciseDefaults() = SingleExerciseDefaults( @@ -33,6 +35,8 @@ internal fun SingleExerciseDefaultsDocument.toLegacySingleExerciseDefaults() = S isAMRAP = isAMRAP, perSetRestTime = perSetRestTime, defaultRackItemIds = defaultRackItemIds, + dropSetEnabled = dropSetEnabled, + dropSetMinWeightKg = dropSetMinWeightKg, ) internal fun com.devil.phoenixproject.data.preferences.JustLiftDefaults.toDocument() = diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepository.kt index 8b6e6d673..5cb8d4cfd 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepository.kt @@ -586,6 +586,13 @@ class SqlDelightSyncRepository( .associate { it.id to it.rackBehaviorOverrides } val localScalingBasisByExerciseId = localExerciseRows .associate { it.id to it.scalingBasis } + val localDropSetEnabledByExerciseId = localExerciseRows + .associate { it.id to it.dropSetEnabled } + val localDropSetMinWeightByExerciseId = localExerciseRows + .associate { it.id to it.dropSetMinWeightKg } + val localDropSetProgressionByExerciseId = localExerciseRows + .filter { it.dropSetEnabled != 0L } + .associate { it.id to it.progressionKg } mergePortalExercisesForRoutine( routineId = portalRoutine.id, @@ -593,6 +600,9 @@ class SqlDelightSyncRepository( localRackDefaultsByExerciseId = localRackDefaultsByExerciseId, localRackOverridesByExerciseId = localRackOverridesByExerciseId, localScalingBasisByExerciseId = localScalingBasisByExerciseId, + localDropSetEnabledByExerciseId = localDropSetEnabledByExerciseId, + localDropSetMinWeightByExerciseId = localDropSetMinWeightByExerciseId, + localDropSetProgressionByExerciseId = localDropSetProgressionByExerciseId, ) } else { Logger.w("SyncRepository") { @@ -1043,6 +1053,8 @@ class SqlDelightSyncRepository( isAMRAP = exRow.isAMRAP == 1L, perSetRestTime = exRow.perSetRestTime == 1L, stallDetectionEnabled = exRow.stallDetectionEnabled == 1L, + dropSetEnabled = exRow.dropSetEnabled == 1L, + dropSetMinWeightKg = exRow.dropSetMinWeightKg.toFloat(), repCountTiming = try { RepCountTiming.valueOf(exRow.repCountTiming) } catch ( @@ -1660,6 +1672,13 @@ class SqlDelightSyncRepository( .associate { it.id to it.rackBehaviorOverrides } val localScalingBasisByExerciseId = localExerciseRows2 .associate { it.id to it.scalingBasis } + val localDropSetEnabledByExerciseId2 = localExerciseRows2 + .associate { it.id to it.dropSetEnabled } + val localDropSetMinWeightByExerciseId2 = localExerciseRows2 + .associate { it.id to it.dropSetMinWeightKg } + val localDropSetProgressionByExerciseId2 = localExerciseRows2 + .filter { it.dropSetEnabled != 0L } + .associate { it.id to it.progressionKg } mergePortalExercisesForRoutine( routineId = portalRoutine.id, @@ -1667,6 +1686,9 @@ class SqlDelightSyncRepository( localRackDefaultsByExerciseId = localRackDefaultsByExerciseId, localRackOverridesByExerciseId = localRackOverridesByExerciseId, localScalingBasisByExerciseId = localScalingBasisByExerciseId, + localDropSetEnabledByExerciseId = localDropSetEnabledByExerciseId2, + localDropSetMinWeightByExerciseId = localDropSetMinWeightByExerciseId2, + localDropSetProgressionByExerciseId = localDropSetProgressionByExerciseId2, ) } } @@ -2161,6 +2183,9 @@ class SqlDelightSyncRepository( localRackDefaultsByExerciseId: Map, localRackOverridesByExerciseId: Map, localScalingBasisByExerciseId: Map, + localDropSetEnabledByExerciseId: Map = emptyMap(), + localDropSetMinWeightByExerciseId: Map = emptyMap(), + localDropSetProgressionByExerciseId: Map = emptyMap(), ) { queries.deleteRoutineExercises(routineId) queries.deleteSupersetsByRoutine(routineId) @@ -2269,7 +2294,7 @@ class SqlDelightSyncRepository( mode = mobileMode, eccentricLoad = PortalPullAdapter.parseEccentricLoad(exercise.eccentricLoad), echoLevel = PortalPullAdapter.parseEchoLevel(exercise.echoLevel), - progressionKg = 0.0, + progressionKg = localDropSetProgressionByExerciseId[exercise.id] ?: 0.0, restSeconds = exercise.restSeconds.toLong(), duration = null, setRestSeconds = setRestSeconds, @@ -2282,6 +2307,8 @@ class SqlDelightSyncRepository( prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = if (exercise.stallDetection) 1L else 0L, + dropSetEnabled = localDropSetEnabledByExerciseId[exercise.id] ?: 0L, + dropSetMinWeightKg = localDropSetMinWeightByExerciseId[exercise.id] ?: 0.0, stopAtTop = if (exercise.stopAtPosition == "TOP") 1L else 0L, repCountTiming = exercise.repCountTiming ?: "TOP", setEchoLevels = setEchoLevels, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt index faab3435a..94266a880 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightWorkoutRepository.kt @@ -403,6 +403,8 @@ class SqlDelightWorkoutRepository(private val db: VitruvianDatabase, private val perSetRestTime = row.perSetRestTime == 1L, // Per-exercise behavior overrides stallDetectionEnabled = row.stallDetectionEnabled == 1L, + dropSetEnabled = row.dropSetEnabled == 1L, + dropSetMinWeightKg = row.dropSetMinWeightKg.toFloat(), repCountTiming = try { com.devil.phoenixproject.domain.model.RepCountTiming.valueOf(row.repCountTiming) } catch ( @@ -751,6 +753,8 @@ class SqlDelightWorkoutRepository(private val db: VitruvianDatabase, private val }, // Per-exercise behavior overrides stallDetectionEnabled = if (exercise.stallDetectionEnabled) 1L else 0L, + dropSetEnabled = if (exercise.dropSetEnabled) 1L else 0L, + dropSetMinWeightKg = exercise.dropSetMinWeightKg.toDouble(), stopAtTop = if (exercise.stopAtTop) 1L else 0L, repCountTiming = exercise.repCountTiming.name, // Per-set echo levels (stored as JSON array of nullable ordinals) 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 94d8b1130..bc35300d6 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 @@ -351,6 +351,8 @@ data class WorkoutParameters( val externalAddedLoadKg: Float = 0f, val counterweightKg: Float = 0f, val progressionRegressionKg: Float = 0f, // Positive = progression, negative = regression + val dropSetEnabled: Boolean = false, + val dropSetMinWeightKg: Float = 0f, val isJustLift: Boolean = false, val useAutoStart: Boolean = false, // true for Just Lift, false for others val stopAtTop: Boolean = false, // false = stop at bottom (extended), true = stop at top (contracted) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ProfilePreferences.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ProfilePreferences.kt index 3ab812ad0..c62f749a3 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ProfilePreferences.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/ProfilePreferences.kt @@ -42,6 +42,8 @@ data class SingleExerciseDefaultsDocument( val isAMRAP: Boolean, val perSetRestTime: Boolean, val defaultRackItemIds: List = emptyList(), + val dropSetEnabled: Boolean = false, + val dropSetMinWeightKg: Float = 0f, ) @Serializable 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 d1ecb33f6..1e2934bf3 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 @@ -90,6 +90,8 @@ data class RoutineExercise( val eccentricLoad: EccentricLoad = EccentricLoad.LOAD_100, val echoLevel: EchoLevel = EchoLevel.HARDER, val progressionKg: Float = 0f, + val dropSetEnabled: Boolean = false, + val dropSetMinWeightKg: Float = 0f, val setRestSeconds: List = emptyList(), // per-set rest times // Per-set echo level overrides; null entries fall back to exercise-level echoLevel val setEchoLevels: List = emptyList(), diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index da94c4146..c3b8966cc 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt @@ -425,7 +425,10 @@ class ActiveSessionEngine( val params = coordinator._workoutParameters.value val currentState = coordinator._workoutState.value - if (params.stallDetectionEnabled && currentState is WorkoutState.Active) { + val dropSetIsActive = params.dropSetEnabled && + params.programMode is ProgramMode.OldSchool && + params.progressionRegressionKg < 0f + if ((params.stallDetectionEnabled || dropSetIsActive) && currentState is WorkoutState.Active) { // Echo levels are defined by the firmware's deload window (e.g. HARDER = // deload after 1.25s below 40 mm/s — Issue #553), so DELOAD_OCCURRED fires // routinely mid-set as the athlete fatigues. It is NOT a cable-release @@ -447,6 +450,27 @@ class ActiveSessionEngine( resetStallTimer() return@collect } + // Drop-set mode: reduce weight instead of arming stall timer. + // Must be BEFORE shouldEnableAutoStop gate so drop-set fires + // even when stallDetectionEnabled is false (fixed-rep Old School). + if (dropSetIsActive) { + val currentDropWeight = coordinator._workoutParameters.value.weightPerCableKg + if (currentDropWeight > params.dropSetMinWeightKg) { + val dropAmount = kotlin.math.abs(params.progressionRegressionKg) + val safeFloor = maxOf(params.dropSetMinWeightKg, Constants.DEFAULT_WEIGHT_INCREMENT_KG) + val newDropWeight = (currentDropWeight - dropAmount).coerceAtLeast(safeFloor) + Logger.d("Drop-set: DELOAD_OCCURRED -> weight ${currentDropWeight}kg -> ${newDropWeight}kg (drop=$dropAmount, floor=${params.dropSetMinWeightKg})") + // Store the pending drop-set weight for the next set boundary + // without modifying the active-set weightPerCableKg (so + // saveWorkoutSession captures the original load, not the + // dropped next-set target). + coordinator.dropSetNextWeightKg = newDropWeight + coordinator.dropSetDropCount++ + } else { + Logger.d("Drop-set: DELOAD_OCCURRED ignored - already at floor (${params.dropSetMinWeightKg}kg)") + } + return@collect + } if (!shouldEnableAutoStop(params)) return@collect Logger.d("DELOAD_OCCURRED: Machine detected cable release - starting auto-stop timer") @@ -470,7 +494,7 @@ class ActiveSessionEngine( } } - // #6: Rep events collector for handling machine rep notifications + // #6: Rep events collector for handling machine rep notifications coordinator.repEventsCollectionJob = scope.launch { bleRepository.repEvents .catch { e -> Logger.e(e) { "repEvents collector error" } } @@ -1714,6 +1738,11 @@ class ActiveSessionEngine( currentState is WorkoutState.SetSummary ) { coordinator._userAdjustedWeightDuringRest = true + if (currentState is WorkoutState.Resting || currentState is WorkoutState.SetSummary) { + // An explicit user weight choice wins over the automatically queued + // drop target that was merely being previewed during the transition. + coordinator.dropSetNextWeightKg = null + } Logger.d("ActiveSessionEngine: User adjusted weight in ${currentState::class.simpleName} - will preserve on next set") } @@ -1916,6 +1945,8 @@ class ActiveSessionEngine( isAMRAP = currentExercise.isAMRAP, perSetRestTime = currentExercise.perSetRestTime, defaultRackItemIds = currentExercise.defaultRackItemIds.filter { it.isNotBlank() }.distinct(), + dropSetEnabled = currentExercise.dropSetEnabled, + dropSetMinWeightKg = currentExercise.dropSetMinWeightKg, ) settingsManager.saveSingleExerciseDefaultsDocument(defaults.toDocument()) Logger.d { "Saved Single Exercise defaults for ${currentExercise.exercise.name}" } @@ -2120,6 +2151,7 @@ class ActiveSessionEngine( coordinator._totalWarmupSets.value = 0 coordinator._selectedBodyweightVariants.value = emptyMap() coordinator.bodyweightCompletionVariantOverride = null + coordinator.dropSetNextWeightKg = null coordinator.clearActiveRackSelection() } @@ -2139,6 +2171,19 @@ class ActiveSessionEngine( private fun clampUpcomingProgressionKg(valueKg: Float): Float = valueKg.coerceIn(-3f, 3f) + /** + * Regular upcoming-set progression is intentionally bounded for safe automatic + * adjustment. A drop-set's negative progression is the user-configured drop + * amount, however, and the editor permits a wider range; do not silently + * change that amount between failures. + */ + private fun progressionForNextSet(exercise: RoutineExercise): Float = + if (exercise.dropSetEnabled && exercise.programMode is ProgramMode.OldSchool && exercise.progressionKg < 0f) { + exercise.progressionKg + } else { + clampUpcomingProgressionKg(exercise.progressionKg) + } + fun updateWorkoutParameters(params: WorkoutParameters) { // Defense: reject near-zero weight writes in Just Lift mode. // The JustLiftScreen param-sync LaunchedEffect can fire before defaults @@ -2163,6 +2208,13 @@ class ActiveSessionEngine( currentState is WorkoutState.SetSummary ) { coordinator._userAdjustedWeightDuringRest = true + if ((currentState is WorkoutState.Resting || currentState is WorkoutState.SetSummary) && + safeParams.weightPerCableKg != coordinator._workoutParameters.value.weightPerCableKg + ) { + // Editing reps/progression alone must not cancel a queued drop, but + // a manual weight edit is an explicit replacement for it. + coordinator.dropSetNextWeightKg = null + } Logger.d("updateWorkoutParameters: User edited params in ${currentState::class.simpleName} - will preserve on transition") } coordinator._workoutParameters.value = safeParams @@ -4428,8 +4480,23 @@ class ActiveSessionEngine( val hasNextSet = nextSetIdx < exerciseForNextSet.setReps.size if (hasNextSet) { val nextSetReps = exerciseForNextSet.setReps.getOrNull(nextSetIdx) - val nextSetWeight = exerciseForNextSet.setWeightsPerCableKg.getOrNull(nextSetIdx) - ?: exerciseForNextSet.weightPerCableKg + // Consume pending drop-set weight only for same-exercise transitions + // so it does not bleed into an unrelated next exercise. + val isSameExercise = !isTransitioningToNextExercise + val pendingDropWeight = coordinator.dropSetNextWeightKg + val nextSetWeight = if (pendingDropWeight != null && isSameExercise) { + pendingDropWeight + } else if (pendingDropWeight != null && !isSameExercise) { + // Different exercise: discard the drop-set weight + coordinator.dropSetNextWeightKg = null + exerciseForNextSet.setWeightsPerCableKg.getOrNull(nextSetIdx) + ?: exerciseForNextSet.weightPerCableKg + } else if (coordinator._userAdjustedWeightDuringRest) { + coordinator._workoutParameters.value.weightPerCableKg + } else { + exerciseForNextSet.setWeightsPerCableKg.getOrNull(nextSetIdx) + ?: exerciseForNextSet.weightPerCableKg + } val isNextSetLastSet = nextSetIdx >= exerciseForNextSet.setReps.size - 1 val nextIsAMRAP = nextSetReps == null || (exerciseForNextSet.isAMRAP && isNextSetLastSet) @@ -4439,10 +4506,12 @@ class ActiveSessionEngine( programMode = exerciseForNextSet.programMode, echoLevel = exerciseForNextSet.getEchoLevelForSet(nextSetIdx), eccentricLoad = exerciseForNextSet.eccentricLoad, - progressionRegressionKg = clampUpcomingProgressionKg(exerciseForNextSet.progressionKg), + progressionRegressionKg = progressionForNextSet(exerciseForNextSet), selectedExerciseId = exerciseForNextSet.exercise.id, isAMRAP = nextIsAMRAP, stallDetectionEnabled = exerciseForNextSet.stallDetectionEnabled, + dropSetEnabled = exerciseForNextSet.dropSetEnabled, + dropSetMinWeightKg = exerciseForNextSet.dropSetMinWeightKg, warmupReps = if (nextExerciseIsBodyweight) 0 else Constants.DEFAULT_WARMUP_REPS, ) Logger.d { "startRestTimer: Issue #203 - Updated params for next set: ${exerciseForNextSet.exercise.name}, setIdx=$nextSetIdx, isAMRAP=$nextIsAMRAP, nextSetReps=$nextSetReps" } @@ -4659,7 +4728,11 @@ class ActiveSessionEngine( val targetReps = currentExercise.setReps[coordinator._currentSetIndex.value] val currentParams = coordinator._workoutParameters.value - val setWeight = if (coordinator._userAdjustedWeightDuringRest) { + val pendingDropWeight = coordinator.dropSetNextWeightKg + val setWeight = if (pendingDropWeight != null) { + coordinator.dropSetNextWeightKg = null + pendingDropWeight + } else if (coordinator._userAdjustedWeightDuringRest) { currentParams.weightPerCableKg } else { currentExercise.setWeightsPerCableKg.getOrNull(coordinator._currentSetIndex.value) @@ -4673,7 +4746,7 @@ class ActiveSessionEngine( val setProgressionKg = if (coordinator._userAdjustedWeightDuringRest) { currentParams.progressionRegressionKg } else { - clampUpcomingProgressionKg(currentExercise.progressionKg) + progressionForNextSet(currentExercise) } coordinator._userAdjustedWeightDuringRest = false @@ -4685,6 +4758,8 @@ class ActiveSessionEngine( weightPerCableKg = setWeight, isAMRAP = nextIsAMRAP, stallDetectionEnabled = currentExercise.stallDetectionEnabled, + dropSetEnabled = currentExercise.dropSetEnabled, + dropSetMinWeightKg = currentExercise.dropSetMinWeightKg, progressionRegressionKg = setProgressionKg, ) Logger.d { "advanceToNextSetInSingleExercise: Issue #203 - setIdx=${coordinator._currentSetIndex.value}, isAMRAP=$nextIsAMRAP" } @@ -4711,7 +4786,16 @@ class ActiveSessionEngine( if (autoplay) { startWorkout(skipCountdown = true) } else { - flowDelegate?.enterSetReady(coordinator._currentExerciseIndex.value, coordinator._currentSetIndex.value) + // The transition has already selected the next set's parameters. Passing + // those values through SetReady prevents it from reloading the routine + // default and losing a pending drop-set target (or a user rest edit). + val params = coordinator._workoutParameters.value + flowDelegate?.enterSetReadyWithAdjustments( + coordinator._currentExerciseIndex.value, + coordinator._currentSetIndex.value, + params.weightPerCableKg, + params.reps, + ) } } @@ -4777,6 +4861,7 @@ class ActiveSessionEngine( currentExercise != null && isAdjacentLinearExercise && flowDelegate?.isSameExercise(currentExercise, nextExercise) == true + val isSameRoutineExercise = !isChangingExercise coordinator._currentExerciseIndex.value = nextExIdx coordinator._currentSetIndex.value = nextSetIdx @@ -4785,7 +4870,20 @@ class ActiveSessionEngine( val currentParams = coordinator._workoutParameters.value val preserveRestEdits = coordinator._userAdjustedWeightDuringRest - val nextSetWeight = if (preserveRestEdits) { + // Consume a pending drop-set weight only after we have crossed the set + // boundary for the same routine entry (or the explicit same-exercise + // continuation path). The rest UI may preview the pending value, but it + // must not consume it before this point. + val pendingDropWeight = coordinator.dropSetNextWeightKg + val nextSetWeight = if (pendingDropWeight != null && (isSameRoutineExercise || isSameExerciseContinuation)) { + coordinator.dropSetNextWeightKg = null + pendingDropWeight + } else if (pendingDropWeight != null) { + // Different exercise: discard the drop-set weight + coordinator.dropSetNextWeightKg = null + nextExercise.setWeightsPerCableKg.getOrNull(nextSetIdx) + ?: nextExercise.weightPerCableKg + } else if (preserveRestEdits) { currentParams.weightPerCableKg } else { nextExercise.setWeightsPerCableKg.getOrNull(nextSetIdx) @@ -4809,7 +4907,7 @@ class ActiveSessionEngine( val nextProgressionKg = if (preserveRestEdits) { currentParams.progressionRegressionKg } else { - clampUpcomingProgressionKg(nextExercise.progressionKg) + progressionForNextSet(nextExercise) } val nextIsBodyweight = isBodyweightExercise(nextExercise) @@ -4849,6 +4947,8 @@ class ActiveSessionEngine( selectedExerciseId = nextExercise.exercise.id, isAMRAP = nextIsAMRAP, stallDetectionEnabled = nextExercise.stallDetectionEnabled, + dropSetEnabled = nextExercise.dropSetEnabled, + dropSetMinWeightKg = nextExercise.dropSetMinWeightKg, warmupReps = if (nextIsBodyweight) 0 else Constants.DEFAULT_WARMUP_REPS, ) Logger.d { diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt index 3ac1acb72..581e9a010 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/DefaultWorkoutSessionManager.kt @@ -911,6 +911,8 @@ class DefaultWorkoutSessionManager( if (!autoplay) { Logger.d { "proceedFromSummary: Autoplay OFF - going to SetReady for next step" } val (nextExIdx, nextSetIdx) = nextStep + val isSameRoutineExercise = + nextExIdx == coordinator._currentExerciseIndex.value // Advance to next step coordinator._currentExerciseIndex.value = nextExIdx @@ -921,8 +923,18 @@ class DefaultWorkoutSessionManager( // Get next exercise and update parameters val nextExercise = routine.exercises[nextExIdx] - val nextSetWeight = nextExercise.setWeightsPerCableKg.getOrNull(nextSetIdx) - ?: nextExercise.weightPerCableKg + val pendingDropWeight = coordinator.dropSetNextWeightKg + val nextSetWeight = if (pendingDropWeight != null && isSameRoutineExercise) { + coordinator.dropSetNextWeightKg = null + pendingDropWeight + } else if (pendingDropWeight != null) { + coordinator.dropSetNextWeightKg = null + nextExercise.setWeightsPerCableKg.getOrNull(nextSetIdx) + ?: nextExercise.weightPerCableKg + } else { + nextExercise.setWeightsPerCableKg.getOrNull(nextSetIdx) + ?: nextExercise.weightPerCableKg + } val nextSetReps = nextExercise.setReps.getOrNull(nextSetIdx) val isNextSetLastSet = nextSetIdx >= nextExercise.setReps.size - 1 val nextIsAMRAP = nextSetReps == null || (nextExercise.isAMRAP && isNextSetLastSet) @@ -937,6 +949,8 @@ class DefaultWorkoutSessionManager( selectedExerciseId = nextExercise.exercise.id, isAMRAP = nextIsAMRAP, stallDetectionEnabled = nextExercise.stallDetectionEnabled, + dropSetEnabled = nextExercise.dropSetEnabled, + dropSetMinWeightKg = nextExercise.dropSetMinWeightKg, ) Logger.d { "proceedFromSummary: Issue #203 - Updated params for next set: ${nextExercise.exercise.name}, setIdx=$nextSetIdx, isAMRAP=$nextIsAMRAP" @@ -949,7 +963,12 @@ class DefaultWorkoutSessionManager( // Issue #656: clear workoutState so the ActiveWorkoutScreen nav gate // unmounts SetSummary and routes to SetReady. coordinator._workoutState.value = WorkoutState.Idle - enterSetReady(nextExIdx, nextSetIdx) + enterSetReadyWithAdjustments( + nextExIdx, + nextSetIdx, + nextSetWeight, + nextSetReps ?: 0, + ) return@launch } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt index ccf74b3f1..bfebf5537 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt @@ -106,6 +106,13 @@ class RoutineFlowManager( private fun clampUpcomingProgressionKg(valueKg: Float): Float = valueKg.coerceIn(-3f, 3f) + private fun progressionForSetReady(exercise: RoutineExercise, valueKg: Float): Float = + if (exercise.dropSetEnabled && exercise.programMode is ProgramMode.OldSchool && valueKg < 0f) { + valueKg + } else { + clampUpcomingProgressionKg(valueKg) + } + private fun shouldPreserveRestEditedProgression(): Boolean = coordinator._userAdjustedWeightDuringRest && (coordinator._workoutState.value is WorkoutState.Resting || @@ -799,6 +806,9 @@ class RoutineFlowManager( coordinator._completedExercises.value = emptySet() coordinator._completedRoutineSetKeys.value = emptySet() coordinator._weightAdjustmentRecommendation.value = null + // A pending drop belongs to the completed set of the prior routine. Never + // let it carry into the first working set of a newly loaded routine. + coordinator.dropSetNextWeightKg = null // Issue #222 diagnostic: Reset bodyweight counter for new routine coordinator.bodyweightSetsCompletedInRoutine = 0 @@ -847,6 +857,8 @@ class RoutineFlowManager( isAMRAP = firstIsAMRAP, // Issue #203: Check both per-set (null reps) and exercise-level flag selectedExerciseId = firstExercise.exercise.id, stallDetectionEnabled = firstExercise.stallDetectionEnabled, + dropSetEnabled = firstExercise.dropSetEnabled, + dropSetMinWeightKg = firstExercise.dropSetMinWeightKg, repCountTiming = firstExercise.repCountTiming, ) @@ -1049,9 +1061,9 @@ class RoutineFlowManager( val setReps = rawSetReps ?: exercise.reps val preserveRestEditedProgression = shouldPreserveRestEditedProgression() val progressionKg = if (preserveRestEditedProgression) { - clampUpcomingProgressionKg(coordinator._workoutParameters.value.progressionRegressionKg) + progressionForSetReady(exercise, coordinator._workoutParameters.value.progressionRegressionKg) } else { - clampUpcomingProgressionKg(exercise.progressionKg) + progressionForSetReady(exercise, exercise.progressionKg) } if (!preserveRestEditedProgression) { coordinator._userAdjustedWeightDuringRest = false @@ -1104,6 +1116,8 @@ class RoutineFlowManager( eccentricLoad = exercise.eccentricLoad, selectedExerciseId = exercise.exercise.id, stallDetectionEnabled = exercise.stallDetectionEnabled, + dropSetEnabled = exercise.dropSetEnabled, + dropSetMinWeightKg = exercise.dropSetMinWeightKg, repCountTiming = exercise.repCountTiming, stopAtTop = exercise.stopAtTop, isAMRAP = isSetAmrap, @@ -1130,9 +1144,9 @@ class RoutineFlowManager( applyDefaultRackSelectionForExercise(exercise) val preserveRestEditedProgression = shouldPreserveRestEditedProgression() val progressionKg = if (preserveRestEditedProgression) { - clampUpcomingProgressionKg(coordinator._workoutParameters.value.progressionRegressionKg) + progressionForSetReady(exercise, coordinator._workoutParameters.value.progressionRegressionKg) } else { - clampUpcomingProgressionKg(exercise.progressionKg) + progressionForSetReady(exercise, exercise.progressionKg) } if (!preserveRestEditedProgression) { coordinator._userAdjustedWeightDuringRest = false @@ -1179,6 +1193,8 @@ class RoutineFlowManager( eccentricLoad = exercise.eccentricLoad, selectedExerciseId = exercise.exercise.id, stallDetectionEnabled = exercise.stallDetectionEnabled, + dropSetEnabled = exercise.dropSetEnabled, + dropSetMinWeightKg = exercise.dropSetMinWeightKg, repCountTiming = exercise.repCountTiming, stopAtTop = exercise.stopAtTop, isAMRAP = isSetAmrap, @@ -1424,6 +1440,8 @@ class RoutineFlowManager( warmupReps = 3, selectedExerciseId = exercise.exercise.id, stallDetectionEnabled = exercise.stallDetectionEnabled, + dropSetEnabled = exercise.dropSetEnabled, + dropSetMinWeightKg = exercise.dropSetMinWeightKg, repCountTiming = exercise.repCountTiming, stopAtTop = exercise.stopAtTop, ) @@ -1498,6 +1516,9 @@ class RoutineFlowManager( coordinator.restTimerJob?.cancel() coordinator.bodyweightTimerJob?.cancel() coordinator._timedExerciseRemainingSeconds.value = null + // Manual navigation abandons the pending transition; never carry a failed + // exercise's queued drop into the newly selected exercise. + coordinator.dropSetNextWeightKg = null resetAutoStopState() // Issue #172: Async navigation with proper BLE cleanup diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt index e1e8a0043..ad146f164 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt @@ -430,6 +430,13 @@ class WorkoutCoordinator( // handles must cancel it). @Volatile internal var stallArmedByDeload = false + internal var dropSetDropCount: Int = 0 + internal var dropSetOriginalWeightKg: Float = 0f + /** Pending drop-set weight for the next set boundary. Set when a DELOAD_OCCURRED + * fires during a drop-set active set; consumed at the next set start. Unlike + * _userAdjustedWeightDuringRest this is weight-only and scoped to same-exercise + * transitions so it does not bleed into unrelated exercises or override reps/echo/etc. */ + internal var dropSetNextWeightKg: Float? = null // Issue #649: defer position/stall auto-stop until the verbal-cue + short // transition window elapses, or a completed working rep clears it. The @@ -458,6 +465,8 @@ class WorkoutCoordinator( stallStartTime = null isCurrentlyStalled = false stallArmedByDeload = false + dropSetDropCount = 0 + dropSetOriginalWeightKg = 0f deferAutoStopDeadlineMs = 0L _autoStopState.value = AutoStopUiState() } 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 d2c70038d..5add7f6b9 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 @@ -1,5 +1,7 @@ package com.devil.phoenixproject.presentation.screen +import com.devil.phoenixproject.util.Constants + import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction @@ -159,6 +161,8 @@ fun ExerciseEditBottomSheet( val eccentricLoad by viewModel.eccentricLoad.collectAsState() val echoLevel by viewModel.echoLevel.collectAsState() val stallDetectionEnabled by viewModel.stallDetectionEnabled.collectAsState() + val dropSetEnabled by viewModel.dropSetEnabled.collectAsState() + val dropSetMinWeight by viewModel.dropSetMinWeight.collectAsState() val repCountTiming by viewModel.repCountTiming.collectAsState() val stopAtTop by viewModel.stopAtTop.collectAsState() val defaultRackItemIds by viewModel.defaultRackItemIds.collectAsState() @@ -577,9 +581,83 @@ fun ExerciseEditBottomSheet( } } } + // Drop-Set Mode (Issue #673) + if (showCableOnlyExerciseControls && !isEchoMode && selectedMode is WorkoutMode.OldSchool) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + border = BorderStroke(2.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.medium), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Drop-Set Mode", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + "Weight decreases only when a rep is failed (cable release)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Auto-disable drop-set when Weight Change Per Rep becomes nonnegative + LaunchedEffect(weightChange) { + if (weightChange >= 0 && dropSetEnabled) { + viewModel.onDropSetEnabledChange(false) + } + } + Switch( + checked = dropSetEnabled, + onCheckedChange = viewModel::onDropSetEnabledChange, + enabled = weightChange < 0, + ) + } + if (weightChange >= 0) { + Text( + "Set a negative Weight Change Per Rep to enable drop-set mode.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = Spacing.small), + ) + } + if (dropSetEnabled) { + Spacer(modifier = Modifier.height(Spacing.medium)) + Text( + "Minimum Weight", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + "Weight won't drop below this value during drop-sets", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + ExpressiveSlider( + value = dropSetMinWeight.toFloat(), + onValueChange = { viewModel.onDropSetMinWeightChange(it.roundToInt()) }, + valueRange = kgToDisplay(Constants.DEFAULT_WEIGHT_INCREMENT_KG, weightUnit)..maxWeight, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } if (showCableOnlyExerciseControls) { // Stall Detection toggle + val stallDisabledByDropSet = dropSetEnabled Surface( modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, @@ -596,13 +674,13 @@ fun ExerciseEditBottomSheet( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = "Stall Detection", + text = if (stallDisabledByDropSet) "Stall Detection (disabled by Drop-Set)" else "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", + text = if (stallDisabledByDropSet) "Stall detection is disabled during drop-set mode" else "Auto-stop set when movement pauses for 5 seconds", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -610,6 +688,7 @@ fun ExerciseEditBottomSheet( Switch( checked = stallDetectionEnabled, onCheckedChange = viewModel::onStallDetectionEnabledChange, + enabled = !stallDisabledByDropSet, ) } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SingleExerciseScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SingleExerciseScreen.kt index 2ec11ee67..cf2733feb 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SingleExerciseScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SingleExerciseScreen.kt @@ -440,6 +440,8 @@ private fun buildSingleExerciseRoutineExercise( isAMRAP = savedDefaults.isAMRAP, perSetRestTime = savedDefaults.perSetRestTime, defaultRackItemIds = savedDefaults.defaultRackItemIds, + dropSetEnabled = savedDefaults.dropSetEnabled, + dropSetMinWeightKg = savedDefaults.dropSetMinWeightKg, ) } else { RoutineExercise( 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 c23aaa980..5a1f16ae7 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 @@ -23,6 +23,7 @@ import com.devil.phoenixproject.domain.model.generateUUID import com.devil.phoenixproject.domain.model.toWorkoutMode import com.devil.phoenixproject.domain.usecase.ResolveRoutineScalingBaselineUseCase import com.devil.phoenixproject.domain.usecase.RoutineScalingBaseline +import com.devil.phoenixproject.util.Constants import com.devil.phoenixproject.util.KmpUtils import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableStateFlow @@ -129,6 +130,10 @@ class ExerciseConfigViewModel constructor( private val _stallDetectionEnabled = MutableStateFlow(true) val stallDetectionEnabled: StateFlow = _stallDetectionEnabled.asStateFlow() + private val _dropSetEnabled = MutableStateFlow(false) + val dropSetEnabled: StateFlow = _dropSetEnabled.asStateFlow() + private val _dropSetMinWeight = MutableStateFlow(0) + val dropSetMinWeight: StateFlow = _dropSetMinWeight.asStateFlow() private val _repCountTiming = MutableStateFlow(RepCountTiming.TOP) val repCountTiming: StateFlow = _repCountTiming.asStateFlow() @@ -247,7 +252,8 @@ class ExerciseConfigViewModel constructor( _sets.value = initialSets - _selectedMode.value = exercise.programMode.toWorkoutMode(exercise.echoLevel) + val loadedMode = exercise.programMode.toWorkoutMode(exercise.echoLevel) + _selectedMode.value = loadedMode _weightChange.value = kgToDisplay(exercise.progressionKg, weightUnit).toInt() logDebug("Issue #164: Loaded progressionKg=${exercise.progressionKg}kg → display=${_weightChange.value}") _rest.value = exercise.setRestSeconds.firstOrNull()?.coerceIn(0, 300) ?: 60 // Use first rest time or default @@ -255,6 +261,14 @@ class ExerciseConfigViewModel constructor( _eccentricLoad.value = exercise.eccentricLoad _echoLevel.value = exercise.echoLevel _stallDetectionEnabled.value = exercise.stallDetectionEnabled + // Sync/import paths can carry stale local-only fields across a mode change. + // Drop sets are valid only for Old School, so normalize the editor state + // before a subsequent save can persist that unsupported configuration. + _dropSetEnabled.value = exercise.dropSetEnabled && loadedMode is WorkoutMode.OldSchool + _dropSetMinWeight.value = maxOf( + kgToDisplay(exercise.dropSetMinWeightKg, weightUnit).toInt(), + kgToDisplay(Constants.DEFAULT_WEIGHT_INCREMENT_KG, weightUnit).toInt(), + ) _repCountTiming.value = exercise.repCountTiming _stopAtTop.value = exercise.stopAtTop @@ -297,6 +311,12 @@ class ExerciseConfigViewModel constructor( fun onSelectedModeChange(mode: WorkoutMode) { _selectedMode.value = mode + // Clear drop-set when leaving Old School mode — the runtime path checks + // dropSetEnabled without requiring Old School, so a stale flag from a + // previous Old School session could react to deload events in unsupported modes. + if (mode !is WorkoutMode.OldSchool && _dropSetEnabled.value) { + onDropSetEnabledChange(false) + } // Load PR for the new mode if (::originalExercise.isInitialized) { originalExercise.exercise.id?.let { exerciseId -> @@ -484,6 +504,14 @@ class ExerciseConfigViewModel constructor( fun onStallDetectionEnabledChange(enabled: Boolean) { _stallDetectionEnabled.value = enabled } + fun onDropSetEnabledChange(enabled: Boolean) { + _dropSetEnabled.value = enabled && + _selectedMode.value is WorkoutMode.OldSchool && + _weightChange.value < 0 + } + fun onDropSetMinWeightChange(kg: Int) { + _dropSetMinWeight.value = kg + } fun onRepCountTimingChange(timing: RepCountTiming) { _repCountTiming.value = timing @@ -880,6 +908,8 @@ class ExerciseConfigViewModel constructor( perSetRestTime = _perSetRestTime.value, isAMRAP = isAMRAP, stallDetectionEnabled = _stallDetectionEnabled.value, + dropSetEnabled = _dropSetEnabled.value, + dropSetMinWeightKg = displayToKg(_dropSetMinWeight.value.toFloat(), weightUnit), repCountTiming = _repCountTiming.value, stopAtTop = _stopAtTop.value, // PR percentage scaling fields (Issue #57) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt index 973756357..e6442d6ad 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt @@ -164,6 +164,8 @@ data class RoutineExerciseBackup( val setWeightsPercentOfPR: String? = null, // JSON array as string // Per-exercise behavior overrides (PR #245) val stallDetectionEnabled: Boolean = true, + val dropSetEnabled: Boolean = false, + val dropSetMinWeightKg: Float = 0f, val stopAtTop: Boolean = false, val repCountTiming: String = "TOP", // Variable warm-up sets (Phase 35C) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt index 0e0496f3e..ea35347ea 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt @@ -254,7 +254,7 @@ object BlePacketFactory { putFloatLE( frame, BleConstants.ActivationPacket.OFFSET_PROGRESSION, - params.progressionRegressionKg, + if (params.dropSetEnabled && params.progressionRegressionKg < 0f) 0f else params.progressionRegressionKg, ) // Diagnostic logging diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt index b77bb1691..2199b90f2 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt @@ -715,6 +715,8 @@ abstract class BaseDataBackupManager( prTypeForScaling = exercise.prTypeForScaling, setWeightsPercentOfPR = exercise.setWeightsPercentOfPR, stallDetectionEnabled = if (exercise.stallDetectionEnabled) 1L else 0L, + dropSetEnabled = if (exercise.dropSetEnabled) 1L else 0L, + dropSetMinWeightKg = exercise.dropSetMinWeightKg.toDouble(), stopAtTop = if (exercise.stopAtTop) 1L else 0L, repCountTiming = exercise.repCountTiming, setEchoLevels = exercise.setEchoLevels, @@ -1536,6 +1538,8 @@ abstract class BaseDataBackupManager( prTypeForScaling = exercise.prTypeForScaling, setWeightsPercentOfPR = exercise.setWeightsPercentOfPR, stallDetectionEnabled = if (exercise.stallDetectionEnabled) 1L else 0L, + dropSetEnabled = if (exercise.dropSetEnabled) 1L else 0L, + dropSetMinWeightKg = exercise.dropSetMinWeightKg.toDouble(), stopAtTop = if (exercise.stopAtTop) 1L else 0L, repCountTiming = exercise.repCountTiming, setEchoLevels = exercise.setEchoLevels, @@ -2648,6 +2652,8 @@ abstract class BaseDataBackupManager( prTypeForScaling = exercise.prTypeForScaling, setWeightsPercentOfPR = exercise.setWeightsPercentOfPR, stallDetectionEnabled = exercise.stallDetectionEnabled != 0L, + dropSetEnabled = exercise.dropSetEnabled != 0L, + dropSetMinWeightKg = exercise.dropSetMinWeightKg.toFloat(), stopAtTop = exercise.stopAtTop != 0L, repCountTiming = exercise.repCountTiming, warmupSets = exercise.warmupSets, diff --git a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq index 9c4d689e4..2e37976b5 100644 --- a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq +++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq @@ -250,6 +250,8 @@ CREATE TABLE RoutineExercise ( scalingBasis TEXT, -- Per-exercise behavior overrides (PR #245) stallDetectionEnabled INTEGER NOT NULL DEFAULT 1, + dropSetEnabled INTEGER NOT NULL DEFAULT 0, + dropSetMinWeightKg REAL NOT NULL DEFAULT 0.0, stopAtTop INTEGER NOT NULL DEFAULT 0, repCountTiming TEXT NOT NULL DEFAULT 'TOP', -- Phase 4 Routine Programming (migration 18) @@ -1248,10 +1250,10 @@ INSERT INTO RoutineExercise ( eccentricLoad, echoLevel, progressionKg, restSeconds, duration, setRestSeconds, perSetRestTime, isAMRAP, supersetId, orderInSuperset, usePercentOfPR, weightPercentOfPR, prTypeForScaling, setWeightsPercentOfPR, - stallDetectionEnabled, stopAtTop, repCountTiming, setEchoLevels, warmupSets, defaultRackItemIds, + stallDetectionEnabled, dropSetEnabled, dropSetMinWeightKg, stopAtTop, repCountTiming, setEchoLevels, warmupSets, defaultRackItemIds, rackBehaviorOverrides, scalingBasis, isBodyweight ) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- Insert with IGNORE for import (won't overwrite existing) insertRoutineExerciseIgnore: @@ -1261,10 +1263,10 @@ INSERT OR IGNORE INTO RoutineExercise ( eccentricLoad, echoLevel, progressionKg, restSeconds, duration, setRestSeconds, perSetRestTime, isAMRAP, supersetId, orderInSuperset, usePercentOfPR, weightPercentOfPR, prTypeForScaling, setWeightsPercentOfPR, - stallDetectionEnabled, stopAtTop, repCountTiming, setEchoLevels, warmupSets, defaultRackItemIds, + stallDetectionEnabled, dropSetEnabled, dropSetMinWeightKg, stopAtTop, repCountTiming, setEchoLevels, warmupSets, defaultRackItemIds, rackBehaviorOverrides, scalingBasis, isBodyweight ) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); deleteRoutineExercises: DELETE FROM RoutineExercise WHERE routineId = ?; diff --git a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/43.sqm b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/43.sqm new file mode 100644 index 000000000..cc505c7cc --- /dev/null +++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/43.sqm @@ -0,0 +1,3 @@ +-- Issue #673: Drop-set option for Old School mode +ALTER TABLE RoutineExercise ADD COLUMN dropSetEnabled INTEGER NOT NULL DEFAULT 0; +ALTER TABLE RoutineExercise ADD COLUMN dropSetMinWeightKg REAL NOT NULL DEFAULT 0.0; diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ConflictResolutionTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ConflictResolutionTest.kt index a30506b32..00fdc6188 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ConflictResolutionTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/data/sync/ConflictResolutionTest.kt @@ -231,6 +231,8 @@ class ConflictResolutionTest { prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = 1L, + dropSetEnabled = 0L, + dropSetMinWeightKg = 0.0, stopAtTop = 0L, repCountTiming = "TOP", setEchoLevels = "", diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/RestTimerProgressionWiringTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/RestTimerProgressionWiringTest.kt index 85abccc78..f3d106661 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/RestTimerProgressionWiringTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/RestTimerProgressionWiringTest.kt @@ -87,9 +87,10 @@ class RestTimerProgressionWiringTest { "Single-exercise rest advance must preserve WorkoutParameters.progressionRegressionKg when the user edits Rest Timer config.", ) assertTrue( - src.contains("clampUpcomingProgressionKg(nextExercise.progressionKg)") && - src.contains("clampUpcomingProgressionKg(exerciseForNextSet.progressionKg)"), - "Rest Timer defaults must be clamped to the signed-off control range before display/advance.", + src.contains("private fun progressionForNextSet(exercise: RoutineExercise)") && + src.contains("progressionForNextSet(nextExercise)") && + src.contains("progressionForNextSet(exerciseForNextSet)"), + "Rest Timer defaults must retain a configured negative drop amount for Old School drop-sets while continuing to clamp ordinary progression values.", ) } diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMRoutineFlowTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMRoutineFlowTest.kt index a403e4a72..6294755ea 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMRoutineFlowTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMRoutineFlowTest.kt @@ -18,6 +18,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlinx.coroutines.test.advanceTimeBy @@ -727,6 +728,7 @@ class DWSMRoutineFlowTest { harness.dwsm.loadRoutine(routine) advanceUntilIdle() + harness.dwsm.coordinator.dropSetNextWeightKg = 17.5f harness.dwsm.jumpToExercise(2) advanceUntilIdle() @@ -736,6 +738,7 @@ class DWSMRoutineFlowTest { params.selectedExerciseId, "After jumpToExercise(2), selected exercise should be the third exercise", ) + assertNull(harness.dwsm.coordinator.dropSetNextWeightKg) // Stop the auto-started workout to clean up monitoring coroutines harness.dwsm.stopWorkout(exitingWorkout = true) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt index 503581ff9..05c1c905e 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt @@ -42,6 +42,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -462,6 +463,7 @@ class DWSMWorkoutLifecycleTest { currentSet = 1, totalSets = 2, ) + harness.dwsm.coordinator.dropSetNextWeightKg = 17.5f harness.dwsm.updateWorkoutParameters( harness.dwsm.coordinator.workoutParameters.value.copy( weightPerCableKg = 42f, @@ -475,6 +477,7 @@ class DWSMWorkoutLifecycleTest { val params = harness.dwsm.coordinator.workoutParameters.value assertEquals(42f, params.weightPerCableKg) assertEquals(11, params.reps) + assertNull(harness.dwsm.coordinator.dropSetNextWeightKg) harness.cleanup() } @@ -523,6 +526,45 @@ class DWSMWorkoutLifecycleTest { harness.cleanup() } + @Test + fun `summary advance discards pending drop weight when moving to a different exercise`() = runTest { + val harness = DWSMTestHarness(this) + val base = createTestRoutine(exerciseCount = 2, setsPerExercise = 1) + val routine = base.copy( + exercises = listOf( + base.exercises[0].copy( + setReps = listOf(8), + setWeightsPerCableKg = listOf(20f), + weightPerCableKg = 20f, + ), + base.exercises[1].copy( + setReps = listOf(10), + setWeightsPerCableKg = listOf(55f), + weightPerCableKg = 55f, + ), + ), + ) + routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } + harness.dwsm.loadRoutine(routine) + advanceUntilIdle() + + harness.dwsm.coordinator.dropSetNextWeightKg = 17.5f + harness.dwsm.coordinator._workoutState.value = WorkoutState.SetSummary( + metrics = emptyList(), + peakLoadKgPerCable = 20f, + avgLoadKgPerCable = 20f, + repCount = 8, + ) + harness.dwsm.proceedFromSummary() + advanceUntilIdle() + + val params = harness.dwsm.coordinator.workoutParameters.value + assertEquals(1, harness.dwsm.coordinator.currentExerciseIndex.value) + assertEquals(55f, params.weightPerCableKg) + assertNull(harness.dwsm.coordinator.dropSetNextWeightKg) + harness.cleanup() + } + @Test fun `rest timer catches up after background delay shorter than rest duration`() = runTest { val harness = DWSMTestHarness(this) @@ -542,6 +584,62 @@ class DWSMWorkoutLifecycleTest { harness.cleanup() } + @Test + fun `drop set weight survives rest preview and is consumed at next same entry set`() = runTest { + val harness = DWSMTestHarness(this) + val routine = createTestRoutine(exerciseCount = 1, setsPerExercise = 2) + routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } + harness.setActiveSummaryCountdownSeconds(0) + harness.dwsm.loadRoutine(routine) + advanceUntilIdle() + + harness.dwsm.coordinator.dropSetNextWeightKg = 17.5f + harness.activeSessionEngine.startRestTimer() + runCurrent() + + assertEquals( + 17.5f, + harness.dwsm.coordinator.workoutParameters.value.weightPerCableKg, + "Rest can preview the upcoming drop weight", + ) + assertEquals( + 17.5f, + harness.dwsm.coordinator.dropSetNextWeightKg, + "Previewing the rest screen must not consume the weight before the set boundary", + ) + + harness.dwsm.skipRest() + runCurrent() + + assertEquals(17.5f, harness.dwsm.coordinator.workoutParameters.value.weightPerCableKg) + assertNull(harness.dwsm.coordinator.dropSetNextWeightKg) + harness.cleanup() + } + + @Test + fun `drop set preserves configured regression amount during rest preview`() = runTest { + val harness = DWSMTestHarness(this) + val base = createTestRoutine(exerciseCount = 1, setsPerExercise = 2) + val routine = base.copy( + exercises = listOf( + base.exercises.first().copy( + progressionKg = -8f, + dropSetEnabled = true, + dropSetMinWeightKg = 10f, + ), + ), + ) + routine.exercises.forEach { harness.fakeExerciseRepo.addExercise(it.exercise) } + harness.dwsm.loadRoutine(routine) + advanceUntilIdle() + + harness.activeSessionEngine.startRestTimer() + runCurrent() + + assertEquals(-8f, harness.dwsm.coordinator.workoutParameters.value.progressionRegressionKg) + harness.cleanup() + } + @Test fun `rest timer clamps to zero after background delay longer than rest duration without autoplay`() = runTest { val harness = DWSMTestHarness(this) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt index 1a53c431f..e81ba030d 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinatorAutoStopResetTest.kt @@ -42,4 +42,18 @@ class WorkoutCoordinatorAutoStopResetTest { assertEquals(0L, coordinator.deferAutoStopDeadlineMs) assertEquals(AutoStopUiState(), coordinator._autoStopState.value) } + + @Test + fun `resetAutoStopState preserves pending drop set weight for the next set boundary`() { + val coordinator = WorkoutCoordinator() + coordinator.dropSetNextWeightKg = 17.5f + + coordinator.resetAutoStopState() + + assertEquals( + 17.5f, + coordinator.dropSetNextWeightKg, + "A set-boundary auto-stop reset must not discard the drop weight before it is consumed", + ) + } }