diff --git a/app/components/presentation/workout-editor/exercise-editor.tsx b/app/components/presentation/workout-editor/exercise-editor.tsx index 83f911e08..fd296ebd3 100644 --- a/app/components/presentation/workout-editor/exercise-editor.tsx +++ b/app/components/presentation/workout-editor/exercise-editor.tsx @@ -1,12 +1,11 @@ -import AppBottomSheet from '@/components/presentation/foundation/app-bottom-sheet'; import DurationEditor from '@/components/presentation/foundation/editors/duration-editor'; import EditableIncrementer from '@/components/presentation/foundation/editors/editable-incrementer'; -import ExerciseFilterer from '@/components/presentation/workout-editor/exercise-filterer'; import FixedIncrementer from '@/components/presentation/foundation/editors/fixed-incrementer'; import Button from '@/components/presentation/foundation/gesture-wrappers/button'; import LabelledForm from '@/components/presentation/foundation/labelled-form'; import LabelledFormRow from '@/components/presentation/foundation/labelled-form-row'; import ListSwitch from '@/components/presentation/foundation/list-switch'; +import { fuzzyMatchScore } from '@/components/presentation/workout-editor/exercise-fuzzy-match'; import RestEditorGroup from '@/components/presentation/workout-editor/rest-editor-group'; import SelectButton from '@/components/presentation/foundation/select-button'; import { spacing, useAppTheme } from '@/hooks/useAppTheme'; @@ -23,58 +22,164 @@ import { WeightedExerciseBlueprint, WeightedExerciseBlueprintPOJO, } from '@/models/blueprint-models'; -import { useAppSelector, useAppSelectorWithArg } from '@/store'; -import { - ExerciseDescriptor, - selectExerciseById, - selectExerciseIds, -} from '@/store/stored-sessions'; +import { useAppSelector } from '@/store'; +import { ExerciseDescriptor } from '@/store/stored-sessions'; import { assertUnreachable } from '@/utils/assert-unreachable'; -import BottomSheet, { - useBottomSheetScrollableCreator, -} from '@gorhom/bottom-sheet'; import { Duration } from '@js-joda/core'; import { T, useTranslate } from '@tolgee/react'; import BigNumber from 'bignumber.js'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { Keyboard, View } from 'react-native'; +import { useEffect, useMemo, useState } from 'react'; +import { View } from 'react-native'; import { Card, Divider, List, SegmentedButtons, + Text, TextInput, } from 'react-native-paper'; import { match, P } from 'ts-pattern'; -import { LegendList } from '@legendapp/list'; interface ExerciseEditorProps { exercise: ExerciseBlueprint; updateExercise: (ex: ExerciseBlueprint) => void; } +interface ExerciseSuggestion extends ExerciseDescriptor { + source: 'user' | 'base'; +} + +const builtInExercisesJson = require('../../../assets/exercises.json') as { + exercises: { name: string }[]; +}; +const builtInExerciseIds = new Set( + builtInExercisesJson.exercises.map((exercise) => exercise.name), +); + const distanceUnitOptions = DistanceUnits.map((value) => ({ value, label: value + 's', })); export function ExerciseEditor(props: ExerciseEditorProps) { - const exerciseIds = useAppSelector(selectExerciseIds); - const bottomSheetRef = useRef(null); - const BottomSheetScrollView = useBottomSheetScrollableCreator(); + const suggestedExercises = useAppSelector((state) => { + const allExercises = new Map(); + const addExerciseDescriptor = ( + exercise: ExerciseDescriptor, + source: ExerciseSuggestion['source'], + ) => { + const id = exercise.name.trim().toLocaleLowerCase(); + if (!id) { + return; + } + const existing = allExercises.get(id); + if (existing && (existing.source === 'user' || source === 'base')) { + return; + } + allExercises.set(id, { ...exercise, source }); + }; + const addBlueprintExercise = (exercise: ExerciseBlueprint) => { + addExerciseDescriptor( + { + name: exercise.name, + force: null, + level: 'beginner', + mechanic: null, + equipment: null, + muscles: [], + instructions: exercise.notes, + category: + exercise instanceof CardioExerciseBlueprint ? 'cardio' : 'strength', + }, + 'user', + ); + }; + + Object.entries(state.storedSessions.savedExercises).forEach( + ([exerciseId, exercise]) => { + addExerciseDescriptor( + exercise, + builtInExerciseIds.has(exerciseId) && exercise.name === exerciseId + ? 'base' + : 'user', + ); + }, + ); + + Object.values(state.program.savedPrograms).forEach((program) => { + program.sessions.forEach((session) => { + session.exercises.forEach((exercise) => { + addBlueprintExercise( + exercise.type === 'CardioExerciseBlueprint' + ? CardioExerciseBlueprint.fromPOJO(exercise) + : WeightedExerciseBlueprint.fromPOJO(exercise), + ); + }); + }); + }); + + state.sessionEditor.sessionBlueprint?.exercises.forEach((exercise) => { + addBlueprintExercise( + exercise.type === 'CardioExerciseBlueprint' + ? CardioExerciseBlueprint.fromPOJO(exercise) + : WeightedExerciseBlueprint.fromPOJO(exercise), + ); + }); + + [ + state.currentSession.workoutSession, + state.currentSession.historySession, + state.currentSession.feedSession, + state.currentSession.sharedSession, + ].forEach((session) => { + session?.blueprint.exercises.forEach((exercise) => { + addBlueprintExercise( + exercise.type === 'CardioExerciseBlueprint' + ? CardioExerciseBlueprint.fromPOJO(exercise) + : WeightedExerciseBlueprint.fromPOJO(exercise), + ); + }); + }); + + return Array.from(allExercises.values()).sort((a, b) => + a.name.localeCompare(b.name), + ); + }); const selectExerciseFromSearch = (ex: ExerciseDescriptor) => { + setShowMatchingExercises(false); updateExercise({ name: ex.name, notes: ex.instructions }); - bottomSheetRef.current?.close(); }; - - const [bottomSheetShown, setBottomSheetShown] = useState(false); - const [filteredExerciseIds, setFilteredExerciseIds] = useState(exerciseIds); - const exerciseListItems = useMemo( - () => ['filter', ...filteredExerciseIds], - [filteredExerciseIds], - ); const { t } = useTranslate(); const { exercise: propsExercise, updateExercise: updatePropsExercise } = props; const [exercise, setExercise] = useState(propsExercise); + const [showMatchingExercises, setShowMatchingExercises] = useState(true); + const matchingExercises = useMemo(() => { + const searchText = exercise.name.trim(); + if (!searchText) { + return []; + } + + return suggestedExercises + .map((item) => ({ + item, + score: fuzzyMatchScore(searchText, item.name), + })) + .filter( + (entry): entry is { item: ExerciseSuggestion; score: number } => + entry.score !== null, + ) + .sort((a, b) => { + const scoreDiff = b.score - a.score; + if (scoreDiff !== 0) { + return scoreDiff; + } + if (a.item.source !== b.item.source) { + return a.item.source === 'user' ? -1 : 1; + } + return a.item.name.localeCompare(b.item.name); + }) + .map((entry) => entry.item) + .slice(0, 8); + }, [exercise.name, suggestedExercises]); // Bit of a hack to let us update exercise immediately without going through the whole props loop useEffect(() => { @@ -149,68 +254,90 @@ export function ExerciseEditor(props: ExerciseEditorProps) { /> - updateExercise({ name })} - selectTextOnFocus={true} - right={ - { - setBottomSheetShown(true); - Keyboard.dismiss(); - bottomSheetRef.current?.expand(); - }} - /> - } - /> + + { + setShowMatchingExercises(true); + updateExercise({ name }); + }} + selectTextOnFocus={true} + /> + {showMatchingExercises && !!matchingExercises.length && ( + + + setShowMatchingExercises(false)} + /> + + + {matchingExercises.map((item, index) => ( + + {!!index && } + + + ))} + + )} + {exerciseEditor} - - {bottomSheetShown && ( - (index === 0 ? 'filters' : 'exercise')} - keyExtractor={(item, index) => (index === 0 ? 'filters' : item)} - renderItem={(i) => { - if (i.index === 0) { - return ( - - ); - } - return ( - - ); - }} - /> - )} - ); } +function NewExerciseListItem(props: { name: string; onPress: () => void }) { + return ( + } + onPress={props.onPress} + /> + ); +} + function ExerciseSearchListItem(props: { - exerciseId: string; + exercise: ExerciseSuggestion; onPress: (exercise: ExerciseDescriptor) => void; }) { - const exercise = useAppSelectorWithArg(selectExerciseById, props.exerciseId); + const { t } = useTranslate(); return ( - props.onPress(exercise)} /> + ( + + )} + right={() => ( + + {props.exercise.source === 'user' + ? t('exercise.source.user.badge') + : t('exercise.source.base.badge')} + + )} + onPress={() => props.onPress(props.exercise)} + /> ); } diff --git a/app/components/presentation/workout-editor/exercise-filterer.tsx b/app/components/presentation/workout-editor/exercise-filterer.tsx index 8586237fb..28891a39f 100644 --- a/app/components/presentation/workout-editor/exercise-filterer.tsx +++ b/app/components/presentation/workout-editor/exercise-filterer.tsx @@ -1,32 +1,41 @@ import ExerciseSearchAndFilters from '@/components/presentation/workout-editor/exercise-search-and-filters'; +import { fuzzyMatchScore } from '@/components/presentation/workout-editor/exercise-fuzzy-match'; import { useAppSelector } from '@/store'; -import { selectExercises } from '@/store/stored-sessions'; +import { ExerciseDescriptor, selectExercises } from '@/store/stored-sessions'; import Enumerable from 'linq'; import { useState } from 'react'; import { useDebouncedCallback } from 'use-debounce'; export default function ExerciseFilterer(props: { onFilteredExerciseIdsChange: (ids: string[]) => void; + exercises?: Record; }) { - const exercises = useAppSelector(selectExercises); + const storedExercises = useAppSelector(selectExercises); + const exercises = props.exercises ?? storedExercises; const { onFilteredExerciseIdsChange } = props; const [muscleFilters, setMuscleFilters] = useState([] as string[]); const [searchText, setSearchText] = useState(''); const search = useDebouncedCallback(() => { - const searchRegex = new RegExp(escapeRegExp(searchText), 'i'); - const matchRegex = new RegExp('^' + escapeRegExp(searchText) + '$', 'i'); + const trimmedSearchText = searchText.trim(); const newFilteredExercises = Enumerable.from(Object.entries(exercises)) + .select((x) => ({ + entry: x, + score: trimmedSearchText + ? fuzzyMatchScore(trimmedSearchText, x[1].name) + : 0, + })) .where( (x) => (!muscleFilters.length || - x[1].muscles.some((exerciseMuscle) => + x.entry[1].muscles.some((exerciseMuscle) => muscleFilters.includes(exerciseMuscle), )) && - (!searchText || searchRegex.test(x[1].name)), + (!trimmedSearchText || x.score !== null), ) - .orderByDescending((x) => matchRegex.test(x[1].name)) - .select((x) => x[0]) + .orderByDescending((x) => x.score ?? 0) + .thenBy((x) => x.entry[1].name) + .select((x) => x.entry[0]) .toArray(); onFilteredExerciseIdsChange(newFilteredExercises); }, 100); @@ -46,6 +55,3 @@ export default function ExerciseFilterer(props: { /> ); } -function escapeRegExp(string: string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/app/components/presentation/workout-editor/exercise-fuzzy-match.spec.ts b/app/components/presentation/workout-editor/exercise-fuzzy-match.spec.ts new file mode 100644 index 000000000..543e9081b --- /dev/null +++ b/app/components/presentation/workout-editor/exercise-fuzzy-match.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { fuzzyMatchScore } from './exercise-fuzzy-match'; + +describe('fuzzyMatchScore', () => { + it('matches subsequences even when the letters are not contiguous', () => { + expect(fuzzyMatchScore('bpr', 'Bench Press Rows')).not.toBeNull(); + }); + + it('prefers exact matches over fuzzy ones', () => { + const exact = fuzzyMatchScore('press', 'press'); + const fuzzy = fuzzyMatchScore('press', 'Bench Press'); + + expect(exact).not.toBeNull(); + expect(fuzzy).not.toBeNull(); + expect(exact!).toBeGreaterThan(fuzzy!); + }); + + it('rejects strings that do not contain the query letters in order', () => { + expect(fuzzyMatchScore('abc', 'cab')).toBeNull(); + }); + + it('ignores accents when matching', () => { + expect(fuzzyMatchScore('epee', 'Épée')).not.toBeNull(); + }); +}); + diff --git a/app/components/presentation/workout-editor/exercise-fuzzy-match.ts b/app/components/presentation/workout-editor/exercise-fuzzy-match.ts new file mode 100644 index 000000000..954df7846 --- /dev/null +++ b/app/components/presentation/workout-editor/exercise-fuzzy-match.ts @@ -0,0 +1,56 @@ +const diacriticRegex = /[\u0300-\u036f]/g; + +export function normalizeFuzzyText(value: string) { + return value + .normalize('NFD') + .replace(diacriticRegex, '') + .toLowerCase() + .trim(); +} + +export function fuzzyMatchScore(query: string, candidate: string) { + const normalizedQuery = normalizeFuzzyText(query); + const normalizedCandidate = normalizeFuzzyText(candidate); + + if (!normalizedQuery) { + return null; + } + + let queryIndex = 0; + let firstMatch = -1; + let lastMatch = -1; + let gapCount = 0; + + for ( + let candidateIndex = 0; + candidateIndex < normalizedCandidate.length && + queryIndex < normalizedQuery.length; + candidateIndex++ + ) { + if (normalizedCandidate[candidateIndex] !== normalizedQuery[queryIndex]) { + continue; + } + + if (firstMatch === -1) { + firstMatch = candidateIndex; + } + if (lastMatch !== -1) { + gapCount += candidateIndex - lastMatch - 1; + } + lastMatch = candidateIndex; + queryIndex++; + } + + if (queryIndex !== normalizedQuery.length || firstMatch === -1 || lastMatch === -1) { + return null; + } + + const span = lastMatch - firstMatch + 1; + const compactness = normalizedQuery.length / span; + const coverage = normalizedQuery.length / normalizedCandidate.length; + const exactBonus = normalizedCandidate === normalizedQuery ? 3 : 0; + const prefixBonus = normalizedCandidate.startsWith(normalizedQuery) ? 1.5 : 0; + + return exactBonus + prefixBonus + compactness * 5 + coverage * 2 - gapCount * 0.1; +} + diff --git a/app/i18n/en.json b/app/i18n/en.json index b0f97a519..da7c7ebf3 100644 --- a/app/i18n/en.json +++ b/app/i18n/en.json @@ -63,6 +63,10 @@ "exercise.remove.confirm.body": "Exercise will be removed from the current workout, future workouts in the plan will not be impacted.", "exercise.remove.confirm.title": "Remove exercise?", "exercise.remove_from_workout.confirm.body": "This will remove the exercise {exercise} from {session}.", + "exercise.source.user.label": "From your exercises", + "exercise.source.base.label": "From base app", + "exercise.source.user.badge": "Yours", + "exercise.source.base.badge": "App", "exercise.reps.label": "Reps", "exercise.resistance.label": "Resistance", "exercise.select_reps.title": "Select Reps", diff --git a/app/i18n/fr.json b/app/i18n/fr.json index c9cdcc0a9..c678d0293 100644 --- a/app/i18n/fr.json +++ b/app/i18n/fr.json @@ -52,6 +52,10 @@ "exercise.reps.label": "Répétitions", "exercise.select_reps.title": "Sélectionner les répétitions", "exercise.sets.label": "Séries", + "exercise.source.user.label": "De vos exercices", + "exercise.source.base.label": "De l'application", + "exercise.source.user.badge": "Vous", + "exercise.source.base.badge": "App", "feed.anonymous_user.label": "Utilisateur anonyme", "feed.backup_account.confirm.body": "Inclure votre compte de fil et les utilisateurs suivis dans cette sauvegarde ?
Attention : Cela permettrait à quiconque ayant cette sauvegarde de publier du contenu avec votre compte", "feed.backup_account.subtitle": "Inclure les données de votre compte de fil dans les sauvegardes",