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:
parent
fd38439756
commit
23d8c91c69
15
App.tsx
15
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<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();
|
||||
}, []);
|
||||
|
|
|
@ -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}%`]);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
80
EditWorkout.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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'},
|
||||
];
|
||||
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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
74
WorkoutItem.tsx
Normal 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
125
WorkoutList.tsx
Normal 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
42
WorkoutsPage.tsx
Normal 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
11
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;
|
||||
`;
|
||||
|
|
5
plan.ts
5
plan.ts
|
@ -3,8 +3,3 @@ export interface Plan {
|
|||
days: string;
|
||||
workouts: string;
|
||||
}
|
||||
|
||||
export interface Workout {
|
||||
name: string;
|
||||
sets: number;
|
||||
}
|
||||
|
|
1
set.ts
1
set.ts
|
@ -5,4 +5,5 @@ export default interface Set {
|
|||
weight: number;
|
||||
created?: string;
|
||||
unit?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
|
4
workout.ts
Normal file
4
workout.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default interface Workout {
|
||||
name: string;
|
||||
sets: number;
|
||||
}
|
Loading…
Reference in New Issue
Block a user