Add workouts page

The workouts page can be used to add a new type of workout,
or to edit the name of an already existing one.
Closes #1.
This commit is contained in:
Brandon Presley 2022-08-26 15:10:28 +12:00
parent fd38439756
commit 23d8c91c69
15 changed files with 364 additions and 22 deletions

15
App.tsx
View File

@ -14,7 +14,15 @@ import {
} from 'react-native-paper';
import {SQLiteDatabase} from 'react-native-sqlite-storage';
import Ionicon from 'react-native-vector-icons/Ionicons';
import {addSound, createPlans, createSets, createSettings, getDb} from './db';
import {
addHidden,
addSound,
createPlans,
createSets,
createSettings,
createWorkouts,
getDb,
} from './db';
import Routes from './Routes';
export const Drawer = createDrawerNavigator<DrawerParamList>();
@ -23,6 +31,7 @@ export type DrawerParamList = {
Settings: {};
Best: {};
Plans: {};
Workouts: {};
};
export const DatabaseContext = React.createContext<SQLiteDatabase>({} as any);
@ -61,13 +70,15 @@ const App = () => {
await _db.executeSql(createSets);
await _db.executeSql(createSettings);
await _db.executeSql(addSound).catch(() => null);
setDb(_db);
await _db.executeSql(createWorkouts);
await _db.executeSql(addHidden).catch(() => null);
const [result] = await _db.executeSql(`SELECT * FROM settings LIMIT 1`);
if (result.rows.length === 0)
return _db.executeSql(`
INSERT INTO settings(minutes,seconds,alarm,vibrate,predict,sets)
VALUES(3,30,false,true,true,3);
`);
setDb(_db);
};
init();
}, []);

View File

@ -22,14 +22,13 @@ export default function BestList() {
const bestWeight = `
SELECT name, reps, unit, MAX(weight) AS weight
FROM sets
WHERE name LIKE ?
WHERE name LIKE ? AND NOT hidden
GROUP BY name;
`;
const bestReps = `
SELECT name, MAX(reps) as reps, unit, weight
FROM sets
WHERE name = ?
AND weight = ?
WHERE name = ? AND weight = ? AND NOT hidden
GROUP BY name;
`;
const [weight] = await db.executeSql(bestWeight, [`%${search}%`]);

View File

@ -10,7 +10,7 @@ export default function ConfirmDialog({
setShow,
}: {
title: string;
children: string;
children: JSX.Element | JSX.Element[] | string;
onOk: () => void;
show: boolean;
setShow: (show: boolean) => void;

View File

@ -9,7 +9,7 @@ import {Plan} from './plan';
import Set from './set';
import {write} from './write';
const setFields = 'id,name,reps,weight,created,unit';
const setFields = 'id,name,reps,weight,created,unit,hidden';
const planFields = 'id,days,workouts';
export default function DrawerMenu({name}: {name: keyof DrawerParamList}) {
@ -27,7 +27,7 @@ export default function DrawerMenu({name}: {name: keyof DrawerParamList}) {
.concat(
sets.map(
set =>
`${set.id},${set.name},${set.reps},${set.weight},${set.created},${set.unit}`,
`${set.id},${set.name},${set.reps},${set.weight},${set.created},${set.unit},${set.hidden}`,
),
)
.join('\n');
@ -63,11 +63,11 @@ export default function DrawerMenu({name}: {name: keyof DrawerParamList}) {
.filter(line => line)
.map(set => {
const cells = set.split(',');
return `('${cells[1]}',${cells[2]},${cells[3]},'${cells[4]}','${cells[5]}')`;
return `('${cells[1]}',${cells[2]},${cells[3]},'${cells[4]}','${cells[5]}',${cells[6]})`;
})
.join(',');
await db.executeSql(
`INSERT INTO sets(name,reps,weight,created,unit) VALUES ${values}`,
`INSERT INTO sets(name,reps,weight,created,unit,hidden) VALUES ${values}`,
);
toast('Data imported.', 3000);
reset({index: 0, routes: [{name}]});

80
EditWorkout.tsx Normal file
View File

@ -0,0 +1,80 @@
import {
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native';
import React, {useCallback, useContext, useState} from 'react';
import {ScrollView} from 'react-native';
import {Button, IconButton} from 'react-native-paper';
import {set} from 'react-native-reanimated';
import {DatabaseContext} from './App';
import MassiveInput from './MassiveInput';
import {WorkoutsPageParams} from './WorkoutsPage';
export default function EditWorkout() {
const [name, setName] = useState('');
const {params} = useRoute<RouteProp<WorkoutsPageParams, 'EditWorkout'>>();
const db = useContext(DatabaseContext);
const navigation = useNavigation();
useFocusEffect(
useCallback(() => {
navigation.getParent()?.setOptions({
headerLeft: () => (
<IconButton icon="arrow-back" onPress={() => navigation.goBack()} />
),
headerRight: null,
title: params.value.name ? 'Edit workout' : 'New workout',
});
}, [navigation, params.value.name]),
);
const update = useCallback(async () => {
console.log(`${EditWorkout.name}.update`, set);
await db.executeSql(`UPDATE sets SET name = ? WHERE name = ?`, [
name,
params.value.name,
]);
await db.executeSql(
`UPDATE plans SET workouts = REPLACE(workouts, ?, ?)
WHERE workouts LIKE ?`,
[params.value.name, name, `%${params.value.name}%`],
);
navigation.goBack();
}, [db, navigation, params.value.name, name]);
const add = useCallback(async () => {
const insert = `
INSERT INTO sets(name, reps, weight, created, unit, hidden)
VALUES (?,0,0,strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'),'kg',true)
`;
await db.executeSql(insert, [name]);
navigation.goBack();
}, [db, navigation, name]);
const save = useCallback(async () => {
if (params.value.name) return update();
return add();
}, [update, add, params.value.name]);
return (
<ScrollView style={{padding: 10, height: '90%'}}>
{params.value.name ? (
<>
<MassiveInput label="Old name" value={params.value.name} disabled />
<MassiveInput label="New name" value={name} onChangeText={setName} />
</>
) : (
<MassiveInput label="Name" value={name} onChangeText={setName} />
)}
<Button
disabled={!name && !!params.value.name}
mode="contained"
icon="save"
onPress={save}>
Save
</Button>
</ScrollView>
);
}

View File

@ -4,10 +4,10 @@ import {IconButton} from 'react-native-paper';
import {SQLiteDatabase} from 'react-native-sqlite-storage';
import {DatabaseContext, Drawer, DrawerParamList} from './App';
import BestPage from './BestPage';
import DrawerMenu from './DrawerMenu';
import HomePage from './HomePage';
import PlanPage from './PlanPage';
import SettingsPage from './SettingsPage';
import WorkoutsPage from './WorkoutsPage';
interface Route {
name: keyof DrawerParamList;
@ -24,6 +24,7 @@ export default function Routes({db}: {db: SQLiteDatabase | null}) {
{name: 'Home', component: HomePage, icon: 'home'},
{name: 'Plans', component: PlanPage, icon: 'calendar'},
{name: 'Best', component: BestPage, icon: 'stats-chart'},
{name: 'Workouts', component: WorkoutsPage, icon: 'barbell'},
{name: 'Settings', component: SettingsPage, icon: 'settings'},
];

View File

@ -39,7 +39,7 @@ export default function SetList() {
const selectSets = `
SELECT * from sets
WHERE name LIKE ?
WHERE name LIKE ? AND NOT hidden
ORDER BY created DESC
LIMIT ? OFFSET ?
`;
@ -85,14 +85,13 @@ export default function SetList() {
const bestWeight = `
SELECT name, reps, unit, MAX(weight) AS weight
FROM sets
WHERE name = ?
WHERE name = ? AND NOT hidden
GROUP BY name;
`;
const bestReps = `
SELECT name, MAX(reps) as reps, unit, weight
FROM sets
WHERE name = ?
AND weight = ?
WHERE name = ? AND weight = ? AND NOT hidden
GROUP BY name;
`;
const [weightResult] = await db.executeSql(bestWeight, [query]);

View File

@ -74,14 +74,14 @@ export default function ViewBest() {
SELECT max(weight) AS weight,
STRFTIME('%Y-%m-%d', created) as created, unit
FROM sets
WHERE name = ?
WHERE name = ? AND NOT hidden
GROUP BY name, STRFTIME('%Y-%m-%d', created)
`;
const selectVolumes = `
SELECT sum(weight * reps) AS value,
STRFTIME('%Y-%m-%d', created) as created, unit
FROM sets
WHERE name = ?
WHERE name = ? AND NOT hidden
GROUP BY name, STRFTIME('%Y-%m-%d', created)
`;
const refresh = async () => {

74
WorkoutItem.tsx Normal file
View File

@ -0,0 +1,74 @@
import {NavigationProp, useNavigation} from '@react-navigation/native';
import React, {useCallback, useContext, useState} from 'react';
import {GestureResponderEvent, Text} from 'react-native';
import {List, Menu} from 'react-native-paper';
import {DatabaseContext} from './App';
import ConfirmDialog from './ConfirmDialog';
import Workout from './workout';
import {WorkoutsPageParams} from './WorkoutsPage';
export default function WorkoutItem({
item,
onRemoved,
}: {
item: Workout;
onRemoved: () => void;
}) {
const [showMenu, setShowMenu] = useState(false);
const [anchor, setAnchor] = useState({x: 0, y: 0});
const [showRemove, setShowRemove] = useState('');
const db = useContext(DatabaseContext);
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
const remove = useCallback(async () => {
await db.executeSql(`DELETE FROM sets WHERE name = ?`, [item.name]);
setShowMenu(false);
onRemoved();
}, [setShowMenu, db, onRemoved, item.name]);
const longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
setShowMenu(true);
},
[setShowMenu, setAnchor],
);
return (
<>
<List.Item
onPress={() => navigation.navigate('EditWorkout', {value: item})}
title={item.name}
onLongPress={longPress}
right={() => (
<Text
style={{
alignSelf: 'center',
}}>
<Menu
anchor={anchor}
visible={showMenu}
onDismiss={() => setShowMenu(false)}>
<Menu.Item
icon="trash"
onPress={() => {
setShowRemove(item.name);
setShowMenu(false);
}}
title="Delete"
/>
</Menu>
</Text>
)}
/>
<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>
</>
);
}

125
WorkoutList.tsx Normal file
View File

@ -0,0 +1,125 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import React, {useCallback, useContext, useEffect, useState} from 'react';
import {FlatList, StyleSheet, View} from 'react-native';
import {List, Searchbar} from 'react-native-paper';
import {DatabaseContext} from './App';
import DrawerMenu from './DrawerMenu';
import MassiveFab from './MassiveFab';
import SetList from './SetList';
import Workout from './workout';
import WorkoutItem from './WorkoutItem';
import {WorkoutsPageParams} from './WorkoutsPage';
const limit = 15;
export default function WorkoutList() {
const [workouts, setWorkouts] = useState<Workout[]>();
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [refreshing, setRefreshing] = useState(false);
const [end, setEnd] = useState(false);
const db = useContext(DatabaseContext);
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
const select = `
SELECT DISTINCT sets.name
FROM sets
WHERE sets.name LIKE ?
ORDER BY sets.name
LIMIT ? OFFSET ?
`;
const refresh = useCallback(async () => {
const [result] = await db.executeSql(select, [`%${search}%`, limit, 0]);
if (!result) return setWorkouts([]);
console.log(`${WorkoutList.name}.refresh:`, {search, limit});
setWorkouts(result.rows.raw());
setOffset(0);
setEnd(false);
}, [search, db, select]);
const refreshLoader = useCallback(async () => {
setRefreshing(true);
refresh().finally(() => setRefreshing(false));
}, [setRefreshing, refresh]);
useEffect(() => {
refresh();
}, [search, refresh]);
useFocusEffect(
useCallback(() => {
refresh();
navigation.getParent()?.setOptions({
headerRight: () => <DrawerMenu name="Home" />,
});
}, [refresh, navigation]),
);
const renderItem = useCallback(
({item}: {item: Workout}) => (
<WorkoutItem item={item} key={item.name} onRemoved={refresh} />
),
[refresh],
);
const next = useCallback(async () => {
if (end) return;
setRefreshing(true);
const newOffset = offset + limit;
console.log(`${SetList.name}.next:`, {
offset,
limit,
newOffset,
search,
});
const [result] = await db
.executeSql(select, [`%${search}%`, limit, newOffset])
.finally(() => setRefreshing(false));
if (result.rows.length === 0) return setEnd(true);
if (!workouts) return;
setWorkouts([...workouts, ...result.rows.raw()]);
if (result.rows.length < limit) return setEnd(true);
setOffset(newOffset);
}, [search, end, offset, workouts, db, select]);
const onAdd = useCallback(async () => {
navigation.navigate('EditWorkout', {
value: {name: '', sets: 3},
});
}, [navigation]);
return (
<View style={styles.container}>
<Searchbar placeholder="Search" value={search} onChangeText={setSearch} />
<FlatList
data={workouts}
style={{height: '95%', paddingBottom: 10}}
ListEmptyComponent={
<List.Item
title="No workouts yet."
description="A workout is something you do at the gym. For example Deadlifts are a workout."
/>
}
renderItem={renderItem}
keyExtractor={w => w.name}
onEndReached={next}
refreshing={refreshing}
onRefresh={refreshLoader}
/>
<MassiveFab onPress={onAdd} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
padding: 10,
paddingBottom: '10%',
},
});

42
WorkoutsPage.tsx Normal file
View File

@ -0,0 +1,42 @@
import {DrawerNavigationProp} from '@react-navigation/drawer';
import {useNavigation} from '@react-navigation/native';
import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import {IconButton} from 'react-native-paper';
import {DrawerParamList} from './App';
import EditWorkout from './EditWorkout';
import Workout from './workout';
import WorkoutList from './WorkoutList';
export type WorkoutsPageParams = {
WorkoutList: {};
EditWorkout: {
value: Workout;
};
};
const Stack = createStackNavigator<WorkoutsPageParams>();
export default function WorkoutsPage() {
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
<Stack.Screen name="WorkoutList" component={WorkoutList} />
<Stack.Screen
name="EditWorkout"
component={EditWorkout}
listeners={{
beforeRemove: () => {
navigation.setOptions({
headerLeft: () => (
<IconButton icon="menu" onPress={navigation.openDrawer} />
),
title: 'Workouts',
});
},
}}
/>
</Stack.Navigator>
);
}

11
db.ts
View File

@ -36,3 +36,14 @@ export const createSettings = `
export const addSound = `
ALTER TABLE settings ADD COLUMN sound TEXT NULL;
`;
export const createWorkouts = `
CREATE TABLE IF NOT EXISTS workouts(
name TEXT PRIMARY KEY,
sets INTEGER DEFAULT 3
);
`;
export const addHidden = `
ALTER TABLE sets ADD COLUMN hidden DEFAULT false;
`;

View File

@ -3,8 +3,3 @@ export interface Plan {
days: string;
workouts: string;
}
export interface Workout {
name: string;
sets: number;
}

1
set.ts
View File

@ -5,4 +5,5 @@ export default interface Set {
weight: number;
created?: string;
unit?: string;
hidden?: boolean;
}

4
workout.ts Normal file
View File

@ -0,0 +1,4 @@
export default interface Workout {
name: string;
sets: number;
}