Merge branch 'master' into alarm-module

This commit is contained in:
Brandon Presley 2022-11-03 19:01:09 +13:00
commit 6f57b235d6
104 changed files with 4412 additions and 2964 deletions

View File

@ -5,12 +5,17 @@ module.exports = {
plugins: ['@typescript-eslint'],
overrides: [
{
files: ['*.ts', '*.tsx'],
files: ['*.ts', '*.tsx', '*.js'],
rules: {
'@typescript-eslint/no-shadow': ['error'],
'no-shadow': 'off',
'no-undef': 'off',
semi: 'off',
curly: 'off',
'react/react-in-jsx-scope': 'off',
'react-native/no-inline-styles': 'off',
'no-spaced-func': 'off',
},
},
],
};
}

View File

@ -4,4 +4,5 @@ module.exports = {
bracketSpacing: false,
singleQuote: true,
trailingComma: 'all',
semi: false,
};

149
App.tsx
View File

@ -2,23 +2,22 @@ import {
DarkTheme as NavigationDarkTheme,
DefaultTheme as NavigationDefaultTheme,
NavigationContainer,
} from '@react-navigation/native';
import React, {useEffect, useMemo, useState} from 'react';
import {useColorScheme} from 'react-native';
} from '@react-navigation/native'
import {useEffect, useMemo, useState} from 'react'
import {DeviceEventEmitter, useColorScheme} from 'react-native'
import React from 'react'
import {
DarkTheme as PaperDarkTheme,
DefaultTheme as PaperDefaultTheme,
Provider,
} from 'react-native-paper';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import {Color} from './color';
import {lightColors} from './colors';
import {runMigrations} from './db';
import MassiveSnack from './MassiveSnack';
import Routes from './Routes';
import Settings from './settings';
import {getSettings} from './settings.service';
import {SettingsContext} from './use-settings';
Provider as PaperProvider,
Snackbar,
} from 'react-native-paper'
import MaterialIcon from 'react-native-vector-icons/MaterialIcons'
import {AppDataSource} from './data-source'
import {settingsRepo} from './db'
import Routes from './Routes'
import {TOAST} from './toast'
import {ThemeContext} from './use-theme'
export const CombinedDefaultTheme = {
...NavigationDefaultTheme,
@ -27,7 +26,7 @@ export const CombinedDefaultTheme = {
...NavigationDefaultTheme.colors,
...PaperDefaultTheme.colors,
},
};
}
export const CombinedDarkTheme = {
...NavigationDarkTheme,
@ -35,61 +34,85 @@ export const CombinedDarkTheme = {
colors: {
...NavigationDarkTheme.colors,
...PaperDarkTheme.colors,
primary: lightColors[0].hex,
background: '#0E0E0E',
},
};
}
const App = () => {
const isDark = useColorScheme() === 'dark';
const [settings, setSettings] = useState<Settings>();
const [color, setColor] = useState(
const isDark = useColorScheme() === 'dark'
const [initialized, setInitialized] = useState(false)
const [snackbar, setSnackbar] = useState('')
const [theme, setTheme] = useState('system')
const [color, setColor] = useState<string>(
isDark
? CombinedDarkTheme.colors.primary.toUpperCase()
: CombinedDefaultTheme.colors.primary.toUpperCase(),
);
? CombinedDarkTheme.colors.primary
: CombinedDefaultTheme.colors.primary,
)
useEffect(() => {
runMigrations().then(async () => {
const gotSettings = await getSettings();
console.log(`${App.name}.runMigrations:`, {gotSettings});
setSettings(gotSettings);
if (gotSettings.color) setColor(gotSettings.color);
});
}, [setColor]);
DeviceEventEmitter.addListener(TOAST, ({value}: {value: string}) => {
console.log(`${Routes.name}.toast:`, {value})
setSnackbar(value)
})
if (AppDataSource.isInitialized) return setInitialized(true)
AppDataSource.initialize().then(async () => {
const settings = await settingsRepo.findOne({where: {}})
console.log(`${App.name}.useEffect:`, {gotSettings: settings})
setTheme(settings.theme)
setColor(settings.color)
setInitialized(true)
})
}, [])
const theme = useMemo(() => {
const darkTheme = {
...CombinedDarkTheme,
colors: {...CombinedDarkTheme.colors, primary: color},
};
const lightTheme = {
...CombinedDefaultTheme,
colors: {...CombinedDefaultTheme.colors, primary: color},
};
let value = isDark ? darkTheme : lightTheme;
if (settings?.theme === 'dark') value = darkTheme;
else if (settings?.theme === 'light') value = lightTheme;
return value;
}, [color, isDark, settings]);
const paperTheme = useMemo(() => {
const darkTheme = color
? {
...CombinedDarkTheme,
colors: {...CombinedDarkTheme.colors, primary: color},
}
: CombinedDarkTheme
const lightTheme = color
? {
...CombinedDefaultTheme,
colors: {...CombinedDefaultTheme.colors, primary: color},
}
: CombinedDefaultTheme
let value = isDark ? darkTheme : lightTheme
if (theme === 'dark') value = darkTheme
else if (theme === 'light') value = lightTheme
return value
}, [isDark, theme, color])
const action = useMemo(
() => ({
label: 'Close',
onPress: () => setSnackbar(''),
color: paperTheme.colors.background,
}),
[paperTheme.colors.background],
)
return (
<Color.Provider value={{color, setColor}}>
<Provider
theme={theme}
settings={{icon: props => <MaterialIcon {...props} />}}>
<NavigationContainer theme={theme}>
<MassiveSnack>
{settings && (
<SettingsContext.Provider value={{settings, setSettings}}>
<Routes />
</SettingsContext.Provider>
)}
</MassiveSnack>
</NavigationContainer>
</Provider>
</Color.Provider>
);
};
<PaperProvider
theme={paperTheme}
settings={{icon: props => <MaterialIcon {...props} />}}>
<NavigationContainer theme={paperTheme}>
{initialized && (
<ThemeContext.Provider value={{theme, setTheme, color, setColor}}>
<Routes />
</ThemeContext.Provider>
)}
</NavigationContainer>
export default App;
<Snackbar
duration={3000}
onDismiss={() => setSnackbar('')}
visible={!!snackbar}
action={action}>
{snackbar}
</Snackbar>
</PaperProvider>
)
}
export default App

View File

@ -2,45 +2,70 @@ import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {FlatList, Image} from 'react-native';
import {List} from 'react-native-paper';
import {getBestReps, getBestWeights} from './best.service';
import {BestPageParams} from './BestPage';
import DrawerHeader from './DrawerHeader';
import Page from './Page';
import Set from './set';
import {useSettings} from './use-settings';
} from '@react-navigation/native'
import {useCallback, useState} from 'react'
import {FlatList, Image} from 'react-native'
import {List} from 'react-native-paper'
import {BestPageParams} from './BestPage'
import {setRepo, settingsRepo} from './db'
import DrawerHeader from './DrawerHeader'
import GymSet from './gym-set'
import Page from './Page'
import Settings from './settings'
export default function BestList() {
const [bests, setBests] = useState<Set[]>();
const [search, setSearch] = useState('');
const navigation = useNavigation<NavigationProp<BestPageParams>>();
const {settings} = useSettings();
const refresh = useCallback(async () => {
const weights = await getBestWeights(search);
console.log(`${BestList.name}.refresh:`, {length: weights.length});
let newBest: Set[] = [];
for (const set of weights) {
const reps = await getBestReps(set.name, set.weight);
newBest.push(...reps);
}
setBests(newBest);
}, [search]);
const [bests, setBests] = useState<GymSet[]>()
const [term, setTerm] = useState('')
const navigation = useNavigation<NavigationProp<BestPageParams>>()
const [settings, setSettings] = useState<Settings>()
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh]),
);
settingsRepo.findOne({where: {}}).then(setSettings)
}, []),
)
useEffect(() => {
refresh();
}, [search, refresh]);
const refresh = useCallback(async (value: string) => {
const weights = await setRepo
.createQueryBuilder()
.select()
.addSelect('MAX(weight)', 'weight')
.where('name LIKE :name', {name: `%${value}%`})
.andWhere('NOT hidden')
.groupBy('name')
.getMany()
console.log(`${BestList.name}.refresh:`, {length: weights.length})
let newBest: GymSet[] = []
for (const set of weights) {
const reps = await setRepo
.createQueryBuilder()
.select()
.addSelect('MAX(reps)', 'reps')
.where('name = :name', {name: set.name})
.andWhere('weight = :weight', {weight: set.weight})
.andWhere('NOT hidden')
.groupBy('name')
.getMany()
newBest.push(...reps)
}
setBests(newBest)
}, [])
const renderItem = ({item}: {item: Set}) => (
useFocusEffect(
useCallback(() => {
refresh(term)
}, [refresh, term]),
)
const search = useCallback(
(value: string) => {
setTerm(value)
refresh(value)
},
[refresh],
)
const renderItem = ({item}: {item: GymSet}) => (
<List.Item
key={item.name}
title={item.name}
@ -53,12 +78,12 @@ export default function BestList() {
null
}
/>
);
)
return (
<>
<DrawerHeader name="Best" />
<Page search={search} setSearch={setSearch}>
<Page term={term} search={search}>
{bests?.length === 0 ? (
<List.Item
title="No exercises yet"
@ -69,5 +94,5 @@ export default function BestList() {
)}
</Page>
</>
);
)
}

View File

@ -1,16 +1,15 @@
import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import BestList from './BestList';
import Set from './set';
import ViewBest from './ViewBest';
import {createStackNavigator} from '@react-navigation/stack'
import BestList from './BestList'
import GymSet from './gym-set'
import ViewBest from './ViewBest'
const Stack = createStackNavigator<BestPageParams>();
const Stack = createStackNavigator<BestPageParams>()
export type BestPageParams = {
BestList: {};
BestList: {}
ViewBest: {
best: Set;
};
};
best: GymSet
}
}
export default function BestPage() {
return (
@ -19,5 +18,5 @@ export default function BestPage() {
<Stack.Screen name="BestList" component={BestList} />
<Stack.Screen name="ViewBest" component={ViewBest} />
</Stack.Navigator>
);
)
}

View File

@ -1,12 +1,11 @@
import * as shape from 'd3-shape';
import React from 'react';
import {View} from 'react-native';
import {Grid, LineChart, XAxis, YAxis} from 'react-native-svg-charts';
import {CombinedDarkTheme, CombinedDefaultTheme} from './App';
import {useColor} from './color';
import {MARGIN, PADDING} from './constants';
import Set from './set';
import useDark from './use-dark';
import {useTheme} from '@react-navigation/native'
import * as shape from 'd3-shape'
import {View} from 'react-native'
import {Grid, LineChart, XAxis, YAxis} from 'react-native-svg-charts'
import {CombinedDarkTheme, CombinedDefaultTheme} from './App'
import {MARGIN, PADDING} from './constants'
import GymSet from './gym-set'
import useDark from './use-dark'
export default function Chart({
yData,
@ -14,21 +13,21 @@ export default function Chart({
xData,
yFormat,
}: {
yData: number[];
xData: Set[];
xFormat: (value: any, index: number) => string;
yFormat: (value: any) => string;
yData: number[]
xData: GymSet[]
xFormat: (value: any, index: number) => string
yFormat: (value: any) => string
}) {
const {color} = useColor();
const dark = useDark();
const {colors} = useTheme()
const dark = useDark()
const axesSvg = {
fontSize: 10,
fill: dark
? CombinedDarkTheme.colors.text
: CombinedDefaultTheme.colors.text,
};
const verticalContentInset = {top: 10, bottom: 10};
const xAxisHeight = 30;
}
const verticalContentInset = {top: 10, bottom: 10}
const xAxisHeight = 30
return (
<>
@ -47,7 +46,7 @@ export default function Chart({
contentInset={verticalContentInset}
curve={shape.curveBasis}
svg={{
stroke: color,
stroke: colors.primary,
}}>
<Grid />
</LineChart>
@ -61,5 +60,5 @@ export default function Chart({
</View>
</View>
</>
);
)
}

View File

@ -1,5 +1,4 @@
import React from 'react';
import {Button, Dialog, Portal, Text} from 'react-native-paper';
import {Button, Dialog, Portal, Text} from 'react-native-paper'
export default function ConfirmDialog({
title,
@ -8,11 +7,11 @@ export default function ConfirmDialog({
show,
setShow,
}: {
title: string;
children: JSX.Element | JSX.Element[] | string;
onOk: () => void;
show: boolean;
setShow: (show: boolean) => void;
title: string
children: JSX.Element | JSX.Element[] | string
onOk: () => void
show: boolean
setShow: (show: boolean) => void
}) {
return (
<Portal>
@ -27,5 +26,5 @@ export default function ConfirmDialog({
</Dialog.Actions>
</Dialog>
</Portal>
);
)
}

View File

@ -1,18 +1,23 @@
import {DrawerNavigationProp} from '@react-navigation/drawer';
import {useNavigation} from '@react-navigation/native';
import React from 'react';
import {Appbar, IconButton} from 'react-native-paper';
import {DrawerParamList} from './drawer-param-list';
import DrawerMenu from './DrawerMenu';
import {DrawerNavigationProp} from '@react-navigation/drawer'
import {useNavigation} from '@react-navigation/native'
import {Appbar, IconButton} from 'react-native-paper'
import {DrawerParamList} from './drawer-param-list'
import DrawerMenu from './DrawerMenu'
import useDark from './use-dark'
export default function DrawerHeader({name}: {name: keyof DrawerParamList}) {
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>()
const dark = useDark()
return (
<Appbar.Header>
<IconButton icon="menu" onPress={navigation.openDrawer} />
<IconButton
color={dark ? 'white' : 'white'}
icon="menu"
onPress={navigation.openDrawer}
/>
<Appbar.Content title={name} />
<DrawerMenu name={name} />
</Appbar.Header>
);
)
}

View File

@ -1,67 +1,75 @@
import {NavigationProp, useNavigation} from '@react-navigation/native';
import React, {useCallback, useState} from 'react';
import DocumentPicker from 'react-native-document-picker';
import {FileSystem} from 'react-native-file-access';
import {Divider, IconButton, Menu} from 'react-native-paper';
import ConfirmDialog from './ConfirmDialog';
import {DrawerParamList} from './drawer-param-list';
import {useSnackbar} from './MassiveSnack';
import {Plan} from './plan';
import {addPlans, deletePlans, getAllPlans} from './plan.service';
import {addSets, deleteSets, getAllSets} from './set.service';
import {write} from './write';
import {NavigationProp, useNavigation} from '@react-navigation/native'
import {useCallback, useState} from 'react'
import DocumentPicker from 'react-native-document-picker'
import {FileSystem} from 'react-native-file-access'
import {Divider, IconButton, Menu} from 'react-native-paper'
import ConfirmDialog from './ConfirmDialog'
import {AppDataSource} from './data-source'
import {planRepo} from './db'
import {DrawerParamList} from './drawer-param-list'
import GymSet from './gym-set'
import {Plan} from './plan'
import {toast} from './toast'
import useDark from './use-dark'
import {write} from './write'
const setFields =
'id,name,reps,weight,created,unit,hidden,sets,minutes,seconds';
const planFields = 'id,days,workouts';
const setFields = 'id,name,reps,weight,created,unit,hidden,sets,minutes,seconds'
const planFields = 'id,days,workouts'
const setRepo = AppDataSource.manager.getRepository(GymSet)
export default function DrawerMenu({name}: {name: keyof DrawerParamList}) {
const [showMenu, setShowMenu] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const {toast} = useSnackbar();
const {reset} = useNavigation<NavigationProp<DrawerParamList>>();
const [showMenu, setShowMenu] = useState(false)
const [showRemove, setShowRemove] = useState(false)
const {reset} = useNavigation<NavigationProp<DrawerParamList>>()
const dark = useDark()
const exportSets = useCallback(async () => {
const sets = await getAllSets();
const sets = await setRepo.find({})
const data = [setFields]
.concat(
sets.map(
set =>
`${set.id},${set.name},${set.reps},${set.weight},${set.created},${set.unit},${set.hidden},${set.sets},${set.minutes},${set.seconds}`,
sets.map(set =>
setFields
.split(',')
.map(fieldString => {
const field = fieldString as keyof GymSet
if (field === 'unit') return set[field] || 'kg'
return set[field]
})
.join(','),
),
)
.join('\n');
console.log(`${DrawerMenu.name}.exportSets`, {length: sets.length});
await write('sets.csv', data);
}, []);
.join('\n')
console.log(`${DrawerMenu.name}.exportSets`, {length: sets.length})
await write('sets.csv', data)
}, [])
const exportPlans = useCallback(async () => {
const plans: Plan[] = await getAllPlans();
const plans = await planRepo.find({})
const data = [planFields]
.concat(plans.map(set => `"${set.id}","${set.days}","${set.workouts}"`))
.join('\n');
console.log(`${DrawerMenu.name}.exportPlans`, {length: plans.length});
await write('plans.csv', data);
}, []);
.join('\n')
console.log(`${DrawerMenu.name}.exportPlans`, {length: plans.length})
await write('plans.csv', data)
}, [])
const download = useCallback(async () => {
setShowMenu(false);
if (name === 'Home') exportSets();
else if (name === 'Plans') exportPlans();
}, [name, exportSets, exportPlans]);
setShowMenu(false)
if (name === 'Home') exportSets()
else if (name === 'Plans') exportPlans()
}, [name, exportSets, exportPlans])
const uploadSets = useCallback(async () => {
const result = await DocumentPicker.pickSingle();
const file = await FileSystem.readFile(result.uri);
console.log(`${DrawerMenu.name}.${uploadSets.name}:`, file.length);
const lines = file.split('\n');
console.log(lines[0]);
if (!setFields.includes(lines[0])) return toast('Invalid csv.', 3000);
const result = await DocumentPicker.pickSingle()
const file = await FileSystem.readFile(result.uri)
console.log(`${DrawerMenu.name}.uploadSets:`, file.length)
const lines = file.split('\n')
console.log(lines[0])
if (!setFields.includes(lines[0])) return toast('Invalid csv.')
const values = lines
.slice(1)
.filter(line => line)
.map(set => {
const [
.map(line => {
let [
,
setName,
reps,
@ -72,23 +80,33 @@ export default function DrawerMenu({name}: {name: keyof DrawerParamList}) {
sets,
minutes,
seconds,
] = set.split(',');
return `('${setName}',${reps},${weight},'${created}','${unit}',${hidden},${
sets ?? 3
},${minutes ?? 3},${seconds ?? 30})`;
] = line.split(',')
const set: GymSet = {
name: setName,
reps: +reps,
weight: +weight,
created,
unit: unit ?? 'kg',
hidden: !!Number(hidden),
sets: +sets,
minutes: +minutes,
seconds: +seconds,
image: '',
}
return set
})
.join(',');
await addSets(setFields.split(',').slice(1).join(','), values);
toast('Data imported.', 3000);
reset({index: 0, routes: [{name}]});
}, [reset, name, toast]);
console.log(`${DrawerMenu.name}.uploadSets:`, {values})
await setRepo.insert(values)
toast('Data imported.')
reset({index: 0, routes: [{name}]})
}, [reset, name])
const uploadPlans = useCallback(async () => {
const result = await DocumentPicker.pickSingle();
const file = await FileSystem.readFile(result.uri);
console.log(`${DrawerMenu.name}.uploadPlans:`, file.length);
const lines = file.split('\n');
if (lines[0] != planFields) return toast('Invalid csv.', 3000);
const result = await DocumentPicker.pickSingle()
const file = await FileSystem.readFile(result.uri)
console.log(`${DrawerMenu.name}.uploadPlans:`, file.length)
const lines = file.split('\n')
if (lines[0] !== planFields) return toast('Invalid csv.')
const values = file
.split('\n')
.slice(1)
@ -96,29 +114,32 @@ export default function DrawerMenu({name}: {name: keyof DrawerParamList}) {
.map(set => {
const [, days, workouts] = set
.split('","')
.map(cell => cell.replace(/"/g, ''));
return `('${days}','${workouts}')`;
.map(cell => cell.replace(/"/g, ''))
const plan: Plan = {
days,
workouts,
}
return plan
})
.join(',');
await addPlans(values);
toast('Data imported.', 3000);
}, [toast]);
await planRepo.insert(values)
toast('Data imported.')
}, [])
const upload = useCallback(async () => {
setShowMenu(false);
if (name === 'Home') await uploadSets();
else if (name === 'Plans') await uploadPlans();
reset({index: 0, routes: [{name}]});
}, [name, uploadPlans, uploadSets, reset]);
setShowMenu(false)
if (name === 'Home') await uploadSets()
else if (name === 'Plans') await uploadPlans()
reset({index: 0, routes: [{name}]})
}, [name, uploadPlans, uploadSets, reset])
const remove = useCallback(async () => {
setShowMenu(false);
setShowRemove(false);
if (name === 'Home') await deleteSets();
else if (name === 'Plans') await deletePlans();
toast('All data has been deleted.', 4000);
reset({index: 0, routes: [{name}]});
}, [reset, name, toast]);
setShowMenu(false)
setShowRemove(false)
if (name === 'Home') await setRepo.delete({})
else if (name === 'Plans') await planRepo.delete({})
toast('All data has been deleted.')
reset({index: 0, routes: [{name}]})
}, [reset, name])
if (name === 'Home' || name === 'Plans')
return (
@ -126,7 +147,11 @@ export default function DrawerMenu({name}: {name: keyof DrawerParamList}) {
visible={showMenu}
onDismiss={() => setShowMenu(false)}
anchor={
<IconButton onPress={() => setShowMenu(true)} icon="more-vert" />
<IconButton
color={dark ? 'white' : 'white'}
onPress={() => setShowMenu(true)}
icon="more-vert"
/>
}>
<Menu.Item icon="arrow-downward" onPress={download} title="Download" />
<Menu.Item icon="arrow-upward" onPress={upload} title="Upload" />
@ -144,7 +169,7 @@ export default function DrawerMenu({name}: {name: keyof DrawerParamList}) {
This irreversibly deletes all data from the app. Are you sure?
</ConfirmDialog>
</Menu>
);
)
return null;
return null
}

View File

@ -3,75 +3,72 @@ import {
RouteProp,
useNavigation,
useRoute,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {ScrollView, StyleSheet, View} from 'react-native';
import {Button, Text} from 'react-native-paper';
import {MARGIN, PADDING} from './constants';
import {DrawerParamList} from './drawer-param-list';
import {PlanPageParams} from './plan-page-params';
import {addPlan, updatePlan} from './plan.service';
import {getNames} from './set.service';
import StackHeader from './StackHeader';
import Switch from './Switch';
import {DAYS} from './time';
} from '@react-navigation/native'
import {useCallback, useEffect, useState} from 'react'
import {ScrollView, StyleSheet, View} from 'react-native'
import {Button, Text} from 'react-native-paper'
import {MARGIN, PADDING} from './constants'
import {planRepo, setRepo} from './db'
import {DrawerParamList} from './drawer-param-list'
import {PlanPageParams} from './plan-page-params'
import StackHeader from './StackHeader'
import Switch from './Switch'
import {DAYS} from './time'
export default function EditPlan() {
const {params} = useRoute<RouteProp<PlanPageParams, 'EditPlan'>>();
const {plan} = params;
const {params} = useRoute<RouteProp<PlanPageParams, 'EditPlan'>>()
const {plan} = params
const [days, setDays] = useState<string[]>(
plan.days ? plan.days.split(',') : [],
);
)
const [workouts, setWorkouts] = useState<string[]>(
plan.workouts ? plan.workouts.split(',') : [],
);
const [names, setNames] = useState<string[]>([]);
const navigation = useNavigation<NavigationProp<DrawerParamList>>();
)
const [names, setNames] = useState<string[]>([])
const navigation = useNavigation<NavigationProp<DrawerParamList>>()
useEffect(() => {
getNames().then(n => {
console.log(EditPlan.name, {n});
setNames(n);
});
}, []);
setRepo
.createQueryBuilder()
.select('name')
.distinct(true)
.getRawMany()
.then(values => {
console.log(EditPlan.name, {values})
setNames(values.map(value => value.name))
})
}, [])
const save = useCallback(async () => {
console.log(`${EditPlan.name}.save`, {days, workouts, plan});
if (!days || !workouts) return;
const newWorkouts = workouts.filter(workout => workout).join(',');
const newDays = days.filter(day => day).join(',');
if (typeof plan.id === 'undefined')
await addPlan({days: newDays, workouts: newWorkouts});
else
await updatePlan({
days: newDays,
workouts: newWorkouts,
id: plan.id,
});
navigation.goBack();
}, [days, workouts, plan, navigation]);
console.log(`${EditPlan.name}.save`, {days, workouts, plan})
if (!days || !workouts) return
const newWorkouts = workouts.filter(workout => workout).join(',')
const newDays = days.filter(day => day).join(',')
await planRepo.save({days: newDays, workouts: newWorkouts, id: plan.id})
navigation.goBack()
}, [days, workouts, plan, navigation])
const toggleWorkout = useCallback(
(on: boolean, name: string) => {
if (on) {
setWorkouts([...workouts, name]);
setWorkouts([...workouts, name])
} else {
setWorkouts(workouts.filter(workout => workout !== name));
setWorkouts(workouts.filter(workout => workout !== name))
}
},
[setWorkouts, workouts],
);
)
const toggleDay = useCallback(
(on: boolean, day: string) => {
if (on) {
setDays([...days, day]);
setDays([...days, day])
} else {
setDays(days.filter(d => d !== day));
setDays(days.filter(d => d !== day))
}
},
[setDays, days],
);
)
return (
<>
@ -110,11 +107,11 @@ export default function EditPlan() {
disabled={workouts.length === 0 && days.length === 0}
mode="contained"
onPress={() => {
navigation.goBack();
navigation.goBack()
navigation.navigate('Workouts', {
screen: 'EditWorkout',
params: {value: {name: ''}},
});
})
}}>
Add workout
</Button>
@ -130,7 +127,7 @@ export default function EditPlan() {
)}
</View>
</>
);
)
}
const styles = StyleSheet.create({
@ -138,4 +135,4 @@ const styles = StyleSheet.create({
fontSize: 20,
marginBottom: MARGIN,
},
});
})

View File

@ -1,81 +1,78 @@
import {RouteProp, useNavigation, useRoute} from '@react-navigation/native';
import React, {useCallback} from 'react';
import {NativeModules, View} from 'react-native';
import {PADDING} from './constants';
import {HomePageParams} from './home-page-params';
import {useSnackbar} from './MassiveSnack';
import Set from './set';
import {addSet, getSet, updateSet} from './set.service';
import SetForm from './SetForm';
import {updateSettings} from './settings.service';
import StackHeader from './StackHeader';
import {useSettings} from './use-settings';
import {
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native'
import {useCallback, useState} from 'react'
import {NativeModules, View} from 'react-native'
import {PADDING} from './constants'
import {setRepo, settingsRepo} from './db'
import GymSet from './gym-set'
import {HomePageParams} from './home-page-params'
import SetForm from './SetForm'
import Settings from './settings'
import StackHeader from './StackHeader'
import {toast} from './toast'
export default function EditSet() {
const {params} = useRoute<RouteProp<HomePageParams, 'EditSet'>>();
const {set} = params;
const navigation = useNavigation();
const {toast} = useSnackbar();
const {settings} = useSettings();
const {params} = useRoute<RouteProp<HomePageParams, 'EditSet'>>()
const {set} = params
const navigation = useNavigation()
const [settings, setSettings] = useState<Settings>()
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({where: {}}).then(setSettings)
}, []),
)
const startTimer = useCallback(
async (name: string) => {
if (!settings.alarm) return;
const {minutes, seconds} = await getSet(name);
const milliseconds = (minutes ?? 3) * 60 * 1000 + (seconds ?? 0) * 1000;
console.log(`startTimer:`, `Starting timer in ${minutes}:${seconds}`);
if (!settings.alarm) return
const {minutes, seconds} = await setRepo.findOne({where: {name}})
const milliseconds = (minutes ?? 3) * 60 * 1000 + (seconds ?? 0) * 1000
NativeModules.AlarmModule.timer(
milliseconds,
!!settings.vibrate,
settings.vibrate,
settings.sound,
!!settings.noSound,
);
const nextAlarm = new Date();
nextAlarm.setTime(nextAlarm.getTime() + milliseconds);
updateSettings({...settings, nextAlarm: nextAlarm.toISOString()});
settings.noSound,
)
},
[settings],
);
const update = useCallback(
async (value: Set) => {
console.log(`${EditSet.name}.update`, value);
await updateSet(value);
navigation.goBack();
},
[navigation],
);
)
const add = useCallback(
async (value: Set) => {
console.log(`${EditSet.name}.add`, {set: value});
startTimer(value.name);
await addSet(value);
if (!settings.notify) return navigation.goBack();
async (value: GymSet) => {
startTimer(value.name)
console.log(`${EditSet.name}.add`, {set: value})
const result = await setRepo.save(value)
console.log({result})
if (!settings.notify) return
if (
value.weight > set.weight ||
(value.reps > set.reps && value.weight === set.weight)
)
toast("Great work King! That's a new record.", 3000);
navigation.goBack();
toast("Great work King! That's a new record.")
},
[navigation, startTimer, set, toast, settings],
);
[startTimer, set, settings],
)
const save = useCallback(
async (value: Set) => {
if (typeof set.id === 'number') return update(value);
return add(value);
async (value: GymSet) => {
if (typeof set.id === 'number') await setRepo.save(value)
else await add(value)
navigation.goBack()
},
[update, add, set.id],
);
[add, set.id, navigation],
)
return (
<>
<StackHeader title="Edit set" />
<View style={{padding: PADDING, flex: 1}}>
<SetForm save={save} set={set} />
{settings && <SetForm settings={settings} save={save} set={set} />}
</View>
</>
);
)
}

View File

@ -1,56 +1,73 @@
import {RouteProp, useNavigation, useRoute} from '@react-navigation/native';
import React, {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 ConfirmDialog from './ConfirmDialog';
import {MARGIN, PADDING} from './constants';
import MassiveInput from './MassiveInput';
import {useSnackbar} from './MassiveSnack';
import {updatePlanWorkouts} from './plan.service';
import {addSet, updateManySet, updateSetImage} from './set.service';
import StackHeader from './StackHeader';
import {useSettings} from './use-settings';
import {WorkoutsPageParams} from './WorkoutsPage';
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 ConfirmDialog from './ConfirmDialog'
import {MARGIN, PADDING} from './constants'
import {getNow, planRepo, setRepo, settingsRepo} from './db'
import MassiveInput from './MassiveInput'
import Settings from './settings'
import StackHeader from './StackHeader'
import {toast} from './toast'
import {WorkoutsPageParams} from './WorkoutsPage'
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 {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 [minutes, setMinutes] = useState(
params.value.minutes?.toString() ?? '3',
);
)
const [seconds, setSeconds] = useState(
params.value.seconds?.toString() ?? '30',
);
const [sets, setSets] = useState(params.value.sets?.toString() ?? '3');
const {toast} = useSnackbar();
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} = useSettings();
)
const [sets, setSets] = useState(params.value.sets?.toString() ?? '3')
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)
}, []),
)
const update = async () => {
await updateManySet({
oldName: params.value.name,
newName: name || params.value.name,
sets: sets ?? '3',
seconds: seconds?.toString() ?? '30',
minutes: minutes?.toString() ?? '3',
steps,
});
await updatePlanWorkouts(params.value.name, name || params.value.name);
if (uri || removeImage) await updateSetImage(params.value.name, uri || '');
navigation.goBack();
};
await setRepo.update(
{name: params.value.name},
{
name: name || params.value.name,
sets: Number(sets),
minutes: +minutes,
seconds: +seconds,
steps,
image: removeImage ? '' : uri,
},
)
await planRepo.query(
`UPDATE plans
SET workouts = REPLACE(workouts, $1, $2)
WHERE workouts LIKE $3`,
[params.value.name, name, `%${params.value.name}%`],
)
navigation.goBack()
}
const add = async () => {
await addSet({
const [{now}] = await getNow()
await setRepo.save({
name,
reps: 0,
weight: 0,
@ -60,45 +77,46 @@ export default function EditWorkout() {
seconds: seconds ? +seconds : 30,
sets: sets ? +sets : 3,
steps,
});
navigation.goBack();
};
created: now,
})
navigation.goBack()
}
const save = async () => {
if (params.value.name) return update();
return add();
};
if (params.value.name) return update()
return add()
}
const changeImage = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
type: 'image/*',
copyTo: 'documentDirectory',
});
if (fileCopyUri) setUri(fileCopyUri);
}, []);
})
if (fileCopyUri) setUri(fileCopyUri)
}, [])
const handleRemove = useCallback(async () => {
setUri('');
setRemoveImage(true);
setShowRemove(false);
}, []);
setUri('')
setRemoveImage(true)
setShowRemove(false)
}, [])
const handleName = (value: string) => {
setName(value.replace(/,|'/g, ''));
setName(value.replace(/,|'/g, ''))
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
toast('Commas and single quotes would break CSV exports')
}
const handleSteps = (value: string) => {
setSteps(value.replace(/,|'/g, ''));
setSteps(value.replace(/,|'/g, ''))
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
toast('Commas and single quotes would break CSV exports')
}
const submitName = () => {
if (settings.steps) stepsRef.current?.focus();
else setsRef.current?.focus();
};
if (settings.steps) stepsRef.current?.focus()
else setsRef.current?.focus()
}
return (
<>
@ -112,7 +130,7 @@ export default function EditWorkout() {
onChangeText={handleName}
onSubmitEditing={submitName}
/>
{!!settings.steps && (
{settings?.steps && (
<MassiveInput
innerRef={stepsRef}
selectTextOnFocus={false}
@ -123,7 +141,7 @@ export default function EditWorkout() {
onSubmitEditing={() => setsRef.current?.focus()}
/>
)}
{!!settings.showSets && (
{settings?.showSets && (
<MassiveInput
innerRef={setsRef}
value={sets}
@ -133,7 +151,7 @@ export default function EditWorkout() {
onSubmitEditing={() => minutesRef.current?.focus()}
/>
)}
{!!settings.alarm && (
{settings?.alarm && (
<>
<MassiveInput
innerRef={minutesRef}
@ -153,7 +171,7 @@ export default function EditWorkout() {
/>
</>
)}
{!!settings.images && uri && (
{settings?.images && uri && (
<TouchableRipple
style={{marginBottom: MARGIN}}
onPress={changeImage}
@ -161,7 +179,7 @@ export default function EditWorkout() {
<Card.Cover source={{uri}} />
</TouchableRipple>
)}
{!!settings.images && !uri && (
{settings?.images && !uri && (
<Button
style={{marginBottom: MARGIN}}
onPress={changeImage}
@ -182,5 +200,5 @@ export default function EditWorkout() {
</ConfirmDialog>
</View>
</>
);
)
}

View File

@ -1,10 +1,9 @@
import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import EditSet from './EditSet';
import {HomePageParams} from './home-page-params';
import SetList from './SetList';
import {createStackNavigator} from '@react-navigation/stack'
import EditSet from './EditSet'
import {HomePageParams} from './home-page-params'
import SetList from './SetList'
const Stack = createStackNavigator<HomePageParams>();
const Stack = createStackNavigator<HomePageParams>()
export default function HomePage() {
return (
@ -13,5 +12,5 @@ export default function HomePage() {
<Stack.Screen name="Sets" component={SetList} />
<Stack.Screen name="EditSet" component={EditSet} />
</Stack.Navigator>
);
)
}

View File

@ -1,15 +1,15 @@
import React from 'react';
import {FAB} from 'react-native-paper';
import {useColor} from './color';
import {lightColors} from './colors';
import {ComponentProps} from 'react'
import {FAB} from 'react-native-paper'
import {CombinedDarkTheme, CombinedDefaultTheme} from './App'
import {lightColors} from './colors'
import {useTheme} from './use-theme'
export default function MassiveFab(
props: Partial<React.ComponentProps<typeof FAB>>,
) {
const {color} = useColor();
const fabColor = lightColors.map(lightColor => lightColor.hex).includes(color)
? 'black'
: undefined;
export default function MassiveFab(props: Partial<ComponentProps<typeof FAB>>) {
const {color} = useTheme()
const fabColor = lightColors.includes(color)
? CombinedDarkTheme.colors.background
: CombinedDefaultTheme.colors.background
return (
<FAB
@ -23,5 +23,5 @@ export default function MassiveFab(
}}
{...props}
/>
);
)
}

View File

@ -1,15 +1,15 @@
import React from 'react';
import {TextInput} from 'react-native-paper';
import {CombinedDefaultTheme} from './App';
import {MARGIN} from './constants';
import useDark from './use-dark';
import {ComponentProps, Ref} from 'react'
import {TextInput} from 'react-native-paper'
import {CombinedDefaultTheme} from './App'
import {MARGIN} from './constants'
import useDark from './use-dark'
export default function MassiveInput(
props: Partial<React.ComponentProps<typeof TextInput>> & {
innerRef?: React.Ref<any>;
props: Partial<ComponentProps<typeof TextInput>> & {
innerRef?: Ref<any>
},
) {
const dark = useDark();
const dark = useDark()
return (
<TextInput
@ -21,5 +21,5 @@ export default function MassiveInput(
blurOnSubmit={false}
{...props}
/>
);
)
}

View File

@ -1,49 +0,0 @@
import React, {useContext, useState} from 'react';
import {Snackbar} from 'react-native-paper';
import {CombinedDarkTheme, CombinedDefaultTheme} from './App';
import useDark from './use-dark';
export const SnackbarContext = React.createContext<{
toast: (value: string, timeout: number) => void;
}>({toast: () => null});
export const useSnackbar = () => {
return useContext(SnackbarContext);
};
export default function MassiveSnack({
children,
}: {
children?: JSX.Element[] | JSX.Element;
}) {
const [snackbar, setSnackbar] = useState('');
const [timeoutId, setTimeoutId] = useState(0);
const dark = useDark();
const toast = (value: string, timeout: number) => {
setSnackbar(value);
clearTimeout(timeoutId);
const id = setTimeout(() => setSnackbar(''), timeout);
setTimeoutId(id);
};
return (
<>
<SnackbarContext.Provider value={{toast}}>
{children}
</SnackbarContext.Provider>
<Snackbar
onDismiss={() => setSnackbar('')}
visible={!!snackbar}
action={{
label: 'Close',
onPress: () => setSnackbar(''),
color: dark
? CombinedDarkTheme.colors.background
: CombinedDefaultTheme.colors.background,
}}>
{snackbar}
</Snackbar>
</>
);
}

View File

@ -1,35 +1,32 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
import {Searchbar} from 'react-native-paper';
import {PADDING} from './constants';
import MassiveFab from './MassiveFab';
import {StyleSheet, View} from 'react-native'
import {Searchbar} from 'react-native-paper'
import {PADDING} from './constants'
import MassiveFab from './MassiveFab'
export default function Page({
onAdd,
children,
term,
search,
setSearch,
}: {
children: JSX.Element | JSX.Element[];
onAdd?: () => void;
search?: string;
setSearch?: (value: string) => void;
children: JSX.Element | JSX.Element[]
onAdd?: () => void
term: string
search: (value: string) => void
}) {
return (
<View style={styles.container}>
{typeof search === 'string' && setSearch && (
<Searchbar
placeholder="Search"
value={search}
onChangeText={setSearch}
icon="search"
clearIcon="clear"
/>
)}
<Searchbar
placeholder="Search"
value={term}
onChangeText={search}
icon="search"
clearIcon="clear"
/>
{children}
{onAdd && <MassiveFab onPress={onAdd} />}
</View>
);
)
}
const styles = StyleSheet.create({
@ -37,4 +34,4 @@ const styles = StyleSheet.create({
flexGrow: 1,
padding: PADDING,
},
});
})

View File

@ -2,62 +2,59 @@ import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import React, {useCallback, useMemo, useState} from 'react';
import {GestureResponderEvent, Text} from 'react-native';
import {Divider, List, Menu} from 'react-native-paper';
import {getBestSet} from './best.service';
import {Plan} from './plan';
import {PlanPageParams} from './plan-page-params';
import {deletePlan} from './plan.service';
import {DAYS} from './time';
} from '@react-navigation/native'
import {useCallback, useMemo, useState} from 'react'
import {GestureResponderEvent, Text} from 'react-native'
import {Divider, List, Menu} from 'react-native-paper'
import {planRepo} from './db'
import {Plan} from './plan'
import {PlanPageParams} from './plan-page-params'
import {DAYS} from './time'
export default function PlanItem({
item,
onRemove,
}: {
item: Plan;
onRemove: () => void;
item: Plan
onRemove: () => void
}) {
const [show, setShow] = useState(false);
const [anchor, setAnchor] = useState({x: 0, y: 0});
const [today, setToday] = useState<string>();
const days = useMemo(() => item.days.split(','), [item.days]);
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
const [show, setShow] = useState(false)
const [anchor, setAnchor] = useState({x: 0, y: 0})
const [today, setToday] = useState<string>()
const days = useMemo(() => item.days.split(','), [item.days])
const navigation = useNavigation<NavigationProp<PlanPageParams>>()
useFocusEffect(
useCallback(() => {
const newToday = DAYS[new Date().getDay()];
setToday(newToday);
const newToday = DAYS[new Date().getDay()]
setToday(newToday)
}, []),
);
)
const remove = useCallback(async () => {
if (typeof item.id === 'number') await deletePlan(item.id);
setShow(false);
onRemove();
}, [setShow, item.id, onRemove]);
if (typeof item.id === 'number') await planRepo.delete(item.id)
setShow(false)
onRemove()
}, [setShow, item.id, onRemove])
const start = useCallback(async () => {
const workouts = item.workouts.split(',');
const first = workouts[0];
const set = await getBestSet(first);
setShow(false);
navigation.navigate('StartPlan', {plan: item, set});
}, [item, navigation]);
console.log(`${PlanItem.name}.start:`, {item})
setShow(false)
navigation.navigate('StartPlan', {plan: item})
}, [item, navigation])
const longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
setShow(true);
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY})
setShow(true)
},
[setAnchor, setShow],
);
)
const edit = useCallback(() => {
setShow(false);
navigation.navigate('EditPlan', {plan: item});
}, [navigation, item]);
setShow(false)
navigation.navigate('EditPlan', {plan: item})
}, [navigation, item])
const title = useMemo(
() =>
@ -74,28 +71,34 @@ export default function PlanItem({
</Text>
)),
[days, today],
);
)
const description = useMemo(
() => item.workouts.replace(/,/g, ', '),
[item.workouts],
);
)
const copy = useCallback(() => {
const plan: Plan = {...item}
delete plan.id
setShow(false)
navigation.navigate('EditPlan', {plan})
}, [navigation, item])
return (
<>
<List.Item
onPress={start}
title={title}
description={description}
onLongPress={longPress}
right={() => (
<Menu anchor={anchor} visible={show} onDismiss={() => setShow(false)}>
<Menu.Item icon="edit" onPress={edit} title="Edit" />
<Divider />
<Menu.Item icon="delete" onPress={remove} title="Delete" />
</Menu>
)}
/>
</>
);
<List.Item
onPress={start}
title={title}
description={description}
onLongPress={longPress}
right={() => (
<Menu anchor={anchor} visible={show} onDismiss={() => setShow(false)}>
<Menu.Item icon="edit" onPress={edit} title="Edit" />
<Menu.Item icon="content-copy" onPress={copy} title="Copy" />
<Divider />
<Menu.Item icon="delete" onPress={remove} title="Delete" />
</Menu>
)}
/>
)
}

View File

@ -2,50 +2,59 @@ import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {FlatList} from 'react-native';
import {List} from 'react-native-paper';
import DrawerHeader from './DrawerHeader';
import Page from './Page';
import {Plan} from './plan';
import {PlanPageParams} from './plan-page-params';
import {getPlans} from './plan.service';
import PlanItem from './PlanItem';
} from '@react-navigation/native'
import {useCallback, useState} from 'react'
import {FlatList} from 'react-native'
import {List} from 'react-native-paper'
import {Like} from 'typeorm'
import {planRepo} from './db'
import DrawerHeader from './DrawerHeader'
import Page from './Page'
import {Plan} from './plan'
import {PlanPageParams} from './plan-page-params'
import PlanItem from './PlanItem'
export default function PlanList() {
const [search, setSearch] = useState('');
const [plans, setPlans] = useState<Plan[]>();
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
const [term, setTerm] = useState('')
const [plans, setPlans] = useState<Plan[]>()
const navigation = useNavigation<NavigationProp<PlanPageParams>>()
const refresh = useCallback(async () => {
getPlans(search).then(setPlans);
}, [search]);
const refresh = useCallback(async (value: string) => {
planRepo
.find({
where: [{days: Like(`%${value}%`)}, {workouts: Like(`%${value}%`)}],
})
.then(setPlans)
}, [])
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh]),
);
refresh(term)
}, [refresh, term]),
)
useEffect(() => {
refresh();
}, [search, refresh]);
const search = useCallback(
(value: string) => {
setTerm(value)
refresh(value)
},
[refresh],
)
const renderItem = useCallback(
({item}: {item: Plan}) => (
<PlanItem item={item} key={item.id} onRemove={refresh} />
<PlanItem item={item} key={item.id} onRemove={() => refresh(term)} />
),
[refresh],
);
[refresh, term],
)
const onAdd = () =>
navigation.navigate('EditPlan', {plan: {days: '', workouts: ''}});
navigation.navigate('EditPlan', {plan: {days: '', workouts: ''}})
return (
<>
<DrawerHeader name="Plans" />
<Page onAdd={onAdd} search={search} setSearch={setSearch}>
<Page onAdd={onAdd} term={term} search={search}>
{plans?.length === 0 ? (
<List.Item
title="No plans yet"
@ -61,5 +70,5 @@ export default function PlanList() {
)}
</Page>
</>
);
)
}

View File

@ -1,11 +1,11 @@
import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import EditPlan from './EditPlan';
import {PlanPageParams} from './plan-page-params';
import PlanList from './PlanList';
import StartPlan from './StartPlan';
import {createStackNavigator} from '@react-navigation/stack'
import EditPlan from './EditPlan'
import EditSet from './EditSet'
import {PlanPageParams} from './plan-page-params'
import PlanList from './PlanList'
import StartPlan from './StartPlan'
const Stack = createStackNavigator<PlanPageParams>();
const Stack = createStackNavigator<PlanPageParams>()
export default function PlanPage() {
return (
@ -14,6 +14,7 @@ export default function PlanPage() {
<Stack.Screen name="PlanList" component={PlanList} />
<Stack.Screen name="EditPlan" component={EditPlan} />
<Stack.Screen name="StartPlan" component={StartPlan} />
<Stack.Screen name="EditSet" component={EditSet} />
</Stack.Navigator>
);
)
}

View File

@ -1,31 +1,30 @@
import {createDrawerNavigator} from '@react-navigation/drawer';
import {useNavigation} from '@react-navigation/native';
import React from 'react';
import {IconButton} from 'react-native-paper';
import BestPage from './BestPage';
import {DrawerParamList} from './drawer-param-list';
import HomePage from './HomePage';
import PlanPage from './PlanPage';
import Route from './route';
import SettingsPage from './SettingsPage';
import TimerPage from './TimerPage';
import useDark from './use-dark';
import WorkoutsPage from './WorkoutsPage';
import {createDrawerNavigator} from '@react-navigation/drawer'
import {useMemo} from 'react'
import {IconButton} from 'react-native-paper'
import BestPage from './BestPage'
import {DrawerParamList} from './drawer-param-list'
import HomePage from './HomePage'
import PlanPage from './PlanPage'
import Route from './route'
import SettingsPage from './SettingsPage'
import useDark from './use-dark'
import WorkoutsPage from './WorkoutsPage'
const Drawer = createDrawerNavigator<DrawerParamList>();
const Drawer = createDrawerNavigator<DrawerParamList>()
export default function Routes() {
const dark = useDark();
const navigation = useNavigation();
const dark = useDark()
const routes: Route[] = [
{name: 'Home', component: HomePage, icon: 'home'},
{name: 'Plans', component: PlanPage, icon: 'event'},
{name: 'Best', component: BestPage, icon: 'insights'},
{name: 'Workouts', component: WorkoutsPage, icon: 'fitness-center'},
{name: 'Timer', component: TimerPage, icon: 'access-time'},
{name: 'Settings', component: SettingsPage, icon: 'settings'},
];
const routes: Route[] = useMemo(
() => [
{name: 'Home', component: HomePage, icon: 'home'},
{name: 'Plans', component: PlanPage, icon: 'event'},
{name: 'Best', component: BestPage, icon: 'insights'},
{name: 'Workouts', component: WorkoutsPage, icon: 'fitness-center'},
{name: 'Settings', component: SettingsPage, icon: 'settings'},
],
[],
)
return (
<Drawer.Navigator
@ -45,5 +44,5 @@ export default function Routes() {
/>
))}
</Drawer.Navigator>
);
)
}

24
Select.tsx Normal file
View File

@ -0,0 +1,24 @@
import {Picker} from '@react-native-picker/picker'
import {useTheme} from 'react-native-paper'
export default function Select({
value,
onChange,
children,
}: {
value: string
onChange: (value: string) => void
children: JSX.Element | JSX.Element[]
}) {
const {colors} = useTheme()
return (
<Picker
style={{color: colors.primary, marginTop: -10}}
dropdownIconColor={colors.text}
selectedValue={value}
onValueChange={onChange}>
{children}
</Picker>
)
}

View File

@ -1,50 +1,52 @@
import React, {useCallback, useRef, useState} from 'react';
import {TextInput, View} from 'react-native';
import DocumentPicker from 'react-native-document-picker';
import {Button, Card, TouchableRipple} from 'react-native-paper';
import ConfirmDialog from './ConfirmDialog';
import {MARGIN} from './constants';
import MassiveInput from './MassiveInput';
import {useSnackbar} from './MassiveSnack';
import Set from './set';
import {getSets} from './set.service';
import {useSettings} from './use-settings';
import {useCallback, useRef, useState} from 'react'
import {TextInput, View} from 'react-native'
import DocumentPicker from 'react-native-document-picker'
import {Button, Card, TouchableRipple} from 'react-native-paper'
import ConfirmDialog from './ConfirmDialog'
import {MARGIN} from './constants'
import {getNow, setRepo} from './db'
import GymSet from './gym-set'
import MassiveInput from './MassiveInput'
import Settings from './settings'
import {format} from './time'
import {toast} from './toast'
export default function SetForm({
save,
set,
settings,
}: {
set: Set;
save: (set: Set) => void;
set: GymSet
save: (set: GymSet) => void
settings: Settings
}) {
const [name, setName] = useState(set.name);
const [reps, setReps] = useState(set.reps.toString());
const [weight, setWeight] = useState(set.weight.toString());
const [newImage, setNewImage] = useState(set.image);
const [unit, setUnit] = useState(set.unit);
const [showRemove, setShowRemove] = useState(false);
const [name, setName] = useState(set.name)
const [reps, setReps] = useState(set.reps.toString())
const [weight, setWeight] = useState(set.weight.toString())
const [newImage, setNewImage] = useState(set.image)
const [unit, setUnit] = useState(set.unit)
const [showRemove, setShowRemove] = useState(false)
const [selection, setSelection] = useState({
start: 0,
end: set.reps.toString().length,
});
const [removeImage, setRemoveImage] = useState(false);
const {toast} = useSnackbar();
const {settings} = useSettings();
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const unitRef = useRef<TextInput>(null);
})
const [removeImage, setRemoveImage] = useState(false)
const weightRef = useRef<TextInput>(null)
const repsRef = useRef<TextInput>(null)
const unitRef = useRef<TextInput>(null)
const handleSubmit = async () => {
console.log(`${SetForm.name}.handleSubmit:`, {set, uri: newImage, name});
if (!name) return;
let image = newImage;
console.log(`${SetForm.name}.handleSubmit:`, {set, uri: newImage, name})
if (!name) return
let image = newImage
if (!newImage && !removeImage)
image = await getSets({search: name, limit: 1, offset: 0}).then(
([gotSet]) => gotSet?.image,
);
console.log(`${SetForm.name}.handleSubmit:`, {image});
image = await setRepo.findOne({where: {name}}).then(s => s?.image)
console.log(`${SetForm.name}.handleSubmit:`, {image})
const [{now}] = await getNow()
save({
name,
created: now,
reps: Number(reps),
weight: Number(weight),
id: set.id,
@ -53,34 +55,35 @@ export default function SetForm({
minutes: Number(set.minutes ?? 3),
seconds: Number(set.seconds ?? 30),
sets: set.sets ?? 3,
});
};
hidden: false,
})
}
const handleName = (value: string) => {
setName(value.replace(/,|'/g, ''));
setName(value.replace(/,|'/g, ''))
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
toast('Commas and single quotes would break CSV exports')
}
const handleUnit = (value: string) => {
setUnit(value.replace(/,|'/g, ''));
setUnit(value.replace(/,|'/g, ''))
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
toast('Commas and single quotes would break CSV exports')
}
const changeImage = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
type: 'image/*',
copyTo: 'documentDirectory',
});
if (fileCopyUri) setNewImage(fileCopyUri);
}, []);
})
if (fileCopyUri) setNewImage(fileCopyUri)
}, [])
const handleRemove = useCallback(async () => {
setNewImage('');
setRemoveImage(true);
setShowRemove(false);
}, []);
setNewImage('')
setRemoveImage(true)
setShowRemove(false)
}, [])
return (
<>
@ -112,7 +115,7 @@ export default function SetForm({
onSubmitEditing={handleSubmit}
innerRef={weightRef}
/>
{!!settings.showUnit && (
{settings.showUnit && (
<MassiveInput
autoCapitalize="none"
label="Unit"
@ -121,10 +124,14 @@ export default function SetForm({
innerRef={unitRef}
/>
)}
{typeof set.id === 'number' && !!settings.showDate && (
<MassiveInput label="Created" disabled value={set.created} />
{typeof set.id === 'number' && settings.showDate && (
<MassiveInput
label="Created"
disabled
value={format(set.created, settings.date)}
/>
)}
{!!settings.images && newImage && (
{settings.images && newImage && (
<TouchableRipple
style={{marginBottom: MARGIN}}
onPress={changeImage}
@ -132,7 +139,7 @@ export default function SetForm({
<Card.Cover source={{uri: newImage}} />
</TouchableRipple>
)}
{!!settings.images && !newImage && (
{settings.images && !newImage && (
<Button
style={{marginBottom: MARGIN}}
onPress={changeImage}
@ -156,5 +163,5 @@ export default function SetForm({
Are you sure you want to remove the image?
</ConfirmDialog>
</>
);
)
}

View File

@ -1,47 +1,48 @@
import {NavigationProp, useNavigation} from '@react-navigation/native';
import React, {useCallback, useState} from 'react';
import {GestureResponderEvent, Image} from 'react-native';
import {Divider, List, Menu, Text} from 'react-native-paper';
import {HomePageParams} from './home-page-params';
import Set from './set';
import {deleteSet} from './set.service';
import {format} from './time';
import useDark from './use-dark';
import {useSettings} from './use-settings';
import {NavigationProp, useNavigation} from '@react-navigation/native'
import {useCallback, useState} from 'react'
import {GestureResponderEvent, Image} from 'react-native'
import {Divider, List, Menu, Text} from 'react-native-paper'
import {setRepo} from './db'
import GymSet from './gym-set'
import {HomePageParams} from './home-page-params'
import Settings from './settings'
import {format} from './time'
import useDark from './use-dark'
export default function SetItem({
item,
onRemove,
settings,
}: {
item: Set;
onRemove: () => void;
item: GymSet
onRemove: () => void
settings: Settings
}) {
const [showMenu, setShowMenu] = useState(false);
const [anchor, setAnchor] = useState({x: 0, y: 0});
const {settings} = useSettings();
const dark = useDark();
const navigation = useNavigation<NavigationProp<HomePageParams>>();
const [showMenu, setShowMenu] = useState(false)
const [anchor, setAnchor] = useState({x: 0, y: 0})
const dark = useDark()
const navigation = useNavigation<NavigationProp<HomePageParams>>()
const remove = useCallback(async () => {
if (typeof item.id === 'number') await deleteSet(item.id);
setShowMenu(false);
onRemove();
}, [setShowMenu, onRemove, item.id]);
if (typeof item.id === 'number') await setRepo.delete(item.id)
setShowMenu(false)
onRemove()
}, [setShowMenu, onRemove, item.id])
const copy = useCallback(() => {
const set: Set = {...item};
delete set.id;
setShowMenu(false);
navigation.navigate('EditSet', {set});
}, [navigation, item]);
const set: GymSet = {...item}
delete set.id
setShowMenu(false)
navigation.navigate('EditSet', {set})
}, [navigation, item])
const longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
setShowMenu(true);
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY})
setShowMenu(true)
},
[setShowMenu, setAnchor],
);
)
return (
<>
@ -51,14 +52,14 @@ export default function SetItem({
description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
onLongPress={longPress}
left={() =>
!!settings.images &&
settings.images &&
item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)
}
right={() => (
<>
{!!settings.showDate && (
{settings.showDate && (
<Text
style={{
alignSelf: 'center',
@ -79,5 +80,5 @@ export default function SetItem({
)}
/>
</>
);
)
}

View File

@ -2,105 +2,118 @@ import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {FlatList} from 'react-native';
import {List} from 'react-native-paper';
import DrawerHeader from './DrawerHeader';
import {HomePageParams} from './home-page-params';
import Page from './Page';
import Set from './set';
import {defaultSet, getSets, getToday} from './set.service';
import SetItem from './SetItem';
import {useSettings} from './use-settings';
} from '@react-navigation/native'
import {useCallback, useEffect, useState} from 'react'
import {FlatList} from 'react-native'
import {List} from 'react-native-paper'
import {Like} from 'typeorm'
import {getNow, setRepo, settingsRepo} from './db'
import DrawerHeader from './DrawerHeader'
import GymSet from './gym-set'
import {HomePageParams} from './home-page-params'
import Page from './Page'
import SetItem from './SetItem'
import Settings from './settings'
const limit = 15;
const limit = 15
export default function SetList() {
const [sets, setSets] = useState<Set[]>();
const [set, setSet] = useState<Set>();
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [end, setEnd] = useState(false);
const {settings} = useSettings();
const navigation = useNavigation<NavigationProp<HomePageParams>>();
const [sets, setSets] = useState<GymSet[]>([])
const [set, setSet] = useState<GymSet>()
const [offset, setOffset] = useState(0)
const [term, setTerm] = useState('')
const [end, setEnd] = useState(false)
const [settings, setSettings] = useState<Settings>()
const navigation = useNavigation<NavigationProp<HomePageParams>>()
const refresh = useCallback(async () => {
const todaysSet = await getToday();
if (todaysSet) setSet({...todaysSet});
const newSets = await getSets({
search: `%${search}%`,
limit,
offset: 0,
format: settings.date || '%Y-%m-%d %H:%M',
});
console.log(`${SetList.name}.refresh:`, {first: newSets[0]});
if (newSets.length === 0) return setSets([]);
setSets(newSets);
setOffset(0);
setEnd(false);
}, [search, settings.date]);
const refresh = useCallback(async (value: string) => {
const newSets = await setRepo.find({
where: {name: Like(`%${value}%`), hidden: 0 as any},
take: limit,
skip: 0,
order: {created: 'DESC'},
})
console.log(`${SetList.name}.refresh:`, {newSets})
setSet(newSets[0])
if (newSets.length === 0) return setSets([])
setSets(newSets)
setOffset(0)
setEnd(false)
}, [])
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh]),
);
useEffect(() => {
refresh();
}, [search, refresh]);
refresh(term)
settingsRepo.findOne({where: {}}).then(setSettings)
}, [refresh, term]),
)
const renderItem = useCallback(
({item}: {item: Set}) => (
<SetItem item={item} key={item.id} onRemove={refresh} />
({item}: {item: GymSet}) => (
<SetItem
settings={settings}
item={item}
key={item.id}
onRemove={() => refresh(term)}
/>
),
[refresh],
);
[refresh, term, settings],
)
const next = useCallback(async () => {
if (end) return;
const newOffset = offset + limit;
console.log(`${SetList.name}.next:`, {offset, newOffset, search});
const newSets = await getSets({
search: `%${search}%`,
limit,
offset: newOffset,
format: settings.date || '%Y-%m-%d %H:%M',
});
if (newSets.length === 0) return setEnd(true);
if (!sets) return;
setSets([...sets, ...newSets]);
if (newSets.length < limit) return setEnd(true);
setOffset(newOffset);
}, [search, end, offset, sets, settings.date]);
if (end) return
const newOffset = offset + limit
console.log(`${SetList.name}.next:`, {offset, newOffset, term})
const newSets = await setRepo.find({
where: {name: Like(`%${term}%`), hidden: 0 as any},
take: limit,
skip: newOffset,
order: {created: 'DESC'},
})
if (newSets.length === 0) return setEnd(true)
if (!sets) return
setSets([...sets, ...newSets])
if (newSets.length < limit) return setEnd(true)
setOffset(newOffset)
}, [term, end, offset, sets])
const onAdd = useCallback(async () => {
console.log(`${SetList.name}.onAdd`, {set});
navigation.navigate('EditSet', {
set: set || {...defaultSet},
});
}, [navigation, set]);
console.log(`${SetList.name}.onAdd`, {set})
const [{now}] = await getNow()
const newSet: GymSet = set || new GymSet()
delete newSet.id
newSet.created = now
navigation.navigate('EditSet', {set: newSet})
}, [navigation, set])
const search = useCallback(
(value: string) => {
setTerm(value)
refresh(value)
},
[refresh],
)
return (
<>
<DrawerHeader name="Home" />
<Page onAdd={onAdd} search={search} setSearch={setSearch}>
<Page onAdd={onAdd} term={term} search={search}>
{sets?.length === 0 ? (
<List.Item
title="No sets yet"
description="A set is a group of repetitions. E.g. 8 reps of Squats."
/>
) : (
<FlatList
data={sets}
style={{flex: 1}}
renderItem={renderItem}
keyExtractor={s => s.id!.toString()}
onEndReached={next}
/>
settings && (
<FlatList
data={sets}
style={{flex: 1}}
renderItem={renderItem}
onEndReached={next}
/>
)
)}
</Page>
</>
);
)
}

View File

@ -1,190 +1,185 @@
import {Picker} from '@react-native-picker/picker';
import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {NativeModules, ScrollView} from 'react-native';
import DocumentPicker from 'react-native-document-picker';
import {Button} from 'react-native-paper';
import {useColor} from './color';
import {darkColors, lightColors} from './colors';
import ConfirmDialog from './ConfirmDialog';
import {MARGIN} from './constants';
import DrawerHeader from './DrawerHeader';
import Input from './input';
import {useSnackbar} from './MassiveSnack';
import Page from './Page';
import Settings from './settings';
import {updateSettings} from './settings.service';
import Switch from './Switch';
import {useSettings} from './use-settings';
import {Picker} from '@react-native-picker/picker'
import {useFocusEffect} from '@react-navigation/native'
import {useCallback, useMemo, useState} from 'react'
import {DeviceEventEmitter, NativeModules, ScrollView, View} from 'react-native'
import DocumentPicker from 'react-native-document-picker'
import {Button} from 'react-native-paper'
import {darkColors, lightColors} from './colors'
import ConfirmDialog from './ConfirmDialog'
import {MARGIN} from './constants'
import {settingsRepo} from './db'
import DrawerHeader from './DrawerHeader'
import Input from './input'
import Page from './Page'
import Select from './Select'
import Switch from './Switch'
import {toast} from './toast'
import {useTheme} from './use-theme'
export default function SettingsPage() {
const [battery, setBattery] = useState(false);
const [ignoring, setIgnoring] = useState(false);
const [search, setSearch] = useState('');
const {settings, setSettings} = useSettings();
const {
vibrate,
sound,
notify,
images,
showUnit,
steps,
showDate,
showSets,
theme,
alarm,
noSound,
} = settings;
const {color, setColor} = useColor();
const {toast} = useSnackbar();
useEffect(() => {
console.log(`${SettingsPage.name}.useEffect:`, {settings});
}, [settings]);
const [battery, setBattery] = useState(false)
const [ignoring, setIgnoring] = useState(false)
const [term, setTerm] = useState('')
const [vibrate, setVibrate] = useState(false)
const [alarm, setAlarm] = useState(false)
const [sound, setSound] = useState('')
const [notify, setNotify] = useState(false)
const [images, setImages] = useState(false)
const [showUnit, setShowUnit] = useState(false)
const [steps, setSteps] = useState(false)
const [date, setDate] = useState('%Y-%m-%d %H:%M')
const {theme, setTheme, color, setColor} = useTheme()
const [showDate, setShowDate] = useState(false)
const [showSets, setShowSets] = useState(false)
const [noSound, setNoSound] = useState(false)
useFocusEffect(
useCallback(() => {
NativeModules.AlarmModule.ignoringBattery(setIgnoring);
NativeModules.AlarmModule.ignoringBattery(setIgnoring)
settingsRepo.findOne({where: {}}).then(settings => {
setAlarm(settings.alarm)
setVibrate(settings.vibrate)
setSound(settings.sound)
setNotify(settings.notify)
setImages(settings.images)
setShowUnit(settings.showUnit)
setSteps(settings.steps)
setDate(settings.date)
setShowDate(settings.showDate)
setShowSets(settings.showSets)
})
}, []),
);
const update = useCallback(
(value: boolean, field: keyof Settings) => {
updateSettings({...settings, [field]: +value});
setSettings({...settings, [field]: +value});
},
[settings, setSettings],
);
)
const changeAlarmEnabled = useCallback(
(enabled: boolean) => {
if (enabled) toast('Timers will now run after each set.', 4000);
else toast('Stopped timers running after each set.', 4000);
if (enabled && !ignoring) setBattery(true);
update(enabled, 'alarm');
if (enabled)
DeviceEventEmitter.emit('toast', {
value: 'Timers will now run after each set',
timeout: 4000,
})
else toast('Stopped timers running after each set.')
if (enabled && !ignoring) setBattery(true)
setAlarm(enabled)
settingsRepo.update({}, {alarm: enabled})
},
[setBattery, ignoring, toast, update],
);
[setBattery, ignoring],
)
const changeVibrate = useCallback(
(enabled: boolean) => {
if (enabled) toast('When a timer completes, vibrate your phone.', 4000);
else toast('Stop vibrating at the end of timers.', 4000);
update(enabled, 'vibrate');
},
[toast, update],
);
const changeVibrate = useCallback((enabled: boolean) => {
if (enabled) toast('When a timer completes, vibrate your phone.')
else toast('Stop vibrating at the end of timers.')
setVibrate(enabled)
settingsRepo.update({}, {vibrate: enabled})
}, [])
const changeSound = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
type: 'audio/*',
copyTo: 'documentDirectory',
});
if (!fileCopyUri) return;
updateSettings({sound: fileCopyUri} as Settings);
setSettings({...settings, sound: fileCopyUri});
toast('This song will now play after rest timers complete.', 4000);
}, [toast, setSettings, settings]);
})
if (!fileCopyUri) return
settingsRepo.update({}, {sound: fileCopyUri})
setSound(fileCopyUri)
toast('This song will now play after rest timers complete.')
}, [])
const changeNotify = useCallback(
(enabled: boolean) => {
update(enabled, 'notify');
if (enabled) toast('Show when a set is a new record.', 4000);
else toast('Stopped showing notifications for new records.', 4000);
},
[toast, update],
);
const changeNotify = useCallback((enabled: boolean) => {
setNotify(enabled)
settingsRepo.update({}, {notify: enabled})
if (enabled) toast('Show when a set is a new record.')
else toast('Stopped showing notifications for new records.')
}, [])
const changeImages = useCallback(
(enabled: boolean) => {
update(enabled, 'images');
if (enabled) toast('Show images for sets.', 4000);
else toast('Stopped showing images for sets.', 4000);
},
[toast, update],
);
const changeImages = useCallback((enabled: boolean) => {
setImages(enabled)
settingsRepo.update({}, {images: enabled})
if (enabled) toast('Show images for sets.')
else toast('Stopped showing images for sets.')
}, [])
const changeUnit = useCallback(
(enabled: boolean) => {
update(enabled, 'showUnit');
if (enabled) toast('Show option to select unit for sets.', 4000);
else toast('Hid unit option for sets.', 4000);
},
[toast, update],
);
const changeUnit = useCallback((enabled: boolean) => {
setShowUnit(enabled)
settingsRepo.update({}, {showUnit: enabled})
if (enabled) toast('Show option to select unit for sets.')
else toast('Hid unit option for sets.')
}, [])
const changeSteps = useCallback(
(enabled: boolean) => {
update(enabled, 'steps');
if (enabled) toast('Show steps for a workout.', 4000);
else toast('Stopped showing steps for workouts.', 4000);
},
[toast, update],
);
const changeSteps = useCallback((enabled: boolean) => {
setSteps(enabled)
settingsRepo.update({}, {steps: enabled})
if (enabled) toast('Show steps for a workout.')
else toast('Stopped showing steps for workouts.')
}, [])
const changeShowDate = useCallback(
(enabled: boolean) => {
update(enabled, 'showDate');
if (enabled) toast('Show date for sets by default.', 4000);
else toast('Stopped showing date for sets by default.', 4000);
},
[toast, update],
);
const changeShowDate = useCallback((enabled: boolean) => {
setShowDate(enabled)
settingsRepo.update({}, {showDate: enabled})
if (enabled) toast('Show date for sets by default.')
else toast('Stopped showing date for sets by default.')
}, [])
const changeShowSets = useCallback(
(enabled: boolean) => {
update(enabled, 'showSets');
if (enabled) toast('Show maximum sets for workouts.', 4000);
else toast('Stopped showing maximum sets for workouts.', 4000);
},
[toast, update],
);
const changeShowSets = useCallback((enabled: boolean) => {
setShowSets(enabled)
settingsRepo.update({}, {showSets: enabled})
if (enabled) toast('Show target sets for workouts.')
else toast('Stopped showing target sets for workouts.')
}, [])
const changeNoSound = useCallback(
(enabled: boolean) => {
update(enabled, 'noSound');
if (enabled) toast('Disable sound on rest timer alarms.', 4000);
else toast('Enabled sound for rest timer alarms.', 4000);
},
[toast, update],
);
const changeNoSound = useCallback((enabled: boolean) => {
setNoSound(enabled)
settingsRepo.update({}, {noSound: enabled})
if (enabled) toast('Disable sound on rest timer alarms.')
else toast('Enabled sound for rest timer alarms.')
}, [])
const switches: Input<boolean>[] = [
{name: 'Rest timers', value: !!alarm, onChange: changeAlarmEnabled},
{name: 'Vibrate', value: !!vibrate, onChange: changeVibrate},
{name: 'Disable sound', value: !!noSound, onChange: changeNoSound},
{name: 'Record notifications', value: !!notify, onChange: changeNotify},
{name: 'Show images', value: !!images, onChange: changeImages},
{name: 'Show unit', value: !!showUnit, onChange: changeUnit},
{name: 'Show steps', value: !!steps, onChange: changeSteps},
{name: 'Show date', value: !!showDate, onChange: changeShowDate},
{name: 'Show sets', value: !!showSets, onChange: changeShowSets},
];
{name: 'Rest timers', value: alarm, onChange: changeAlarmEnabled},
{name: 'Vibrate', value: vibrate, onChange: changeVibrate},
{name: 'Disable sound', value: noSound, onChange: changeNoSound},
{name: 'Notifications', value: notify, onChange: changeNotify},
{name: 'Show images', value: images, onChange: changeImages},
{name: 'Show unit', value: showUnit, onChange: changeUnit},
{name: 'Show steps', value: steps, onChange: changeSteps},
{name: 'Show date', value: showDate, onChange: changeShowDate},
{name: 'Show sets', value: showSets, onChange: changeShowSets},
]
const changeTheme = useCallback(
(value: string) => {
updateSettings({...settings, theme: value as any});
setSettings({...settings, theme: value as any});
settingsRepo.update({}, {theme: value})
setTheme(value)
},
[settings, setSettings],
);
[setTheme],
)
const changeDate = useCallback(
const changeDate = useCallback((value: string) => {
settingsRepo.update({}, {date: value})
setDate(value)
}, [])
const soundString = useMemo(() => {
if (!sound) return null
const split = sound.split('/')
return ': ' + split.pop()
}, [sound])
const changeColor = useCallback(
(value: string) => {
updateSettings({...settings, date: value as any});
setSettings({...settings, date: value as any});
setColor(value)
settingsRepo.update({}, {color: value})
},
[settings, setSettings],
);
[setColor],
)
return (
<>
<DrawerHeader name="Settings" />
<Page search={search} setSearch={setSearch}>
<Page term={term} search={setTerm}>
<ScrollView style={{marginTop: MARGIN}}>
{switches
.filter(input =>
input.name.toLowerCase().includes(search.toLowerCase()),
input.name.toLowerCase().includes(term.toLowerCase()),
)
.map(input => (
<Switch
@ -195,39 +190,28 @@ export default function SettingsPage() {
{input.name}
</Switch>
))}
{'theme'.includes(search.toLowerCase()) && (
<Picker
style={{color}}
dropdownIconColor={color}
selectedValue={theme}
onValueChange={changeTheme}>
<View style={{marginBottom: 10}} />
{'theme'.includes(term.toLowerCase()) && (
<Select value={theme} onChange={changeTheme}>
<Picker.Item value="system" label="Follow system theme" />
<Picker.Item value="dark" label="Dark theme" />
<Picker.Item value="light" label="Light theme" />
</Picker>
</Select>
)}
{'color'.includes(search.toLowerCase()) && (
<Picker
style={{color, marginTop: -10}}
dropdownIconColor={color}
selectedValue={color}
onValueChange={value => setColor(value)}>
{'color'.includes(term.toLowerCase()) && (
<Select value={color} onChange={changeColor}>
{lightColors.concat(darkColors).map(colorOption => (
<Picker.Item
key={colorOption.hex}
value={colorOption.hex}
key={colorOption}
value={colorOption}
label="Primary color"
color={colorOption.hex}
color={colorOption}
/>
))}
</Picker>
</Select>
)}
{'date format'.includes(search.toLowerCase()) && (
<Picker
style={{color, marginTop: -10}}
dropdownIconColor={color}
selectedValue={settings.date}
onValueChange={changeDate}>
{'date format'.includes(term.toLowerCase()) && (
<Select value={date} onChange={changeDate}>
<Picker.Item value="%Y-%m-%d %H:%M" label="1990-12-24 15:05" />
<Picker.Item value="%Y-%m-%d" label="1990-12-24" />
<Picker.Item value="%d/%m" label="24/12 (dd/MM)" />
@ -240,14 +224,11 @@ export default function SettingsPage() {
label="24/12/1990 3:05 PM"
/>
<Picker.Item value="%d/%m %h:%M %p" label="24/12 3:05 PM" />
</Picker>
</Select>
)}
{'alarm sound'.includes(search.toLowerCase()) && (
{'alarm sound'.includes(term.toLowerCase()) && (
<Button style={{alignSelf: 'flex-start'}} onPress={changeSound}>
Alarm sound
{sound
? ': ' + sound.split('/')[sound.split('/').length - 1]
: null}
Alarm sound{soundString}
</Button>
)}
</ScrollView>
@ -256,12 +237,12 @@ export default function SettingsPage() {
show={battery}
setShow={setBattery}
onOk={() => {
NativeModules.AlarmModule.ignoreBattery();
setBattery(false);
NativeModules.AlarmModule.ignoreBattery()
setBattery(false)
}}>
Disable battery optimizations for Massive to use rest timers.
</ConfirmDialog>
</Page>
</>
);
)
}

View File

@ -1,30 +1,36 @@
import {useNavigation} from '@react-navigation/native';
import React from 'react';
import Share from 'react-native-share';
import {FileSystem} from 'react-native-file-access';
import {Appbar, IconButton} from 'react-native-paper';
import {captureScreen} from 'react-native-view-shot';
import {useNavigation} from '@react-navigation/native'
import Share from 'react-native-share'
import {FileSystem} from 'react-native-file-access'
import {Appbar, IconButton} from 'react-native-paper'
import {captureScreen} from 'react-native-view-shot'
import useDark from './use-dark'
export default function StackHeader({title}: {title: string}) {
const navigation = useNavigation();
const navigation = useNavigation()
const dark = useDark()
return (
<Appbar.Header>
<IconButton icon="arrow-back" onPress={navigation.goBack} />
<IconButton
color={dark ? 'white' : 'white'}
icon="arrow-back"
onPress={navigation.goBack}
/>
<Appbar.Content title={title} />
<IconButton
color={dark ? 'white' : 'white'}
onPress={() =>
captureScreen().then(async uri => {
const base64 = await FileSystem.readFile(uri, 'base64');
const url = `data:image/jpeg;base64,${base64}`;
const base64 = await FileSystem.readFile(uri, 'base64')
const url = `data:image/jpeg;base64,${base64}`
Share.open({
type: 'image/jpeg',
url,
});
})
})
}
icon="share"
/>
</Appbar.Header>
);
)
}

View File

@ -1,108 +1,120 @@
import {RouteProp, useFocusEffect, useRoute} from '@react-navigation/native';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {NativeModules, TextInput, View} from 'react-native';
import {FlatList} from 'react-native-gesture-handler';
import {Button, List, RadioButton} from 'react-native-paper';
import {getBestSet} from './best.service';
import {useColor} from './color';
import {PADDING} from './constants';
import CountMany from './count-many';
import MassiveInput from './MassiveInput';
import {useSnackbar} from './MassiveSnack';
import {PlanPageParams} from './plan-page-params';
import Set from './set';
import {addSet, countMany} from './set.service';
import SetForm from './SetForm';
import StackHeader from './StackHeader';
import {useSettings} from './use-settings';
import {RouteProp, useRoute} from '@react-navigation/native'
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {NativeModules, TextInput, View} from 'react-native'
import {FlatList} from 'react-native-gesture-handler'
import {Button} from 'react-native-paper'
import {getBestSet} from './best.service'
import {PADDING} from './constants'
import CountMany from './count-many'
import {AppDataSource} from './data-source'
import {getNow, setRepo, settingsRepo} from './db'
import GymSet from './gym-set'
import MassiveInput from './MassiveInput'
import {PlanPageParams} from './plan-page-params'
import SetForm from './SetForm'
import Settings from './settings'
import StackHeader from './StackHeader'
import StartPlanItem from './StartPlanItem'
import {toast} from './toast'
export default function StartPlan() {
const {params} = useRoute<RouteProp<PlanPageParams, 'StartPlan'>>();
const {set} = params;
const [name, setName] = useState(set.name);
const [reps, setReps] = useState(set.reps.toString());
const [weight, setWeight] = useState(set.weight.toString());
const [unit, setUnit] = useState<string>();
const {toast} = useSnackbar();
const [minutes, setMinutes] = useState(set.minutes);
const [seconds, setSeconds] = useState(set.seconds);
const [best, setBest] = useState<Set>(set);
const [selected, setSelected] = useState(0);
const {settings} = useSettings();
const [counts, setCounts] = useState<CountMany[]>();
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const unitRef = useRef<TextInput>(null);
const workouts = useMemo(() => params.plan.workouts.split(','), [params]);
const {color} = useColor();
const {params} = useRoute<RouteProp<PlanPageParams, 'StartPlan'>>()
const [reps, setReps] = useState('')
const [weight, setWeight] = useState('')
const [unit, setUnit] = useState<string>('kg')
const [best, setBest] = useState<GymSet>()
const [selected, setSelected] = useState(0)
const [settings, setSettings] = useState<Settings>()
const [counts, setCounts] = useState<CountMany[]>()
const weightRef = useRef<TextInput>(null)
const repsRef = useRef<TextInput>(null)
const unitRef = useRef<TextInput>(null)
const workouts = useMemo(() => params.plan.workouts.split(','), [params])
const [selection, setSelection] = useState({
start: 0,
end: set.reps.toString().length,
});
end: 0,
})
useFocusEffect(
useCallback(() => {
countMany(workouts).then(newCounts => {
setCounts(newCounts);
console.log(`${StartPlan.name}.focus:`, {newCounts});
});
}, [params]),
);
const refresh = useCallback(() => {
const questions = workouts
.map((workout, index) => `('${workout}',${index})`)
.join(',')
console.log({questions, workouts})
const select = `
SELECT workouts.name, COUNT(sets.id) as total
FROM (select 0 as name, 0 as sequence union values ${questions}) as workouts
LEFT JOIN sets ON sets.name = workouts.name
AND sets.created LIKE STRFTIME('%Y-%m-%d%%', 'now', 'localtime')
AND NOT sets.hidden
GROUP BY workouts.name
ORDER BY workouts.sequence
LIMIT -1
OFFSET 1
`
return AppDataSource.manager.query(select).then(newCounts => {
setCounts(newCounts)
console.log(`${StartPlan.name}.focus:`, {newCounts})
return newCounts
})
}, [workouts])
const select = useCallback(
async (index: number, newCounts?: CountMany[]) => {
setSelected(index)
console.log(`${StartPlan.name}.next:`, {best, index})
if (!counts && !newCounts) return
const workout = counts ? counts[index] : newCounts[index]
console.log(`${StartPlan.name}.next:`, {workout})
const newBest = await getBestSet(workout.name)
delete newBest.id
console.log(`${StartPlan.name}.next:`, {newBest})
setReps(newBest.reps.toString())
setWeight(newBest.weight.toString())
setUnit(newBest.unit)
setBest(newBest)
},
[counts, best],
)
useEffect(() => {
refresh().then(newCounts => select(0, newCounts))
settingsRepo.findOne({where: {}}).then(setSettings)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refresh])
const handleSubmit = async () => {
console.log(`${SetForm.name}.handleSubmit:`, {reps, weight, unit, best});
await addSet({
name,
console.log(`${SetForm.name}.handleSubmit:`, {reps, weight, unit, best})
const [{now}] = await getNow()
await setRepo.save({
...best,
weight: +weight,
reps: +reps,
minutes: set.minutes,
seconds: set.seconds,
steps: set.steps,
image: set.image,
unit,
});
countMany(workouts).then(setCounts);
created: now,
hidden: false,
})
await refresh()
if (
settings.notify &&
(+weight > best.weight || (+reps > best.reps && +weight === best.weight))
)
toast("Great work King! That's a new record.", 5000);
else if (settings.alarm) toast('Resting...', 3000);
else toast('Added set', 3000);
if (!settings.alarm) return;
const milliseconds = Number(minutes) * 60 * 1000 + Number(seconds) * 1000;
const args = [milliseconds, !!settings.vibrate, settings.sound];
NativeModules.AlarmModule.timer(...args);
};
toast("Great work King! That's a new record.")
else if (settings.alarm) toast('Resting...')
else toast('Added set')
if (!settings.alarm) return
const milliseconds =
Number(best.minutes) * 60 * 1000 + Number(best.seconds) * 1000
const {vibrate, sound, noSound} = settings
const args = [milliseconds, vibrate, sound, noSound]
NativeModules.AlarmModule.timer(...args)
}
const handleUnit = useCallback(
(value: string) => {
setUnit(value.replace(/,|'/g, ''));
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
},
[toast],
);
const select = useCallback(
async (index: number) => {
setSelected(index);
console.log(`${StartPlan.name}.next:`, {name});
if (!counts) return;
const workout = counts[index];
console.log(`${StartPlan.name}.next:`, {workout});
const newBest = await getBestSet(workout.name);
setMinutes(newBest.minutes);
setSeconds(newBest.seconds);
setName(newBest.name);
setReps(newBest.reps.toString());
setWeight(newBest.weight.toString());
setUnit(newBest.unit);
setBest(newBest);
},
[name, workouts],
);
const handleUnit = useCallback((value: string) => {
setUnit(value.replace(/,|'/g, ''))
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports')
}, [])
return (
<>
@ -128,7 +140,7 @@ export default function StartPlan() {
innerRef={weightRef}
blurOnSubmit
/>
{!!settings.showUnit && (
{settings?.showUnit && (
<MassiveInput
autoCapitalize="none"
label="Unit"
@ -140,26 +152,12 @@ export default function StartPlan() {
{counts && (
<FlatList
data={counts}
renderItem={({item, index}) => (
<List.Item
title={item.name}
description={
settings.showSets
? `${item.total} / ${item.sets ?? 3}`
: item.total.toString()
}
onPress={() => select(index)}
left={() => (
<View
style={{alignItems: 'center', justifyContent: 'center'}}>
<RadioButton
onPress={() => select(index)}
value={index.toString()}
status={selected === index ? 'checked' : 'unchecked'}
color={color}
/>
</View>
)}
renderItem={props => (
<StartPlanItem
{...props}
onUndo={refresh}
onSelect={select}
selected={selected}
/>
)}
/>
@ -170,5 +168,5 @@ export default function StartPlan() {
</Button>
</View>
</>
);
)
}

94
StartPlanItem.tsx Normal file
View File

@ -0,0 +1,94 @@
import {NavigationProp, useNavigation} from '@react-navigation/native'
import React, {useCallback, useState} from 'react'
import {GestureResponderEvent, ListRenderItemInfo, View} from 'react-native'
import {List, Menu, RadioButton, useTheme} from 'react-native-paper'
import {Like} from 'typeorm'
import CountMany from './count-many'
import {getNow, setRepo} from './db'
import {PlanPageParams} from './plan-page-params'
import {toast} from './toast'
interface Props extends ListRenderItemInfo<CountMany> {
onSelect: (index: number) => void
selected: number
onUndo: () => void
}
export default function StartPlanItem(props: Props) {
const {index, item, onSelect, selected, onUndo} = props
const {colors} = useTheme()
const [anchor, setAnchor] = useState({x: 0, y: 0})
const [showMenu, setShowMenu] = useState(false)
const {navigate} = useNavigation<NavigationProp<PlanPageParams>>()
const undo = useCallback(async () => {
const [{now}] = await getNow()
const created = now.split('T')[0]
const first = await setRepo.findOne({
where: {
name: item.name,
hidden: 0 as any,
created: Like(`${created}%`),
},
order: {created: 'desc'},
})
setShowMenu(false)
if (!first) return toast('Nothing to undo.')
await setRepo.delete(first.id)
onUndo()
}, [setShowMenu, onUndo, item.name])
const longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY})
setShowMenu(true)
},
[setShowMenu, setAnchor],
)
const edit = async () => {
const [{now}] = await getNow()
const created = now.split('T')[0]
const first = await setRepo.findOne({
where: {
name: item.name,
hidden: 0 as any,
created: Like(`${created}%`),
},
order: {created: 'desc'},
})
setShowMenu(false)
if (!first) return toast('Nothing to edit.')
navigate('EditSet', {set: first})
}
return (
<List.Item
onLongPress={longPress}
title={item.name}
description={item.total.toString()}
onPress={() => onSelect(index)}
left={() => (
<View style={{alignItems: 'center', justifyContent: 'center'}}>
<RadioButton
onPress={() => onSelect(index)}
value={index.toString()}
status={selected === index ? 'checked' : 'unchecked'}
color={colors.primary}
/>
</View>
)}
right={() => (
<>
<Menu
anchor={anchor}
visible={showMenu}
onDismiss={() => setShowMenu(false)}>
<Menu.Item icon="edit" onPress={edit} title="Edit" />
<Menu.Item icon="undo" onPress={undo} title="Undo" />
</Menu>
</>
)}
/>
)
}

View File

@ -1,11 +1,10 @@
import React, {useMemo} from 'react';
import {Pressable} from 'react-native';
import {Switch as PaperSwitch, Text} from 'react-native-paper';
import {CombinedDarkTheme, CombinedDefaultTheme} from './App';
import {useColor} from './color';
import {colorShade} from './colors';
import {MARGIN} from './constants';
import useDark from './use-dark';
import {useMemo} from 'react'
import {Pressable} from 'react-native'
import {Switch as PaperSwitch, Text, useTheme} from 'react-native-paper'
import {CombinedDarkTheme, CombinedDefaultTheme} from './App'
import {colorShade} from './colors'
import {MARGIN} from './constants'
import useDark from './use-dark'
export default function Switch({
value,
@ -13,25 +12,25 @@ export default function Switch({
onPress,
children,
}: {
value?: boolean;
onValueChange: (value: boolean) => void;
onPress: () => void;
children: string;
value?: boolean
onValueChange: (value: boolean) => void
onPress: () => void
children: string
}) {
const {color} = useColor();
const dark = useDark();
const {colors} = useTheme()
const dark = useDark()
const track = useMemo(() => {
if (dark)
return {
false: CombinedDarkTheme.colors.placeholder,
true: colorShade(color, -40),
};
true: colorShade(colors.primary, -40),
}
return {
false: CombinedDefaultTheme.colors.placeholder,
true: colorShade(color, -40),
};
}, [dark, color]);
true: colorShade(colors.primary, -40),
}
}, [dark, colors.primary])
return (
<Pressable
@ -43,12 +42,12 @@ export default function Switch({
}}>
<PaperSwitch
trackColor={track}
color={color}
color={colors.primary}
style={{marginRight: MARGIN}}
value={value}
onValueChange={onValueChange}
/>
<Text>{children}</Text>
</Pressable>
);
)
}

View File

@ -1,41 +1,67 @@
import {Picker} from '@react-native-picker/picker';
import {RouteProp, useRoute} from '@react-navigation/native';
import React, {useEffect, useState} from 'react';
import {View} from 'react-native';
import {getOneRepMax, getVolumes, getWeightsBy} from './best.service';
import {BestPageParams} from './BestPage';
import Chart from './Chart';
import {PADDING} from './constants';
import {Metrics} from './metrics';
import {Periods} from './periods';
import Set from './set';
import StackHeader from './StackHeader';
import {formatMonth} from './time';
import useDark from './use-dark';
import Volume from './volume';
import {Picker} from '@react-native-picker/picker'
import {RouteProp, useRoute} from '@react-navigation/native'
import {useEffect, useState} from 'react'
import {View} from 'react-native'
import {BestPageParams} from './BestPage'
import Chart from './Chart'
import {PADDING} from './constants'
import {setRepo} from './db'
import GymSet from './gym-set'
import {Metrics} from './metrics'
import {Periods} from './periods'
import StackHeader from './StackHeader'
import {formatMonth} from './time'
import useDark from './use-dark'
import Volume from './volume'
export default function ViewBest() {
const {params} = useRoute<RouteProp<BestPageParams, 'ViewBest'>>();
const dark = useDark();
const [weights, setWeights] = useState<Set[]>([]);
const [volumes, setVolumes] = useState<Volume[]>([]);
const [metric, setMetric] = useState(Metrics.Weight);
const [period, setPeriod] = useState(Periods.Monthly);
const {params} = useRoute<RouteProp<BestPageParams, 'ViewBest'>>()
const dark = useDark()
const [weights, setWeights] = useState<GymSet[]>([])
const [volumes, setVolumes] = useState<Volume[]>([])
const [metric, setMetric] = useState(Metrics.Weight)
const [period, setPeriod] = useState(Periods.Monthly)
useEffect(() => {
console.log(`${ViewBest.name}.useEffect`, {metric});
console.log(`${ViewBest.name}.useEffect`, {period});
console.log(`${ViewBest.name}.useEffect`, {metric})
console.log(`${ViewBest.name}.useEffect`, {period})
let difference = '-7 days'
if (period === Periods.Monthly) difference = '-1 months'
else if (period === Periods.Yearly) difference = '-1 years'
let group = '%Y-%m-%d'
if (period === Periods.Yearly) group = '%Y-%m'
const builder = setRepo
.createQueryBuilder()
.select("STRFTIME('%Y-%m-%d', created)", 'created')
.addSelect('unit')
.where('name = :name', {name: params.best.name})
.andWhere('NOT hidden')
.andWhere("DATE(created) >= DATE('now', 'weekday 0', :difference)", {
difference,
})
.groupBy('name')
.addGroupBy(`STRFTIME('${group}', created)`)
switch (metric) {
case Metrics.Weight:
getWeightsBy(params.best.name, period).then(setWeights);
break;
builder.addSelect('MAX(weight)', 'weight').getRawMany().then(setWeights)
break
case Metrics.Volume:
getVolumes(params.best.name, period).then(setVolumes);
break;
builder
.addSelect('SUM(weight * reps)', 'value')
.getRawMany()
.then(setVolumes)
break
default:
getOneRepMax({name: params.best.name, period}).then(setWeights);
// Brzycki formula https://en.wikipedia.org/wiki/One-repetition_maximum#Brzycki
builder
.addSelect('MAX(weight / (1.0278 - 0.0278 * reps))', 'weight')
.getRawMany()
.then(newWeights => {
console.log({weights: newWeights})
setWeights(newWeights)
})
}
}, [params.best.name, metric, period]);
}, [params.best.name, metric, period])
return (
<>
@ -80,5 +106,5 @@ export default function ViewBest() {
)}
</View>
</>
);
)
}

View File

@ -1,48 +1,44 @@
import {NavigationProp, useNavigation} from '@react-navigation/native';
import React, {useCallback, useMemo, useState} from 'react';
import {GestureResponderEvent, Image} from 'react-native';
import {List, Menu, Text} from 'react-native-paper';
import ConfirmDialog from './ConfirmDialog';
import Set from './set';
import {deleteSetsBy} from './set.service';
import {useSettings} from './use-settings';
import {WorkoutsPageParams} from './WorkoutsPage';
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 GymSet from './gym-set'
import {WorkoutsPageParams} from './WorkoutsPage'
export default function WorkoutItem({
item,
onRemoved,
onRemove,
images,
}: {
item: Set;
onRemoved: () => void;
item: GymSet
onRemove: () => void
images: boolean
}) {
const [showMenu, setShowMenu] = useState(false);
const [anchor, setAnchor] = useState({x: 0, y: 0});
const [showRemove, setShowRemove] = useState('');
const {settings} = useSettings();
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
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 deleteSetsBy(item.name);
setShowMenu(false);
onRemoved();
}, [setShowMenu, onRemoved, item.name]);
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);
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY})
setShowMenu(true)
},
[setShowMenu, setAnchor],
);
)
const description = useMemo(() => {
const seconds = item.seconds?.toString().padStart(2, '0');
if (settings.alarm && settings.showSets)
return `${item.sets} x ${item.minutes || 0}:${seconds}`;
else if (settings.alarm && !settings.showSets)
return `${item.minutes || 0}:${seconds}`;
return `${item.sets}`;
}, [item, settings]);
const seconds = item.seconds?.toString().padStart(2, '0')
return `${item.sets} x ${item.minutes || 0}:${seconds}`
}, [item])
return (
<>
@ -52,7 +48,7 @@ export default function WorkoutItem({
description={description}
onLongPress={longPress}
left={() =>
!!settings.images &&
images &&
item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)
@ -69,8 +65,8 @@ export default function WorkoutItem({
<Menu.Item
icon="delete"
onPress={() => {
setShowRemove(item.name);
setShowMenu(false);
setShowRemove(item.name)
setShowMenu(false)
}}
title="Delete"
/>
@ -87,5 +83,5 @@ export default function WorkoutItem({
sure?
</ConfirmDialog>
</>
);
)
}

View File

@ -2,87 +2,106 @@ import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {FlatList} from 'react-native';
import {List} from 'react-native-paper';
import DrawerHeader from './DrawerHeader';
import Page from './Page';
import Set from './set';
import {getDistinctSets} from './set.service';
import SetList from './SetList';
import WorkoutItem from './WorkoutItem';
import {WorkoutsPageParams} from './WorkoutsPage';
} from '@react-navigation/native'
import {useCallback, useState} from 'react'
import {FlatList} from 'react-native'
import {List} from 'react-native-paper'
import DrawerHeader from './DrawerHeader'
import Page from './Page'
import GymSet from './gym-set'
import SetList from './SetList'
import WorkoutItem from './WorkoutItem'
import {WorkoutsPageParams} from './WorkoutsPage'
import {setRepo, settingsRepo} from './db'
import Settings from './settings'
const limit = 15;
const limit = 15
export default function WorkoutList() {
const [workouts, setWorkouts] = useState<Set[]>();
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [end, setEnd] = useState(false);
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
const [workouts, setWorkouts] = useState<GymSet[]>()
const [offset, setOffset] = useState(0)
const [term, setTerm] = useState('')
const [end, setEnd] = useState(false)
const [settings, setSettings] = useState<Settings>()
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>()
const refresh = useCallback(async () => {
const newWorkouts = await getDistinctSets({
search: `%${search}%`,
limit,
offset: 0,
});
console.log(`${WorkoutList.name}`, {newWorkout: newWorkouts[0]});
setWorkouts(newWorkouts);
setOffset(0);
setEnd(false);
}, [search]);
useEffect(() => {
refresh();
}, [search, refresh]);
const refresh = useCallback(async (value: string) => {
const newWorkouts = await setRepo
.createQueryBuilder()
.select()
.where('name LIKE :name', {name: `%${value}%`})
.groupBy('name')
.orderBy('name')
.limit(limit)
.getMany()
console.log(`${WorkoutList.name}`, {newWorkout: newWorkouts[0]})
setWorkouts(newWorkouts)
setOffset(0)
setEnd(false)
}, [])
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh]),
);
refresh(term)
settingsRepo.findOne({where: {}}).then(setSettings)
}, [refresh, term]),
)
const renderItem = useCallback(
({item}: {item: Set}) => (
<WorkoutItem item={item} key={item.name} onRemoved={refresh} />
({item}: {item: GymSet}) => (
<WorkoutItem
images={settings?.images}
item={item}
key={item.name}
onRemove={() => refresh(term)}
/>
),
[refresh],
);
[refresh, term, settings?.images],
)
const next = useCallback(async () => {
if (end) return;
const newOffset = offset + limit;
if (end) return
const newOffset = offset + limit
console.log(`${SetList.name}.next:`, {
offset,
limit,
newOffset,
search,
});
const newWorkouts = await getDistinctSets({
search: `%${search}%`,
limit,
offset: newOffset,
});
if (newWorkouts.length === 0) return setEnd(true);
if (!workouts) return;
setWorkouts([...workouts, ...newWorkouts]);
if (newWorkouts.length < limit) return setEnd(true);
setOffset(newOffset);
}, [search, end, offset, workouts]);
term,
})
const newWorkouts = await setRepo
.createQueryBuilder()
.select()
.where('name LIKE :name', {name: `%${term}%`})
.groupBy('name')
.orderBy('name')
.limit(limit)
.offset(newOffset)
.getMany()
if (newWorkouts.length === 0) return setEnd(true)
if (!workouts) return
setWorkouts([...workouts, ...newWorkouts])
if (newWorkouts.length < limit) return setEnd(true)
setOffset(newOffset)
}, [term, end, offset, workouts])
const onAdd = useCallback(async () => {
navigation.navigate('EditWorkout', {
value: {name: '', sets: 3, image: '', steps: '', reps: 0, weight: 0},
});
}, [navigation]);
value: new GymSet(),
})
}, [navigation])
const search = useCallback(
(value: string) => {
setTerm(value)
refresh(value)
},
[refresh],
)
return (
<>
<DrawerHeader name="Workouts" />
<Page onAdd={onAdd} search={search} setSearch={setSearch}>
<Page onAdd={onAdd} term={term} search={search}>
{workouts?.length === 0 ? (
<List.Item
title="No workouts yet."
@ -99,5 +118,5 @@ export default function WorkoutList() {
)}
</Page>
</>
);
)
}

View File

@ -1,17 +1,16 @@
import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import EditWorkout from './EditWorkout';
import Set from './set';
import WorkoutList from './WorkoutList';
import {createStackNavigator} from '@react-navigation/stack'
import EditWorkout from './EditWorkout'
import GymSet from './gym-set'
import WorkoutList from './WorkoutList'
export type WorkoutsPageParams = {
WorkoutList: {};
WorkoutList: {}
EditWorkout: {
value: Set;
};
};
value: GymSet
}
}
const Stack = createStackNavigator<WorkoutsPageParams>();
const Stack = createStackNavigator<WorkoutsPageParams>()
export default function WorkoutsPage() {
return (
@ -20,5 +19,5 @@ export default function WorkoutsPage() {
<Stack.Screen name="WorkoutList" component={WorkoutList} />
<Stack.Screen name="EditWorkout" component={EditWorkout} />
</Stack.Navigator>
);
)
}

View File

@ -4,7 +4,7 @@ apply plugin: "kotlin-android"
import com.android.build.OutputFile
project.ext.react = [
enableHermes: false, // clean and rebuild if changing
enableHermes: true, // clean and rebuild if changing
]
project.ext.vectoricons = [
@ -17,7 +17,7 @@ apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
def enableSeparateBuildPerCPUArchitecture = true
def enableProguardInReleaseBuilds = true
def jscFlavor = 'org.webkit:android-jsc:+'
def enableHermes = project.ext.react.get("enableHermes", false);
def enableHermes = project.ext.react.get("enableHermes", true);
def reactNativeArchitectures() {
def value = project.getProperties().get("reactNativeArchitectures")
@ -43,8 +43,8 @@ android {
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 36071
versionName "1.45"
versionCode 36082
versionName "1.56"
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
if (isNewArchitectureEnabled()) {

View File

@ -44,3 +44,6 @@
-dontwarn java.nio.file.*
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn okio.**
-keep class com.facebook.hermes.unicode.** { *; }
-keep class com.facebook.jni.** { *; }

View File

@ -1,9 +1,15 @@
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: ['react-native-reanimated/plugin', 'react-native-paper/babel'],
plugins: [
'@babel/plugin-transform-flow-strip-types',
['@babel/plugin-proposal-decorators', {legacy: true}],
['@babel/plugin-proposal-class-properties', {loose: true}],
'react-native-reanimated/plugin',
'react-native-paper/babel',
],
env: {
production: {
plugins: ['transform-remove-console'],
},
},
};
}

View File

@ -1,117 +1,15 @@
import {db} from './db';
import {Periods} from './periods';
import Set from './set';
import {defaultSet} from './set.service';
import Volume from './volume';
import {setRepo} from './db'
import GymSet from './gym-set'
export const getOneRepMax = async ({
name,
period,
}: {
name: string;
period: Periods;
}) => {
// Brzycki formula https://en.wikipedia.org/wiki/One-repetition_maximum#Brzycki
const select = `
SELECT max(weight / (1.0278 - 0.0278 * reps)) AS weight,
STRFTIME('%Y-%m-%d', created) as created, unit
FROM sets
WHERE name = ? AND NOT hidden
AND DATE(created) >= DATE('now', 'weekday 0', ?)
GROUP BY name, STRFTIME(?, created)
`;
let difference = '-7 days';
if (period === Periods.Monthly) difference = '-1 months';
else if (period === Periods.Yearly) difference = '-1 years';
let group = '%Y-%m-%d';
if (period === Periods.Yearly) group = '%Y-%m';
const [result] = await db.executeSql(select, [name, difference, group]);
return result.rows.raw();
};
export const getBestSet = async (name: string): Promise<Set> => {
const bestWeight = `
SELECT name, reps, unit, MAX(weight) AS weight
FROM sets
WHERE name = ?
GROUP BY name;
`;
const bestReps = `
SELECT name, MAX(reps) as reps, unit, weight, sets, minutes, seconds, image
FROM sets
WHERE name = ? AND weight = ?
GROUP BY name;
`;
const [weightResult] = await db.executeSql(bestWeight, [name]);
if (!weightResult.rows.length) return {...defaultSet};
const [repsResult] = await db.executeSql(bestReps, [
name,
weightResult.rows.item(0).weight,
]);
return repsResult.rows.item(0);
};
export const getWeightsBy = async (
name: string,
period: Periods,
): Promise<Set[]> => {
const select = `
SELECT max(weight) AS weight,
STRFTIME('%Y-%m-%d', created) as created, unit
FROM sets
WHERE name = ? AND NOT hidden
AND DATE(created) >= DATE('now', 'weekday 0', ?)
GROUP BY name, STRFTIME(?, created)
`;
let difference = '-7 days';
if (period === Periods.Monthly) difference = '-1 months';
else if (period === Periods.Yearly) difference = '-1 years';
let group = '%Y-%m-%d';
if (period === Periods.Yearly) group = '%Y-%m';
const [result] = await db.executeSql(select, [name, difference, group]);
return result.rows.raw();
};
export const getVolumes = async (
name: string,
period: Periods,
): Promise<Volume[]> => {
const select = `
SELECT sum(weight * reps) AS value,
STRFTIME('%Y-%m-%d', created) as created, unit
FROM sets
WHERE name = ? AND NOT hidden
AND DATE(created) >= DATE('now', 'weekday 0', ?)
GROUP BY name, STRFTIME('%Y-%m-%d', created)
`;
let difference = '-7 days';
if (period === Periods.Monthly) difference = '-1 months';
else if (period === Periods.Yearly) difference = '-1 years';
const [result] = await db.executeSql(select, [name, difference]);
return result.rows.raw();
};
export const getBestWeights = async (search: string): Promise<Set[]> => {
const select = `
SELECT name, reps, unit, MAX(weight) AS weight
FROM sets
WHERE name LIKE ? AND NOT hidden
GROUP BY name;
`;
const [result] = await db.executeSql(select, [`%${search}%`]);
return result.rows.raw();
};
export const getBestReps = async (
name: string,
weight: number,
): Promise<Set[]> => {
const select = `
SELECT name, MAX(reps) as reps, unit, weight, image
FROM sets
WHERE name = ? AND weight = ? AND NOT hidden
GROUP BY name;
`;
const [result] = await db.executeSql(select, [name, weight]);
return result.rows.raw();
};
export const getBestSet = async (name: string): Promise<GymSet> => {
return setRepo
.createQueryBuilder()
.select()
.addSelect('MAX(weight)', 'weight')
.where('name = :name', {name})
.groupBy('name')
.addGroupBy('reps')
.orderBy('weight', 'DESC')
.addOrderBy('reps', 'DESC')
.getOne()
}

View File

@ -1,11 +0,0 @@
import React, {useContext} from 'react';
export const Color = React.createContext({
color: '',
setColor: (_value: string) => {},
});
export const useColor = () => {
const context = useContext(Color);
return context;
};

View File

@ -1,36 +1,32 @@
export const lightColors = [
{hex: '#FA8072', name: 'Salmon'},
{hex: '#B3E5FC', name: 'Cyan'},
{hex: '#FFC0CB', name: 'Pink'},
{hex: '#E9DCC9', name: 'Linen'},
];
'#B3E5FC',
'#FA8072',
'#FFC0CB',
'#E9DCC9',
'#BBA1CE',
]
export const darkColors = [
{hex: '#8156A7', name: 'Purple'},
{hex: '#007AFF', name: 'Blue'},
{hex: '#000000', name: 'Black'},
{hex: '#CD5C5C', name: 'Red'},
];
export const darkColors = ['#8156A7', '#007AFF', '#000000', '#CD5C5C']
export const colorShade = (color: any, amount: number) => {
color = color.replace(/^#/, '');
color = color.replace(/^#/, '')
if (color.length === 3)
color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2];
color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2]
let [r, g, b] = color.match(/.{2}/g);
[r, g, b] = [
let [r, g, b] = color.match(/.{2}/g)
;[r, g, b] = [
parseInt(r, 16) + amount,
parseInt(g, 16) + amount,
parseInt(b, 16) + amount,
];
]
r = Math.max(Math.min(255, r), 0).toString(16);
g = Math.max(Math.min(255, g), 0).toString(16);
b = Math.max(Math.min(255, b), 0).toString(16);
r = Math.max(Math.min(255, r), 0).toString(16)
g = Math.max(Math.min(255, g), 0).toString(16)
b = Math.max(Math.min(255, b), 0).toString(16)
const rr = (r.length < 2 ? '0' : '') + r;
const gg = (g.length < 2 ? '0' : '') + g;
const bb = (b.length < 2 ? '0' : '') + b;
const rr = (r.length < 2 ? '0' : '') + r
const gg = (g.length < 2 ? '0' : '') + g
const bb = (b.length < 2 ? '0' : '') + b
return `#${rr}${gg}${bb}`;
};
return `#${rr}${gg}${bb}`
}

View File

@ -1,2 +1,2 @@
export const MARGIN = 10;
export const PADDING = 10;
export const MARGIN = 10
export const PADDING = 10

View File

@ -1,5 +1,5 @@
export default interface CountMany {
name: string;
total: number;
sets?: number;
name: string
total: number
sets?: number
}

61
data-source.ts Normal file
View File

@ -0,0 +1,61 @@
import {DataSource} from 'typeorm'
import GymSet from './gym-set'
import {Sets1667185586014 as sets1667185586014} from './migrations/1667185586014-sets'
import {plans1667186124792} from './migrations/1667186124792-plans'
import {settings1667186130041} from './migrations/1667186130041-settings'
import {addSound1667186139844} from './migrations/1667186139844-add-sound'
import {addHidden1667186159379} from './migrations/1667186159379-add-hidden'
import {addNotify1667186166140} from './migrations/1667186166140-add-notify'
import {addImage1667186171548} from './migrations/1667186171548-add-image'
import {addImages1667186179488} from './migrations/1667186179488-add-images'
import {insertSettings1667186203827} from './migrations/1667186203827-insert-settings'
import {addSteps1667186211251} from './migrations/1667186211251-add-steps'
import {addSets1667186250618} from './migrations/1667186250618-add-sets'
import {addMinutes1667186255650} from './migrations/1667186255650-add-minutes'
import {addSeconds1667186259174} from './migrations/1667186259174-add-seconds'
import {addShowUnit1667186265588} from './migrations/1667186265588-add-show-unit'
import {addColor1667186320954} from './migrations/1667186320954-add-color'
import {addSteps1667186348425} from './migrations/1667186348425-add-steps'
import {addDate1667186431804} from './migrations/1667186431804-add-date'
import {addShowDate1667186435051} from './migrations/1667186435051-add-show-date'
import {addTheme1667186439366} from './migrations/1667186439366-add-theme'
import {addShowSets1667186443614} from './migrations/1667186443614-add-show-sets'
import {addSetsCreated1667186451005} from './migrations/1667186451005-add-sets-created'
import {addNoSound1667186456118} from './migrations/1667186456118-add-no-sound'
import {dropMigrations1667190214743} from './migrations/1667190214743-drop-migrations'
import {Plan} from './plan'
import Settings from './settings'
export const AppDataSource = new DataSource({
type: 'react-native',
database: 'massive.db',
location: 'default',
entities: [GymSet, Plan, Settings],
migrationsRun: true,
migrationsTableName: 'typeorm_migrations',
migrations: [
sets1667185586014,
plans1667186124792,
settings1667186130041,
addSound1667186139844,
addHidden1667186159379,
addNotify1667186166140,
addImage1667186171548,
addImages1667186179488,
insertSettings1667186203827,
addSteps1667186211251,
addSets1667186250618,
addMinutes1667186255650,
addSeconds1667186259174,
addShowUnit1667186265588,
addColor1667186320954,
addSteps1667186348425,
addDate1667186431804,
addShowDate1667186435051,
addTheme1667186439366,
addShowSets1667186443614,
addSetsCreated1667186451005,
addNoSound1667186456118,
dropMigrations1667190214743,
],
})

161
db.ts
View File

@ -1,151 +1,14 @@
import {
enablePromise,
openDatabase,
SQLiteDatabase,
} from 'react-native-sqlite-storage';
import {AppDataSource} from './data-source'
import GymSet from './gym-set'
import {Plan} from './plan'
import Settings from './settings'
enablePromise(true);
export const setRepo = AppDataSource.manager.getRepository(GymSet)
export const planRepo = AppDataSource.manager.getRepository(Plan)
export const settingsRepo = AppDataSource.manager.getRepository(Settings)
const migrations = [
`
CREATE TABLE IF NOT EXISTS sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
reps INTEGER NOT NULL,
weight INTEGER NOT NULL,
created TEXT NOT NULL,
unit TEXT DEFAULT 'kg'
)
`,
`
CREATE TABLE IF NOT EXISTS plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
days TEXT NOT NULL,
workouts TEXT NOT NULL
)
`,
`
CREATE TABLE IF NOT EXISTS settings (
minutes INTEGER NOT NULL DEFAULT 3,
seconds INTEGER NOT NULL DEFAULT 30,
alarm BOOLEAN NOT NULL DEFAULT 0,
vibrate BOOLEAN NOT NULL DEFAULT 1,
sets INTEGER NOT NULL DEFAULT 3
)
`,
`ALTER TABLE settings ADD COLUMN sound TEXT NULL`,
`
CREATE TABLE IF NOT EXISTS workouts(
name TEXT PRIMARY KEY,
sets INTEGER DEFAULT 3
)
`,
`
ALTER TABLE sets ADD COLUMN hidden DEFAULT false
`,
`
ALTER TABLE settings ADD COLUMN notify DEFAULT false
`,
`
ALTER TABLE sets ADD COLUMN image TEXT NULL
`,
`
ALTER TABLE settings ADD COLUMN images BOOLEAN DEFAULT true
`,
`
SELECT * FROM settings LIMIT 1
`,
`
INSERT INTO settings(minutes) VALUES(3)
`,
`
ALTER TABLE workouts ADD COLUMN steps TEXT NULL
`,
`
INSERT OR IGNORE INTO workouts (name) SELECT DISTINCT name FROM sets
`,
`
ALTER TABLE sets ADD COLUMN sets INTEGER NOT NULL DEFAULT 3
`,
`
ALTER TABLE sets ADD COLUMN minutes INTEGER NOT NULL DEFAULT 3
`,
`
ALTER TABLE sets ADD COLUMN seconds INTEGER NOT NULL DEFAULT 30
`,
`
ALTER TABLE settings ADD COLUMN showUnit BOOLEAN DEFAULT true
`,
`
ALTER TABLE sets ADD COLUMN steps TEXT NULL
`,
`
UPDATE sets SET steps = (
SELECT workouts.steps FROM workouts WHERE workouts.name = sets.name
)
`,
`
DROP TABLE workouts
`,
`
ALTER TABLE settings ADD COLUMN color TEXT NULL
`,
`
UPDATE settings SET showUnit = 1
`,
`
ALTER TABLE settings ADD COLUMN workouts BOOLEAN DEFAULT true
`,
`
ALTER TABLE settings ADD COLUMN steps BOOLEAN DEFAULT true
`,
`
ALTER TABLE settings ADD COLUMN nextAlarm TEXT NULL
`,
`
ALTER TABLE settings ADD COLUMN newSet TEXT NULL
`,
`
ALTER TABLE settings ADD COLUMN date TEXT NULL
`,
`
ALTER TABLE settings ADD COLUMN showDate BOOLEAN DEFAULT false
`,
`
ALTER TABLE settings ADD COLUMN theme TEXT
`,
`
ALTER TABLE settings ADD COLUMN showSets BOOLEAN DEFAULT true
`,
`
CREATE INDEX sets_created ON sets(created)
`,
`
ALTER TABLE settings ADD COLUMN noSound BOOLEAN DEFAULT false
`,
`
CREATE INDEX sets_created ON sets(created)
`,
];
export let db: SQLiteDatabase;
export const runMigrations = async () => {
db = await openDatabase({name: 'massive.db'});
await db.executeSql(`
CREATE TABLE IF NOT EXISTS migrations(
id INTEGER PRIMARY KEY AUTOINCREMENT,
command TEXT NOT NULL
)
`);
const [result] = await db.executeSql(`SELECT * FROM migrations`);
const missing = migrations.slice(result.rows.length);
for (const command of missing) {
await db.executeSql(command).catch(console.error);
const insert = `
INSERT INTO migrations (command)
VALUES (?)
`;
await db.executeSql(insert, [command]);
}
};
export const getNow = (): Promise<{now: string}[]> => {
return AppDataSource.manager.query(
"SELECT STRFTIME('%Y-%m-%dT%H:%M:%S','now','localtime') AS now",
)
}

View File

@ -1,7 +1,11 @@
#!/bin/sh
set -ex
git push origin HEAD > /dev/null &
yarn tsc
yarn lint
git push origin HEAD
cd android || exit 1
build=app/build.gradle
versionCode=$(

View File

@ -1,8 +1,7 @@
export type DrawerParamList = {
Home: {};
Settings: {};
Best: {};
Plans: {};
Workouts: {};
Timer: {};
};
Home: {}
Settings: {}
Best: {}
Plans: {}
Workouts: {}
}

40
gym-set.ts Normal file
View File

@ -0,0 +1,40 @@
import {Column, Entity, PrimaryGeneratedColumn} from 'typeorm'
@Entity('sets')
export default class GymSet {
@PrimaryGeneratedColumn()
id?: number
@Column('text')
name: string
@Column('int')
reps: number
@Column('int')
weight: number
@Column('int')
sets = 3
@Column('int')
minutes = 3
@Column('int')
seconds = 30
@Column('boolean')
hidden = false
@Column('text')
created: string
@Column('text')
unit: string
@Column('text')
image: string
@Column('text')
steps?: string
}

View File

@ -1,8 +1,8 @@
import Set from './set';
import GymSet from './gym-set'
export type HomePageParams = {
Sets: {};
Sets: {}
EditSet: {
set: Set;
};
};
set: GymSet
}
}

View File

@ -1,7 +1,6 @@
import {AppRegistry} from 'react-native';
import 'react-native-gesture-handler';
import 'react-native-sqlite-storage';
import App from './App';
import {name as appName} from './app.json';
import {AppRegistry} from 'react-native'
import 'react-native-gesture-handler'
import App from './App'
import {name as appName} from './app.json'
AppRegistry.registerComponent(appName, () => App);
AppRegistry.registerComponent(appName, () => App)

View File

@ -1,5 +1,5 @@
export default interface Input<T> {
name: string;
value?: T;
onChange: (value: T) => void;
name: string
value?: T
onChange: (value: T) => void
}

View File

@ -1,5 +1,6 @@
#!/bin/sh
cd android || exit 1
./gradlew assembleRelease
set -ex
cd android
[ "$1" != "--nobuild" ] && ./gradlew assembleRelease
adb -d install app/build/outputs/apk/release/app-arm64-v8a-release.apk

14
jest.config.js Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
preset: 'react-native',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
transform: {
'^.+\\.(js|jsx)$': 'babel-jest',
},
transformIgnorePatterns: [
'node_modules/(?!(jest-)?@?react-native|@react-native-community|@react-navigation)',
],
setupFiles: [
'./node_modules/react-native-gesture-handler/jestSetup',
'./jestSetup.ts',
],
}

29
jestSetup.ts Normal file
View File

@ -0,0 +1,29 @@
import 'react-native-gesture-handler/jestSetup'
import {NativeModules as RNNativeModules} from 'react-native'
//RNNativeModules.UIManager = RNNativeModules.UIManager || {};
//RNNativeModules.UIManager.RCTView = RNNativeModules.UIManager.RCTView || {};
//RNNativeModules.RNGestureHandlerModule =
// RNNativeModules.RNGestureHandlerModule || {
// State: {BEGAN: 'BEGAN', FAILED: 'FAILED', ACTIVE: 'ACTIVE', END: 'END'},
// attachGestureHandler: jest.fn(),
// createGestureHandler: jest.fn(),
// dropGestureHandler: jest.fn(),
// updateGestureHandler: jest.fn(),
// };
//RNNativeModules.PlatformConstants = RNNativeModules.PlatformConstants || {
// forceTouchAvailable: false,
//};
RNNativeModules.RNViewShot = RNNativeModules.RNViewShot || {
captureScreen: jest.fn(),
}
jest.mock('react-native-file-access', () => jest.fn())
jest.mock('react-native-share', () => jest.fn())
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper')
jest.useFakeTimers()
jest.mock('react-native-reanimated', () => {
const Reanimated = require('react-native-reanimated/mock')
Reanimated.default.call = () => {}
return Reanimated
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 163 KiB

View File

@ -14,4 +14,4 @@ module.exports = {
},
}),
},
};
}

View File

@ -0,0 +1,20 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class Sets1667185586014 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
reps INTEGER NOT NULL,
weight INTEGER NOT NULL,
created TEXT NOT NULL,
unit TEXT DEFAULT 'kg'
)
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE sets')
}
}

View File

@ -0,0 +1,17 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class plans1667186124792 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
days TEXT NOT NULL,
workouts TEXT NOT NULL
)
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('plans')
}
}

View File

@ -0,0 +1,19 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class settings1667186130041 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS settings (
minutes INTEGER NOT NULL DEFAULT 3,
seconds INTEGER NOT NULL DEFAULT 30,
alarm BOOLEAN NOT NULL DEFAULT 0,
vibrate BOOLEAN NOT NULL DEFAULT 1,
sets INTEGER NOT NULL DEFAULT 3
)
`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('settings')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addSound1667186139844 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE settings ADD COLUMN sound TEXT NULL')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('settings', 'sound')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addHidden1667186159379 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE sets ADD COLUMN hidden DEFAULT false')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('sets', 'hidden')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addNotify1667186166140 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE settings ADD COLUMN notify DEFAULT false')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('settings', 'notify')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addImage1667186171548 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE sets ADD COLUMN image TEXT NULL')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('sets', 'image')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addImages1667186179488 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE settings ADD COLUMN images BOOLEAN DEFAULT true')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('settings', 'images')
}
}

View File

@ -0,0 +1,11 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class insertSettings1667186203827 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('INSERT INTO settings(minutes) VALUES(3)')
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DELETE FROM settings')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addSteps1667186211251 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE settings ADD COLUMN steps BOOLEAN DEFAULT true')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('settings', 'steps')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addSets1667186250618 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE sets ADD COLUMN sets INTEGER NOT NULL DEFAULT 3')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('sets', 'sets')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addMinutes1667186255650 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE sets ADD COLUMN minutes INTEGER NOT NULL DEFAULT 3')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('sets', 'minutes')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addSeconds1667186259174 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE sets ADD COLUMN seconds INTEGER NOT NULL DEFAULT 30')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('sets', 'seconds')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addShowUnit1667186265588 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE settings ADD COLUMN showUnit BOOLEAN DEFAULT true')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('settings', 'showUnit')
}
}

View File

@ -0,0 +1,22 @@
import {MigrationInterface, QueryRunner, TableColumn} from 'typeorm'
import {darkColors} from '../colors'
export class addColor1667186320954 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.addColumn(
'settings',
new TableColumn({
name: 'color',
type: 'text',
isNullable: false,
default: `'${darkColors[0]}'`,
}),
)
.catch(console.error)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('settings', 'color')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addSteps1667186348425 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE sets ADD COLUMN steps TEXT NULL')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('sets', 'steps')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addDate1667186431804 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE settings ADD COLUMN date TEXT NULL')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('settings', 'date')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addShowDate1667186435051 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE settings ADD COLUMN showDate BOOLEAN DEFAULT false')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('settings', 'showDate')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addTheme1667186439366 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE settings ADD COLUMN theme TEXT')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('settings', 'theme')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addShowSets1667186443614 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE settings ADD COLUMN showSets BOOLEAN DEFAULT true')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('settings', 'showSets')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addSetsCreated1667186451005 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('CREATE INDEX sets_created ON sets(created)')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropIndex('sets', 'sets_created')
}
}

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from 'typeorm'
export class addNoSound1667186456118 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner
.query('ALTER TABLE settings ADD COLUMN noSound BOOLEAN DEFAULT false')
.catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('settings', 'noSound')
}
}

View File

@ -0,0 +1,19 @@
import {MigrationInterface, QueryRunner, Table, TableColumn} from 'typeorm'
export class dropMigrations1667190214743 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('migrations').catch(() => null)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'migrations',
columns: [
new TableColumn({name: 'id', type: 'integer'}),
new TableColumn({name: 'command', type: 'text'}),
],
}),
)
}
}

24
mock-providers.tsx Normal file
View File

@ -0,0 +1,24 @@
import {NavigationContainer} from '@react-navigation/native'
import React from 'react'
import {DefaultTheme, Provider as PaperProvider} from 'react-native-paper'
import MaterialIcon from 'react-native-vector-icons/MaterialIcons'
import {ThemeContext} from './use-theme'
export const theme = {
theme: 'system',
setTheme: jest.fn(),
color: DefaultTheme.colors.primary,
setColor: jest.fn(),
}
export const MockProviders = ({
children,
}: {
children: JSX.Element | JSX.Element[]
}) => (
<PaperProvider settings={{icon: props => <MaterialIcon {...props} />}}>
<ThemeContext.Provider value={theme}>
<NavigationContainer>{children}</NavigationContainer>
</ThemeContext.Provider>
</PaperProvider>
)

View File

@ -1,6 +1,6 @@
{
"name": "massive",
"version": "1.45",
"version": "1.56",
"private": true,
"license": "GPL-3.0-only",
"scripts": {
@ -8,20 +8,27 @@
"release": "react-native run-android --variant=release",
"start": "react-native start",
"test": "jest",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --quiet"
"lint": "eslint . --ext .js,.jsx,.ts,.tsx"
},
"dependencies": {
"@babel/plugin-transform-flow-strip-types": "^7.19.0",
"@babel/preset-env": "^7.19.1",
"@react-native-masked-view/masked-view": "^0.2.7",
"@react-native-picker/picker": "^2.4.4",
"@react-navigation/drawer": "^6.5.0",
"@react-navigation/native": "^6.0.13",
"@react-navigation/stack": "^6.3.0",
"@testing-library/jest-native": "^5.1.2",
"@testing-library/react-native": "^11.3.0",
"@types/d3-shape": "^3.1.0",
"@types/jest": "^29.2.0",
"@types/react-native-sqlite-storage": "^5.0.2",
"@types/react-native-svg-charts": "^5.0.12",
"@types/react-native-vector-icons": "^6.4.12",
"babel-jest": "^29.2.2",
"babel-plugin-transform-remove-console": "^6.9.4",
"eslint-plugin-flowtype": "^8.0.3",
"jest": "^29.2.2",
"react": "^18.2.0",
"react-native": "^0.70.4",
"react-native-document-picker": "^8.1.2",
@ -38,12 +45,16 @@
"react-native-svg": "^13.4.0",
"react-native-svg-charts": "^5.4.0",
"react-native-vector-icons": "^9.2.0",
"react-native-view-shot": "^3.4.0"
"react-native-view-shot": "^3.4.0",
"react-test-renderer": "^18.2.0",
"typeorm": "^0.3.10"
},
"devDependencies": {
"@babel/core": "^7.12.9",
"@babel/plugin-proposal-decorators": "^7.20.0",
"@babel/runtime": "^7.12.5",
"@react-native-community/eslint-config": "^2.0.0",
"@types/node": "^18.11.7",
"@types/react-native": "^0.69.0",
"@types/react-test-renderer": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^5.29.0",

View File

@ -1,13 +1,15 @@
import {Plan} from './plan';
import Set from './set';
import GymSet from './gym-set'
import {Plan} from './plan'
export type PlanPageParams = {
PlanList: {};
PlanList: {}
EditPlan: {
plan: Plan;
};
plan: Plan
}
StartPlan: {
plan: Plan;
set: Set;
};
};
plan: Plan
}
EditSet: {
set: GymSet
}
}

View File

@ -1,60 +0,0 @@
import {db} from './db';
import {Plan} from './plan';
import {DAYS} from './time';
export const getPlans = async (search: string): Promise<Plan[]> => {
const select = `
SELECT * from plans
WHERE days LIKE ? OR workouts LIKE ?
`;
const [result] = await db.executeSql(select, [`%${search}%`, `%${search}%`]);
return result.rows.raw();
};
export const getTodaysPlan = async (): Promise<Plan[]> => {
const today = DAYS[new Date().getDay()];
const [result] = await db.executeSql(
`SELECT * FROM plans WHERE days LIKE ? LIMIT 1`,
[`%${today}%`],
);
return result.rows.raw();
};
export const updatePlanWorkouts = async (oldName: string, newName: string) => {
const update = `
UPDATE plans SET workouts = REPLACE(workouts, ?, ?)
WHERE workouts LIKE ?
`;
return db.executeSql(update, [oldName, newName, `%${oldName}%`]);
};
export const updatePlan = async (value: Plan) => {
const update = `UPDATE plans SET days = ?, workouts = ? WHERE id = ?`;
return db.executeSql(update, [value.days, value.workouts, value.id]);
};
export const addPlan = async (value: Plan) => {
const insert = `INSERT INTO plans(days, workouts) VALUES (?, ?)`;
return db.executeSql(insert, [value.days, value.workouts]);
};
export const addPlans = async (values: string) => {
const insert = `
INSERT INTO plans(days,workouts) VALUES ${values}
`;
return db.executeSql(insert);
};
export const deletePlans = async () => {
return db.executeSql(`DELETE FROM plans`);
};
export const deletePlan = async (id: number) => {
return db.executeSql(`DELETE FROM plans WHERE id = ?`, [id]);
};
export const getAllPlans = async (): Promise<Plan[]> => {
const select = `SELECT * from plans`;
const [result] = await db.executeSql(select);
return result.rows.raw();
};

16
plan.ts
View File

@ -1,5 +1,13 @@
export interface Plan {
id?: number;
days: string;
workouts: string;
import {Column, Entity, PrimaryGeneratedColumn} from 'typeorm'
@Entity('plans')
export class Plan {
@PrimaryGeneratedColumn()
id?: number
@Column('text')
days: string
@Column('text')
workouts: string
}

View File

@ -1,7 +1,7 @@
import {DrawerParamList} from './drawer-param-list';
import {DrawerParamList} from './drawer-param-list'
export default interface Route {
name: keyof DrawerParamList;
component: React.ComponentType<any>;
icon: string;
name: keyof DrawerParamList
component: React.ComponentType<any>
icon: string
}

View File

@ -1,197 +0,0 @@
import CountMany from './count-many';
import {db} from './db';
import Set from './set';
export const updateSet = async (value: Set) => {
const update = `
UPDATE sets
SET name = ?, reps = ?, weight = ?, unit = ?, image = ?
WHERE id = ?
`;
return db.executeSql(update, [
value.name,
value.reps,
value.weight,
value.unit,
value.image,
value.id,
]);
};
export const addSets = async (columns: string, values: string) => {
console.log({columns, values});
const insert = `
INSERT INTO sets(${columns})
VALUES ${values}
`;
return db.executeSql(insert);
};
export const addSet = async (value: Set) => {
const keys = Object.keys(value) as (keyof Set)[];
const questions = keys.map(() => '?').join(',');
const insert = `
INSERT INTO sets(${keys.join(',')},created)
VALUES (${questions},strftime('%Y-%m-%dT%H:%M:%S','now','localtime'))
`;
const values = keys.map(key => value[key]);
return db.executeSql(insert, values);
};
export const deleteSets = async () => {
return db.executeSql(`DELETE FROM sets`);
};
export const deleteSet = async (id: number) => {
return db.executeSql(`DELETE FROM sets WHERE id = ?`, [id]);
};
export const deleteSetsBy = async (name: string) => {
return db.executeSql(`DELETE FROM sets WHERE name = ?`, [name]);
};
export const getAllSets = async (): Promise<Set[]> => {
const select = `SELECT * from sets`;
const [result] = await db.executeSql(select);
return result.rows.raw();
};
interface PageParams {
search: string;
limit: number;
offset: number;
format?: string;
}
export const getSet = async (name: string): Promise<Set> => {
const select = `
SELECT *
FROM sets
WHERE name = ?
LIMIT 1
`;
const [result] = await db.executeSql(select, [name]);
return result.rows.item(0);
};
export const getSets = async ({
search,
limit,
offset,
format,
}: PageParams): Promise<Set[]> => {
const select = `
SELECT id, name, reps, weight, sets, minutes, seconds,
created, unit, image, steps
FROM sets
WHERE name LIKE ? AND NOT hidden
ORDER BY STRFTIME('%Y-%m-%d %H:%M', created) DESC
LIMIT ? OFFSET ?
`;
const [result] = await db.executeSql(select, [`%${search}%`, limit, offset]);
return result.rows.raw();
};
export const defaultSet: Set = {
name: '',
reps: 10,
weight: 20,
unit: 'kg',
};
export const updateManySet = async ({
oldName,
newName,
minutes,
seconds,
sets,
steps,
}: {
oldName: string;
newName: string;
minutes: string;
seconds: string;
sets: string;
steps?: string;
}) => {
const update = `
UPDATE sets SET name = ?, minutes = ?, seconds = ?, sets = ?, steps = ?
WHERE name = ?
`;
return db.executeSql(update, [
newName,
minutes,
seconds,
sets,
steps,
oldName,
]);
};
export const updateSetImage = async (name: string, image: string) => {
const update = `UPDATE sets SET image = ? WHERE name = ?`;
return db.executeSql(update, [image, name]);
};
export const getNames = async (): Promise<string[]> => {
const [result] = await db.executeSql('SELECT DISTINCT name FROM sets');
const values: {name: string}[] = result.rows.raw();
return values.map(value => value.name);
};
export const getToday = async (): Promise<Set | undefined> => {
const select = `
SELECT name, reps, weight, sets, minutes, seconds, unit, image FROM sets
WHERE NOT hidden
AND created LIKE strftime('%Y-%m-%d%%', 'now', 'localtime')
ORDER BY created DESC
LIMIT 1
`;
const [result] = await db.executeSql(select);
return result.rows.item(0);
};
export const countToday = async (name: string): Promise<number> => {
const select = `
SELECT COUNT(*) as total FROM sets
WHERE created LIKE strftime('%Y-%m-%d%%', 'now', 'localtime')
AND name = ? AND NOT hidden
`;
const [result] = await db.executeSql(select, [name]);
return Number(result.rows.item(0)?.total);
};
export const countMany = async (names: string[]): Promise<CountMany[]> => {
const questions = names.map(_ => '?').join(',');
console.log({questions, names});
const select = `
SELECT workouts.name, COUNT(sets.id) as total, workouts.sets
FROM (
SELECT distinct name, sets FROM sets
WHERE name IN (${questions})
) workouts
LEFT JOIN sets ON sets.name = workouts.name
AND sets.created LIKE STRFTIME('%Y-%m-%d%%', 'now', 'localtime')
AND NOT sets.hidden
GROUP BY workouts.name;
`;
const [result] = await db.executeSql(select, names);
return result.rows.raw();
};
export const getDistinctSets = async ({
search,
limit,
offset,
}: PageParams): Promise<Set[]> => {
const select = `
SELECT name, image, sets, minutes, seconds, steps
FROM sets
WHERE sets.name LIKE ?
GROUP BY sets.name
ORDER BY sets.name
LIMIT ? OFFSET ?
`;
const [result] = await db.executeSql(select, [search, limit, offset]);
return result.rows.raw();
};

14
set.ts
View File

@ -1,14 +0,0 @@
export default interface Set {
id?: number;
name: string;
reps: number;
weight: number;
sets?: number;
minutes?: number;
seconds?: number;
created?: string;
unit?: string;
hidden?: boolean;
image?: string;
steps?: string;
}

View File

@ -1,23 +0,0 @@
import {db} from './db';
import Settings from './settings';
export const getSettings = async (): Promise<Settings> => {
const [result] = await db.executeSql(`SELECT * FROM settings LIMIT 1`);
return result.rows.item(0);
};
export const updateSettings = async (value: Settings) => {
console.log(`${updateSettings.name}`, {value});
const keys = Object.keys(value) as (keyof Settings)[];
const sets = keys.map(key => `${key}=?`).join(',');
const update = `UPDATE settings SET ${sets}`;
const values = keys.map(key => value[key]);
return db.executeSql(update, values);
};
export const getNext = async (): Promise<string | undefined> => {
const [result] = await db.executeSql(
`SELECT nextAlarm FROM settings LIMIT 1`,
);
return result.rows.item(0)?.nextAlarm;
};

View File

@ -1,15 +1,43 @@
export default interface Settings {
alarm: number;
vibrate: number;
sound: string;
notify: number;
images: number;
showUnit: number;
color: string;
steps: number;
date: string;
showDate: number;
theme: 'system' | 'dark' | 'light';
showSets: number;
noSound: number;
import {Column, Entity, PrimaryColumn} from 'typeorm'
@Entity()
export default class Settings {
@PrimaryColumn('boolean')
alarm: boolean
@Column('boolean')
vibrate: boolean
@Column('text')
sound: string
@Column('boolean')
notify: boolean
@Column('boolean')
images: boolean
@Column('boolean')
showUnit: boolean
@Column('text')
color: string
@Column('boolean')
steps: boolean
@Column('text')
date: string
@Column('boolean')
showDate: boolean
@Column('text')
theme: string
@Column('boolean')
showSets: boolean
@Column('boolean')
noSound: boolean
}

50
time.ts
View File

@ -6,26 +6,26 @@ export const DAYS = [
'Thursday',
'Friday',
'Saturday',
];
]
export function formatMonth(iso: string) {
const date = new Date(iso);
const dd = date.getDate().toString();
const mm = (date.getMonth() + 1).toString();
return `${dd}/${mm}`;
const date = new Date(iso)
const dd = date.getDate().toString()
const mm = (date.getMonth() + 1).toString()
return `${dd}/${mm}`
}
function twelveHour(twentyFourHour: string) {
const [hourString, minute] = twentyFourHour.split(':');
const hour = +hourString % 24;
return (hour % 12 || 12) + ':' + minute + (hour < 12 ? ' AM' : ' PM');
const [hourString, minute] = twentyFourHour.split(':')
const hour = +hourString % 24
return (hour % 12 || 12) + ':' + minute + (hour < 12 ? ' AM' : ' PM')
}
function dayOfWeek(iso: string) {
const date = new Date(iso);
const day = date.getDay();
const target = DAYS[day === 0 ? 0 : day - 1];
return target.slice(0, 3);
const date = new Date(iso)
const day = date.getDay()
const target = DAYS[day]
return target.slice(0, 3)
}
/**
@ -33,29 +33,29 @@ function dayOfWeek(iso: string) {
* @param kind Intended format for the date, e.g. '%Y-%m-%d %H:%M'
*/
export function format(iso: string, kind: string) {
const split = iso.split('T');
const [year, month, day] = split[0].split('-');
const time = twelveHour(split[1]);
const split = iso.split('T')
const [year, month, day] = split[0].split('-')
const time = twelveHour(split[1])
switch (kind) {
case '%Y-%m-%d %H:%M':
return iso.replace('T', ' ').replace(/:\d{2}/, '');
return iso.replace('T', ' ').replace(/:\d{2}/, '')
case '%Y-%m-%d':
return split[0];
return split[0]
case '%H:%M':
return split[1].replace(/:\d{2}/, '');
return split[1].replace(/:\d{2}/, '')
case '%d/%m/%y %h:%M %p':
return `${day}/${month}/${year} ${time}`;
return `${day}/${month}/${year} ${time}`
case '%d/%m %h:%M %p':
return `${day}/${month} ${time}`;
return `${day}/${month} ${time}`
case '%d/%m/%y':
return `${day}/${month}/${year}`;
return `${day}/${month}/${year}`
case '%d/%m':
return `${day}/${month}`;
return `${day}/${month}`
case '%h:%M %p':
return time;
return time
case '%A %h:%M %p':
return dayOfWeek(iso) + ' ' + time;
return dayOfWeek(iso) + ' ' + time
default:
return iso;
return iso
}
}

7
toast.ts Normal file
View File

@ -0,0 +1,7 @@
import {DeviceEventEmitter} from 'react-native'
export const TOAST = 'toast'
export function toast(value: string) {
DeviceEventEmitter.emit(TOAST, {value})
}

View File

@ -5,7 +5,7 @@
"jsx": "react-native",
"module": "CommonJS",
"moduleResolution": "node",
"types": ["react-native"],
"types": ["react-native", "jest"],
"resolveJsonModule": true,
"allowJs": true,
"noEmit": true,
@ -13,8 +13,15 @@
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
"strict": false,
"skipLibCheck": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
"exclude": ["node_modules", "babel.config.js", "metro.config.js"]
"exclude": [
"node_modules",
"babel.config.js",
"metro.config.js",
"jest.config.js"
]
}

View File

@ -1,11 +1,11 @@
import {useColorScheme} from 'react-native';
import {useSettings} from './use-settings';
import {useColorScheme} from 'react-native'
import {useTheme} from './use-theme'
export default function useDark() {
const dark = useColorScheme() === 'dark';
const {settings} = useSettings();
const dark = useColorScheme() === 'dark'
const {theme} = useTheme()
if (settings.theme === 'dark') return true;
if (settings.theme === 'light') return false;
return dark;
if (theme === 'dark') return true
if (theme === 'light') return false
return dark
}

View File

@ -1,27 +0,0 @@
import React, {useContext} from 'react';
import Settings from './settings';
export const SettingsContext = React.createContext<{
settings: Settings;
setSettings: (value: Settings) => void;
}>({
settings: {
alarm: 0,
color: '',
date: '',
images: 1,
notify: 0,
showDate: 0,
showSets: 1,
showUnit: 1,
sound: '',
steps: 0,
theme: 'system',
vibrate: 1,
},
setSettings: () => null,
});
export function useSettings() {
return useContext(SettingsContext);
}

Some files were not shown because too many files have changed in this diff Show More