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 [removeImage, setRemoveImage] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const [name, setName] = useState(params.value.name);
const [steps, setSteps] = useState(params.value.steps);
const [uri, setUri] = useState(params.value.image);
const [name, setName] = useState(params.gymSet.name);
const [steps, setSteps] = useState(params.gymSet.steps);
const [uri, setUri] = useState(params.gymSet.image);
const [minutes, setMinutes] = useState(
params.value.minutes?.toString() ?? "3"
params.gymSet.minutes?.toString() ?? "3"
);
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 setsRef = useRef<TextInput>(null);
const stepsRef = useRef<TextInput>(null);
@ -48,9 +48,9 @@ export default function EditWorkout() {
const update = async () => {
await setRepo.update(
{ name: params.value.name },
{ name: params.gymSet.name },
{
name: name || params.value.name,
name: name || params.gymSet.name,
sets: Number(sets),
minutes: +minutes,
seconds: +seconds,
@ -62,7 +62,7 @@ export default function EditWorkout() {
`UPDATE plans
SET workouts = REPLACE(workouts, $1, $2)
WHERE workouts LIKE $3`,
[params.value.name, name, `%${params.value.name}%`]
[params.gymSet.name, name, `%${params.gymSet.name}%`]
);
navigation.goBack();
};
@ -84,7 +84,7 @@ export default function EditWorkout() {
};
const save = async () => {
if (params.value.name) return update();
if (params.gymSet.name) return update();
return add();
};
@ -109,7 +109,9 @@ export default function EditWorkout() {
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 }}>
<ScrollView style={{ flex: 1 }}>
<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,
}: {
onEdit: () => void;
onCopy: () => void;
onCopy?: () => void;
onClear: () => void;
onDelete: () => void;
onSelect: () => void;
ids?: number[];
ids?: unknown[];
}) {
const [showMenu, setShowMenu] = useState(false);
const [showRemove, setShowRemove] = useState(false);
@ -64,12 +64,14 @@ export default function ListMenu({
onPress={edit}
disabled={ids?.length === 0}
/>
<Menu.Item
leadingIcon="content-copy"
title="Copy"
onPress={copy}
disabled={ids?.length === 0}
/>
{onCopy && (
<Menu.Item
leadingIcon="content-copy"
title="Copy"
onPress={copy}
disabled={ids?.length === 0}
/>
)}
<Divider />
<Menu.Item
leadingIcon="delete"

View File

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

View File

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

View File

@ -1,39 +1,26 @@
import { NavigationProp, useNavigation } from "@react-navigation/native";
import { useCallback, useMemo, useState } from "react";
import { GestureResponderEvent, Image } from "react-native";
import { List, Menu, Text } from "react-native-paper";
import ConfirmDialog from "./ConfirmDialog";
import { setRepo } from "./db";
import { useCallback, useMemo } from "react";
import { Image } from "react-native";
import { List } from "react-native-paper";
import { DARK_RIPPLE } from "./constants";
import { LIGHT_RIPPLE } from "./constants";
import GymSet from "./gym-set";
import useDark from "./use-dark";
import { WorkoutsPageParams } from "./WorkoutsPage";
export default function WorkoutItem({
item,
onRemove,
setNames,
names,
images,
}: {
item: GymSet;
onRemove: () => void;
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 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 dark = useDark();
const description = useMemo(() => {
const seconds = item.seconds?.toString().padStart(2, "0");
@ -47,50 +34,33 @@ export default function WorkoutItem({
);
}, [item.image, images]);
const right = useCallback(() => {
return (
<Text
style={{
alignSelf: "center",
}}
>
<Menu
anchor={anchor}
visible={showMenu}
onDismiss={() => setShowMenu(false)}
>
<Menu.Item
leadingIcon="delete"
onPress={() => {
setShowRemove(item.name);
setShowMenu(false);
}}
title="Delete"
/>
</Menu>
</Text>
);
}, [anchor, showMenu, item.name]);
const long = useCallback(() => {
if (names.length > 0) return;
setNames([item.name]);
}, [names.length, item.name, setNames]);
const backgroundColor = useMemo(() => {
if (!names.includes(item.name)) return;
if (dark) return DARK_RIPPLE;
return LIGHT_RIPPLE;
}, [dark, names, item.name]);
const press = useCallback(() => {
if (names.length === 0)
return navigation.navigate("EditWorkout", { gymSet: item });
const removing = names.find((name) => name === item.name);
if (removing) setNames(names.filter((name) => name !== item.name));
else setNames([...names, item.name]);
}, [names, item, navigation, setNames]);
return (
<>
<List.Item
onPress={() => navigation.navigate("EditWorkout", { value: item })}
title={item.name}
description={description}
onLongPress={longPress}
left={left}
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>
</>
<List.Item
onPress={press}
title={item.name}
description={description}
onLongPress={long}
left={left}
style={{ backgroundColor }}
/>
);
}

View File

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

View File

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

View File

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