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'], plugins: ['@typescript-eslint'],
overrides: [ overrides: [
{ {
files: ['*.ts', '*.tsx'], files: ['*.ts', '*.tsx', '*.js'],
rules: { rules: {
'@typescript-eslint/no-shadow': ['error'], '@typescript-eslint/no-shadow': ['error'],
'no-shadow': 'off', 'no-shadow': 'off',
'no-undef': '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, bracketSpacing: false,
singleQuote: true, singleQuote: true,
trailingComma: 'all', trailingComma: 'all',
semi: false,
}; };

149
App.tsx
View File

@ -2,23 +2,22 @@ import {
DarkTheme as NavigationDarkTheme, DarkTheme as NavigationDarkTheme,
DefaultTheme as NavigationDefaultTheme, DefaultTheme as NavigationDefaultTheme,
NavigationContainer, NavigationContainer,
} from '@react-navigation/native'; } from '@react-navigation/native'
import React, {useEffect, useMemo, useState} from 'react'; import {useEffect, useMemo, useState} from 'react'
import {useColorScheme} from 'react-native'; import {DeviceEventEmitter, useColorScheme} from 'react-native'
import React from 'react'
import { import {
DarkTheme as PaperDarkTheme, DarkTheme as PaperDarkTheme,
DefaultTheme as PaperDefaultTheme, DefaultTheme as PaperDefaultTheme,
Provider, Provider as PaperProvider,
} from 'react-native-paper'; Snackbar,
import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; } from 'react-native-paper'
import {Color} from './color'; import MaterialIcon from 'react-native-vector-icons/MaterialIcons'
import {lightColors} from './colors'; import {AppDataSource} from './data-source'
import {runMigrations} from './db'; import {settingsRepo} from './db'
import MassiveSnack from './MassiveSnack'; import Routes from './Routes'
import Routes from './Routes'; import {TOAST} from './toast'
import Settings from './settings'; import {ThemeContext} from './use-theme'
import {getSettings} from './settings.service';
import {SettingsContext} from './use-settings';
export const CombinedDefaultTheme = { export const CombinedDefaultTheme = {
...NavigationDefaultTheme, ...NavigationDefaultTheme,
@ -27,7 +26,7 @@ export const CombinedDefaultTheme = {
...NavigationDefaultTheme.colors, ...NavigationDefaultTheme.colors,
...PaperDefaultTheme.colors, ...PaperDefaultTheme.colors,
}, },
}; }
export const CombinedDarkTheme = { export const CombinedDarkTheme = {
...NavigationDarkTheme, ...NavigationDarkTheme,
@ -35,61 +34,85 @@ export const CombinedDarkTheme = {
colors: { colors: {
...NavigationDarkTheme.colors, ...NavigationDarkTheme.colors,
...PaperDarkTheme.colors, ...PaperDarkTheme.colors,
primary: lightColors[0].hex,
background: '#0E0E0E',
}, },
}; }
const App = () => { const App = () => {
const isDark = useColorScheme() === 'dark'; const isDark = useColorScheme() === 'dark'
const [settings, setSettings] = useState<Settings>(); const [initialized, setInitialized] = useState(false)
const [color, setColor] = useState( const [snackbar, setSnackbar] = useState('')
const [theme, setTheme] = useState('system')
const [color, setColor] = useState<string>(
isDark isDark
? CombinedDarkTheme.colors.primary.toUpperCase() ? CombinedDarkTheme.colors.primary
: CombinedDefaultTheme.colors.primary.toUpperCase(), : CombinedDefaultTheme.colors.primary,
); )
useEffect(() => { useEffect(() => {
runMigrations().then(async () => { DeviceEventEmitter.addListener(TOAST, ({value}: {value: string}) => {
const gotSettings = await getSettings(); console.log(`${Routes.name}.toast:`, {value})
console.log(`${App.name}.runMigrations:`, {gotSettings}); setSnackbar(value)
setSettings(gotSettings); })
if (gotSettings.color) setColor(gotSettings.color); if (AppDataSource.isInitialized) return setInitialized(true)
}); AppDataSource.initialize().then(async () => {
}, [setColor]); 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 paperTheme = useMemo(() => {
const darkTheme = { const darkTheme = color
...CombinedDarkTheme, ? {
colors: {...CombinedDarkTheme.colors, primary: color}, ...CombinedDarkTheme,
}; colors: {...CombinedDarkTheme.colors, primary: color},
const lightTheme = { }
...CombinedDefaultTheme, : CombinedDarkTheme
colors: {...CombinedDefaultTheme.colors, primary: color}, const lightTheme = color
}; ? {
let value = isDark ? darkTheme : lightTheme; ...CombinedDefaultTheme,
if (settings?.theme === 'dark') value = darkTheme; colors: {...CombinedDefaultTheme.colors, primary: color},
else if (settings?.theme === 'light') value = lightTheme; }
return value; : CombinedDefaultTheme
}, [color, isDark, settings]); 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 ( return (
<Color.Provider value={{color, setColor}}> <PaperProvider
<Provider theme={paperTheme}
theme={theme} settings={{icon: props => <MaterialIcon {...props} />}}>
settings={{icon: props => <MaterialIcon {...props} />}}> <NavigationContainer theme={paperTheme}>
<NavigationContainer theme={theme}> {initialized && (
<MassiveSnack> <ThemeContext.Provider value={{theme, setTheme, color, setColor}}>
{settings && ( <Routes />
<SettingsContext.Provider value={{settings, setSettings}}> </ThemeContext.Provider>
<Routes /> )}
</SettingsContext.Provider> </NavigationContainer>
)}
</MassiveSnack>
</NavigationContainer>
</Provider>
</Color.Provider>
);
};
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, NavigationProp,
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
} from '@react-navigation/native'; } from '@react-navigation/native'
import React, {useCallback, useEffect, useState} from 'react'; import {useCallback, useState} from 'react'
import {FlatList, Image} from 'react-native'; import {FlatList, Image} from 'react-native'
import {List} from 'react-native-paper'; import {List} from 'react-native-paper'
import {getBestReps, getBestWeights} from './best.service'; import {BestPageParams} from './BestPage'
import {BestPageParams} from './BestPage'; import {setRepo, settingsRepo} from './db'
import DrawerHeader from './DrawerHeader'; import DrawerHeader from './DrawerHeader'
import Page from './Page'; import GymSet from './gym-set'
import Set from './set'; import Page from './Page'
import {useSettings} from './use-settings'; import Settings from './settings'
export default function BestList() { export default function BestList() {
const [bests, setBests] = useState<Set[]>(); const [bests, setBests] = useState<GymSet[]>()
const [search, setSearch] = useState(''); const [term, setTerm] = useState('')
const navigation = useNavigation<NavigationProp<BestPageParams>>(); const navigation = useNavigation<NavigationProp<BestPageParams>>()
const {settings} = useSettings(); const [settings, setSettings] = useState<Settings>()
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]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
refresh(); settingsRepo.findOne({where: {}}).then(setSettings)
}, [refresh]), }, []),
); )
useEffect(() => { const refresh = useCallback(async (value: string) => {
refresh(); const weights = await setRepo
}, [search, refresh]); .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 <List.Item
key={item.name} key={item.name}
title={item.name} title={item.name}
@ -53,12 +78,12 @@ export default function BestList() {
null null
} }
/> />
); )
return ( return (
<> <>
<DrawerHeader name="Best" /> <DrawerHeader name="Best" />
<Page search={search} setSearch={setSearch}> <Page term={term} search={search}>
{bests?.length === 0 ? ( {bests?.length === 0 ? (
<List.Item <List.Item
title="No exercises yet" title="No exercises yet"
@ -69,5 +94,5 @@ export default function BestList() {
)} )}
</Page> </Page>
</> </>
); )
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,15 @@
import React from 'react'; import {ComponentProps, Ref} from 'react'
import {TextInput} from 'react-native-paper'; import {TextInput} from 'react-native-paper'
import {CombinedDefaultTheme} from './App'; import {CombinedDefaultTheme} from './App'
import {MARGIN} from './constants'; import {MARGIN} from './constants'
import useDark from './use-dark'; import useDark from './use-dark'
export default function MassiveInput( export default function MassiveInput(
props: Partial<React.ComponentProps<typeof TextInput>> & { props: Partial<ComponentProps<typeof TextInput>> & {
innerRef?: React.Ref<any>; innerRef?: Ref<any>
}, },
) { ) {
const dark = useDark(); const dark = useDark()
return ( return (
<TextInput <TextInput
@ -21,5 +21,5 @@ export default function MassiveInput(
blurOnSubmit={false} blurOnSubmit={false}
{...props} {...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 {StyleSheet, View} from 'react-native'; import {Searchbar} from 'react-native-paper'
import {Searchbar} from 'react-native-paper'; import {PADDING} from './constants'
import {PADDING} from './constants'; import MassiveFab from './MassiveFab'
import MassiveFab from './MassiveFab';
export default function Page({ export default function Page({
onAdd, onAdd,
children, children,
term,
search, search,
setSearch,
}: { }: {
children: JSX.Element | JSX.Element[]; children: JSX.Element | JSX.Element[]
onAdd?: () => void; onAdd?: () => void
search?: string; term: string
setSearch?: (value: string) => void; search: (value: string) => void
}) { }) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
{typeof search === 'string' && setSearch && ( <Searchbar
<Searchbar placeholder="Search"
placeholder="Search" value={term}
value={search} onChangeText={search}
onChangeText={setSearch} icon="search"
icon="search" clearIcon="clear"
clearIcon="clear" />
/>
)}
{children} {children}
{onAdd && <MassiveFab onPress={onAdd} />} {onAdd && <MassiveFab onPress={onAdd} />}
</View> </View>
); )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@ -37,4 +34,4 @@ const styles = StyleSheet.create({
flexGrow: 1, flexGrow: 1,
padding: PADDING, padding: PADDING,
}, },
}); })

View File

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

View File

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

View File

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

View File

@ -1,31 +1,30 @@
import {createDrawerNavigator} from '@react-navigation/drawer'; import {createDrawerNavigator} from '@react-navigation/drawer'
import {useNavigation} from '@react-navigation/native'; import {useMemo} from 'react'
import React from 'react'; import {IconButton} from 'react-native-paper'
import {IconButton} from 'react-native-paper'; import BestPage from './BestPage'
import BestPage from './BestPage'; import {DrawerParamList} from './drawer-param-list'
import {DrawerParamList} from './drawer-param-list'; import HomePage from './HomePage'
import HomePage from './HomePage'; import PlanPage from './PlanPage'
import PlanPage from './PlanPage'; import Route from './route'
import Route from './route'; import SettingsPage from './SettingsPage'
import SettingsPage from './SettingsPage'; import useDark from './use-dark'
import TimerPage from './TimerPage'; import WorkoutsPage from './WorkoutsPage'
import useDark from './use-dark';
import WorkoutsPage from './WorkoutsPage';
const Drawer = createDrawerNavigator<DrawerParamList>(); const Drawer = createDrawerNavigator<DrawerParamList>()
export default function Routes() { export default function Routes() {
const dark = useDark(); const dark = useDark()
const navigation = useNavigation();
const routes: Route[] = [ const routes: Route[] = useMemo(
{name: 'Home', component: HomePage, icon: 'home'}, () => [
{name: 'Plans', component: PlanPage, icon: 'event'}, {name: 'Home', component: HomePage, icon: 'home'},
{name: 'Best', component: BestPage, icon: 'insights'}, {name: 'Plans', component: PlanPage, icon: 'event'},
{name: 'Workouts', component: WorkoutsPage, icon: 'fitness-center'}, {name: 'Best', component: BestPage, icon: 'insights'},
{name: 'Timer', component: TimerPage, icon: 'access-time'}, {name: 'Workouts', component: WorkoutsPage, icon: 'fitness-center'},
{name: 'Settings', component: SettingsPage, icon: 'settings'}, {name: 'Settings', component: SettingsPage, icon: 'settings'},
]; ],
[],
)
return ( return (
<Drawer.Navigator <Drawer.Navigator
@ -45,5 +44,5 @@ export default function Routes() {
/> />
))} ))}
</Drawer.Navigator> </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 {useCallback, useRef, useState} from 'react'
import {TextInput, View} from 'react-native'; import {TextInput, View} from 'react-native'
import DocumentPicker from 'react-native-document-picker'; import DocumentPicker from 'react-native-document-picker'
import {Button, Card, TouchableRipple} from 'react-native-paper'; import {Button, Card, TouchableRipple} from 'react-native-paper'
import ConfirmDialog from './ConfirmDialog'; import ConfirmDialog from './ConfirmDialog'
import {MARGIN} from './constants'; import {MARGIN} from './constants'
import MassiveInput from './MassiveInput'; import {getNow, setRepo} from './db'
import {useSnackbar} from './MassiveSnack'; import GymSet from './gym-set'
import Set from './set'; import MassiveInput from './MassiveInput'
import {getSets} from './set.service'; import Settings from './settings'
import {useSettings} from './use-settings'; import {format} from './time'
import {toast} from './toast'
export default function SetForm({ export default function SetForm({
save, save,
set, set,
settings,
}: { }: {
set: Set; set: GymSet
save: (set: Set) => void; save: (set: GymSet) => void
settings: Settings
}) { }) {
const [name, setName] = useState(set.name); const [name, setName] = useState(set.name)
const [reps, setReps] = useState(set.reps.toString()); const [reps, setReps] = useState(set.reps.toString())
const [weight, setWeight] = useState(set.weight.toString()); const [weight, setWeight] = useState(set.weight.toString())
const [newImage, setNewImage] = useState(set.image); const [newImage, setNewImage] = useState(set.image)
const [unit, setUnit] = useState(set.unit); const [unit, setUnit] = useState(set.unit)
const [showRemove, setShowRemove] = useState(false); const [showRemove, setShowRemove] = useState(false)
const [selection, setSelection] = useState({ const [selection, setSelection] = useState({
start: 0, start: 0,
end: set.reps.toString().length, end: set.reps.toString().length,
}); })
const [removeImage, setRemoveImage] = useState(false); const [removeImage, setRemoveImage] = useState(false)
const {toast} = useSnackbar(); const weightRef = useRef<TextInput>(null)
const {settings} = useSettings(); const repsRef = useRef<TextInput>(null)
const weightRef = useRef<TextInput>(null); const unitRef = useRef<TextInput>(null)
const repsRef = useRef<TextInput>(null);
const unitRef = useRef<TextInput>(null);
const handleSubmit = async () => { const handleSubmit = async () => {
console.log(`${SetForm.name}.handleSubmit:`, {set, uri: newImage, name}); console.log(`${SetForm.name}.handleSubmit:`, {set, uri: newImage, name})
if (!name) return; if (!name) return
let image = newImage; let image = newImage
if (!newImage && !removeImage) if (!newImage && !removeImage)
image = await getSets({search: name, limit: 1, offset: 0}).then( image = await setRepo.findOne({where: {name}}).then(s => s?.image)
([gotSet]) => gotSet?.image,
); console.log(`${SetForm.name}.handleSubmit:`, {image})
console.log(`${SetForm.name}.handleSubmit:`, {image}); const [{now}] = await getNow()
save({ save({
name, name,
created: now,
reps: Number(reps), reps: Number(reps),
weight: Number(weight), weight: Number(weight),
id: set.id, id: set.id,
@ -53,34 +55,35 @@ export default function SetForm({
minutes: Number(set.minutes ?? 3), minutes: Number(set.minutes ?? 3),
seconds: Number(set.seconds ?? 30), seconds: Number(set.seconds ?? 30),
sets: set.sets ?? 3, sets: set.sets ?? 3,
}); hidden: false,
}; })
}
const handleName = (value: string) => { const handleName = (value: string) => {
setName(value.replace(/,|'/g, '')); setName(value.replace(/,|'/g, ''))
if (value.match(/,|'/)) 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) => { const handleUnit = (value: string) => {
setUnit(value.replace(/,|'/g, '')); setUnit(value.replace(/,|'/g, ''))
if (value.match(/,|'/)) 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 changeImage = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({ const {fileCopyUri} = await DocumentPicker.pickSingle({
type: 'image/*', type: 'image/*',
copyTo: 'documentDirectory', copyTo: 'documentDirectory',
}); })
if (fileCopyUri) setNewImage(fileCopyUri); if (fileCopyUri) setNewImage(fileCopyUri)
}, []); }, [])
const handleRemove = useCallback(async () => { const handleRemove = useCallback(async () => {
setNewImage(''); setNewImage('')
setRemoveImage(true); setRemoveImage(true)
setShowRemove(false); setShowRemove(false)
}, []); }, [])
return ( return (
<> <>
@ -112,7 +115,7 @@ export default function SetForm({
onSubmitEditing={handleSubmit} onSubmitEditing={handleSubmit}
innerRef={weightRef} innerRef={weightRef}
/> />
{!!settings.showUnit && ( {settings.showUnit && (
<MassiveInput <MassiveInput
autoCapitalize="none" autoCapitalize="none"
label="Unit" label="Unit"
@ -121,10 +124,14 @@ export default function SetForm({
innerRef={unitRef} innerRef={unitRef}
/> />
)} )}
{typeof set.id === 'number' && !!settings.showDate && ( {typeof set.id === 'number' && settings.showDate && (
<MassiveInput label="Created" disabled value={set.created} /> <MassiveInput
label="Created"
disabled
value={format(set.created, settings.date)}
/>
)} )}
{!!settings.images && newImage && ( {settings.images && newImage && (
<TouchableRipple <TouchableRipple
style={{marginBottom: MARGIN}} style={{marginBottom: MARGIN}}
onPress={changeImage} onPress={changeImage}
@ -132,7 +139,7 @@ export default function SetForm({
<Card.Cover source={{uri: newImage}} /> <Card.Cover source={{uri: newImage}} />
</TouchableRipple> </TouchableRipple>
)} )}
{!!settings.images && !newImage && ( {settings.images && !newImage && (
<Button <Button
style={{marginBottom: MARGIN}} style={{marginBottom: MARGIN}}
onPress={changeImage} onPress={changeImage}
@ -156,5 +163,5 @@ export default function SetForm({
Are you sure you want to remove the image? Are you sure you want to remove the image?
</ConfirmDialog> </ConfirmDialog>
</> </>
); )
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,108 +1,120 @@
import {RouteProp, useFocusEffect, useRoute} from '@react-navigation/native'; import {RouteProp, useRoute} from '@react-navigation/native'
import React, {useCallback, useMemo, useRef, useState} from 'react'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {NativeModules, TextInput, View} from 'react-native'; import {NativeModules, TextInput, View} from 'react-native'
import {FlatList} from 'react-native-gesture-handler'; import {FlatList} from 'react-native-gesture-handler'
import {Button, List, RadioButton} from 'react-native-paper'; import {Button} from 'react-native-paper'
import {getBestSet} from './best.service'; import {getBestSet} from './best.service'
import {useColor} from './color'; import {PADDING} from './constants'
import {PADDING} from './constants'; import CountMany from './count-many'
import CountMany from './count-many'; import {AppDataSource} from './data-source'
import MassiveInput from './MassiveInput'; import {getNow, setRepo, settingsRepo} from './db'
import {useSnackbar} from './MassiveSnack'; import GymSet from './gym-set'
import {PlanPageParams} from './plan-page-params'; import MassiveInput from './MassiveInput'
import Set from './set'; import {PlanPageParams} from './plan-page-params'
import {addSet, countMany} from './set.service'; import SetForm from './SetForm'
import SetForm from './SetForm'; import Settings from './settings'
import StackHeader from './StackHeader'; import StackHeader from './StackHeader'
import {useSettings} from './use-settings'; import StartPlanItem from './StartPlanItem'
import {toast} from './toast'
export default function StartPlan() { export default function StartPlan() {
const {params} = useRoute<RouteProp<PlanPageParams, 'StartPlan'>>(); const {params} = useRoute<RouteProp<PlanPageParams, 'StartPlan'>>()
const {set} = params; const [reps, setReps] = useState('')
const [name, setName] = useState(set.name); const [weight, setWeight] = useState('')
const [reps, setReps] = useState(set.reps.toString()); const [unit, setUnit] = useState<string>('kg')
const [weight, setWeight] = useState(set.weight.toString()); const [best, setBest] = useState<GymSet>()
const [unit, setUnit] = useState<string>(); const [selected, setSelected] = useState(0)
const {toast} = useSnackbar(); const [settings, setSettings] = useState<Settings>()
const [minutes, setMinutes] = useState(set.minutes); const [counts, setCounts] = useState<CountMany[]>()
const [seconds, setSeconds] = useState(set.seconds); const weightRef = useRef<TextInput>(null)
const [best, setBest] = useState<Set>(set); const repsRef = useRef<TextInput>(null)
const [selected, setSelected] = useState(0); const unitRef = useRef<TextInput>(null)
const {settings} = useSettings(); const workouts = useMemo(() => params.plan.workouts.split(','), [params])
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 [selection, setSelection] = useState({ const [selection, setSelection] = useState({
start: 0, start: 0,
end: set.reps.toString().length, end: 0,
}); })
useFocusEffect( const refresh = useCallback(() => {
useCallback(() => { const questions = workouts
countMany(workouts).then(newCounts => { .map((workout, index) => `('${workout}',${index})`)
setCounts(newCounts); .join(',')
console.log(`${StartPlan.name}.focus:`, {newCounts}); console.log({questions, workouts})
}); const select = `
}, [params]), 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 () => { const handleSubmit = async () => {
console.log(`${SetForm.name}.handleSubmit:`, {reps, weight, unit, best}); console.log(`${SetForm.name}.handleSubmit:`, {reps, weight, unit, best})
await addSet({ const [{now}] = await getNow()
name, await setRepo.save({
...best,
weight: +weight, weight: +weight,
reps: +reps, reps: +reps,
minutes: set.minutes,
seconds: set.seconds,
steps: set.steps,
image: set.image,
unit, unit,
}); created: now,
countMany(workouts).then(setCounts); hidden: false,
})
await refresh()
if ( if (
settings.notify && settings.notify &&
(+weight > best.weight || (+reps > best.reps && +weight === best.weight)) (+weight > best.weight || (+reps > best.reps && +weight === best.weight))
) )
toast("Great work King! That's a new record.", 5000); toast("Great work King! That's a new record.")
else if (settings.alarm) toast('Resting...', 3000); else if (settings.alarm) toast('Resting...')
else toast('Added set', 3000); else toast('Added set')
if (!settings.alarm) return; if (!settings.alarm) return
const milliseconds = Number(minutes) * 60 * 1000 + Number(seconds) * 1000; const milliseconds =
const args = [milliseconds, !!settings.vibrate, settings.sound]; Number(best.minutes) * 60 * 1000 + Number(best.seconds) * 1000
NativeModules.AlarmModule.timer(...args); const {vibrate, sound, noSound} = settings
}; const args = [milliseconds, vibrate, sound, noSound]
NativeModules.AlarmModule.timer(...args)
}
const handleUnit = useCallback( const handleUnit = useCallback((value: string) => {
(value: string) => { setUnit(value.replace(/,|'/g, ''))
setUnit(value.replace(/,|'/g, '')); if (value.match(/,|'/))
if (value.match(/,|'/)) toast('Commas and single quotes would break CSV exports')
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],
);
return ( return (
<> <>
@ -128,7 +140,7 @@ export default function StartPlan() {
innerRef={weightRef} innerRef={weightRef}
blurOnSubmit blurOnSubmit
/> />
{!!settings.showUnit && ( {settings?.showUnit && (
<MassiveInput <MassiveInput
autoCapitalize="none" autoCapitalize="none"
label="Unit" label="Unit"
@ -140,26 +152,12 @@ export default function StartPlan() {
{counts && ( {counts && (
<FlatList <FlatList
data={counts} data={counts}
renderItem={({item, index}) => ( renderItem={props => (
<List.Item <StartPlanItem
title={item.name} {...props}
description={ onUndo={refresh}
settings.showSets onSelect={select}
? `${item.total} / ${item.sets ?? 3}` selected={selected}
: 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>
)}
/> />
)} )}
/> />
@ -170,5 +168,5 @@ export default function StartPlan() {
</Button> </Button>
</View> </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 {useMemo} from 'react'
import {Pressable} from 'react-native'; import {Pressable} from 'react-native'
import {Switch as PaperSwitch, Text} from 'react-native-paper'; import {Switch as PaperSwitch, Text, useTheme} from 'react-native-paper'
import {CombinedDarkTheme, CombinedDefaultTheme} from './App'; import {CombinedDarkTheme, CombinedDefaultTheme} from './App'
import {useColor} from './color'; import {colorShade} from './colors'
import {colorShade} from './colors'; import {MARGIN} from './constants'
import {MARGIN} from './constants'; import useDark from './use-dark'
import useDark from './use-dark';
export default function Switch({ export default function Switch({
value, value,
@ -13,25 +12,25 @@ export default function Switch({
onPress, onPress,
children, children,
}: { }: {
value?: boolean; value?: boolean
onValueChange: (value: boolean) => void; onValueChange: (value: boolean) => void
onPress: () => void; onPress: () => void
children: string; children: string
}) { }) {
const {color} = useColor(); const {colors} = useTheme()
const dark = useDark(); const dark = useDark()
const track = useMemo(() => { const track = useMemo(() => {
if (dark) if (dark)
return { return {
false: CombinedDarkTheme.colors.placeholder, false: CombinedDarkTheme.colors.placeholder,
true: colorShade(color, -40), true: colorShade(colors.primary, -40),
}; }
return { return {
false: CombinedDefaultTheme.colors.placeholder, false: CombinedDefaultTheme.colors.placeholder,
true: colorShade(color, -40), true: colorShade(colors.primary, -40),
}; }
}, [dark, color]); }, [dark, colors.primary])
return ( return (
<Pressable <Pressable
@ -43,12 +42,12 @@ export default function Switch({
}}> }}>
<PaperSwitch <PaperSwitch
trackColor={track} trackColor={track}
color={color} color={colors.primary}
style={{marginRight: MARGIN}} style={{marginRight: MARGIN}}
value={value} value={value}
onValueChange={onValueChange} onValueChange={onValueChange}
/> />
<Text>{children}</Text> <Text>{children}</Text>
</Pressable> </Pressable>
); )
} }

View File

@ -1,41 +1,67 @@
import {Picker} from '@react-native-picker/picker'; import {Picker} from '@react-native-picker/picker'
import {RouteProp, useRoute} from '@react-navigation/native'; import {RouteProp, useRoute} from '@react-navigation/native'
import React, {useEffect, useState} from 'react'; import {useEffect, useState} from 'react'
import {View} from 'react-native'; import {View} from 'react-native'
import {getOneRepMax, getVolumes, getWeightsBy} from './best.service'; import {BestPageParams} from './BestPage'
import {BestPageParams} from './BestPage'; import Chart from './Chart'
import Chart from './Chart'; import {PADDING} from './constants'
import {PADDING} from './constants'; import {setRepo} from './db'
import {Metrics} from './metrics'; import GymSet from './gym-set'
import {Periods} from './periods'; import {Metrics} from './metrics'
import Set from './set'; import {Periods} from './periods'
import StackHeader from './StackHeader'; import StackHeader from './StackHeader'
import {formatMonth} from './time'; import {formatMonth} from './time'
import useDark from './use-dark'; import useDark from './use-dark'
import Volume from './volume'; import Volume from './volume'
export default function ViewBest() { export default function ViewBest() {
const {params} = useRoute<RouteProp<BestPageParams, 'ViewBest'>>(); const {params} = useRoute<RouteProp<BestPageParams, 'ViewBest'>>()
const dark = useDark(); const dark = useDark()
const [weights, setWeights] = useState<Set[]>([]); const [weights, setWeights] = useState<GymSet[]>([])
const [volumes, setVolumes] = useState<Volume[]>([]); const [volumes, setVolumes] = useState<Volume[]>([])
const [metric, setMetric] = useState(Metrics.Weight); const [metric, setMetric] = useState(Metrics.Weight)
const [period, setPeriod] = useState(Periods.Monthly); const [period, setPeriod] = useState(Periods.Monthly)
useEffect(() => { useEffect(() => {
console.log(`${ViewBest.name}.useEffect`, {metric}); console.log(`${ViewBest.name}.useEffect`, {metric})
console.log(`${ViewBest.name}.useEffect`, {period}); 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) { switch (metric) {
case Metrics.Weight: case Metrics.Weight:
getWeightsBy(params.best.name, period).then(setWeights); builder.addSelect('MAX(weight)', 'weight').getRawMany().then(setWeights)
break; break
case Metrics.Volume: case Metrics.Volume:
getVolumes(params.best.name, period).then(setVolumes); builder
break; .addSelect('SUM(weight * reps)', 'value')
.getRawMany()
.then(setVolumes)
break
default: 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 ( return (
<> <>
@ -80,5 +106,5 @@ export default function ViewBest() {
)} )}
</View> </View>
</> </>
); )
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,15 @@
module.exports = { module.exports = {
presets: ['module:metro-react-native-babel-preset'], 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: { env: {
production: { production: {
plugins: ['transform-remove-console'], plugins: ['transform-remove-console'],
}, },
}, },
}; }

View File

@ -1,117 +1,15 @@
import {db} from './db'; import {setRepo} from './db'
import {Periods} from './periods'; import GymSet from './gym-set'
import Set from './set';
import {defaultSet} from './set.service';
import Volume from './volume';
export const getOneRepMax = async ({ export const getBestSet = async (name: string): Promise<GymSet> => {
name, return setRepo
period, .createQueryBuilder()
}: { .select()
name: string; .addSelect('MAX(weight)', 'weight')
period: Periods; .where('name = :name', {name})
}) => { .groupBy('name')
// Brzycki formula https://en.wikipedia.org/wiki/One-repetition_maximum#Brzycki .addGroupBy('reps')
const select = ` .orderBy('weight', 'DESC')
SELECT max(weight / (1.0278 - 0.0278 * reps)) AS weight, .addOrderBy('reps', 'DESC')
STRFTIME('%Y-%m-%d', created) as created, unit .getOne()
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();
};

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

View File

@ -1,5 +1,5 @@
export default interface CountMany { export default interface CountMany {
name: string; name: string
total: number; total: number
sets?: 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 { import {AppDataSource} from './data-source'
enablePromise, import GymSet from './gym-set'
openDatabase, import {Plan} from './plan'
SQLiteDatabase, import Settings from './settings'
} from 'react-native-sqlite-storage';
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 = [ export const getNow = (): Promise<{now: string}[]> => {
` return AppDataSource.manager.query(
CREATE TABLE IF NOT EXISTS sets ( "SELECT STRFTIME('%Y-%m-%dT%H:%M:%S','now','localtime') AS now",
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]);
}
};

View File

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

View File

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

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 = { export type HomePageParams = {
Sets: {}; Sets: {}
EditSet: { EditSet: {
set: Set; set: GymSet
}; }
}; }

View File

@ -1,7 +1,6 @@
import {AppRegistry} from 'react-native'; import {AppRegistry} from 'react-native'
import 'react-native-gesture-handler'; import 'react-native-gesture-handler'
import 'react-native-sqlite-storage'; import App from './App'
import App from './App'; import {name as appName} from './app.json'
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> { export default interface Input<T> {
name: string; name: string
value?: T; value?: T
onChange: (value: T) => void; onChange: (value: T) => void
} }

View File

@ -1,5 +1,6 @@
#!/bin/sh #!/bin/sh
cd android || exit 1 set -ex
./gradlew assembleRelease cd android
[ "$1" != "--nobuild" ] && ./gradlew assembleRelease
adb -d install app/build/outputs/apk/release/app-arm64-v8a-release.apk 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", "name": "massive",
"version": "1.45", "version": "1.56",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
@ -8,20 +8,27 @@
"release": "react-native run-android --variant=release", "release": "react-native run-android --variant=release",
"start": "react-native start", "start": "react-native start",
"test": "jest", "test": "jest",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --quiet" "lint": "eslint . --ext .js,.jsx,.ts,.tsx"
}, },
"dependencies": { "dependencies": {
"@babel/plugin-transform-flow-strip-types": "^7.19.0",
"@babel/preset-env": "^7.19.1", "@babel/preset-env": "^7.19.1",
"@react-native-masked-view/masked-view": "^0.2.7", "@react-native-masked-view/masked-view": "^0.2.7",
"@react-native-picker/picker": "^2.4.4", "@react-native-picker/picker": "^2.4.4",
"@react-navigation/drawer": "^6.5.0", "@react-navigation/drawer": "^6.5.0",
"@react-navigation/native": "^6.0.13", "@react-navigation/native": "^6.0.13",
"@react-navigation/stack": "^6.3.0", "@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/d3-shape": "^3.1.0",
"@types/jest": "^29.2.0",
"@types/react-native-sqlite-storage": "^5.0.2", "@types/react-native-sqlite-storage": "^5.0.2",
"@types/react-native-svg-charts": "^5.0.12", "@types/react-native-svg-charts": "^5.0.12",
"@types/react-native-vector-icons": "^6.4.12", "@types/react-native-vector-icons": "^6.4.12",
"babel-jest": "^29.2.2",
"babel-plugin-transform-remove-console": "^6.9.4", "babel-plugin-transform-remove-console": "^6.9.4",
"eslint-plugin-flowtype": "^8.0.3",
"jest": "^29.2.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-native": "^0.70.4", "react-native": "^0.70.4",
"react-native-document-picker": "^8.1.2", "react-native-document-picker": "^8.1.2",
@ -38,12 +45,16 @@
"react-native-svg": "^13.4.0", "react-native-svg": "^13.4.0",
"react-native-svg-charts": "^5.4.0", "react-native-svg-charts": "^5.4.0",
"react-native-vector-icons": "^9.2.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": { "devDependencies": {
"@babel/core": "^7.12.9", "@babel/core": "^7.12.9",
"@babel/plugin-proposal-decorators": "^7.20.0",
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@react-native-community/eslint-config": "^2.0.0", "@react-native-community/eslint-config": "^2.0.0",
"@types/node": "^18.11.7",
"@types/react-native": "^0.69.0", "@types/react-native": "^0.69.0",
"@types/react-test-renderer": "^18.0.0", "@types/react-test-renderer": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^5.29.0", "@typescript-eslint/eslint-plugin": "^5.29.0",

View File

@ -1,13 +1,15 @@
import {Plan} from './plan'; import GymSet from './gym-set'
import Set from './set'; import {Plan} from './plan'
export type PlanPageParams = { export type PlanPageParams = {
PlanList: {}; PlanList: {}
EditPlan: { EditPlan: {
plan: Plan; plan: Plan
}; }
StartPlan: { StartPlan: {
plan: Plan; plan: Plan
set: Set; }
}; 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 { import {Column, Entity, PrimaryGeneratedColumn} from 'typeorm'
id?: number;
days: string; @Entity('plans')
workouts: string; 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 { export default interface Route {
name: keyof DrawerParamList; name: keyof DrawerParamList
component: React.ComponentType<any>; component: React.ComponentType<any>
icon: string; 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 { import {Column, Entity, PrimaryColumn} from 'typeorm'
alarm: number;
vibrate: number; @Entity()
sound: string; export default class Settings {
notify: number; @PrimaryColumn('boolean')
images: number; alarm: boolean
showUnit: number;
color: string; @Column('boolean')
steps: number; vibrate: boolean
date: string;
showDate: number; @Column('text')
theme: 'system' | 'dark' | 'light'; sound: string
showSets: number;
noSound: number; @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', 'Thursday',
'Friday', 'Friday',
'Saturday', 'Saturday',
]; ]
export function formatMonth(iso: string) { export function formatMonth(iso: string) {
const date = new Date(iso); const date = new Date(iso)
const dd = date.getDate().toString(); const dd = date.getDate().toString()
const mm = (date.getMonth() + 1).toString(); const mm = (date.getMonth() + 1).toString()
return `${dd}/${mm}`; return `${dd}/${mm}`
} }
function twelveHour(twentyFourHour: string) { function twelveHour(twentyFourHour: string) {
const [hourString, minute] = twentyFourHour.split(':'); const [hourString, minute] = twentyFourHour.split(':')
const hour = +hourString % 24; const hour = +hourString % 24
return (hour % 12 || 12) + ':' + minute + (hour < 12 ? ' AM' : ' PM'); return (hour % 12 || 12) + ':' + minute + (hour < 12 ? ' AM' : ' PM')
} }
function dayOfWeek(iso: string) { function dayOfWeek(iso: string) {
const date = new Date(iso); const date = new Date(iso)
const day = date.getDay(); const day = date.getDay()
const target = DAYS[day === 0 ? 0 : day - 1]; const target = DAYS[day]
return target.slice(0, 3); 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' * @param kind Intended format for the date, e.g. '%Y-%m-%d %H:%M'
*/ */
export function format(iso: string, kind: string) { export function format(iso: string, kind: string) {
const split = iso.split('T'); const split = iso.split('T')
const [year, month, day] = split[0].split('-'); const [year, month, day] = split[0].split('-')
const time = twelveHour(split[1]); const time = twelveHour(split[1])
switch (kind) { switch (kind) {
case '%Y-%m-%d %H:%M': case '%Y-%m-%d %H:%M':
return iso.replace('T', ' ').replace(/:\d{2}/, ''); return iso.replace('T', ' ').replace(/:\d{2}/, '')
case '%Y-%m-%d': case '%Y-%m-%d':
return split[0]; return split[0]
case '%H:%M': case '%H:%M':
return split[1].replace(/:\d{2}/, ''); return split[1].replace(/:\d{2}/, '')
case '%d/%m/%y %h:%M %p': case '%d/%m/%y %h:%M %p':
return `${day}/${month}/${year} ${time}`; return `${day}/${month}/${year} ${time}`
case '%d/%m %h:%M %p': case '%d/%m %h:%M %p':
return `${day}/${month} ${time}`; return `${day}/${month} ${time}`
case '%d/%m/%y': case '%d/%m/%y':
return `${day}/${month}/${year}`; return `${day}/${month}/${year}`
case '%d/%m': case '%d/%m':
return `${day}/${month}`; return `${day}/${month}`
case '%h:%M %p': case '%h:%M %p':
return time; return time
case '%A %h:%M %p': case '%A %h:%M %p':
return dayOfWeek(iso) + ' ' + time; return dayOfWeek(iso) + ' ' + time
default: 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", "jsx": "react-native",
"module": "CommonJS", "module": "CommonJS",
"moduleResolution": "node", "moduleResolution": "node",
"types": ["react-native"], "types": ["react-native", "jest"],
"resolveJsonModule": true, "resolveJsonModule": true,
"allowJs": true, "allowJs": true,
"noEmit": true, "noEmit": true,
@ -13,8 +13,15 @@
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": false,
"skipLibCheck": true "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 {useColorScheme} from 'react-native'
import {useSettings} from './use-settings'; import {useTheme} from './use-theme'
export default function useDark() { export default function useDark() {
const dark = useColorScheme() === 'dark'; const dark = useColorScheme() === 'dark'
const {settings} = useSettings(); const {theme} = useTheme()
if (settings.theme === 'dark') return true; if (theme === 'dark') return true
if (settings.theme === 'light') return false; if (theme === 'light') return false
return dark; 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