From cb93489f09feef26cdd9855bb87d7be5a725f6e3 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 24 Jul 2026 21:13:45 -0400 Subject: [PATCH 1/9] feat: add drop-set option for Old School mode (Issue #673) Implements drop-set mode for Old School workouts where weight decreases only when a rep is failed (cable release detected via DELOAD_OCCURRED). Changes: - Data model: add dropSetEnabled + dropSetMinWeightKg to RoutineExercise and WorkoutParameters - DB migration 43: additive ALTER TABLE for new columns - BLE: zero progressionRegressionKg when drop-set enabled (machine does per-rep adjustment, app controls drops) - RoutineFlowManager: propagate drop-set fields at all 4 WorkoutParameters construction sites - ActiveSessionEngine: drop-set deload handler reduces weight instead of arming stall timer; weight deferred to next set via pendingWeightChangeKg - WorkoutCoordinator: dropSetDropCount and dropSetOriginalWeightKg state - ViewModel: state flows, load/save mapping, event handlers - UI: Drop-Set Mode toggle + Minimum Weight slider in ExerciseEditBottomSheet, gated by Old School mode + negative Weight Change Per Rep - Stall detection disabled when drop-set active (mutual exclusivity) - Backup/restore: new fields with Kotlinx defaults for backward compat - Sync: portal pull defaults drop-set to disabled Cross-set drops only (no mid-set BLE). Old School mode only. Mutual exclusivity with deload-based stall detection (Approach A). Closes #673 --- .../repository/SqlDelightSyncRepository.kt | 4 + .../repository/SqlDelightWorkoutRepository.kt | 4 + .../phoenixproject/domain/model/Models.kt | 2 + .../phoenixproject/domain/model/Routine.kt | 2 + .../manager/ActiveSessionEngine.kt | 27 ++++++- .../manager/RoutineFlowManager.kt | 8 ++ .../manager/WorkoutCoordinator.kt | 4 + .../screen/ExerciseEditBottomSheet.kt | 75 ++++++++++++++++++- .../viewmodel/ExerciseConfigViewModel.kt | 14 ++++ .../devil/phoenixproject/util/BackupModels.kt | 2 + .../phoenixproject/util/BlePacketFactory.kt | 2 +- .../phoenixproject/util/DataBackupManager.kt | 6 ++ .../database/VitruvianDatabase.sq | 10 ++- .../phoenixproject/database/migrations/43.sqm | 3 + 14 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/43.sqm 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..1af7b80a0 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 @@ -1043,6 +1043,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 ( @@ -2282,6 +2284,8 @@ class SqlDelightSyncRepository( prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = if (exercise.stallDetection) 1L else 0L, + dropSetEnabled = 0L, + dropSetMinWeightKg = 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/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..ed97f68df 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,7 @@ class ActiveSessionEngine( val params = coordinator._workoutParameters.value val currentState = coordinator._workoutState.value - if (params.stallDetectionEnabled && currentState is WorkoutState.Active) { + if ((params.stallDetectionEnabled || params.dropSetEnabled) && 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 @@ -448,6 +448,11 @@ class ActiveSessionEngine( return@collect } if (!shouldEnableAutoStop(params)) return@collect + // Drop-set mode: reduce weight instead of arming stall timer + if (params.dropSetEnabled && params.progressionRegressionKg < 0f) { + handleDropSetDeload(params) + return@collect + } Logger.d("DELOAD_OCCURRED: Machine detected cable release - starting auto-stop timer") val hasMeaningfulRange = repCounter.hasMeaningfulRange(WorkoutCoordinator.MIN_RANGE_THRESHOLD) @@ -470,7 +475,25 @@ class ActiveSessionEngine( } } - // #6: Rep events collector for handling machine rep notifications + /** + * Drop-set mode: DELOAD_OCCURRED triggers weight reduction instead of stall timer. + * Weight is deferred to next set boundary (machine cannot accept mid-set BLE commands). + */ + private fun handleDropSetDeload(params: WorkoutParameters) { + val currentWeight = coordinator._workoutParameters.value.weightPerCableKg + val dropAmount = kotlin.math.abs(params.progressionRegressionKg) + val newWeight = (currentWeight - dropAmount).coerceAtLeast(params.dropSetMinWeightKg) + + Logger.d("Drop-set: DELOAD_OCCURRED -> weight ${currentWeight}kg -> ${newWeight}kg (drop=$dropAmount, floor=${params.dropSetMinWeightKg})") + + // Update internal state immediately (HUD shows reduced weight) + // adjustWeight during WorkoutState.Active sets pendingWeightChangeKg (deferred to next set start) + adjustWeight(newWeight, sendToMachine = false) + + coordinator.dropSetDropCount++ + } + + // #6: Rep events collector for handling machine rep notifications coordinator.repEventsCollectionJob = scope.launch { bleRepository.repEvents .catch { e -> Logger.e(e) { "repEvents collector error" } } 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..cc676ed79 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 @@ -847,6 +847,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, ) @@ -1104,6 +1106,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, @@ -1179,6 +1183,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 +1430,8 @@ class RoutineFlowManager( warmupReps = 3, selectedExerciseId = exercise.exercise.id, stallDetectionEnabled = exercise.stallDetectionEnabled, + dropSetEnabled = exercise.dropSetEnabled, + dropSetMinWeightKg = exercise.dropSetMinWeightKg, repCountTiming = exercise.repCountTiming, stopAtTop = exercise.stopAtTop, ) 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..fa667127c 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,8 @@ class WorkoutCoordinator( // handles must cancel it). @Volatile internal var stallArmedByDeload = false + internal var dropSetDropCount: Int = 0 + internal var dropSetOriginalWeightKg: Float = 0f // 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 +460,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..23f11e04b 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 @@ -159,6 +159,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 +579,77 @@ 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, + ) + } + 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 = 0f..maxWeightChange.toFloat(), + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } if (showCableOnlyExerciseControls) { // Stall Detection toggle + val stallDisabledByDropSet = dropSetEnabled Surface( modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, @@ -596,13 +666,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 +680,7 @@ fun ExerciseEditBottomSheet( Switch( checked = stallDetectionEnabled, onCheckedChange = viewModel::onStallDetectionEnabledChange, + enabled = !stallDisabledByDropSet, ) } } 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..33a9b99c4 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 @@ -129,6 +129,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() @@ -255,6 +259,8 @@ class ExerciseConfigViewModel constructor( _eccentricLoad.value = exercise.eccentricLoad _echoLevel.value = exercise.echoLevel _stallDetectionEnabled.value = exercise.stallDetectionEnabled + _dropSetEnabled.value = exercise.dropSetEnabled + _dropSetMinWeight.value = kgToDisplay(exercise.dropSetMinWeightKg, weightUnit).toInt() _repCountTiming.value = exercise.repCountTiming _stopAtTop.value = exercise.stopAtTop @@ -484,6 +490,12 @@ class ExerciseConfigViewModel constructor( fun onStallDetectionEnabledChange(enabled: Boolean) { _stallDetectionEnabled.value = enabled } + fun onDropSetEnabledChange(enabled: Boolean) { + _dropSetEnabled.value = enabled + } + fun onDropSetMinWeightChange(kg: Int) { + _dropSetMinWeight.value = kg + } fun onRepCountTimingChange(timing: RepCountTiming) { _repCountTiming.value = timing @@ -880,6 +892,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..7d1925576 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, 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, 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; From 026c7651628a884cafb88103c9f3d6ae0c4c820d Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 24 Jul 2026 21:42:48 -0400 Subject: [PATCH 2/9] fix: address CI failures and Codex review comments on PR #682 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SchemaManifest.kt: add dropSetEnabled/dropSetMinWeightKg columns to RoutineExercise CREATE TABLE and SchemaHealOperations (iOS Schema Sync) - DataBackupManager.kt: add .toDouble() for Float→Double conversion on dropSetMinWeightKg (Lint/Unit Tests) - ActiveSessionEngine.kt: preserve dropped weight across set boundary via _userAdjustedWeightDuringRest - ActiveSessionEngine.kt: refresh drop-set settings when advancing exercises (startRestTimer, startNextSetOrExercise, advanceToNextSetInSingleExercise) - ActiveSessionEngine.kt: add floor guard in handleDropSetDeload to deduplicate persistent deload events - ExerciseEditBottomSheet.kt: widen min-weight slider range to 0..110 kg - ExerciseEditBottomSheet.kt: auto-disable drop-set when Weight Change Per Rep becomes nonnegative --- .../data/local/SchemaManifest.kt | 5 +++++ .../manager/ActiveSessionEngine.kt | 22 +++++++++++++++++-- .../screen/ExerciseEditBottomSheet.kt | 8 ++++++- .../phoenixproject/util/DataBackupManager.kt | 4 ++-- 4 files changed, 34 insertions(+), 5 deletions(-) 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..4b422636b 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 @@ -1024,6 +1024,8 @@ internal val manifestTables: List = listOf( defaultRackItemIds TEXT NOT NULL DEFAULT '[]', rackBehaviorOverrides TEXT NOT NULL DEFAULT '{}', isBodyweight INTEGER, + dropSetEnabled INTEGER NOT NULL DEFAULT 0, + dropSetMinWeightKg REAL NOT NULL DEFAULT 0.0, FOREIGN KEY (routineId) REFERENCES Routine(id) ON DELETE CASCADE, FOREIGN KEY (exerciseId) REFERENCES Exercise(id) ON DELETE SET NULL, FOREIGN KEY (supersetId) REFERENCES Superset(id) ON DELETE SET NULL @@ -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/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index ed97f68df..3d6f11601 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 @@ -481,6 +481,11 @@ class ActiveSessionEngine( */ private fun handleDropSetDeload(params: WorkoutParameters) { val currentWeight = coordinator._workoutParameters.value.weightPerCableKg + // Guard: if already at the floor, don't drop again for the same deload event + if (currentWeight <= params.dropSetMinWeightKg) { + Logger.d("Drop-set: DELOAD_OCCURRED ignored - already at floor (${params.dropSetMinWeightKg}kg)") + return + } val dropAmount = kotlin.math.abs(params.progressionRegressionKg) val newWeight = (currentWeight - dropAmount).coerceAtLeast(params.dropSetMinWeightKg) @@ -490,6 +495,9 @@ class ActiveSessionEngine( // adjustWeight during WorkoutState.Active sets pendingWeightChangeKg (deferred to next set start) adjustWeight(newWeight, sendToMachine = false) + // Mark as user-adjusted so set-boundary advancement preserves the dropped weight + coordinator._userAdjustedWeightDuringRest = true + coordinator.dropSetDropCount++ } @@ -4451,8 +4459,12 @@ class ActiveSessionEngine( val hasNextSet = nextSetIdx < exerciseForNextSet.setReps.size if (hasNextSet) { val nextSetReps = exerciseForNextSet.setReps.getOrNull(nextSetIdx) - val nextSetWeight = exerciseForNextSet.setWeightsPerCableKg.getOrNull(nextSetIdx) - ?: exerciseForNextSet.weightPerCableKg + val nextSetWeight = 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) @@ -4466,6 +4478,8 @@ class ActiveSessionEngine( 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" } @@ -4708,6 +4722,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" } @@ -4872,6 +4888,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/screen/ExerciseEditBottomSheet.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt index 23f11e04b..286ef6dc6 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 @@ -609,6 +609,12 @@ fun ExerciseEditBottomSheet( 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, @@ -639,7 +645,7 @@ fun ExerciseEditBottomSheet( ExpressiveSlider( value = dropSetMinWeight.toFloat(), onValueChange = { viewModel.onDropSetMinWeightChange(it.roundToInt()) }, - valueRange = 0f..maxWeightChange.toFloat(), + valueRange = 0f..110f, modifier = Modifier.fillMaxWidth(), ) } 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 7d1925576..2199b90f2 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt @@ -716,7 +716,7 @@ abstract class BaseDataBackupManager( setWeightsPercentOfPR = exercise.setWeightsPercentOfPR, stallDetectionEnabled = if (exercise.stallDetectionEnabled) 1L else 0L, dropSetEnabled = if (exercise.dropSetEnabled) 1L else 0L, - dropSetMinWeightKg = exercise.dropSetMinWeightKg, + dropSetMinWeightKg = exercise.dropSetMinWeightKg.toDouble(), stopAtTop = if (exercise.stopAtTop) 1L else 0L, repCountTiming = exercise.repCountTiming, setEchoLevels = exercise.setEchoLevels, @@ -1539,7 +1539,7 @@ abstract class BaseDataBackupManager( setWeightsPercentOfPR = exercise.setWeightsPercentOfPR, stallDetectionEnabled = if (exercise.stallDetectionEnabled) 1L else 0L, dropSetEnabled = if (exercise.dropSetEnabled) 1L else 0L, - dropSetMinWeightKg = exercise.dropSetMinWeightKg, + dropSetMinWeightKg = exercise.dropSetMinWeightKg.toDouble(), stopAtTop = if (exercise.stopAtTop) 1L else 0L, repCountTiming = exercise.repCountTiming, setEchoLevels = exercise.setEchoLevels, From bb8f2661564a2bce48da00d745c2fa03456f9d69 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 24 Jul 2026 22:00:37 -0400 Subject: [PATCH 3/9] fix: correct SchemaManifest column order and inline handleDropSetDeload - SchemaManifest: move dropSetEnabled/dropSetMinWeightKg after stallDetectionEnabled to match .sq column order (iOS Schema Sync Check) - ActiveSessionEngine: inline drop-set deload handler at call site to fix 'Unresolved reference' and 'private not applicable to local function' errors --- .../data/local/SchemaManifest.kt | 4 +- .../manager/ActiveSessionEngine.kt | 38 ++++++------------- 2 files changed, 13 insertions(+), 29 deletions(-) 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 4b422636b..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 '', @@ -1024,8 +1026,6 @@ internal val manifestTables: List = listOf( defaultRackItemIds TEXT NOT NULL DEFAULT '[]', rackBehaviorOverrides TEXT NOT NULL DEFAULT '{}', isBodyweight INTEGER, - dropSetEnabled INTEGER NOT NULL DEFAULT 0, - dropSetMinWeightKg REAL NOT NULL DEFAULT 0.0, FOREIGN KEY (routineId) REFERENCES Routine(id) ON DELETE CASCADE, FOREIGN KEY (exerciseId) REFERENCES Exercise(id) ON DELETE SET NULL, FOREIGN KEY (supersetId) REFERENCES Superset(id) ON DELETE SET NULL 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 3d6f11601..25bec1eee 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 @@ -450,7 +450,17 @@ class ActiveSessionEngine( if (!shouldEnableAutoStop(params)) return@collect // Drop-set mode: reduce weight instead of arming stall timer if (params.dropSetEnabled && params.progressionRegressionKg < 0f) { - handleDropSetDeload(params) + val currentDropWeight = coordinator._workoutParameters.value.weightPerCableKg + if (currentDropWeight > params.dropSetMinWeightKg) { + val dropAmount = kotlin.math.abs(params.progressionRegressionKg) + val newDropWeight = (currentDropWeight - dropAmount).coerceAtLeast(params.dropSetMinWeightKg) + Logger.d("Drop-set: DELOAD_OCCURRED -> weight ${currentDropWeight}kg -> ${newDropWeight}kg (drop=$dropAmount, floor=${params.dropSetMinWeightKg})") + adjustWeight(newDropWeight, sendToMachine = false) + coordinator._userAdjustedWeightDuringRest = true + coordinator.dropSetDropCount++ + } else { + Logger.d("Drop-set: DELOAD_OCCURRED ignored - already at floor (${params.dropSetMinWeightKg}kg)") + } return@collect } Logger.d("DELOAD_OCCURRED: Machine detected cable release - starting auto-stop timer") @@ -475,32 +485,6 @@ class ActiveSessionEngine( } } - /** - * Drop-set mode: DELOAD_OCCURRED triggers weight reduction instead of stall timer. - * Weight is deferred to next set boundary (machine cannot accept mid-set BLE commands). - */ - private fun handleDropSetDeload(params: WorkoutParameters) { - val currentWeight = coordinator._workoutParameters.value.weightPerCableKg - // Guard: if already at the floor, don't drop again for the same deload event - if (currentWeight <= params.dropSetMinWeightKg) { - Logger.d("Drop-set: DELOAD_OCCURRED ignored - already at floor (${params.dropSetMinWeightKg}kg)") - return - } - val dropAmount = kotlin.math.abs(params.progressionRegressionKg) - val newWeight = (currentWeight - dropAmount).coerceAtLeast(params.dropSetMinWeightKg) - - Logger.d("Drop-set: DELOAD_OCCURRED -> weight ${currentWeight}kg -> ${newWeight}kg (drop=$dropAmount, floor=${params.dropSetMinWeightKg})") - - // Update internal state immediately (HUD shows reduced weight) - // adjustWeight during WorkoutState.Active sets pendingWeightChangeKg (deferred to next set start) - adjustWeight(newWeight, sendToMachine = false) - - // Mark as user-adjusted so set-boundary advancement preserves the dropped weight - coordinator._userAdjustedWeightDuringRest = true - - coordinator.dropSetDropCount++ - } - // #6: Rep events collector for handling machine rep notifications coordinator.repEventsCollectionJob = scope.launch { bleRepository.repEvents From 228389800dfcaec8a3e72ad49b71b8ed8b277319 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 24 Jul 2026 22:09:46 -0400 Subject: [PATCH 4/9] fix: add dropSetEnabled/dropSetMinWeightKg to SQLDelight test fixtures 5 test files constructing RoutineExercise SQLDelight rows need the new migration-43 columns (dropSetEnabled=0, dropSetMinWeightKg=0.0). --- .../data/migration/MigrationManagerTest.kt | 6 ++++++ .../SqlDelightCompletedSetRepositoryTest.kt | 2 ++ .../repository/SqlDelightSyncRepositoryTest.kt | 14 ++++++++++++++ .../SqlDelightTrainingCycleRepositoryTest.kt | 2 ++ .../data/sync/ConflictResolutionTest.kt | 2 ++ 5 files changed, 26 insertions(+) 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..0a76e5f1d 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 @@ -457,6 +457,8 @@ class MigrationManagerTest { one_rep_max_kg = null, mvtOverrideMs = null, isBodyweight = null, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, ) queries.insertRecord( exerciseId = "deadlift", @@ -932,6 +934,8 @@ class MigrationManagerTest { one_rep_max_kg = oneRepMaxKg, mvtOverrideMs = null, isBodyweight = null, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, ) } @@ -973,6 +977,8 @@ class MigrationManagerTest { rackBehaviorOverrides = "{}", scalingBasis = null, isBodyweight = null, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, ) } 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..ea1c41599 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 @@ -153,6 +153,8 @@ class SqlDelightCompletedSetRepositoryTest { rackBehaviorOverrides = "{}", scalingBasis = null, isBodyweight = null, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, ) } 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..9c0210262 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 @@ -515,6 +515,8 @@ class SqlDelightSyncRepositoryTest { null, null, isBodyweight = null, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, ) // Strategy 0: resolves by catalog id (unambiguous), ignoring name @@ -744,6 +746,8 @@ class SqlDelightSyncRepositoryTest { rackBehaviorOverrides = "{}", scalingBasis = null, isBodyweight = null, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, ) repository.mergePortalRoutines( @@ -826,6 +830,8 @@ class SqlDelightSyncRepositoryTest { rackBehaviorOverrides = "{}", scalingBasis = "ESTIMATED_1RM", isBodyweight = null, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, ) repository.mergePortalRoutines( @@ -861,6 +867,8 @@ class SqlDelightSyncRepositoryTest { @Test fun `mergePortalRoutines stores isBodyweight flag without corrupting equipment`() = runTest { + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, // #635 regression: the pull path used to write a "Bodyweight" sentinel string // into exerciseEquipment, which permanently poisoned classification because // snapshot Exercises re-derived isBodyweight from the equipment string. @@ -891,6 +899,8 @@ class SqlDelightSyncRepositoryTest { one_rep_max_kg = null, mvtOverrideMs = null, isBodyweight = 0, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, ) repository.mergePortalRoutines( routines = listOf( @@ -909,6 +919,8 @@ class SqlDelightSyncRepositoryTest { reps = 10, weight = 0f, isBodyweight = true, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, ), PullRoutineExerciseDto( id = "rex-635-cable", @@ -919,6 +931,8 @@ class SqlDelightSyncRepositoryTest { reps = 8, weight = 40f, isBodyweight = false, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, ), // Older Edge Function payloads omit the field entirely — the // stored flag must stay NULL (derive from catalog equipment), 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..f4c354551 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 @@ -281,6 +281,8 @@ class SqlDelightTrainingCycleRepositoryTest { rackBehaviorOverrides = "{}", scalingBasis = null, isBodyweight = null, + dropSetEnabled = 0, + dropSetMinWeightKg = 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..c2a2c9bdb 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 @@ -239,6 +239,8 @@ class ConflictResolutionTest { rackBehaviorOverrides = "{}", scalingBasis = null, isBodyweight = null, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, ) // Verify exercise exists From ee95dfa0e3a564f9ee6ddd64c5fd27bfcc53c363 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 24 Jul 2026 22:20:36 -0400 Subject: [PATCH 5/9] fix: add drop-set fields to RoutineExercise test constructors only Previous commit incorrectly added dropSetEnabled/dropSetMinWeightKg to Exercise table inserts. This adds them only to RoutineExercise SQLDelight constructor calls, after stallDetectionEnabled (matching column order). --- .../data/migration/MigrationManagerTest.kt | 8 ++------ .../SqlDelightCompletedSetRepositoryTest.kt | 4 ++-- .../repository/SqlDelightSyncRepositoryTest.kt | 18 ++++-------------- .../SqlDelightTrainingCycleRepositoryTest.kt | 4 ++-- .../data/sync/ConflictResolutionTest.kt | 4 ++-- 5 files changed, 12 insertions(+), 26 deletions(-) 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 0a76e5f1d..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 @@ -457,8 +457,6 @@ class MigrationManagerTest { one_rep_max_kg = null, mvtOverrideMs = null, isBodyweight = null, - dropSetEnabled = 0, - dropSetMinWeightKg = 0.0, ) queries.insertRecord( exerciseId = "deadlift", @@ -934,8 +932,6 @@ class MigrationManagerTest { one_rep_max_kg = oneRepMaxKg, mvtOverrideMs = null, isBodyweight = null, - dropSetEnabled = 0, - dropSetMinWeightKg = 0.0, ) } @@ -969,6 +965,8 @@ class MigrationManagerTest { prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = 1, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, stopAtTop = 0, repCountTiming = "TOP", setEchoLevels = "", @@ -977,8 +975,6 @@ class MigrationManagerTest { rackBehaviorOverrides = "{}", scalingBasis = null, isBodyweight = null, - dropSetEnabled = 0, - dropSetMinWeightKg = 0.0, ) } 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 ea1c41599..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 = "", @@ -153,8 +155,6 @@ class SqlDelightCompletedSetRepositoryTest { rackBehaviorOverrides = "{}", scalingBasis = null, isBodyweight = null, - dropSetEnabled = 0, - dropSetMinWeightKg = 0.0, ) } 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 9c0210262..92f68b5c5 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 @@ -515,8 +515,6 @@ class SqlDelightSyncRepositoryTest { null, null, isBodyweight = null, - dropSetEnabled = 0, - dropSetMinWeightKg = 0.0, ) // Strategy 0: resolves by catalog id (unambiguous), ignoring name @@ -738,6 +736,8 @@ class SqlDelightSyncRepositoryTest { prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = 1, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, stopAtTop = 0, repCountTiming = "TOP", setEchoLevels = "", @@ -746,8 +746,6 @@ class SqlDelightSyncRepositoryTest { rackBehaviorOverrides = "{}", scalingBasis = null, isBodyweight = null, - dropSetEnabled = 0, - dropSetMinWeightKg = 0.0, ) repository.mergePortalRoutines( @@ -822,6 +820,8 @@ class SqlDelightSyncRepositoryTest { prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = 1, + dropSetEnabled = 0, + dropSetMinWeightKg = 0.0, stopAtTop = 0, repCountTiming = "TOP", setEchoLevels = "", @@ -830,8 +830,6 @@ class SqlDelightSyncRepositoryTest { rackBehaviorOverrides = "{}", scalingBasis = "ESTIMATED_1RM", isBodyweight = null, - dropSetEnabled = 0, - dropSetMinWeightKg = 0.0, ) repository.mergePortalRoutines( @@ -867,8 +865,6 @@ class SqlDelightSyncRepositoryTest { @Test fun `mergePortalRoutines stores isBodyweight flag without corrupting equipment`() = runTest { - dropSetEnabled = 0, - dropSetMinWeightKg = 0.0, // #635 regression: the pull path used to write a "Bodyweight" sentinel string // into exerciseEquipment, which permanently poisoned classification because // snapshot Exercises re-derived isBodyweight from the equipment string. @@ -899,8 +895,6 @@ class SqlDelightSyncRepositoryTest { one_rep_max_kg = null, mvtOverrideMs = null, isBodyweight = 0, - dropSetEnabled = 0, - dropSetMinWeightKg = 0.0, ) repository.mergePortalRoutines( routines = listOf( @@ -919,8 +913,6 @@ class SqlDelightSyncRepositoryTest { reps = 10, weight = 0f, isBodyweight = true, - dropSetEnabled = 0, - dropSetMinWeightKg = 0.0, ), PullRoutineExerciseDto( id = "rex-635-cable", @@ -931,8 +923,6 @@ class SqlDelightSyncRepositoryTest { reps = 8, weight = 40f, isBodyweight = false, - dropSetEnabled = 0, - dropSetMinWeightKg = 0.0, ), // Older Edge Function payloads omit the field entirely — the // stored flag must stay NULL (derive from catalog equipment), 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 f4c354551..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 = "", @@ -281,8 +283,6 @@ class SqlDelightTrainingCycleRepositoryTest { rackBehaviorOverrides = "{}", scalingBasis = null, isBodyweight = null, - dropSetEnabled = 0, - dropSetMinWeightKg = 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 c2a2c9bdb..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 = "", @@ -239,8 +241,6 @@ class ConflictResolutionTest { rackBehaviorOverrides = "{}", scalingBasis = null, isBodyweight = null, - dropSetEnabled = 0, - dropSetMinWeightKg = 0.0, ) // Verify exercise exists From bc920c6bf6e087538d37fbbdc1d17d2266b7e778 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 24 Jul 2026 23:24:42 -0400 Subject: [PATCH 6/9] fix: address all Codex review comments and CI failure on PR #682 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SchemaParityTest: bump EXPECTED_SCHEMA_VERSION 43→44 (CI fix) - ActiveSessionEngine: move drop-set check BEFORE shouldEnableAutoStop gate so drop-set fires even when stallDetectionEnabled is false - ActiveSessionEngine: use dedicated dropSetNextWeightKg instead of adjustWeight during active set so saveWorkoutSession captures the original load (not the dropped next-set target) - ActiveSessionEngine: consume dropSetNextWeightKg only for same-exercise transitions so dropped weight does not bleed into unrelated exercises - DefaultWorkoutSessionManager: consume dropSetNextWeightKg in autoplay-off proceedFromSummary path; add dropSetEnabled/dropSetMinWeightKg to params - ExerciseConfigViewModel: clear drop-set when leaving Old School mode - ExerciseEditBottomSheet: use unit-aware maxWeight for min-weight slider - SqlDelightSyncRepository: preserve local drop-set fields during portal pulls (snapshot before delete, restore during merge) - SingleExerciseDefaults/Document: add dropSetEnabled + dropSetMinWeightKg - SingleExerciseScreen: restore drop-set fields from saved defaults - WorkoutCoordinator: add dropSetNextWeightKg pending field Addresses all 10 Codex review comments (5×P1, 2×P1, 3×P2). Closes #673 --- .../data/local/SchemaParityTest.kt | 2 +- .../data/preferences/PreferencesManager.kt | 2 + .../ProfileWorkoutDefaultsMapper.kt | 4 ++ .../repository/SqlDelightSyncRepository.kt | 18 ++++++- .../domain/model/ProfilePreferences.kt | 2 + .../manager/ActiveSessionEngine.kt | 47 ++++++++++++++++--- .../manager/DefaultWorkoutSessionManager.kt | 17 ++++++- .../manager/WorkoutCoordinator.kt | 6 +++ .../screen/ExerciseEditBottomSheet.kt | 2 +- .../screen/SingleExerciseScreen.kt | 2 + .../viewmodel/ExerciseConfigViewModel.kt | 6 +++ 11 files changed, 95 insertions(+), 13 deletions(-) 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/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 1af7b80a0..16f650ce2 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,10 @@ 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 } mergePortalExercisesForRoutine( routineId = portalRoutine.id, @@ -593,6 +597,8 @@ class SqlDelightSyncRepository( localRackDefaultsByExerciseId = localRackDefaultsByExerciseId, localRackOverridesByExerciseId = localRackOverridesByExerciseId, localScalingBasisByExerciseId = localScalingBasisByExerciseId, + localDropSetEnabledByExerciseId = localDropSetEnabledByExerciseId, + localDropSetMinWeightByExerciseId = localDropSetMinWeightByExerciseId, ) } else { Logger.w("SyncRepository") { @@ -1662,6 +1668,10 @@ 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 } mergePortalExercisesForRoutine( routineId = portalRoutine.id, @@ -1669,6 +1679,8 @@ class SqlDelightSyncRepository( localRackDefaultsByExerciseId = localRackDefaultsByExerciseId, localRackOverridesByExerciseId = localRackOverridesByExerciseId, localScalingBasisByExerciseId = localScalingBasisByExerciseId, + localDropSetEnabledByExerciseId = localDropSetEnabledByExerciseId2, + localDropSetMinWeightByExerciseId = localDropSetMinWeightByExerciseId2, ) } } @@ -2163,6 +2175,8 @@ class SqlDelightSyncRepository( localRackDefaultsByExerciseId: Map, localRackOverridesByExerciseId: Map, localScalingBasisByExerciseId: Map, + localDropSetEnabledByExerciseId: Map = emptyMap(), + localDropSetMinWeightByExerciseId: Map = emptyMap(), ) { queries.deleteRoutineExercises(routineId) queries.deleteSupersetsByRoutine(routineId) @@ -2284,8 +2298,8 @@ class SqlDelightSyncRepository( prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = if (exercise.stallDetection) 1L else 0L, - dropSetEnabled = 0L, - dropSetMinWeightKg = 0.0, + dropSetEnabled = localDropSetEnabledByExerciseId[catalogExercise?.id] ?: 0L, + dropSetMinWeightKg = localDropSetMinWeightByExerciseId[catalogExercise?.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/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/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index 25bec1eee..220ecbf6e 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 @@ -447,22 +447,27 @@ class ActiveSessionEngine( resetStallTimer() return@collect } - if (!shouldEnableAutoStop(params)) return@collect - // Drop-set mode: reduce weight instead of arming stall timer + // 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 (params.dropSetEnabled && params.progressionRegressionKg < 0f) { val currentDropWeight = coordinator._workoutParameters.value.weightPerCableKg if (currentDropWeight > params.dropSetMinWeightKg) { val dropAmount = kotlin.math.abs(params.progressionRegressionKg) val newDropWeight = (currentDropWeight - dropAmount).coerceAtLeast(params.dropSetMinWeightKg) Logger.d("Drop-set: DELOAD_OCCURRED -> weight ${currentDropWeight}kg -> ${newDropWeight}kg (drop=$dropAmount, floor=${params.dropSetMinWeightKg})") - adjustWeight(newDropWeight, sendToMachine = false) - coordinator._userAdjustedWeightDuringRest = true + // 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") val hasMeaningfulRange = repCounter.hasMeaningfulRange(WorkoutCoordinator.MIN_RANGE_THRESHOLD) @@ -1931,6 +1936,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}" } @@ -4443,7 +4450,19 @@ class ActiveSessionEngine( val hasNextSet = nextSetIdx < exerciseForNextSet.setReps.size if (hasNextSet) { val nextSetReps = exerciseForNextSet.setReps.getOrNull(nextSetIdx) - val nextSetWeight = if (coordinator._userAdjustedWeightDuringRest) { + // 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) { + coordinator.dropSetNextWeightKg = null + 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) @@ -4680,7 +4699,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) @@ -4808,7 +4831,17 @@ class ActiveSessionEngine( val currentParams = coordinator._workoutParameters.value val preserveRestEdits = coordinator._userAdjustedWeightDuringRest - val nextSetWeight = if (preserveRestEdits) { + // Consume pending drop-set weight for same-exercise transitions only. + val pendingDropWeight = coordinator.dropSetNextWeightKg + val nextSetWeight = if (pendingDropWeight != null && 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) 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..68c96706d 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 @@ -921,8 +921,19 @@ 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 isSameExercise = nextExIdx == coordinator._currentExerciseIndex.value + val nextSetWeight = if (pendingDropWeight != null && isSameExercise) { + 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 +948,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" 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 fa667127c..2faab62a6 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 @@ -432,6 +432,11 @@ class WorkoutCoordinator( 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 @@ -462,6 +467,7 @@ class WorkoutCoordinator( stallArmedByDeload = false dropSetDropCount = 0 dropSetOriginalWeightKg = 0f + dropSetNextWeightKg = null 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 286ef6dc6..f79e96a22 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 @@ -645,7 +645,7 @@ fun ExerciseEditBottomSheet( ExpressiveSlider( value = dropSetMinWeight.toFloat(), onValueChange = { viewModel.onDropSetMinWeightChange(it.roundToInt()) }, - valueRange = 0f..110f, + valueRange = 0f..maxWeight, modifier = Modifier.fillMaxWidth(), ) } 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 33a9b99c4..fac256afd 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 @@ -303,6 +303,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 -> From 178aa0e51dd7a6770e4563d94b8001050d74e9e5 Mon Sep 17 00:00:00 2001 From: phoenixworker Date: Sat, 25 Jul 2026 01:01:42 -0400 Subject: [PATCH 7/9] fix: preserve drop-set workflow state --- .../SqlDelightSyncRepositoryTest.kt | 8 +- .../repository/SqlDelightSyncRepository.kt | 4 +- .../manager/ActiveSessionEngine.kt | 33 ++++++++- .../manager/DefaultWorkoutSessionManager.kt | 12 ++- .../manager/RoutineFlowManager.kt | 3 + .../manager/WorkoutCoordinator.kt | 1 - .../manager/DWSMWorkoutLifecycleTest.kt | 74 +++++++++++++++++++ .../WorkoutCoordinatorAutoStopResetTest.kt | 14 ++++ 8 files changed, 136 insertions(+), 13 deletions(-) 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 92f68b5c5..eecdf3800 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 @@ -780,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", @@ -820,8 +820,8 @@ class SqlDelightSyncRepositoryTest { prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = 1, - dropSetEnabled = 0, - dropSetMinWeightKg = 0.0, + dropSetEnabled = 1, + dropSetMinWeightKg = 17.5, stopAtTop = 0, repCountTiming = "TOP", setEchoLevels = "", @@ -861,6 +861,8 @@ class SqlDelightSyncRepositoryTest { .executeAsList() .single() assertEquals("ESTIMATED_1RM", exercise.scalingBasis) + assertEquals(1L, exercise.dropSetEnabled) + assertEquals(17.5, exercise.dropSetMinWeightKg) } @Test 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 16f650ce2..d6f2056bb 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 @@ -2298,8 +2298,8 @@ class SqlDelightSyncRepository( prTypeForScaling = "MAX_WEIGHT", setWeightsPercentOfPR = null, stallDetectionEnabled = if (exercise.stallDetection) 1L else 0L, - dropSetEnabled = localDropSetEnabledByExerciseId[catalogExercise?.id] ?: 0L, - dropSetMinWeightKg = localDropSetMinWeightByExerciseId[catalogExercise?.id] ?: 0.0, + 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/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index 220ecbf6e..832bf2d20 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 @@ -1734,6 +1734,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") } @@ -2142,6 +2147,7 @@ class ActiveSessionEngine( coordinator._totalWarmupSets.value = 0 coordinator._selectedBodyweightVariants.value = emptyMap() coordinator.bodyweightCompletionVariantOverride = null + coordinator.dropSetNextWeightKg = null coordinator.clearActiveRackSelection() } @@ -2185,6 +2191,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 @@ -4455,7 +4468,6 @@ class ActiveSessionEngine( val isSameExercise = !isTransitioningToNextExercise val pendingDropWeight = coordinator.dropSetNextWeightKg val nextSetWeight = if (pendingDropWeight != null && isSameExercise) { - coordinator.dropSetNextWeightKg = null pendingDropWeight } else if (pendingDropWeight != null && !isSameExercise) { // Different exercise: discard the drop-set weight @@ -4757,7 +4769,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, + ) } } @@ -4823,6 +4844,7 @@ class ActiveSessionEngine( currentExercise != null && isAdjacentLinearExercise && flowDelegate?.isSameExercise(currentExercise, nextExercise) == true + val isSameRoutineExercise = !isChangingExercise coordinator._currentExerciseIndex.value = nextExIdx coordinator._currentSetIndex.value = nextSetIdx @@ -4831,9 +4853,12 @@ class ActiveSessionEngine( val currentParams = coordinator._workoutParameters.value val preserveRestEdits = coordinator._userAdjustedWeightDuringRest - // Consume pending drop-set weight for same-exercise transitions only. + // 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 && isSameExerciseContinuation) { + val nextSetWeight = if (pendingDropWeight != null && (isSameRoutineExercise || isSameExerciseContinuation)) { coordinator.dropSetNextWeightKg = null pendingDropWeight } else if (pendingDropWeight != null) { 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 68c96706d..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 @@ -922,8 +924,7 @@ class DefaultWorkoutSessionManager( // Get next exercise and update parameters val nextExercise = routine.exercises[nextExIdx] val pendingDropWeight = coordinator.dropSetNextWeightKg - val isSameExercise = nextExIdx == coordinator._currentExerciseIndex.value - val nextSetWeight = if (pendingDropWeight != null && isSameExercise) { + val nextSetWeight = if (pendingDropWeight != null && isSameRoutineExercise) { coordinator.dropSetNextWeightKg = null pendingDropWeight } else if (pendingDropWeight != null) { @@ -962,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 cc676ed79..0e9737caa 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 @@ -799,6 +799,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 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 2faab62a6..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 @@ -467,7 +467,6 @@ class WorkoutCoordinator( stallArmedByDeload = false dropSetDropCount = 0 dropSetOriginalWeightKg = 0f - dropSetNextWeightKg = null deferAutoStopDeadlineMs = 0L _autoStopState.value = AutoStopUiState() } 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..6edca7a64 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,38 @@ 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 `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", + ) + } } From 189d486e6265ee1799374865314b3c9b842967b0 Mon Sep 17 00:00:00 2001 From: phoenixworker Date: Sat, 25 Jul 2026 01:11:41 -0400 Subject: [PATCH 8/9] fix: harden drop-set mode transitions --- .../viewmodel/ExerciseConfigViewModelTest.kt | 34 +++++++++++++++++++ .../manager/ActiveSessionEngine.kt | 26 +++++++++++--- .../manager/RoutineFlowManager.kt | 15 +++++--- .../viewmodel/ExerciseConfigViewModel.kt | 12 +++++-- .../RestTimerProgressionWiringTest.kt | 7 ++-- .../manager/DWSMWorkoutLifecycleTest.kt | 24 +++++++++++++ 6 files changed, 103 insertions(+), 15 deletions(-) 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/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index 832bf2d20..761966ff6 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 || params.dropSetEnabled) && 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 @@ -450,7 +453,7 @@ class ActiveSessionEngine( // 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 (params.dropSetEnabled && params.progressionRegressionKg < 0f) { + if (dropSetIsActive) { val currentDropWeight = coordinator._workoutParameters.value.weightPerCableKg if (currentDropWeight > params.dropSetMinWeightKg) { val dropAmount = kotlin.math.abs(params.progressionRegressionKg) @@ -2167,6 +2170,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 @@ -4489,7 +4505,7 @@ 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, @@ -4729,7 +4745,7 @@ class ActiveSessionEngine( val setProgressionKg = if (coordinator._userAdjustedWeightDuringRest) { currentParams.progressionRegressionKg } else { - clampUpcomingProgressionKg(currentExercise.progressionKg) + progressionForNextSet(currentExercise) } coordinator._userAdjustedWeightDuringRest = false @@ -4890,7 +4906,7 @@ class ActiveSessionEngine( val nextProgressionKg = if (preserveRestEdits) { currentParams.progressionRegressionKg } else { - clampUpcomingProgressionKg(nextExercise.progressionKg) + progressionForNextSet(nextExercise) } val nextIsBodyweight = isBodyweightExercise(nextExercise) 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 0e9737caa..be7fa7997 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 || @@ -1054,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 @@ -1137,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 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 fac256afd..b5a73c0fb 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 @@ -251,7 +251,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 @@ -259,7 +260,10 @@ class ExerciseConfigViewModel constructor( _eccentricLoad.value = exercise.eccentricLoad _echoLevel.value = exercise.echoLevel _stallDetectionEnabled.value = exercise.stallDetectionEnabled - _dropSetEnabled.value = exercise.dropSetEnabled + // 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 = kgToDisplay(exercise.dropSetMinWeightKg, weightUnit).toInt() _repCountTiming.value = exercise.repCountTiming _stopAtTop.value = exercise.stopAtTop @@ -497,7 +501,9 @@ class ExerciseConfigViewModel constructor( _stallDetectionEnabled.value = enabled } fun onDropSetEnabledChange(enabled: Boolean) { - _dropSetEnabled.value = enabled + _dropSetEnabled.value = enabled && + _selectedMode.value is WorkoutMode.OldSchool && + _weightChange.value < 0 } fun onDropSetMinWeightChange(kg: Int) { _dropSetMinWeight.value = kg 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/DWSMWorkoutLifecycleTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/DWSMWorkoutLifecycleTest.kt index 6edca7a64..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 @@ -616,6 +616,30 @@ class DWSMWorkoutLifecycleTest { 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) From 4e606cbe0206bf9c3a97a48db39330999dd952b2 Mon Sep 17 00:00:00 2001 From: phoenixworker Date: Sat, 25 Jul 2026 01:26:12 -0400 Subject: [PATCH 9/9] fix: close remaining drop-set review gaps --- .../data/repository/SqlDelightSyncRepositoryTest.kt | 3 ++- .../data/repository/SqlDelightSyncRepository.kt | 11 ++++++++++- .../presentation/manager/ActiveSessionEngine.kt | 3 ++- .../presentation/manager/RoutineFlowManager.kt | 3 +++ .../presentation/screen/ExerciseEditBottomSheet.kt | 4 +++- .../presentation/viewmodel/ExerciseConfigViewModel.kt | 6 +++++- .../presentation/manager/DWSMRoutineFlowTest.kt | 3 +++ 7 files changed, 28 insertions(+), 5 deletions(-) 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 eecdf3800..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 @@ -807,7 +807,7 @@ class SqlDelightSyncRepositoryTest { mode = "OldSchool", eccentricLoad = 100, echoLevel = 1, - progressionKg = 0.0, + progressionKg = -8.0, restSeconds = 90, duration = null, setRestSeconds = "[]", @@ -863,6 +863,7 @@ class SqlDelightSyncRepositoryTest { 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/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightSyncRepository.kt index d6f2056bb..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 @@ -590,6 +590,9 @@ class SqlDelightSyncRepository( .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, @@ -599,6 +602,7 @@ class SqlDelightSyncRepository( localScalingBasisByExerciseId = localScalingBasisByExerciseId, localDropSetEnabledByExerciseId = localDropSetEnabledByExerciseId, localDropSetMinWeightByExerciseId = localDropSetMinWeightByExerciseId, + localDropSetProgressionByExerciseId = localDropSetProgressionByExerciseId, ) } else { Logger.w("SyncRepository") { @@ -1672,6 +1676,9 @@ class SqlDelightSyncRepository( .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, @@ -1681,6 +1688,7 @@ class SqlDelightSyncRepository( localScalingBasisByExerciseId = localScalingBasisByExerciseId, localDropSetEnabledByExerciseId = localDropSetEnabledByExerciseId2, localDropSetMinWeightByExerciseId = localDropSetMinWeightByExerciseId2, + localDropSetProgressionByExerciseId = localDropSetProgressionByExerciseId2, ) } } @@ -2177,6 +2185,7 @@ class SqlDelightSyncRepository( localScalingBasisByExerciseId: Map, localDropSetEnabledByExerciseId: Map = emptyMap(), localDropSetMinWeightByExerciseId: Map = emptyMap(), + localDropSetProgressionByExerciseId: Map = emptyMap(), ) { queries.deleteRoutineExercises(routineId) queries.deleteSupersetsByRoutine(routineId) @@ -2285,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, 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 761966ff6..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 @@ -457,7 +457,8 @@ class ActiveSessionEngine( val currentDropWeight = coordinator._workoutParameters.value.weightPerCableKg if (currentDropWeight > params.dropSetMinWeightKg) { val dropAmount = kotlin.math.abs(params.progressionRegressionKg) - val newDropWeight = (currentDropWeight - dropAmount).coerceAtLeast(params.dropSetMinWeightKg) + 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 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 be7fa7997..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 @@ -1516,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/screen/ExerciseEditBottomSheet.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt index f79e96a22..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 @@ -645,7 +647,7 @@ fun ExerciseEditBottomSheet( ExpressiveSlider( value = dropSetMinWeight.toFloat(), onValueChange = { viewModel.onDropSetMinWeightChange(it.roundToInt()) }, - valueRange = 0f..maxWeight, + valueRange = kgToDisplay(Constants.DEFAULT_WEIGHT_INCREMENT_KG, weightUnit)..maxWeight, modifier = Modifier.fillMaxWidth(), ) } 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 b5a73c0fb..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 @@ -264,7 +265,10 @@ class ExerciseConfigViewModel constructor( // 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 = kgToDisplay(exercise.dropSetMinWeightKg, weightUnit).toInt() + _dropSetMinWeight.value = maxOf( + kgToDisplay(exercise.dropSetMinWeightKg, weightUnit).toInt(), + kgToDisplay(Constants.DEFAULT_WEIGHT_INCREMENT_KG, weightUnit).toInt(), + ) _repCountTiming.value = exercise.repCountTiming _stopAtTop.value = exercise.stopAtTop 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)