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 }) + } +}