Pause adding multi-edit to workouts

Got up to the point where i'm find/replacing the
old names with new names, and I got confused
about the purpose of this feature.
This commit is contained in:
Brandon Presley 2023-08-14 13:14:34 +12:00
parent 79cde3a219
commit dc5434991a
9 changed files with 346 additions and 121 deletions

View File

@ -23,16 +23,16 @@ export default function EditWorkout() {
const { params } = useRoute<RouteProp<WorkoutsPageParams, "EditWorkout">>(); const { params } = useRoute<RouteProp<WorkoutsPageParams, "EditWorkout">>();
const [removeImage, setRemoveImage] = useState(false); const [removeImage, setRemoveImage] = useState(false);
const [showRemove, setShowRemove] = useState(false); const [showRemove, setShowRemove] = useState(false);
const [name, setName] = useState(params.value.name); const [name, setName] = useState(params.gymSet.name);
const [steps, setSteps] = useState(params.value.steps); const [steps, setSteps] = useState(params.gymSet.steps);
const [uri, setUri] = useState(params.value.image); const [uri, setUri] = useState(params.gymSet.image);
const [minutes, setMinutes] = useState( const [minutes, setMinutes] = useState(
params.value.minutes?.toString() ?? "3" params.gymSet.minutes?.toString() ?? "3"
); );
const [seconds, setSeconds] = useState( const [seconds, setSeconds] = useState(
params.value.seconds?.toString() ?? "30" params.gymSet.seconds?.toString() ?? "30"
); );
const [sets, setSets] = useState(params.value.sets?.toString() ?? "3"); const [sets, setSets] = useState(params.gymSet.sets?.toString() ?? "3");
const navigation = useNavigation(); const navigation = useNavigation();
const setsRef = useRef<TextInput>(null); const setsRef = useRef<TextInput>(null);
const stepsRef = useRef<TextInput>(null); const stepsRef = useRef<TextInput>(null);
@ -48,9 +48,9 @@ export default function EditWorkout() {
const update = async () => { const update = async () => {
await setRepo.update( await setRepo.update(
{ name: params.value.name }, { name: params.gymSet.name },
{ {
name: name || params.value.name, name: name || params.gymSet.name,
sets: Number(sets), sets: Number(sets),
minutes: +minutes, minutes: +minutes,
seconds: +seconds, seconds: +seconds,
@ -62,7 +62,7 @@ export default function EditWorkout() {
`UPDATE plans `UPDATE plans
SET workouts = REPLACE(workouts, $1, $2) SET workouts = REPLACE(workouts, $1, $2)
WHERE workouts LIKE $3`, WHERE workouts LIKE $3`,
[params.value.name, name, `%${params.value.name}%`] [params.gymSet.name, name, `%${params.gymSet.name}%`]
); );
navigation.goBack(); navigation.goBack();
}; };
@ -84,7 +84,7 @@ export default function EditWorkout() {
}; };
const save = async () => { const save = async () => {
if (params.value.name) return update(); if (params.gymSet.name) return update();
return add(); return add();
}; };
@ -109,7 +109,9 @@ export default function EditWorkout() {
return ( return (
<> <>
<StackHeader title={params.value.name ? "Edit workout" : "Add workout"} /> <StackHeader
title={params.gymSet.name ? "Edit workout" : "Add workout"}
/>
<View style={{ padding: PADDING, flex: 1 }}> <View style={{ padding: PADDING, flex: 1 }}>
<ScrollView style={{ flex: 1 }}> <ScrollView style={{ flex: 1 }}>
<AppInput <AppInput

223
EditWorkouts.tsx Normal file
View File

@ -0,0 +1,223 @@
import {
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from "@react-navigation/native";
import { useCallback, useRef, useState } from "react";
import { ScrollView, TextInput, View } from "react-native";
import DocumentPicker from "react-native-document-picker";
import { Button, Card, TouchableRipple } from "react-native-paper";
import { In } from "typeorm";
import AppInput from "./AppInput";
import ConfirmDialog from "./ConfirmDialog";
import { MARGIN, PADDING } from "./constants";
import { getNow, planRepo, setRepo, settingsRepo } from "./db";
import { fixNumeric } from "./fix-numeric";
import { defaultSet } from "./gym-set";
import Settings from "./settings";
import StackHeader from "./StackHeader";
import { toast } from "./toast";
import { WorkoutsPageParams } from "./WorkoutsPage";
export default function EditWorkouts() {
const { params } = useRoute<RouteProp<WorkoutsPageParams, "EditWorkouts">>();
const [removeImage, setRemoveImage] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const [name, setName] = useState("");
const [oldNames, setOldNames] = useState(params.names.join(", "));
const [steps, setSteps] = useState("");
const [oldSteps, setOldSteps] = useState("");
const [uri, setUri] = useState("");
const [oldUri, setOldUri] = useState("");
const [minutes, setMinutes] = useState("");
const [oldMinutes, setOldMinutes] = useState("");
const [seconds, setSeconds] = useState("");
const [oldSeconds, setOldSeconds] = useState("");
const [sets, setSets] = useState("");
const [oldSets, setOldSets] = useState("");
const navigation = useNavigation();
const setsRef = useRef<TextInput>(null);
const stepsRef = useRef<TextInput>(null);
const minutesRef = useRef<TextInput>(null);
const secondsRef = useRef<TextInput>(null);
const [settings, setSettings] = useState<Settings>();
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
setRepo
.createQueryBuilder()
.select()
.where("name IN (:...names)", { names: params.names })
.groupBy("name")
.getMany()
.then((gymSets) => {
console.log({ gymSets });
setOldNames(gymSets.map((set) => set.name).join(", "));
setOldSteps(gymSets.map((set) => set.steps).join(", "));
setOldUri(gymSets.map((set) => set.steps).join(", "));
});
}, [params.names])
);
const update = async () => {
await setRepo.update(
{ name: In(params.names) },
{
name: name || undefined,
sets: sets ? Number(sets) : undefined,
minutes: minutes ? Number(minutes) : undefined,
seconds: seconds ? Number(seconds) : undefined,
steps: steps || undefined,
image: removeImage ? "" : uri,
}
);
await planRepo
.createQueryBuilder()
.update()
.set({
workouts: () => `REPLACE(workouts, '${params.gymSet.name}', '${name}')`,
})
.where("workouts LIKE :name", { name: `%${params.gymSet.name}%` })
.execute();
navigation.goBack();
};
const add = async () => {
const now = await getNow();
await setRepo.save({
...defaultSet,
name,
hidden: true,
image: uri,
minutes: minutes ? +minutes : 3,
seconds: seconds ? +seconds : 30,
sets: sets ? +sets : 3,
steps,
created: now,
});
navigation.goBack();
};
const save = async () => {
if (params.gymSet.name) return update();
return add();
};
const changeImage = useCallback(async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: "documentDirectory",
});
if (fileCopyUri) setUri(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setUri("");
setRemoveImage(true);
setShowRemove(false);
}, []);
const submitName = () => {
if (settings.steps) stepsRef.current?.focus();
else setsRef.current?.focus();
};
return (
<>
<StackHeader
title={params.gymSet.name ? "Edit workout" : "Add workout"}
/>
<View style={{ padding: PADDING, flex: 1 }}>
<ScrollView style={{ flex: 1 }}>
<AppInput
autoFocus
label="Name"
value={name}
onChangeText={setName}
onSubmitEditing={submitName}
/>
{settings?.steps && (
<AppInput
innerRef={stepsRef}
selectTextOnFocus={false}
value={steps}
onChangeText={setSteps}
label="Steps"
multiline
onSubmitEditing={() => setsRef.current?.focus()}
/>
)}
<AppInput
innerRef={setsRef}
value={sets}
onChangeText={(newSets) => {
const fixed = fixNumeric(newSets);
setSets(fixed);
if (fixed.length !== newSets.length)
toast("Sets must be a number");
}}
label="Sets per workout"
keyboardType="numeric"
onSubmitEditing={() => minutesRef.current?.focus()}
/>
{settings?.alarm && (
<>
<AppInput
innerRef={minutesRef}
onSubmitEditing={() => secondsRef.current?.focus()}
value={minutes}
onChangeText={(newMinutes) => {
const fixed = fixNumeric(newMinutes);
setMinutes(fixed);
if (fixed.length !== newMinutes.length)
toast("Reps must be a number");
}}
label="Rest minutes"
keyboardType="numeric"
/>
<AppInput
innerRef={secondsRef}
value={seconds}
onChangeText={setSeconds}
label="Rest seconds"
keyboardType="numeric"
blurOnSubmit
/>
</>
)}
{settings?.images && uri && (
<TouchableRipple
style={{ marginBottom: MARGIN }}
onPress={changeImage}
onLongPress={() => setShowRemove(true)}
>
<Card.Cover source={{ uri }} />
</TouchableRipple>
)}
{settings?.images && !uri && (
<Button
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="add-photo-alternate"
>
Image
</Button>
)}
</ScrollView>
<Button disabled={!name} mode="outlined" icon="save" onPress={save}>
Save
</Button>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemove}
setShow={setShowRemove}
>
Are you sure you want to remove the image?
</ConfirmDialog>
</View>
</>
);
}

View File

@ -11,11 +11,11 @@ export default function ListMenu({
ids, ids,
}: { }: {
onEdit: () => void; onEdit: () => void;
onCopy: () => void; onCopy?: () => void;
onClear: () => void; onClear: () => void;
onDelete: () => void; onDelete: () => void;
onSelect: () => void; onSelect: () => void;
ids?: number[]; ids?: unknown[];
}) { }) {
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [showRemove, setShowRemove] = useState(false); const [showRemove, setShowRemove] = useState(false);
@ -64,12 +64,14 @@ export default function ListMenu({
onPress={edit} onPress={edit}
disabled={ids?.length === 0} disabled={ids?.length === 0}
/> />
<Menu.Item {onCopy && (
leadingIcon="content-copy" <Menu.Item
title="Copy" leadingIcon="content-copy"
onPress={copy} title="Copy"
disabled={ids?.length === 0} onPress={copy}
/> disabled={ids?.length === 0}
/>
)}
<Divider /> <Divider />
<Menu.Item <Menu.Item
leadingIcon="delete" leadingIcon="delete"

View File

@ -16,7 +16,6 @@ export default function SetItem({
setIds, setIds,
}: { }: {
item: GymSet; item: GymSet;
onRemove: () => void;
settings: Settings; settings: Settings;
ids: number[]; ids: number[];
setIds: (value: number[]) => void; setIds: (value: number[]) => void;

View File

@ -52,12 +52,11 @@ export default function SetList() {
settings={settings} settings={settings}
item={item} item={item}
key={item.id} key={item.id}
onRemove={() => refresh(term)}
ids={ids} ids={ids}
setIds={setIds} setIds={setIds}
/> />
), ),
[refresh, term, settings, ids] [settings, ids]
); );
const next = useCallback(async () => { const next = useCallback(async () => {

View File

@ -1,39 +1,26 @@
import { NavigationProp, useNavigation } from "@react-navigation/native"; import { NavigationProp, useNavigation } from "@react-navigation/native";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo } from "react";
import { GestureResponderEvent, Image } from "react-native"; import { Image } from "react-native";
import { List, Menu, Text } from "react-native-paper"; import { List } from "react-native-paper";
import ConfirmDialog from "./ConfirmDialog"; import { DARK_RIPPLE } from "./constants";
import { setRepo } from "./db"; import { LIGHT_RIPPLE } from "./constants";
import GymSet from "./gym-set"; import GymSet from "./gym-set";
import useDark from "./use-dark";
import { WorkoutsPageParams } from "./WorkoutsPage"; import { WorkoutsPageParams } from "./WorkoutsPage";
export default function WorkoutItem({ export default function WorkoutItem({
item, item,
onRemove, setNames,
names,
images, images,
}: { }: {
item: GymSet; item: GymSet;
onRemove: () => void;
images: boolean; images: boolean;
setNames: (value: string[]) => void;
names: string[];
}) { }) {
const [showMenu, setShowMenu] = useState(false);
const [anchor, setAnchor] = useState({ x: 0, y: 0 });
const [showRemove, setShowRemove] = useState("");
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>(); const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
const dark = useDark();
const remove = useCallback(async () => {
await setRepo.delete({ name: item.name });
setShowMenu(false);
onRemove();
}, [setShowMenu, onRemove, item.name]);
const longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
setShowMenu(true);
},
[setShowMenu, setAnchor]
);
const description = useMemo(() => { const description = useMemo(() => {
const seconds = item.seconds?.toString().padStart(2, "0"); const seconds = item.seconds?.toString().padStart(2, "0");
@ -47,50 +34,33 @@ export default function WorkoutItem({
); );
}, [item.image, images]); }, [item.image, images]);
const right = useCallback(() => { const long = useCallback(() => {
return ( if (names.length > 0) return;
<Text setNames([item.name]);
style={{ }, [names.length, item.name, setNames]);
alignSelf: "center",
}} const backgroundColor = useMemo(() => {
> if (!names.includes(item.name)) return;
<Menu if (dark) return DARK_RIPPLE;
anchor={anchor} return LIGHT_RIPPLE;
visible={showMenu} }, [dark, names, item.name]);
onDismiss={() => setShowMenu(false)}
> const press = useCallback(() => {
<Menu.Item if (names.length === 0)
leadingIcon="delete" return navigation.navigate("EditWorkout", { gymSet: item });
onPress={() => { const removing = names.find((name) => name === item.name);
setShowRemove(item.name); if (removing) setNames(names.filter((name) => name !== item.name));
setShowMenu(false); else setNames([...names, item.name]);
}} }, [names, item, navigation, setNames]);
title="Delete"
/>
</Menu>
</Text>
);
}, [anchor, showMenu, item.name]);
return ( return (
<> <List.Item
<List.Item onPress={press}
onPress={() => navigation.navigate("EditWorkout", { value: item })} title={item.name}
title={item.name} description={description}
description={description} onLongPress={long}
onLongPress={longPress} left={left}
left={left} style={{ backgroundColor }}
right={right} />
/>
<ConfirmDialog
title={`Delete ${showRemove}`}
show={!!showRemove}
setShow={(show) => (show ? null : setShowRemove(""))}
onOk={remove}
>
This irreversibly deletes ALL sets related to this workout. Are you
sure?
</ConfirmDialog>
</>
); );
} }

View File

@ -10,6 +10,7 @@ import { LIMIT } from "./constants";
import { setRepo, settingsRepo } from "./db"; import { setRepo, settingsRepo } from "./db";
import DrawerHeader from "./DrawerHeader"; import DrawerHeader from "./DrawerHeader";
import GymSet from "./gym-set"; import GymSet from "./gym-set";
import ListMenu from "./ListMenu";
import Page from "./Page"; import Page from "./Page";
import SetList from "./SetList"; import SetList from "./SetList";
import Settings from "./settings"; import Settings from "./settings";
@ -22,6 +23,7 @@ export default function WorkoutList() {
const [term, setTerm] = useState(""); const [term, setTerm] = useState("");
const [end, setEnd] = useState(false); const [end, setEnd] = useState(false);
const [settings, setSettings] = useState<Settings>(); const [settings, setSettings] = useState<Settings>();
const [names, setNames] = useState<string[]>([]);
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>(); const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
const refresh = useCallback(async (value: string) => { const refresh = useCallback(async (value: string) => {
@ -52,13 +54,14 @@ export default function WorkoutList() {
images={settings?.images} images={settings?.images}
item={item} item={item}
key={item.name} key={item.name}
onRemove={() => refresh(term)} names={names}
setNames={setNames}
/> />
), ),
[refresh, term, settings?.images] [settings?.images, names]
); );
const next = useCallback(async () => { const next = async () => {
if (end) return; if (end) return;
const newOffset = offset + LIMIT; const newOffset = offset + LIMIT;
console.log(`${SetList.name}.next:`, { console.log(`${SetList.name}.next:`, {
@ -81,11 +84,11 @@ export default function WorkoutList() {
setWorkouts([...workouts, ...newWorkouts]); setWorkouts([...workouts, ...newWorkouts]);
if (newWorkouts.length < LIMIT) return setEnd(true); if (newWorkouts.length < LIMIT) return setEnd(true);
setOffset(newOffset); setOffset(newOffset);
}, [term, end, offset, workouts]); };
const onAdd = useCallback(async () => { const onAdd = useCallback(async () => {
navigation.navigate("EditWorkout", { navigation.navigate("EditWorkout", {
value: new GymSet(), gymSet: new GymSet(),
}); });
}, [navigation]); }, [navigation]);
@ -97,9 +100,33 @@ export default function WorkoutList() {
[refresh] [refresh]
); );
const clear = useCallback(() => {
setNames([]);
}, []);
const remove = async () => {
setNames([]);
await setRepo.delete(names.length > 0 ? names : {});
await refresh(term);
};
const select = () => {
setNames(workouts.map((workout) => workout.name));
};
const edit = useCallback(() => {}, []);
return ( return (
<> <>
<DrawerHeader name="Workouts" /> <DrawerHeader name="Workouts">
<ListMenu
onClear={clear}
onDelete={remove}
onEdit={edit}
ids={names}
onSelect={select}
/>
</DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}> <Page onAdd={onAdd} term={term} search={search}>
{workouts?.length === 0 ? ( {workouts?.length === 0 ? (
<List.Item <List.Item

View File

@ -6,7 +6,10 @@ import WorkoutList from "./WorkoutList";
export type WorkoutsPageParams = { export type WorkoutsPageParams = {
WorkoutList: {}; WorkoutList: {};
EditWorkout: { EditWorkout: {
value: GymSet; gymSet: GymSet;
};
EditWorkouts: {
names: string[];
}; };
}; };

View File

@ -1,14 +1,14 @@
import { createStackNavigator } from '@react-navigation/stack' import { createStackNavigator } from "@react-navigation/stack";
import React from 'react' import React from "react";
import 'react-native' import "react-native";
import { render, waitFor } from '@testing-library/react-native' import { render, waitFor } from "@testing-library/react-native";
import EditWorkout from '../EditWorkout' import EditWorkout from "../EditWorkout";
import GymSet from '../gym-set' import GymSet from "../gym-set";
import { MockProviders } from '../mock-providers' import { MockProviders } from "../mock-providers";
import Settings from '../settings' import Settings from "../settings";
import { WorkoutsPageParams } from '../WorkoutsPage' import { WorkoutsPageParams } from "../WorkoutsPage";
jest.mock('../db.ts', () => ({ jest.mock("../db.ts", () => ({
settingsRepo: { settingsRepo: {
findOne: () => findOne: () =>
Promise.resolve({ Promise.resolve({
@ -16,28 +16,28 @@ jest.mock('../db.ts', () => ({
alarm: true, alarm: true,
} as Settings), } as Settings),
}, },
})) }));
test('renders correctly', async () => { test("renders correctly", async () => {
const Stack = createStackNavigator<WorkoutsPageParams>() const Stack = createStackNavigator<WorkoutsPageParams>();
const { getByText, getAllByText } = render( const { getByText, getAllByText } = render(
<MockProviders> <MockProviders>
<Stack.Navigator> <Stack.Navigator>
<Stack.Screen <Stack.Screen
initialParams={{ initialParams={{
value: { name: 'Bench press' } as GymSet, gymSet: { name: "Bench press" } as GymSet,
}} }}
name='EditWorkout' name="EditWorkout"
component={EditWorkout} component={EditWorkout}
/> />
</Stack.Navigator> </Stack.Navigator>
</MockProviders>, </MockProviders>
) );
const title = await waitFor(() => getByText(/Edit workout/i)) const title = await waitFor(() => getByText(/Edit workout/i));
expect(title).toBeDefined() expect(title).toBeDefined();
expect(getAllByText(/Name/i).length).toBeGreaterThan(0) expect(getAllByText(/Name/i).length).toBeGreaterThan(0);
expect(getAllByText(/Sets/i).length).toBeGreaterThan(0) expect(getAllByText(/Sets/i).length).toBeGreaterThan(0);
expect(getAllByText(/Minutes/i).length).toBeGreaterThan(0) expect(getAllByText(/Minutes/i).length).toBeGreaterThan(0);
expect(getAllByText(/Seconds/i).length).toBeGreaterThan(0) expect(getAllByText(/Seconds/i).length).toBeGreaterThan(0);
expect(getAllByText(/Save/i).length).toBeGreaterThan(0) expect(getAllByText(/Save/i).length).toBeGreaterThan(0);
}) });