diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml
index 9984892a6..9c9fabbdb 100644
--- a/shared/src/commonMain/composeResources/values-de/strings.xml
+++ b/shared/src/commonMain/composeResources/values-de/strings.xml
@@ -796,4 +796,6 @@
Aktiviere den Disco-Modus in den LED-Einstellungen, damit dein Trainer Party macht.
Party starten
Die Altersbestätigung wurde gespeichert, aber der vulgäre Modus konnte nicht aktiviert werden. Versuche es erneut.
+ Zuvor abgeschlossen
+ Zuvor abgeschlossene Übungen filtern
diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml
index 43f7aee41..6008cbde9 100644
--- a/shared/src/commonMain/composeResources/values-es/strings.xml
+++ b/shared/src/commonMain/composeResources/values-es/strings.xml
@@ -796,4 +796,6 @@
Activa el modo disco en las preferencias LED para que tu entrenador se una a la fiesta.
¡A bailar!
La confirmación de edad se guardó, pero no se pudo activar el modo vulgar. Inténtalo de nuevo.
+ Completado anteriormente
+ Filtrar ejercicios completados anteriormente
diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml
index 9063e7bd2..963ef1579 100644
--- a/shared/src/commonMain/composeResources/values-fr/strings.xml
+++ b/shared/src/commonMain/composeResources/values-fr/strings.xml
@@ -796,4 +796,6 @@
Activez le mode disco dans les préférences LED pour faire danser votre machine.
C\'est parti !
La confirmation de l\'âge a été enregistrée, mais le mode vulgaire n\'a pas pu être activé. Réessayez.
+ Déjà terminés
+ Filtrer les exercices déjà terminés
diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml
index 9534e81a6..dd0f3b1dc 100644
--- a/shared/src/commonMain/composeResources/values-it/strings.xml
+++ b/shared/src/commonMain/composeResources/values-it/strings.xml
@@ -74,4 +74,6 @@
Warm-up Set %1$d
Working Set
+ Completati in precedenza
+ Filtra gli esercizi completati in precedenza
diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml
index 3d71e4093..3f6e13bab 100644
--- a/shared/src/commonMain/composeResources/values-nl/strings.xml
+++ b/shared/src/commonMain/composeResources/values-nl/strings.xml
@@ -775,4 +775,6 @@
Schakel de discomodus in bij de LED-voorkeuren en laat je trainer feesten.
Tijd voor een feestje
De leeftijdsbevestiging is opgeslagen, maar de vulgaire modus kon niet worden ingeschakeld. Probeer het opnieuw.
+ Eerder voltooid
+ Eerder voltooide oefeningen filteren
diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index 2dd70e472..3c6c06b34 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -931,4 +931,6 @@
Turn on Disco Mode in LED preferences to make your trainer party.
Let\'s party
Age confirmation was saved, but Vulgar Mode could not be enabled. Try again.
+ Previously completed
+ Filter exercises previously completed
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ExercisePicker.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ExercisePicker.kt
index 334bf3102..8107678d8 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ExercisePicker.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/ExercisePicker.kt
@@ -47,7 +47,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
@@ -66,7 +65,9 @@ import com.devil.phoenixproject.data.repository.ExerciseVideoEntity
import com.devil.phoenixproject.domain.model.Exercise
import com.devil.phoenixproject.presentation.components.exercisepicker.ExerciseFilterShelf
import com.devil.phoenixproject.presentation.components.exercisepicker.ExerciseListEmptyState
+import com.devil.phoenixproject.presentation.components.exercisepicker.ExercisePickerFilterState
import com.devil.phoenixproject.presentation.components.exercisepicker.GroupedExerciseList
+import com.devil.phoenixproject.presentation.components.exercisepicker.filterExercisePickerCandidates
import com.devil.phoenixproject.presentation.util.isCompactAccessibilityLayout
import com.devil.phoenixproject.ui.theme.ThemeMode
import kotlinx.coroutines.launch
@@ -110,6 +111,9 @@ fun ExercisePickerDialog(
fullScreen: Boolean = false,
themeMode: ThemeMode = ThemeMode.DARK,
enableCustomExercises: Boolean = true,
+ enablePreviouslyCompletedFilter: Boolean = false,
+ completedExerciseIds: Set = emptySet(),
+ completedExerciseIdsLoading: Boolean = false,
) {
if (!showDialog) return
@@ -117,6 +121,10 @@ fun ExercisePickerDialog(
var searchQuery by remember { mutableStateOf("") }
var showFavoritesOnly by remember { mutableStateOf(false) }
var showCustomOnly by remember { mutableStateOf(false) }
+ var showPreviouslyCompletedOnly by remember { mutableStateOf(false) }
+ LaunchedEffect(showDialog) {
+ if (showDialog) showPreviouslyCompletedOnly = false
+ }
var selectedMuscles by remember { mutableStateOf(setOf()) }
var selectedEquipment by remember { mutableStateOf(setOf()) }
var showCreateDialog by remember { mutableStateOf(false) }
@@ -124,34 +132,48 @@ fun ExercisePickerDialog(
val customExercises by exerciseRepository.getCustomExercises().collectAsState(initial = emptyList())
- val allExercises by remember(searchQuery, showFavoritesOnly, showCustomOnly) {
+ val candidateExercises by remember(searchQuery) {
when {
- showCustomOnly -> exerciseRepository.getCustomExercises()
- showFavoritesOnly -> exerciseRepository.getFavorites()
searchQuery.isNotBlank() -> exerciseRepository.searchExercises(searchQuery)
else -> exerciseRepository.getAllExercises()
}
}.collectAsState(initial = emptyList())
- val exercises = remember(allExercises, selectedMuscles, selectedEquipment) {
- allExercises.filter { exercise ->
- val matchesMuscle = selectedMuscles.isEmpty() ||
- selectedMuscles.any { muscle ->
- exercise.muscleGroups.contains(muscle, ignoreCase = true)
- }
- val matchesEquipment = selectedEquipment.isEmpty() ||
- selectedEquipment.any { equipment ->
- val databaseValues = getEquipmentDatabaseValues(equipment)
- val equipmentList = exercise.equipment.uppercase().split(",").map { it.trim() }
- databaseValues.any { dbValue -> equipmentList.contains(dbValue.uppercase()) }
- }
- matchesMuscle && matchesEquipment
+ val isCompletedFilterLoading =
+ enablePreviouslyCompletedFilter && showPreviouslyCompletedOnly && completedExerciseIdsLoading
+ val exercises = remember(
+ candidateExercises,
+ showFavoritesOnly,
+ showCustomOnly,
+ selectedMuscles,
+ selectedEquipment,
+ showPreviouslyCompletedOnly,
+ completedExerciseIds,
+ isCompletedFilterLoading,
+ ) {
+ if (isCompletedFilterLoading) {
+ emptyList()
+ } else {
+ filterExercisePickerCandidates(
+ candidates = candidateExercises,
+ filters = ExercisePickerFilterState(
+ showFavoritesOnly = showFavoritesOnly,
+ showCustomOnly = showCustomOnly,
+ selectedMuscles = selectedMuscles,
+ selectedEquipment = selectedEquipment,
+ showPreviouslyCompletedOnly =
+ enablePreviouslyCompletedFilter && showPreviouslyCompletedOnly,
+ ),
+ completedExerciseIds = completedExerciseIds,
+ )
}
}
fun clearAllFilters() {
+ searchQuery = ""
showFavoritesOnly = false
showCustomOnly = false
+ showPreviouslyCompletedOnly = false
selectedMuscles = emptySet()
selectedEquipment = emptySet()
}
@@ -232,14 +254,13 @@ fun ExercisePickerDialog(
searchQuery = searchQuery,
onSearchQueryChange = { searchQuery = it },
showFavoritesOnly = showFavoritesOnly,
- onToggleFavorites = {
- showFavoritesOnly = !showFavoritesOnly
- if (showFavoritesOnly) showCustomOnly = false
- },
+ onToggleFavorites = { showFavoritesOnly = !showFavoritesOnly },
showCustomOnly = showCustomOnly,
- onToggleCustom = {
- showCustomOnly = !showCustomOnly
- if (showCustomOnly) showFavoritesOnly = false
+ onToggleCustom = { showCustomOnly = !showCustomOnly },
+ enablePreviouslyCompletedFilter = enablePreviouslyCompletedFilter,
+ showPreviouslyCompletedOnly = showPreviouslyCompletedOnly,
+ onTogglePreviouslyCompleted = {
+ showPreviouslyCompletedOnly = !showPreviouslyCompletedOnly
},
customExerciseCount = customExercises.size,
selectedMuscles = selectedMuscles,
@@ -275,6 +296,7 @@ fun ExercisePickerDialog(
enableCustomExercises = enableCustomExercises,
onCreateExercise = { showCreateDialog = true },
onEditExercise = { exercise -> exerciseToEdit = exercise },
+ isLoading = isCompletedFilterLoading,
fullScreen = true,
)
}
@@ -295,14 +317,13 @@ fun ExercisePickerDialog(
searchQuery = searchQuery,
onSearchQueryChange = { searchQuery = it },
showFavoritesOnly = showFavoritesOnly,
- onToggleFavorites = {
- showFavoritesOnly = !showFavoritesOnly
- if (showFavoritesOnly) showCustomOnly = false
- },
+ onToggleFavorites = { showFavoritesOnly = !showFavoritesOnly },
showCustomOnly = showCustomOnly,
- onToggleCustom = {
- showCustomOnly = !showCustomOnly
- if (showCustomOnly) showFavoritesOnly = false
+ onToggleCustom = { showCustomOnly = !showCustomOnly },
+ enablePreviouslyCompletedFilter = enablePreviouslyCompletedFilter,
+ showPreviouslyCompletedOnly = showPreviouslyCompletedOnly,
+ onTogglePreviouslyCompleted = {
+ showPreviouslyCompletedOnly = !showPreviouslyCompletedOnly
},
customExerciseCount = customExercises.size,
selectedMuscles = selectedMuscles,
@@ -338,6 +359,7 @@ fun ExercisePickerDialog(
enableCustomExercises = enableCustomExercises,
onCreateExercise = { showCreateDialog = true },
onEditExercise = { exercise -> exerciseToEdit = exercise },
+ isLoading = isCompletedFilterLoading,
fullScreen = false,
)
}
@@ -361,6 +383,9 @@ fun ExercisePickerContent(
onToggleFavorites: () -> Unit,
showCustomOnly: Boolean,
onToggleCustom: () -> Unit,
+ enablePreviouslyCompletedFilter: Boolean = false,
+ showPreviouslyCompletedOnly: Boolean = false,
+ onTogglePreviouslyCompleted: () -> Unit = {},
customExerciseCount: Int,
selectedMuscles: Set,
onToggleMuscle: (String) -> Unit,
@@ -389,6 +414,7 @@ fun ExercisePickerContent(
val hasActiveFilters = searchQuery.isNotBlank() ||
showFavoritesOnly ||
showCustomOnly ||
+ showPreviouslyCompletedOnly ||
selectedMuscles.isNotEmpty() ||
selectedEquipment.isNotEmpty()
@@ -466,6 +492,9 @@ fun ExercisePickerContent(
onToggleFavorites = onToggleFavorites,
showCustomOnly = showCustomOnly,
onToggleCustom = onToggleCustom,
+ enablePreviouslyCompletedFilter = enablePreviouslyCompletedFilter,
+ showPreviouslyCompletedOnly = showPreviouslyCompletedOnly,
+ onTogglePreviouslyCompleted = onTogglePreviouslyCompleted,
selectedMuscles = selectedMuscles,
onToggleMuscle = onToggleMuscle,
selectedEquipment = selectedEquipment,
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/exercisepicker/CompletedExerciseIds.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/exercisepicker/CompletedExerciseIds.kt
new file mode 100644
index 000000000..0d8efc48f
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/exercisepicker/CompletedExerciseIds.kt
@@ -0,0 +1,15 @@
+package com.devil.phoenixproject.presentation.components.exercisepicker
+
+import com.devil.phoenixproject.domain.model.WorkoutSession
+
+data class CompletedExerciseIdsState(
+ val profileId: String?,
+ val ids: Set = emptySet(),
+ val isLoading: Boolean,
+)
+
+internal fun completedExerciseIdsFromHistory(
+ sessions: List,
+): Set = sessions.mapNotNullTo(linkedSetOf()) { session ->
+ session.exerciseId?.trim()?.takeIf(String::isNotEmpty)
+}
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/exercisepicker/ExerciseFilterShelf.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/exercisepicker/ExerciseFilterShelf.kt
index 9609ee336..85a2ef1a9 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/exercisepicker/ExerciseFilterShelf.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/exercisepicker/ExerciseFilterShelf.kt
@@ -22,6 +22,8 @@ import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import vitruvianprojectphoenix.shared.generated.resources.*
@@ -37,6 +39,9 @@ fun ExerciseFilterShelf(
onToggleFavorites: () -> Unit,
showCustomOnly: Boolean,
onToggleCustom: () -> Unit,
+ enablePreviouslyCompletedFilter: Boolean = false,
+ showPreviouslyCompletedOnly: Boolean = false,
+ onTogglePreviouslyCompleted: () -> Unit = {},
selectedMuscles: Set,
onToggleMuscle: (String) -> Unit,
selectedEquipment: Set,
@@ -57,7 +62,9 @@ fun ExerciseFilterShelf(
"Bodyweight",
)
- val hasActiveFilters = showFavoritesOnly || showCustomOnly ||
+ val previouslyCompletedDescription =
+ stringResource(Res.string.cd_filter_previously_completed)
+ val hasActiveFilters = showFavoritesOnly || showCustomOnly || showPreviouslyCompletedOnly ||
selectedMuscles.isNotEmpty() || selectedEquipment.isNotEmpty()
LazyRow(
@@ -128,6 +135,23 @@ fun ExerciseFilterShelf(
)
}
+ if (enablePreviouslyCompletedFilter) {
+ item {
+ FilterChip(
+ selected = showPreviouslyCompletedOnly,
+ onClick = onTogglePreviouslyCompleted,
+ label = { Text(stringResource(Res.string.label_previously_completed)) },
+ modifier = Modifier.semantics {
+ contentDescription = previouslyCompletedDescription
+ },
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
+ selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ )
+ }
+ }
+
// Divider
item {
VerticalDivider(
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/exercisepicker/ExercisePickerFilters.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/exercisepicker/ExercisePickerFilters.kt
new file mode 100644
index 000000000..601460ab1
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/components/exercisepicker/ExercisePickerFilters.kt
@@ -0,0 +1,41 @@
+package com.devil.phoenixproject.presentation.components.exercisepicker
+
+import com.devil.phoenixproject.domain.model.Exercise
+import com.devil.phoenixproject.presentation.components.getEquipmentDatabaseValues
+
+internal data class ExercisePickerFilterState(
+ val showFavoritesOnly: Boolean = false,
+ val showCustomOnly: Boolean = false,
+ val selectedMuscles: Set = emptySet(),
+ val selectedEquipment: Set = emptySet(),
+ val showPreviouslyCompletedOnly: Boolean = false,
+)
+
+/**
+ * Applies every exercise-picker narrowing predicate to the repository-selected candidates.
+ * The source list controls search semantics and ordering; this helper only removes entries.
+ */
+internal fun filterExercisePickerCandidates(
+ candidates: List,
+ filters: ExercisePickerFilterState,
+ completedExerciseIds: Set = emptySet(),
+): List = candidates.filter { exercise ->
+ val matchesFavorites = !filters.showFavoritesOnly || exercise.isFavorite
+ val matchesCustom = !filters.showCustomOnly || exercise.isCustom
+ val matchesMuscle = filters.selectedMuscles.isEmpty() ||
+ filters.selectedMuscles.any { muscle ->
+ exercise.muscleGroups.contains(muscle, ignoreCase = true)
+ }
+ val matchesEquipment = filters.selectedEquipment.isEmpty() ||
+ filters.selectedEquipment.any { equipment ->
+ val databaseValues = getEquipmentDatabaseValues(equipment)
+ val exerciseEquipment = exercise.equipment.uppercase().split(",").map { it.trim() }
+ databaseValues.any { databaseValue ->
+ databaseValue.uppercase() in exerciseEquipment
+ }
+ }
+ val matchesPreviouslyCompleted = !filters.showPreviouslyCompletedOnly ||
+ (exercise.id?.trim()?.takeIf(String::isNotEmpty) in completedExerciseIds)
+
+ matchesFavorites && matchesCustom && matchesMuscle && matchesEquipment && matchesPreviouslyCompleted
+}
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt
index 24b3bb072..e680ef498 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ProfileScreen.kt
@@ -31,8 +31,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
-import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -48,10 +48,10 @@ import com.devil.phoenixproject.data.repository.ActiveProfileContext
import com.devil.phoenixproject.data.repository.ExerciseRepository
import com.devil.phoenixproject.data.repository.ProfileContextRecoveryException
import com.devil.phoenixproject.data.repository.UserProfile
-import com.devil.phoenixproject.presentation.components.ExercisePickerDialog
import com.devil.phoenixproject.presentation.components.AdultsOnlyConfirmDialog
import com.devil.phoenixproject.presentation.components.DiscoModeUnlockDialog
import com.devil.phoenixproject.presentation.components.DominatrixUnlockDialog
+import com.devil.phoenixproject.presentation.components.ExercisePickerDialog
import com.devil.phoenixproject.presentation.components.LoadingIndicator
import com.devil.phoenixproject.presentation.components.LoadingIndicatorSize
import com.devil.phoenixproject.presentation.components.ProfileAvatar
@@ -144,8 +144,16 @@ fun ProfileScreen(
exerciseRepository: ExerciseRepository = koinInject(),
) {
val state by viewModel.uiState.collectAsState()
+ val completedExerciseIdsState by viewModel.completedExerciseIdsState.collectAsState()
val ready = state.context as? ActiveProfileContext.Ready
val readyProfileId = ready?.profile?.id
+ val pickerCompletedExerciseIds = if (completedExerciseIdsState.profileId == readyProfileId) {
+ completedExerciseIdsState.ids
+ } else {
+ emptySet()
+ }
+ val pickerCompletedExerciseIdsLoading =
+ completedExerciseIdsState.isLoading || completedExerciseIdsState.profileId != readyProfileId
var pickerProfileId by rememberSaveable { mutableStateOf(null) }
var editTargetProfileId by rememberSaveable { mutableStateOf(null) }
var deleteTargetProfileId by rememberSaveable { mutableStateOf(null) }
@@ -218,6 +226,7 @@ fun ProfileScreen(
if (editTargetProfileId == event.profileId) editTargetProfileId = null
if (pendingIdentityProfileId == event.profileId) pendingIdentityProfileId = null
}
+
is ProfileUiEvent.IdentityUpdateFailed -> {
val failure = applyProfileIdentityFailure(
ownership = ProfileIdentityOverlayOwnership(
@@ -233,10 +242,12 @@ fun ProfileScreen(
pendingIdentityProfileId = failure.ownership.pendingIdentityProfileId
if (failure.showError) snackbarHostState.showSnackbar(updateFailedMessage)
}
+
is ProfileUiEvent.ProfileDeleted -> {
if (deleteTargetProfileId == event.profileId) deleteTargetProfileId = null
if (pendingIdentityProfileId == event.profileId) pendingIdentityProfileId = null
}
+
is ProfileUiEvent.ProfileRecoveryRequired -> {
if (pendingIdentityProfileId == event.profileId) {
editTargetProfileId = null
@@ -245,6 +256,7 @@ fun ProfileScreen(
currentOnProfileRecoveryRequired(event.cause)
}
}
+
is ProfileUiEvent.PreferenceMutationSucceeded -> {
if (trackedPreferenceTokens.containsKey(event.token)) {
val ownedProfileId = trackedPreferenceTokens[event.token]
@@ -256,17 +268,20 @@ fun ProfileScreen(
) {
when (event.kind) {
ProfilePreferenceMutationKind.UPDATE -> Unit
+
ProfilePreferenceMutationKind.ADULT_ENABLE,
- ProfilePreferenceMutationKind.ADULT_DECLINE
+ ProfilePreferenceMutationKind.ADULT_DECLINE,
-> {
showAdultsOnlyDialog = false
adultTargetProfileId = null
adultPreferenceError = null
}
+
ProfilePreferenceMutationKind.DISCO_UNLOCK -> {
onPlayDiscoUnlockSound()
showDiscoUnlockDialog = true
}
+
ProfilePreferenceMutationKind.DOMINATRIX_UNLOCK -> {
onPlayDominatrixUnlockSound()
showDominatrixUnlockDialog = true
@@ -275,6 +290,7 @@ fun ProfileScreen(
}
}
}
+
is ProfileUiEvent.PreferenceUpdateFailed -> {
if (trackedPreferenceTokens.containsKey(event.token)) {
val ownedProfileId = trackedPreferenceTokens[event.token]
@@ -286,7 +302,7 @@ fun ProfileScreen(
) {
when (event.kind) {
ProfilePreferenceMutationKind.ADULT_ENABLE,
- ProfilePreferenceMutationKind.ADULT_DECLINE
+ ProfilePreferenceMutationKind.ADULT_DECLINE,
-> {
adultPreferenceError = if (
ProfilePreferenceSection.LOCAL_SAFETY in event.committedSections
@@ -296,9 +312,10 @@ fun ProfileScreen(
updateFailedMessage
}
}
+
ProfilePreferenceMutationKind.UPDATE,
ProfilePreferenceMutationKind.DISCO_UNLOCK,
- ProfilePreferenceMutationKind.DOMINATRIX_UNLOCK
+ ProfilePreferenceMutationKind.DOMINATRIX_UNLOCK,
-> snackbarHostState.showSnackbar(updateFailedMessage)
}
}
@@ -467,6 +484,9 @@ fun ProfileScreen(
enableVideoPlayback = enableVideoPlayback,
themeMode = themeMode,
enableCustomExercises = false,
+ enablePreviouslyCompletedFilter = true,
+ completedExerciseIds = pickerCompletedExerciseIds,
+ completedExerciseIdsLoading = pickerCompletedExerciseIdsLoading,
)
if (showAdultsOnlyDialog && adultTargetProfileId == ready?.profile?.id) {
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineEditorScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineEditorScreen.kt
index 2844c3eb7..d6e428c86 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineEditorScreen.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineEditorScreen.kt
@@ -138,6 +138,13 @@ fun RoutineEditorScreen(
// Issue #266/#410: Get user preferences for weight increment
val userPreferences by viewModel.userPreferences.collectAsState()
val rackItems by viewModel.rackItems.collectAsState()
+ val activeProfileId by viewModel.activeProfileId.collectAsState()
+ val completedExerciseIdsState by viewModel.completedExerciseIdsState.collectAsState()
+ val pickerCompletedExerciseIds = completedExerciseIdsState.ids.takeIf {
+ completedExerciseIdsState.profileId == activeProfileId
+ } ?: emptySet()
+ val pickerCompletedExerciseIdsLoading =
+ completedExerciseIdsState.isLoading || completedExerciseIdsState.profileId != activeProfileId
// 1. Initialize State
var state by remember { mutableStateOf(RoutineEditorState()) }
@@ -249,9 +256,9 @@ fun RoutineEditorScreen(
// always false in state; collapse UI uses state.collapsedSupersets instead.
val isDirty = hasSnapshot && (
state.routineName != snapshotName ||
- state.exercises != snapshotExercises ||
- state.supersets != snapshotSupersets
- )
+ state.exercises != snapshotExercises ||
+ state.supersets != snapshotSupersets
+ )
// Drag and Drop State
val lazyListState = rememberLazyListState()
@@ -848,6 +855,9 @@ fun RoutineEditorScreen(
},
exerciseRepository = exerciseRepository,
enableVideoPlayback = false,
+ enablePreviouslyCompletedFilter = true,
+ completedExerciseIds = pickerCompletedExerciseIds,
+ completedExerciseIdsLoading = pickerCompletedExerciseIdsLoading,
)
}
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 a4770ccb2..2ec11ee67 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
@@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
-import com.devil.phoenixproject.presentation.components.LoadingIndicator
-import com.devil.phoenixproject.presentation.components.LoadingIndicatorSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
@@ -37,6 +35,10 @@ import com.devil.phoenixproject.presentation.components.ConnectionErrorDialog
import com.devil.phoenixproject.presentation.components.CreateExerciseDialog
import com.devil.phoenixproject.presentation.components.CustomExerciseSaveAction
import com.devil.phoenixproject.presentation.components.ExercisePickerContent
+import com.devil.phoenixproject.presentation.components.LoadingIndicator
+import com.devil.phoenixproject.presentation.components.LoadingIndicatorSize
+import com.devil.phoenixproject.presentation.components.exercisepicker.ExercisePickerFilterState
+import com.devil.phoenixproject.presentation.components.exercisepicker.filterExercisePickerCandidates
import com.devil.phoenixproject.presentation.components.resolveCustomExerciseDeleteTarget
import com.devil.phoenixproject.presentation.components.resolveCustomExerciseSaveAction
import com.devil.phoenixproject.presentation.manager.DefaultWorkoutSessionManager
@@ -66,6 +68,11 @@ fun SingleExerciseScreen(
val enableVideoPlayback by viewModel.enableVideoPlayback.collectAsState()
val userPreferences by viewModel.userPreferences.collectAsState()
val rackItems by viewModel.rackItems.collectAsState()
+ val activeProfileId by viewModel.activeProfileId.collectAsState()
+ val completedExerciseIdsState by viewModel.completedExerciseIdsState.collectAsState()
+ val pickerCompletedExerciseIds = completedExerciseIdsState.ids.takeIf {
+ completedExerciseIdsState.profileId == activeProfileId
+ } ?: emptySet()
val connectionError by viewModel.connectionError.collectAsState()
@@ -86,53 +93,47 @@ fun SingleExerciseScreen(
var selectedEquipment by remember { mutableStateOf>(emptySet()) }
var showFavoritesOnly by remember { mutableStateOf(false) }
var showCustomOnly by remember { mutableStateOf(false) }
+ var showPreviouslyCompletedOnly by remember { mutableStateOf(false) }
var showCreateDialog by remember { mutableStateOf(false) }
var exerciseToEdit by remember { mutableStateOf(null) }
// Get exercises from repository
- val allExercises by remember(searchQuery, selectedMuscles, showFavoritesOnly, showCustomOnly) {
+ val candidateExercises by remember(searchQuery) {
when {
- showFavoritesOnly -> exerciseRepository.getFavorites()
-
- showCustomOnly -> exerciseRepository.getCustomExercises()
-
searchQuery.isNotBlank() -> exerciseRepository.searchExercises(searchQuery)
-
- selectedMuscles.isNotEmpty() -> {
- // Get exercises for all selected muscle groups and combine
- val flows = selectedMuscles.map { muscle ->
- exerciseRepository.filterByMuscleGroup(muscle)
- }
- // For now, just use the first one - ideally we'd combine all flows
- flows.firstOrNull() ?: exerciseRepository.getAllExercises()
- }
-
else -> exerciseRepository.getAllExercises()
}
}.collectAsState(initial = emptyList())
- // Apply equipment filter
- val exercises = remember(allExercises, selectedEquipment) {
- if (selectedEquipment.isNotEmpty()) {
- allExercises.filter { exercise ->
- selectedEquipment.any { selectedEq ->
- val databaseValues = when (selectedEq) {
- "Long Bar" -> listOf("BAR", "LONG_BAR", "BARBELL")
- "Short Bar" -> listOf("SHORT_BAR")
- "Ankle Strap" -> listOf("ANKLE_STRAP", "STRAPS")
- "Handles" -> listOf("HANDLES", "SINGLE_HANDLE", "BOTH_HANDLES")
- "Bench" -> listOf("BENCH")
- "Rope" -> listOf("ROPE")
- "Belt" -> listOf("BELT")
- "Bodyweight" -> listOf("BODYWEIGHT")
- else -> emptyList()
- }
- val equipmentList = exercise.equipment.uppercase().split(",").map { it.trim() }
- databaseValues.any { dbValue -> equipmentList.contains(dbValue.uppercase()) }
- }
- }
+ val isCompletedFilterLoading =
+ showPreviouslyCompletedOnly && (
+ completedExerciseIdsState.isLoading ||
+ completedExerciseIdsState.profileId != activeProfileId
+ )
+ val exercises = remember(
+ candidateExercises,
+ showFavoritesOnly,
+ showCustomOnly,
+ selectedMuscles,
+ selectedEquipment,
+ showPreviouslyCompletedOnly,
+ pickerCompletedExerciseIds,
+ isCompletedFilterLoading,
+ ) {
+ if (isCompletedFilterLoading) {
+ emptyList()
} else {
- allExercises
+ filterExercisePickerCandidates(
+ candidates = candidateExercises,
+ filters = ExercisePickerFilterState(
+ showFavoritesOnly = showFavoritesOnly,
+ showCustomOnly = showCustomOnly,
+ selectedMuscles = selectedMuscles,
+ selectedEquipment = selectedEquipment,
+ showPreviouslyCompletedOnly = showPreviouslyCompletedOnly,
+ ),
+ completedExerciseIds = pickerCompletedExerciseIds,
+ )
}
}
@@ -262,24 +263,13 @@ fun SingleExerciseScreen(
searchQuery = searchQuery,
onSearchQueryChange = { searchQuery = it },
showFavoritesOnly = showFavoritesOnly,
- onToggleFavorites = {
- showFavoritesOnly = !showFavoritesOnly
- if (showFavoritesOnly) {
- searchQuery = ""
- selectedMuscles = emptySet()
- selectedEquipment = emptySet()
- showCustomOnly = false
- }
- },
+ onToggleFavorites = { showFavoritesOnly = !showFavoritesOnly },
showCustomOnly = showCustomOnly,
- onToggleCustom = {
- showCustomOnly = !showCustomOnly
- if (showCustomOnly) {
- searchQuery = ""
- selectedMuscles = emptySet()
- selectedEquipment = emptySet()
- showFavoritesOnly = false
- }
+ onToggleCustom = { showCustomOnly = !showCustomOnly },
+ enablePreviouslyCompletedFilter = true,
+ showPreviouslyCompletedOnly = showPreviouslyCompletedOnly,
+ onTogglePreviouslyCompleted = {
+ showPreviouslyCompletedOnly = !showPreviouslyCompletedOnly
},
customExerciseCount = customCount,
selectedMuscles = selectedMuscles,
@@ -304,6 +294,7 @@ fun SingleExerciseScreen(
selectedEquipment = emptySet()
showFavoritesOnly = false
showCustomOnly = false
+ showPreviouslyCompletedOnly = false
},
onToggleFavorite = { exercise ->
exercise.id?.let { id ->
@@ -320,6 +311,7 @@ fun SingleExerciseScreen(
enableCustomExercises = true,
onCreateExercise = { showCreateDialog = true },
onEditExercise = { exercise -> exerciseToEdit = exercise },
+ isLoading = isCompletedFilterLoading,
onViewExerciseDetail = { exercise ->
exercise.id?.let { id ->
navController.navigate(NavigationRoutes.ExerciseDetail.createRoute(id))
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt
index 5fc91375b..3f3df877e 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/MainViewModel.kt
@@ -8,6 +8,7 @@ import com.devil.phoenixproject.data.integration.ExternalActivityRepository
import com.devil.phoenixproject.data.integration.HealthIntegration
import com.devil.phoenixproject.data.integration.IntegrationSyncCursorRepository
import com.devil.phoenixproject.data.preferences.PreferencesManager
+import com.devil.phoenixproject.data.repository.ActiveProfileContext
import com.devil.phoenixproject.data.repository.AutoStopUiState
import com.devil.phoenixproject.data.repository.BiomechanicsRepository
import com.devil.phoenixproject.data.repository.BleRepository
@@ -25,6 +26,7 @@ import com.devil.phoenixproject.data.repository.WorkoutRepository
import com.devil.phoenixproject.data.sync.SyncTriggerManager
import com.devil.phoenixproject.domain.model.AppliedRoutineModifier
import com.devil.phoenixproject.domain.model.Badge
+import com.devil.phoenixproject.domain.model.BleCompatibilitySetting
import com.devil.phoenixproject.domain.model.BodyweightVariantOption
import com.devil.phoenixproject.domain.model.ConnectionState
import com.devil.phoenixproject.domain.model.EchoLevel
@@ -36,13 +38,11 @@ import com.devil.phoenixproject.domain.model.RackItem
import com.devil.phoenixproject.domain.model.RackItemBehavior
import com.devil.phoenixproject.domain.model.RackLoadAdjustment
import com.devil.phoenixproject.domain.model.RepCount
-import com.devil.phoenixproject.domain.model.BleCompatibilitySetting
import com.devil.phoenixproject.domain.model.Routine
import com.devil.phoenixproject.domain.model.RoutineExercise
import com.devil.phoenixproject.domain.model.RoutineFlowState
-import com.devil.phoenixproject.domain.model.RoutineLaunchOrigin
-import com.devil.phoenixproject.presentation.navigation.NavigationRoutes
import com.devil.phoenixproject.domain.model.RoutineGroup
+import com.devil.phoenixproject.domain.model.RoutineLaunchOrigin
import com.devil.phoenixproject.domain.model.SessionBodyweightState
import com.devil.phoenixproject.domain.model.Superset
import com.devil.phoenixproject.domain.model.UserPreferences
@@ -60,6 +60,8 @@ import com.devil.phoenixproject.domain.usecase.RecommendWeightAdjustmentUseCase
import com.devil.phoenixproject.domain.usecase.RecordPersonalMvtSampleUseCase
import com.devil.phoenixproject.domain.usecase.RepCounterFromMachine
import com.devil.phoenixproject.domain.usecase.ResolveRoutineWeightsUseCase
+import com.devil.phoenixproject.presentation.components.exercisepicker.CompletedExerciseIdsState
+import com.devil.phoenixproject.presentation.components.exercisepicker.completedExerciseIdsFromHistory
import com.devil.phoenixproject.presentation.manager.BleConnectionManager
import com.devil.phoenixproject.presentation.manager.DefaultWorkoutSessionManager
import com.devil.phoenixproject.presentation.manager.GamificationManager
@@ -70,6 +72,7 @@ import com.devil.phoenixproject.presentation.manager.ResumableProgressInfo
import com.devil.phoenixproject.presentation.manager.SettingsManager
import com.devil.phoenixproject.presentation.manager.WorkoutServiceController
import com.devil.phoenixproject.presentation.manager.currentProfileTestSoundEvents
+import com.devil.phoenixproject.presentation.navigation.NavigationRoutes
import com.devil.phoenixproject.util.BackupDestination
import com.devil.phoenixproject.util.BackupStats
import com.devil.phoenixproject.util.DataBackupManager
@@ -82,7 +85,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -164,6 +170,46 @@ class MainViewModel constructor(
.map { it?.id ?: "default" }
.stateIn(viewModelScope, SharingStarted.Eagerly, "default")
+ /**
+ * Picker-safe completed IDs. The tag and loading sentinel prevent a picker from ever
+ * using a prior profile's history during an active-profile transition.
+ */
+ val completedExerciseIdsState: StateFlow =
+ userProfileRepository.activeProfileContext
+ .flatMapLatest { context ->
+ when (context) {
+ is ActiveProfileContext.Switching -> flowOf(
+ CompletedExerciseIdsState(
+ profileId = context.targetProfileId,
+ isLoading = true,
+ ),
+ )
+
+ is ActiveProfileContext.Ready ->
+ workoutRepository.getHistoryVisibleSessions(context.profile.id)
+ .map { sessions ->
+ CompletedExerciseIdsState(
+ profileId = context.profile.id,
+ ids = completedExerciseIdsFromHistory(sessions),
+ isLoading = false,
+ )
+ }
+ .onStart {
+ emit(
+ CompletedExerciseIdsState(
+ profileId = context.profile.id,
+ isLoading = true,
+ ),
+ )
+ }
+ }
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = CompletedExerciseIdsState(profileId = null, isLoading = true),
+ )
+
// === Phase 2b: GamificationManager (extracted from this class) ===
val gamificationManager: GamificationManager = GamificationManager(
gamificationRepository,
@@ -444,9 +490,8 @@ class MainViewModel constructor(
* back to the DB — the StateFlow intentionally filters out "cycle_routine_"-prefixed
* template-cycle routines (issue #620), which must still be loadable for editing.
*/
- suspend fun getRoutineById(routineId: String): Routine? =
- workoutSessionManager.getRoutineById(routineId)
- ?: workoutRepository.getRoutineById(routineId)
+ suspend fun getRoutineById(routineId: String): Routine? = workoutSessionManager.getRoutineById(routineId)
+ ?: workoutRepository.getRoutineById(routineId)
fun saveRoutine(routine: Routine) = workoutSessionManager.saveRoutine(routine)
fun updateRoutine(routine: Routine) = workoutSessionManager.updateRoutine(routine)
fun saveRackBehaviorOverridesForExercise(
@@ -545,6 +590,7 @@ class MainViewModel constructor(
fun confirmSessionBodyWeight(weightKg: Float?, saveToProfile: Boolean) = workoutSessionManager.confirmSessionBodyWeight(weightKg, saveToProfile)
fun skipSessionBodyWeightPrompt() = workoutSessionManager.skipSessionBodyWeightPrompt()
fun returnToOverview() = workoutSessionManager.returnToOverview()
+
/**
* Returns the navigation route to pop to when exiting the current routine flow.
*
@@ -559,12 +605,11 @@ class MainViewModel constructor(
* viewModel.exitRoutineFlow() // 2. clears origin
* navController.popBackStack(dest, false) // 3. navigate
*/
- fun routineExitDestination(): String =
- if (workoutSessionManager.coordinator.routineLaunchOrigin == RoutineLaunchOrigin.TRAINING_CYCLES) {
- NavigationRoutes.TrainingCycles.route
- } else {
- NavigationRoutes.DailyRoutines.route
- }
+ fun routineExitDestination(): String = if (workoutSessionManager.coordinator.routineLaunchOrigin == RoutineLaunchOrigin.TRAINING_CYCLES) {
+ NavigationRoutes.TrainingCycles.route
+ } else {
+ NavigationRoutes.DailyRoutines.route
+ }
fun exitRoutineFlow() = workoutSessionManager.exitRoutineFlow()
fun showRoutineComplete() = workoutSessionManager.showRoutineComplete()
diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt
index 14817ab78..7d7478d69 100644
--- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt
+++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/viewmodel/ProfileViewModel.kt
@@ -25,8 +25,10 @@ import com.devil.phoenixproject.domain.model.WorkoutPreferences
import com.devil.phoenixproject.domain.model.WorkoutSession
import com.devil.phoenixproject.domain.usecase.CurrentOneRepMax
import com.devil.phoenixproject.domain.usecase.ResolveCurrentOneRepMaxUseCase
-import com.devil.phoenixproject.presentation.components.canDeleteProfile
import com.devil.phoenixproject.presentation.components.ProfileMeasurementKey
+import com.devil.phoenixproject.presentation.components.canDeleteProfile
+import com.devil.phoenixproject.presentation.components.exercisepicker.CompletedExerciseIdsState
+import com.devil.phoenixproject.presentation.components.exercisepicker.completedExerciseIdsFromHistory
import com.devil.phoenixproject.presentation.components.latestImportedBodyWeightMeasuredAt
import com.devil.phoenixproject.presentation.components.normalizedProfileColorIndex
import kotlin.coroutines.cancellation.CancellationException
@@ -35,11 +37,17 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
@@ -99,8 +107,7 @@ data class ProfileUiState(
val prHighlights: ProfileLoadable = ProfileLoadable.Empty,
val recentSessions: ProfileLoadable> = ProfileLoadable.Empty,
val identityMutation: ProfileIdentityMutation? = null,
- val preferenceMutations:
- Map = emptyMap(),
+ val preferenceMutations: Map = emptyMap(),
val importedBodyWeightMeasuredAt: Long? = null,
) {
val identityMutationInFlight: Boolean
@@ -151,6 +158,43 @@ class ProfileViewModel(
private val _events = Channel(Channel.BUFFERED)
val events: Flow = _events.receiveAsFlow()
+ /** Profile-tagged history IDs for the Profile exercise picker only. */
+ val completedExerciseIdsState: StateFlow =
+ profiles.activeProfileContext
+ .flatMapLatest { context ->
+ when (context) {
+ is ActiveProfileContext.Switching -> flowOf(
+ CompletedExerciseIdsState(
+ profileId = context.targetProfileId,
+ isLoading = true,
+ ),
+ )
+
+ is ActiveProfileContext.Ready ->
+ workouts.getHistoryVisibleSessions(context.profile.id)
+ .map { sessions ->
+ CompletedExerciseIdsState(
+ profileId = context.profile.id,
+ ids = completedExerciseIdsFromHistory(sessions),
+ isLoading = false,
+ )
+ }
+ .onStart {
+ emit(
+ CompletedExerciseIdsState(
+ profileId = context.profile.id,
+ isLoading = true,
+ ),
+ )
+ }
+ }
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = CompletedExerciseIdsState(profileId = null, isLoading = true),
+ )
+
private val selectedExerciseIds = mutableMapOf()
private var resolvedSelectionProfileId: String? = null
private var insightsJob: Job? = null
@@ -240,12 +284,11 @@ class ProfileViewModel(
profiles.updateVbt(profileId, value)
}
- fun updateLocalSafety(value: ProfileLocalSafetyPreferences): Long? =
- startSinglePreferenceMutation(
- section = ProfilePreferenceSection.LOCAL_SAFETY,
- ) { profileId ->
- profiles.updateLocalSafety(profileId, value)
- }
+ fun updateLocalSafety(value: ProfileLocalSafetyPreferences): Long? = startSinglePreferenceMutation(
+ section = ProfilePreferenceSection.LOCAL_SAFETY,
+ ) { profileId ->
+ profiles.updateLocalSafety(profileId, value)
+ }
fun confirmAdultsOnlyAndEnableVulgar(): Long? {
val ready = currentReadyForPreferenceMutation() ?: return null
@@ -254,9 +297,11 @@ class ProfileViewModel(
ProfilePreferenceSection.LOCAL_SAFETY,
ProfilePreferenceSection.VBT,
)
+
!ready.preferences.vbt.value.vulgarModeEnabled -> setOf(
ProfilePreferenceSection.VBT,
)
+
else -> return null
}
return startPreferenceMutation(
@@ -536,16 +581,16 @@ class ProfileViewModel(
}
}
- private fun requireAuthoritativeReady(profileId: String): ActiveProfileContext.Ready =
- when (val context = profiles.activeProfileContext.value) {
- is ActiveProfileContext.Switching -> throw ProfileContextUnavailableException()
- is ActiveProfileContext.Ready -> {
- if (context.profile.id != profileId) {
- throw StaleProfileContextException(profileId, context.profile.id)
- }
- context
+ private fun requireAuthoritativeReady(profileId: String): ActiveProfileContext.Ready = when (val context = profiles.activeProfileContext.value) {
+ is ActiveProfileContext.Switching -> throw ProfileContextUnavailableException()
+
+ is ActiveProfileContext.Ready -> {
+ if (context.profile.id != profileId) {
+ throw StaleProfileContextException(profileId, context.profile.id)
}
+ context
}
+ }
private fun currentReadyForPreferenceMutation(): ActiveProfileContext.Ready? {
val profileId = currentMutationProfileId() ?: return null
@@ -848,10 +893,9 @@ class ProfileViewModel(
return repositoryReady.profile.id == profileId && uiReady.profile.id == profileId
}
- private fun isSelectionCurrent(profileId: String, exerciseId: String): Boolean =
- isProfileCurrent(profileId) &&
- uiState.value.selectedExercise?.id == exerciseId &&
- selectedExerciseIds[profileId] == exerciseId
+ private fun isSelectionCurrent(profileId: String, exerciseId: String): Boolean = isProfileCurrent(profileId) &&
+ uiState.value.selectedExercise?.id == exerciseId &&
+ selectedExerciseIds[profileId] == exerciseId
private inline fun updateIfProfileCurrent(
profileId: String,
diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/exercisepicker/ExercisePickerFiltersTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/exercisepicker/ExercisePickerFiltersTest.kt
new file mode 100644
index 000000000..4aed368c7
--- /dev/null
+++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/components/exercisepicker/ExercisePickerFiltersTest.kt
@@ -0,0 +1,103 @@
+package com.devil.phoenixproject.presentation.components.exercisepicker
+
+import com.devil.phoenixproject.domain.model.Exercise
+import com.devil.phoenixproject.domain.model.WorkoutSession
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ExercisePickerFiltersTest {
+ private fun exercise(
+ id: String?,
+ name: String = id ?: "unnamed",
+ favorite: Boolean = false,
+ custom: Boolean = false,
+ muscleGroups: String = "Legs",
+ equipment: String = "BAR",
+ ) = Exercise(
+ id = id,
+ name = name,
+ muscleGroup = muscleGroups.substringBefore(','),
+ muscleGroups = muscleGroups,
+ equipment = equipment,
+ isFavorite = favorite,
+ isCustom = custom,
+ )
+
+ @Test
+ fun disabledPreviouslyCompletedFilterPreservesCandidateOrder() {
+ val candidates = listOf(
+ exercise("squat"),
+ exercise("bench"),
+ exercise("row"),
+ )
+
+ val result = filterExercisePickerCandidates(
+ candidates = candidates,
+ filters = ExercisePickerFilterState(),
+ completedExerciseIds = setOf("row"),
+ )
+
+ assertEquals(candidates, result)
+ }
+
+ @Test
+ fun allEnabledFiltersIntersectWithoutReorderingCandidates() {
+ val matching = exercise(
+ id = " squat ",
+ favorite = true,
+ custom = true,
+ muscleGroups = "Legs, Core",
+ equipment = "BAR, BENCH",
+ )
+ val wrongCompleted = exercise(
+ id = "bench",
+ favorite = true,
+ custom = true,
+ muscleGroups = "Legs",
+ equipment = "BAR",
+ )
+ val wrongFavorite = exercise(
+ id = "deadlift",
+ favorite = false,
+ custom = true,
+ muscleGroups = "Legs",
+ equipment = "BAR",
+ )
+ val blankId = exercise(
+ id = " ",
+ favorite = true,
+ custom = true,
+ muscleGroups = "Legs",
+ equipment = "BAR",
+ )
+
+ val result = filterExercisePickerCandidates(
+ candidates = listOf(wrongCompleted, matching, wrongFavorite, blankId),
+ filters = ExercisePickerFilterState(
+ showFavoritesOnly = true,
+ showCustomOnly = true,
+ selectedMuscles = setOf("Legs"),
+ selectedEquipment = setOf("Long Bar"),
+ showPreviouslyCompletedOnly = true,
+ ),
+ completedExerciseIds = setOf("squat"),
+ )
+
+ assertEquals(listOf(matching), result)
+ }
+
+ @Test
+ fun completedIdsTrimValuesAndIgnoreBlankOrMissingTags() {
+ val completedIds = completedExerciseIdsFromHistory(
+ listOf(
+ WorkoutSession(exerciseId = " squat "),
+ WorkoutSession(exerciseId = null),
+ WorkoutSession(exerciseId = ""),
+ WorkoutSession(exerciseId = "squat"),
+ WorkoutSession(exerciseId = "bench"),
+ ),
+ )
+
+ assertEquals(linkedSetOf("squat", "bench"), completedIds)
+ }
+}
diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt
index 1e8ae43be..d06445343 100644
--- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt
+++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepository.kt
@@ -1,8 +1,8 @@
package com.devil.phoenixproject.testutil
+import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS
import com.devil.phoenixproject.data.repository.PersonalRecordEntity
import com.devil.phoenixproject.data.repository.PhaseStatisticsData
-import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS
import com.devil.phoenixproject.data.repository.WorkoutRepository
import com.devil.phoenixproject.domain.model.HeuristicStatistics
import com.devil.phoenixproject.domain.model.Routine
@@ -106,16 +106,16 @@ class FakeWorkoutRepository : WorkoutRepository {
// deletedAt IS NULL AND (workingReps > 0 OR totalReps > 0)
// so unit tests using this fake exercise the same data the
// production SqlDelightWorkoutRepository returns.
- override fun getHistoryVisibleSessions(profileId: String): Flow> =
- _sessionsFlow.map { all ->
- all.filter { session ->
- // No deletedAt field on WorkoutSession today; when soft
- // delete lands, gate on it here too. For now the in-memory
- // fake never stores deleted rows, so only the rep guard is
- // required to match the SQL behavior.
- session.workingReps > 0 || session.totalReps > 0
- }
+ override fun getHistoryVisibleSessions(profileId: String): Flow> = _sessionsFlow.map { all ->
+ all.filter { session ->
+ // No deletedAt field on WorkoutSession today; when soft
+ // delete lands, gate on it here too. For now the in-memory
+ // fake never stores deleted rows, so only the profile and
+ // positive-rep guards are required to match the SQL behavior.
+ session.profileId == profileId &&
+ (session.workingReps > 0 || session.totalReps > 0)
}
+ }
override suspend fun getRecentCompletedSessionsForExercise(
exerciseId: String,
@@ -335,14 +335,13 @@ class FakeWorkoutRepository : WorkoutRepository {
)
}
- override suspend fun getExerciseIdsWithVelocityData(profileId: String): List =
- sessions.values
- .filter { s ->
- s.profileId == profileId &&
- s.avgMcvMmS != null &&
- s.workingReps > 0 &&
- s.exerciseId != null
- }
- .map { it.exerciseId!! }
- .distinct()
+ override suspend fun getExerciseIdsWithVelocityData(profileId: String): List = sessions.values
+ .filter { s ->
+ s.profileId == profileId &&
+ s.avgMcvMmS != null &&
+ s.workingReps > 0 &&
+ s.exerciseId != null
+ }
+ .map { it.exerciseId!! }
+ .distinct()
}
diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepositoryTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepositoryTest.kt
new file mode 100644
index 000000000..1d5a4328c
--- /dev/null
+++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/testutil/FakeWorkoutRepositoryTest.kt
@@ -0,0 +1,39 @@
+package com.devil.phoenixproject.testutil
+
+import com.devil.phoenixproject.domain.model.WorkoutSession
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+
+class FakeWorkoutRepositoryTest {
+ @Test
+ fun historyVisibleSessionsUsesRequestedProfileAndPositiveRepContract() = runTest {
+ val repository = FakeWorkoutRepository()
+ repository.addSessions(
+ listOf(
+ WorkoutSession(
+ id = "profile-a-completed",
+ profileId = "profile-a",
+ exerciseId = "squat",
+ workingReps = 8,
+ ),
+ WorkoutSession(
+ id = "profile-b-completed",
+ profileId = "profile-b",
+ exerciseId = "bench",
+ totalReps = 8,
+ ),
+ WorkoutSession(
+ id = "profile-a-ghost",
+ profileId = "profile-a",
+ exerciseId = "row",
+ ),
+ ),
+ )
+
+ val visible = repository.getHistoryVisibleSessions("profile-a").first()
+
+ assertEquals(listOf("squat"), visible.map { it.exerciseId })
+ }
+}