From 23d8c91c690623ecb33bed3aab998794eb59f712 Mon Sep 17 00:00:00 2001 From: Brandon Presley Date: Fri, 26 Aug 2022 15:10:28 +1200 Subject: [PATCH] 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. --- App.tsx | 15 +++++- BestList.tsx | 5 +- ConfirmDialog.tsx | 2 +- DrawerMenu.tsx | 8 +-- EditWorkout.tsx | 80 +++++++++++++++++++++++++++++ Routes.tsx | 3 +- SetList.tsx | 7 ++- ViewBest.tsx | 4 +- WorkoutItem.tsx | 74 +++++++++++++++++++++++++++ WorkoutList.tsx | 125 ++++++++++++++++++++++++++++++++++++++++++++++ WorkoutsPage.tsx | 42 ++++++++++++++++ db.ts | 11 ++++ plan.ts | 5 -- set.ts | 1 + workout.ts | 4 ++ 15 files changed, 364 insertions(+), 22 deletions(-) create mode 100644 EditWorkout.tsx create mode 100644 WorkoutItem.tsx create mode 100644 WorkoutList.tsx create mode 100644 WorkoutsPage.tsx create mode 100644 workout.ts diff --git a/App.tsx b/App.tsx index a37ad27..4e99f7e 100644 --- a/App.tsx +++ b/App.tsx @@ -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(); @@ -23,6 +31,7 @@ export type DrawerParamList = { Settings: {}; Best: {}; Plans: {}; + Workouts: {}; }; export const DatabaseContext = React.createContext({} 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(); }, []); diff --git a/BestList.tsx b/BestList.tsx index dfed1f5..041f90f 100644 --- a/BestList.tsx +++ b/BestList.tsx @@ -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}%`]); diff --git a/ConfirmDialog.tsx b/ConfirmDialog.tsx index a8ccb59..42eca21 100644 --- a/ConfirmDialog.tsx +++ b/ConfirmDialog.tsx @@ -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; diff --git a/DrawerMenu.tsx b/DrawerMenu.tsx index aae3fec..ae04229 100644 --- a/DrawerMenu.tsx +++ b/DrawerMenu.tsx @@ -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}]}); diff --git a/EditWorkout.tsx b/EditWorkout.tsx new file mode 100644 index 0000000..11aff9b --- /dev/null +++ b/EditWorkout.tsx @@ -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>(); + const db = useContext(DatabaseContext); + const navigation = useNavigation(); + + useFocusEffect( + useCallback(() => { + navigation.getParent()?.setOptions({ + headerLeft: () => ( + 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 ( + + {params.value.name ? ( + <> + + + + ) : ( + + )} + + + ); +} diff --git a/Routes.tsx b/Routes.tsx index 7519e01..a0212ec 100644 --- a/Routes.tsx +++ b/Routes.tsx @@ -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'}, ]; diff --git a/SetList.tsx b/SetList.tsx index 8fa30e7..a10b74a 100644 --- a/SetList.tsx +++ b/SetList.tsx @@ -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]); diff --git a/ViewBest.tsx b/ViewBest.tsx index 49e40ee..2abf9fa 100644 --- a/ViewBest.tsx +++ b/ViewBest.tsx @@ -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 () => { diff --git a/WorkoutItem.tsx b/WorkoutItem.tsx new file mode 100644 index 0000000..9f00ed2 --- /dev/null +++ b/WorkoutItem.tsx @@ -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>(); + + 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 ( + <> + navigation.navigate('EditWorkout', {value: item})} + title={item.name} + onLongPress={longPress} + right={() => ( + + setShowMenu(false)}> + { + setShowRemove(item.name); + setShowMenu(false); + }} + title="Delete" + /> + + + )} + /> + (show ? null : setShowRemove(''))} + onOk={remove}> + This irreversibly deletes ALL sets related to this workout. Are you + sure? + + + ); +} diff --git a/WorkoutList.tsx b/WorkoutList.tsx new file mode 100644 index 0000000..5116c9b --- /dev/null +++ b/WorkoutList.tsx @@ -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(); + 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>(); + + 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: () => , + }); + }, [refresh, navigation]), + ); + + const renderItem = useCallback( + ({item}: {item: Workout}) => ( + + ), + [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 ( + + + + } + renderItem={renderItem} + keyExtractor={w => w.name} + onEndReached={next} + refreshing={refreshing} + onRefresh={refreshLoader} + /> + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexGrow: 1, + padding: 10, + paddingBottom: '10%', + }, +}); diff --git a/WorkoutsPage.tsx b/WorkoutsPage.tsx new file mode 100644 index 0000000..04812d5 --- /dev/null +++ b/WorkoutsPage.tsx @@ -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(); + +export default function WorkoutsPage() { + const navigation = useNavigation>(); + + return ( + + + { + navigation.setOptions({ + headerLeft: () => ( + + ), + title: 'Workouts', + }); + }, + }} + /> + + ); +} diff --git a/db.ts b/db.ts index ac7cc73..05b9a5a 100644 --- a/db.ts +++ b/db.ts @@ -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; +`; diff --git a/plan.ts b/plan.ts index 62b49bc..9866dbd 100644 --- a/plan.ts +++ b/plan.ts @@ -3,8 +3,3 @@ export interface Plan { days: string; workouts: string; } - -export interface Workout { - name: string; - sets: number; -} diff --git a/set.ts b/set.ts index edd6c17..d28cef1 100644 --- a/set.ts +++ b/set.ts @@ -5,4 +5,5 @@ export default interface Set { weight: number; created?: string; unit?: string; + hidden?: boolean; } diff --git a/workout.ts b/workout.ts new file mode 100644 index 0000000..bdf5f2f --- /dev/null +++ b/workout.ts @@ -0,0 +1,4 @@ +export default interface Workout { + name: string; + sets: number; +}