Compare commits

..

5 Commits

Author SHA1 Message Date
667b96ec33 Start simplifying Switch.tsx 2022-12-24 19:49:43 +13:00
d088cf313b Remove log from SettingsPage 2022-12-24 19:35:20 +13:00
c2f98046cc Add update log to SettingsPage 2022-12-24 19:32:14 +13:00
b47115204a Remove log from App.tsx 2022-12-24 19:32:06 +13:00
a69bfd62a6 Use react-hook-forms on SettingsPage
This greatly reduces our lines of code.
Also I thought it might improve performance
to address #135 but it didn't make any difference.
2022-12-24 18:19:35 +13:00
136 changed files with 6554 additions and 8134 deletions

View File

@ -1,12 +1,12 @@
module.exports = { module.exports = {
root: true, root: true,
extends: '@react-native', extends: '@react-native-community',
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
overrides: [ overrides: [
{ {
files: ['*.ts', '*.tsx', '*.js'], files: ['*.ts', '*.tsx', '*.js'],
rules: { rules: {
'jsx-quotes': 0,
'prettier/prettier': 0,
'@typescript-eslint/no-shadow': ['error'], '@typescript-eslint/no-shadow': ['error'],
'no-shadow': 'off', 'no-shadow': 'off',
'no-undef': 'off', 'no-undef': 'off',
@ -18,5 +18,4 @@ module.exports = {
}, },
}, },
], ],
ignorePatterns: ['coverage/', 'mock-providers.tsx'],
} }

1
.gitignore vendored
View File

@ -73,4 +73,3 @@ massive-build
!.yarn/releases !.yarn/releases
!.yarn/sdks !.yarn/sdks
!.yarn/versions !.yarn/versions
coverage

8
.prettierrc.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
arrowParens: 'avoid',
bracketSameLine: true,
bracketSpacing: false,
singleQuote: true,
trailingComma: 'all',
semi: false,
};

126
App.tsx
View File

@ -2,21 +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 { DeviceEventEmitter, useColorScheme } from "react-native"; import {DeviceEventEmitter, useColorScheme} from 'react-native'
import React from 'react'
import { import {
MD3DarkTheme as PaperDarkTheme, DarkTheme as PaperDarkTheme,
MD3LightTheme as PaperDefaultTheme, DefaultTheme as PaperDefaultTheme,
Provider as PaperProvider, Provider as PaperProvider,
Snackbar, Snackbar,
} from "react-native-paper"; } from 'react-native-paper'
import MaterialIcon from "react-native-vector-icons/MaterialIcons"; import MaterialIcon from 'react-native-vector-icons/MaterialIcons'
import { AppDataSource } from "./data-source"; import {AppDataSource} from './data-source'
import { settingsRepo } from "./db"; import {settingsRepo} from './db'
import Routes from "./Routes"; import Routes from './Routes'
import { TOAST } from "./toast"; import {TOAST} from './toast'
import { ThemeContext } from "./use-theme"; import {ThemeContext} from './use-theme'
export const CombinedDefaultTheme = { export const CombinedDefaultTheme = {
...NavigationDefaultTheme, ...NavigationDefaultTheme,
@ -25,7 +26,7 @@ export const CombinedDefaultTheme = {
...NavigationDefaultTheme.colors, ...NavigationDefaultTheme.colors,
...PaperDefaultTheme.colors, ...PaperDefaultTheme.colors,
}, },
}; }
export const CombinedDarkTheme = { export const CombinedDarkTheme = {
...NavigationDarkTheme, ...NavigationDarkTheme,
@ -34,76 +35,84 @@ export const CombinedDarkTheme = {
...NavigationDarkTheme.colors, ...NavigationDarkTheme.colors,
...PaperDarkTheme.colors, ...PaperDarkTheme.colors,
}, },
}; }
const App = () => { const App = () => {
const phoneTheme = useColorScheme(); const isDark = useColorScheme() === 'dark'
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false)
const [snackbar, setSnackbar] = useState(""); const [snackbar, setSnackbar] = useState('')
const [appTheme, setAppTheme] = useState("system"); const [theme, setTheme] = useState('system')
const [lightColor, setLightColor] = useState<string>( const [lightColor, setLightColor] = useState<string>(
CombinedDefaultTheme.colors.primary CombinedDefaultTheme.colors.primary,
); )
const [darkColor, setDarkColor] = useState<string>( const [darkColor, setDarkColor] = useState<string>(
CombinedDarkTheme.colors.primary CombinedDarkTheme.colors.primary,
); )
useEffect(() => { useEffect(() => {
(async () => { const init = async () => {
if (!AppDataSource.isInitialized) await AppDataSource.initialize(); if (!AppDataSource.isInitialized) await AppDataSource.initialize()
const settings = await settingsRepo.findOne({ where: {} }); const settings = await settingsRepo.findOne({where: {}})
setAppTheme(settings.theme); setTheme(settings.theme)
if (settings.lightColor) setLightColor(settings.lightColor); if (settings.lightColor) setLightColor(settings.lightColor)
if (settings.darkColor) setDarkColor(settings.darkColor); if (settings.darkColor) setDarkColor(settings.darkColor)
setInitialized(true); setInitialized(true)
})(); }
init()
const description = DeviceEventEmitter.addListener( const description = DeviceEventEmitter.addListener(
TOAST, TOAST,
({ value }: { value: string }) => { ({value}: {value: string}) => {
setSnackbar(value); setSnackbar(value)
} },
); )
return description.remove; return description.remove
}, []); }, [])
const paperTheme = useMemo(() => { const paperTheme = useMemo(() => {
const darkTheme = lightColor const darkTheme = lightColor
? { ? {
...CombinedDarkTheme, ...CombinedDarkTheme,
colors: { ...CombinedDarkTheme.colors, primary: darkColor }, colors: {...CombinedDarkTheme.colors, primary: darkColor},
} }
: CombinedDarkTheme; : CombinedDarkTheme
const lightTheme = lightColor const lightTheme = lightColor
? { ? {
...CombinedDefaultTheme, ...CombinedDefaultTheme,
colors: { ...CombinedDefaultTheme.colors, primary: lightColor }, colors: {...CombinedDefaultTheme.colors, primary: lightColor},
} }
: CombinedDefaultTheme; : CombinedDefaultTheme
let value = phoneTheme === "dark" ? darkTheme : lightTheme; let value = isDark ? darkTheme : lightTheme
if (appTheme === "dark") value = darkTheme; if (theme === 'dark') value = darkTheme
else if (appTheme === "light") value = lightTheme; else if (theme === 'light') value = lightTheme
return value; return value
}, [phoneTheme, appTheme, lightColor, darkColor]); }, [isDark, theme, lightColor, darkColor])
const action = useMemo(
() => ({
label: 'Close',
onPress: () => setSnackbar(''),
color: paperTheme.colors.background,
}),
[paperTheme.colors.background],
)
return ( return (
<PaperProvider <PaperProvider
theme={paperTheme} theme={paperTheme}
settings={{ icon: (props) => <MaterialIcon {...props} /> }} settings={{icon: props => <MaterialIcon {...props} />}}>
>
<NavigationContainer theme={paperTheme}> <NavigationContainer theme={paperTheme}>
{initialized && ( {initialized && (
<ThemeContext.Provider <ThemeContext.Provider
value={{ value={{
theme: appTheme, theme,
setTheme: setAppTheme, setTheme,
lightColor, lightColor,
setLightColor, setLightColor,
darkColor, darkColor,
setDarkColor, setDarkColor,
}} }}>
>
<Routes /> <Routes />
</ThemeContext.Provider> </ThemeContext.Provider>
)} )}
@ -111,18 +120,13 @@ const App = () => {
<Snackbar <Snackbar
duration={3000} duration={3000}
onDismiss={() => setSnackbar("")} onDismiss={() => setSnackbar('')}
visible={!!snackbar} visible={!!snackbar}
action={{ action={action}>
label: "Close",
onPress: () => setSnackbar(""),
textColor: paperTheme.colors.background,
}}
>
{snackbar} {snackbar}
</Snackbar> </Snackbar>
</PaperProvider> </PaperProvider>
); )
}; }
export default App; export default App

View File

@ -1,31 +0,0 @@
import { ComponentProps, useMemo } from "react";
import { FAB, useTheme } from "react-native-paper";
import { CombinedDarkTheme, CombinedDefaultTheme } from "./App";
import { lightColors } from "./colors";
export default function AppFab(props: Partial<ComponentProps<typeof FAB>>) {
const { colors } = useTheme();
const fabColor = useMemo(
() =>
lightColors.map((color) => color.hex).includes(colors.primary)
? CombinedDarkTheme.colors.background
: CombinedDefaultTheme.colors.background,
[colors.primary]
);
return (
<FAB
icon="add"
testID="add"
color={fabColor}
style={{
position: "absolute",
right: 20,
bottom: 20,
backgroundColor: colors.primary,
}}
{...props}
/>
);
}

View File

@ -1,26 +0,0 @@
import React, { ComponentProps, Ref } from "react";
import { TextInput } from "react-native-paper";
import { CombinedDefaultTheme } from "./App";
import { MARGIN } from "./constants";
import useDark from "./use-dark";
function AppInput(
props: Partial<ComponentProps<typeof TextInput>> & {
innerRef?: Ref<any>;
}
) {
const dark = useDark();
return (
<TextInput
selectionColor={dark ? "#2A2A2A" : CombinedDefaultTheme.colors.border}
style={{ marginBottom: MARGIN, minWidth: 100 }}
selectTextOnFocus
ref={props.innerRef}
blurOnSubmit={false}
{...props}
/>
);
}
export default React.memo(AppInput);

98
BestList.tsx Normal file
View File

@ -0,0 +1,98 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native'
import {useCallback, useState} from 'react'
import {FlatList, Image} from 'react-native'
import {List} from 'react-native-paper'
import {BestPageParams} from './BestPage'
import {setRepo, settingsRepo} from './db'
import DrawerHeader from './DrawerHeader'
import GymSet from './gym-set'
import Page from './Page'
import Settings from './settings'
export default function BestList() {
const [bests, setBests] = useState<GymSet[]>()
const [term, setTerm] = useState('')
const navigation = useNavigation<NavigationProp<BestPageParams>>()
const [settings, setSettings] = useState<Settings>()
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({where: {}}).then(setSettings)
}, []),
)
const refresh = useCallback(async (value: string) => {
const weights = await setRepo
.createQueryBuilder()
.select()
.addSelect('MAX(weight)', 'weight')
.where('name LIKE :name', {name: `%${value}%`})
.andWhere('NOT hidden')
.groupBy('name')
.getMany()
console.log(`${BestList.name}.refresh:`, {length: weights.length})
let newBest: GymSet[] = []
for (const set of weights) {
const reps = await setRepo
.createQueryBuilder()
.select()
.addSelect('MAX(reps)', 'reps')
.where('name = :name', {name: set.name})
.andWhere('weight = :weight', {weight: set.weight})
.andWhere('NOT hidden')
.groupBy('name')
.getMany()
newBest.push(...reps)
}
setBests(newBest)
}, [])
useFocusEffect(
useCallback(() => {
refresh(term)
}, [refresh, term]),
)
const search = useCallback(
(value: string) => {
setTerm(value)
refresh(value)
},
[refresh],
)
const renderItem = ({item}: {item: GymSet}) => (
<List.Item
key={item.name}
title={item.name}
description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
onPress={() => navigation.navigate('ViewBest', {best: item})}
left={() =>
(settings.images && item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)) ||
null
}
/>
)
return (
<>
<DrawerHeader name="Best" />
<Page term={term} search={search}>
{bests?.length === 0 ? (
<List.Item
title="No exercises yet"
description="Once sets have been added, this will highlight your personal bests."
/>
) : (
<FlatList style={{flex: 1}} renderItem={renderItem} data={bests} />
)}
</Page>
</>
)
}

22
BestPage.tsx Normal file
View File

@ -0,0 +1,22 @@
import {createStackNavigator} from '@react-navigation/stack'
import BestList from './BestList'
import GymSet from './gym-set'
import ViewBest from './ViewBest'
const Stack = createStackNavigator<BestPageParams>()
export type BestPageParams = {
BestList: {}
ViewBest: {
best: GymSet
}
}
export default function BestPage() {
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
<Stack.Screen name="BestList" component={BestList} />
<Stack.Screen name="ViewBest" component={ViewBest} />
</Stack.Navigator>
)
}

View File

@ -1,11 +1,11 @@
import { useTheme } from "@react-navigation/native"; import {useTheme} from '@react-navigation/native'
import * as shape from "d3-shape"; 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 { MARGIN, PADDING } from "./constants"; import {MARGIN, PADDING} from './constants'
import GymSet from "./gym-set"; import GymSet from './gym-set'
import useDark from "./use-dark"; import useDark from './use-dark'
export default function Chart({ export default function Chart({
yData, yData,
@ -13,21 +13,21 @@ export default function Chart({
xData, xData,
yFormat, yFormat,
}: { }: {
yData: number[]; yData: number[]
xData: GymSet[]; xData: GymSet[]
xFormat: (value: any, index: number) => string; xFormat: (value: any, index: number) => string
yFormat: (value: any) => string; yFormat: (value: any) => string
}) { }) {
const { colors } = useTheme(); 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 (
<> <>
@ -35,36 +35,34 @@ export default function Chart({
style={{ style={{
height: 300, height: 300,
padding: PADDING, padding: PADDING,
flexDirection: "row", flexDirection: 'row',
}} }}>
>
<YAxis <YAxis
data={yData} data={yData}
style={{ marginBottom: xAxisHeight }} style={{marginBottom: xAxisHeight}}
contentInset={verticalContentInset} contentInset={verticalContentInset}
svg={axesSvg} svg={axesSvg}
formatLabel={yFormat} formatLabel={yFormat}
/> />
<View style={{ flex: 1, marginLeft: MARGIN }}> <View style={{flex: 1, marginLeft: MARGIN}}>
<LineChart <LineChart
style={{ flex: 1 }} style={{flex: 1}}
data={yData} data={yData}
contentInset={verticalContentInset} contentInset={verticalContentInset}
curve={shape.curveBasis} curve={shape.curveBasis}
svg={{ svg={{
stroke: colors.primary, stroke: colors.primary,
}} }}>
>
<Grid /> <Grid />
</LineChart> </LineChart>
<XAxis <XAxis
data={xData} data={xData}
formatLabel={xFormat} formatLabel={xFormat}
contentInset={{ left: 15, right: 16 }} contentInset={{left: 15, right: 16}}
svg={axesSvg} svg={axesSvg}
/> />
</View> </View>
</View> </View>
</> </>
); )
} }

View File

@ -1,4 +1,4 @@
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,17 +8,17 @@ export default function ConfirmDialog({
setShow, setShow,
onCancel, onCancel,
}: { }: {
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
onCancel?: () => void; onCancel?: () => void
}) { }) {
const cancel = () => { const cancel = () => {
setShow(false); setShow(false)
onCancel && onCancel(); onCancel && onCancel()
}; }
return ( return (
<Portal> <Portal>
@ -33,5 +33,5 @@ export default function ConfirmDialog({
</Dialog.Actions> </Dialog.Actions>
</Dialog> </Dialog>
</Portal> </Portal>
); )
} }

View File

@ -1,22 +1,28 @@
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 { 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 useDark from './use-dark'
export default function DrawerHeader({ export default function DrawerHeader({
name, name,
children, children,
}: { }: {
name: string; name: keyof DrawerParamList
children?: JSX.Element | JSX.Element[]; children?: JSX.Element | JSX.Element[]
}) { }) {
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} />
{children} {children}
</Appbar.Header> </Appbar.Header>
); )
} }

View File

@ -3,133 +3,102 @@ import {
RouteProp, RouteProp,
useNavigation, useNavigation,
useRoute, useRoute,
} from "@react-navigation/native"; } from '@react-navigation/native'
import { 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, IconButton, Text } from "react-native-paper"; import {Button, Text} from 'react-native-paper'
import { MARGIN, PADDING } from "./constants"; import {MARGIN, PADDING} from './constants'
import { planRepo, setRepo } from "./db"; import {planRepo, setRepo} from './db'
import { defaultSet } from "./gym-set"; import {DrawerParamList} from './drawer-param-list'
import { PlanPageParams } from "./plan-page-params"; import {PlanPageParams} from './plan-page-params'
import StackHeader from "./StackHeader"; import StackHeader from './StackHeader'
import Switch from "./Switch"; import Switch from './Switch'
import { DAYS } from "./time"; import {DAYS} from './time'
import AppInput from "./AppInput";
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 [title, setTitle] = useState<string>(plan?.title);
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<PlanPageParams>>(); const navigation = useNavigation<NavigationProp<DrawerParamList>>()
useEffect(() => { useEffect(() => {
setRepo setRepo
.createQueryBuilder() .createQueryBuilder()
.select("name") .select('name')
.distinct(true) .distinct(true)
.orderBy("name")
.getRawMany() .getRawMany()
.then((values) => { .then(values => {
console.log(EditPlan.name, { values }); console.log(EditPlan.name, {values})
setNames(values.map((value) => value.name)); 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(',')
await planRepo.save({ await planRepo.save({days: newDays, workouts: newWorkouts, id: plan.id})
title: title, navigation.goBack()
days: newDays, }, [days, workouts, plan, navigation])
workouts: newWorkouts,
id: plan.id,
});
}, [title, days, workouts, plan]);
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 (
<> <>
<StackHeader <StackHeader title="Edit plan" />
title={typeof plan.id === "number" ? "Edit plan" : "Add plan"} <View style={{padding: PADDING, flex: 1}}>
> <ScrollView style={{flex: 1}}>
{typeof plan.id === "number" && (
<IconButton
onPress={async () => {
await save();
const newPlan = await planRepo.findOne({
where: { id: plan.id },
});
let first = await setRepo.findOne({
where: { name: workouts[0] },
order: { created: "desc" },
});
if (!first) first = { ...defaultSet, name: workouts[0] };
delete first.id;
navigation.navigate("StartPlan", { plan: newPlan, first });
}}
icon="play-arrow"
/>
)}
</StackHeader>
<View style={{ padding: PADDING, flex: 1 }}>
<ScrollView style={{ flex: 1 }}>
<AppInput
label="Title"
value={title}
onChangeText={(value) => setTitle(value)}
/>
<Text style={styles.title}>Days</Text> <Text style={styles.title}>Days</Text>
{DAYS.map((day) => ( {DAYS.map(day => (
<Switch <Switch
key={day} key={day}
onChange={(value) => toggleDay(value, day)} onChange={value => toggleDay(value, day)}
value={days.includes(day)} onPress={() => toggleDay(!days.includes(day), day)}
title={day} value={days.includes(day)}>
/> {day}
</Switch>
))} ))}
<Text style={[styles.title, { marginTop: MARGIN }]}>Workouts</Text> <Text style={[styles.title, {marginTop: MARGIN}]}>Workouts</Text>
{names.length === 0 ? ( {names.length === 0 ? (
<View> <View>
<Text>No workouts found.</Text> <Text>No workouts found.</Text>
</View> </View>
) : ( ) : (
names.map((name) => ( names.map(name => (
<Switch <Switch
key={name} key={name}
onChange={(value) => toggleWorkout(value, name)} onChange={value => toggleWorkout(value, name)}
value={workouts.includes(name)} value={workouts.includes(name)}
title={name} onPress={() => toggleWorkout(!workouts.includes(name), name)}>
/> {name}
</Switch>
)) ))
)} )}
</ScrollView> </ScrollView>
@ -137,18 +106,14 @@ export default function EditPlan() {
<Button <Button
disabled={workouts.length === 0 && days.length === 0} disabled={workouts.length === 0 && days.length === 0}
style={styles.button} style={styles.button}
mode="outlined" mode="contained"
icon="save" icon="save"
onPress={async () => { onPress={save}>
await save();
navigation.navigate("PlanList");
}}
>
Save Save
</Button> </Button>
</View> </View>
</> </>
); )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@ -157,4 +122,4 @@ const styles = StyleSheet.create({
marginBottom: MARGIN, marginBottom: MARGIN,
}, },
button: {}, button: {},
}); })

View File

@ -1,152 +1,124 @@
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import { import {
RouteProp, RouteProp,
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
useRoute, useRoute,
} from "@react-navigation/native"; } from '@react-navigation/native'
import { format } from "date-fns"; import {format} from 'date-fns'
import { useCallback, useRef, useState } from "react"; import {useCallback, useRef, useState} from 'react'
import { NativeModules, TextInput, View } from "react-native"; import {NativeModules, TextInput, View} from 'react-native'
import DocumentPicker from "react-native-document-picker"; import DocumentPicker from 'react-native-document-picker'
import { Button, Card, IconButton, TouchableRipple } from "react-native-paper"; import {Button, Card, TouchableRipple} from 'react-native-paper'
import AppInput from "./AppInput"; import ConfirmDialog from './ConfirmDialog'
import ConfirmDialog from "./ConfirmDialog"; import {MARGIN, PADDING} from './constants'
import { MARGIN, PADDING } from "./constants"; import {getNow, setRepo, settingsRepo} from './db'
import { getNow, setRepo, settingsRepo } from "./db"; import GymSet from './gym-set'
import GymSet from "./gym-set"; import {HomePageParams} from './home-page-params'
import { HomePageParams } from "./home-page-params"; import MassiveInput from './MassiveInput'
import Settings from "./settings"; import Settings from './settings'
import StackHeader from "./StackHeader"; import StackHeader from './StackHeader'
import { toast } from "./toast"; import {toast} from './toast'
import { fixNumeric } from "./fix-numeric";
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 [settings, setSettings] = useState<Settings>({} as Settings); const [settings, setSettings] = useState<Settings>({} as 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 [created, setCreated] = useState<Date>( const [showRemove, setShowRemove] = useState(false)
set.created ? new Date(set.created) : new Date() const [removeImage, setRemoveImage] = useState(false)
); const weightRef = useRef<TextInput>(null)
const [createdDirty, setCreatedDirty] = useState(false); const repsRef = useRef<TextInput>(null)
const [showRemove, setShowRemove] = useState(false); const unitRef = useRef<TextInput>(null)
const [removeImage, setRemoveImage] = useState(false);
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const unitRef = useRef<TextInput>(null);
const [selection, setSelection] = useState({ const [selection, setSelection] = useState({
start: 0, start: 0,
end: set.reps?.toString().length, end: set.reps?.toString().length,
}); })
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings); settingsRepo.findOne({where: {}}).then(setSettings)
}, []) }, []),
); )
const startTimer = useCallback( const startTimer = useCallback(
async (value: string) => { async (value: string) => {
if (!settings.alarm) return; if (!settings.alarm) return
const first = await setRepo.findOne({ where: { name: value } }); const first = await setRepo.findOne({where: {name: value}})
const milliseconds = const milliseconds =
(first?.minutes ?? 3) * 60 * 1000 + (first?.seconds ?? 0) * 1000; (first?.minutes ?? 3) * 60 * 1000 + (first?.seconds ?? 0) * 1000
if (milliseconds) NativeModules.AlarmModule.timer(milliseconds); const {vibrate, sound, noSound} = settings
const args = [milliseconds, vibrate, sound, noSound]
NativeModules.AlarmModule.timer(...args)
}, },
[settings] [settings],
); )
const added = useCallback( const added = useCallback(
async (value: GymSet) => { async (value: GymSet) => {
startTimer(value.name); startTimer(value.name)
console.log(`${EditSet.name}.add`, { set: value }); console.log(`${EditSet.name}.add`, {set: value})
if (!settings.notify) return; 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."); toast("Great work King! That's a new record.")
}
}, },
[startTimer, set, settings] [startTimer, set, settings],
); )
const handleSubmit = async () => { const handleSubmit = async () => {
if (!name) return; console.log(`${EditSet.name}.handleSubmit:`, {set, uri: newImage, name})
if (!name) return
let image = newImage
if (!newImage && !removeImage)
image = await setRepo.findOne({where: {name}}).then(s => s?.image)
const newSet: Partial<GymSet> = { console.log(`${EditSet.name}.handleSubmit:`, {image})
const [{now}] = await getNow()
const saved = await setRepo.save({
id: set.id, id: set.id,
name, name,
created: set.created || now,
reps: Number(reps), reps: Number(reps),
weight: Number(weight), weight: Number(weight),
unit, unit,
image,
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, hidden: false,
}; })
if (typeof set.id !== 'number') added(saved)
newSet.image = newImage; navigation.goBack()
if (!newImage && !removeImage) { }
newSet.image = await setRepo
.findOne({ where: { name } })
.then((s) => s?.image);
}
if (createdDirty) newSet.created = created.toISOString();
if (typeof set.id !== "number") newSet.created = await getNow();
const saved = await setRepo.save(newSet);
if (typeof set.id !== "number") added(saved);
navigation.goBack();
};
const changeImage = useCallback(async () => { const changeImage = useCallback(async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({ const {fileCopyUri} = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images, type: DocumentPicker.types.images,
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)
}, []); }, [])
const pickDate = useCallback(() => {
DateTimePickerAndroid.open({
value: created,
onChange: (_, date) => {
if (date === created) return;
setCreated(date);
setCreatedDirty(true);
DateTimePickerAndroid.open({
value: date,
onChange: (__, time) => setCreated(time),
mode: "time",
});
},
mode: "date",
});
}, [created]);
return ( return (
<> <>
<StackHeader <StackHeader title="Edit set" />
title={typeof set.id === "number" ? "Edit set" : "Add set"}
/>
<View style={{ padding: PADDING, flex: 1 }}> <View style={{padding: PADDING, flex: 1}}>
<AppInput <MassiveInput
label="Name" label="Name"
value={name} value={name}
onChangeText={setName} onChangeText={setName}
@ -155,68 +127,29 @@ export default function EditSet() {
onSubmitEditing={() => repsRef.current?.focus()} onSubmitEditing={() => repsRef.current?.focus()}
/> />
<View style={{ flexDirection: "row" }}> <MassiveInput
<AppInput label="Reps"
style={{ keyboardType="numeric"
flex: 1, value={reps}
marginBottom: MARGIN, onChangeText={setReps}
}} onSubmitEditing={() => weightRef.current?.focus()}
label="Reps" selection={selection}
keyboardType="numeric" onSelectionChange={e => setSelection(e.nativeEvent.selection)}
value={reps} autoFocus={!!name}
onChangeText={(newReps) => { innerRef={repsRef}
const fixed = fixNumeric(newReps); />
setReps(fixed);
if (fixed.length !== newReps.length)
toast("Reps must be a number");
}}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
innerRef={repsRef}
/>
<IconButton
icon="add"
onPress={() => setReps((Number(reps) + 1).toString())}
/>
<IconButton
icon="remove"
onPress={() => setReps((Number(reps) - 1).toString())}
/>
</View>
<View <MassiveInput
style={{ label="Weight"
flexDirection: "row", keyboardType="numeric"
marginBottom: MARGIN, value={weight}
}} onChangeText={setWeight}
> onSubmitEditing={handleSubmit}
<AppInput innerRef={weightRef}
style={{ flex: 1 }} />
label="Weight"
keyboardType="numeric"
value={weight}
onChangeText={(newWeight) => {
const fixed = fixNumeric(newWeight);
setWeight(fixed);
if (fixed.length !== newWeight.length)
toast("Weight must be a number");
}}
onSubmitEditing={handleSubmit}
innerRef={weightRef}
/>
<IconButton
icon="add"
onPress={() => setWeight((Number(weight) + 2.5).toString())}
/>
<IconButton
icon="remove"
onPress={() => setWeight((Number(weight) - 2.5).toString())}
/>
</View>
{settings.showUnit && ( {settings.showUnit && (
<AppInput <MassiveInput
autoCapitalize="none" autoCapitalize="none"
label="Unit" label="Unit"
value={unit} value={unit}
@ -225,30 +158,28 @@ export default function EditSet() {
/> />
)} )}
{settings.showDate && ( {typeof set.id === 'number' && settings.showDate && (
<AppInput <MassiveInput
label="Created" label="Created"
value={format(created, settings.date || "P")} disabled
onPressOut={pickDate} value={format(new Date(set.created), settings.date)}
/> />
)} )}
{settings.images && newImage && ( {settings.images && newImage && (
<TouchableRipple <TouchableRipple
style={{ marginBottom: MARGIN }} style={{marginBottom: MARGIN}}
onPress={changeImage} onPress={changeImage}
onLongPress={() => setShowRemove(true)} onLongPress={() => setShowRemove(true)}>
> <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}
icon="add-photo-alternate" icon="add-photo-alternate">
>
Image Image
</Button> </Button>
)} )}
@ -256,11 +187,10 @@ export default function EditSet() {
<Button <Button
disabled={!name} disabled={!name}
mode="outlined" mode="contained"
icon="save" icon="save"
style={{ margin: MARGIN }} style={{margin: MARGIN}}
onPress={handleSubmit} onPress={handleSubmit}>
>
Save Save
</Button> </Button>
@ -268,10 +198,9 @@ export default function EditSet() {
title="Remove image" title="Remove image"
onOk={handleRemove} onOk={handleRemove}
show={showRemove} show={showRemove}
setShow={setShowRemove} setShow={setShowRemove}>
>
Are you sure you want to remove the image? Are you sure you want to remove the image?
</ConfirmDialog> </ConfirmDialog>
</> </>
); )
} }

View File

@ -3,85 +3,85 @@ import {
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
useRoute, useRoute,
} from "@react-navigation/native"; } from '@react-navigation/native'
import { useCallback, useState } from "react"; import {useCallback, useState} from 'react'
import { View } from "react-native"; import {View} from 'react-native'
import DocumentPicker from "react-native-document-picker"; import DocumentPicker from 'react-native-document-picker'
import { Button, Card, IconButton, TouchableRipple } from "react-native-paper"; import {Button, Card, TouchableRipple} from 'react-native-paper'
import { In } from "typeorm"; import {In} from 'typeorm'
import AppInput from "./AppInput"; import ConfirmDialog from './ConfirmDialog'
import ConfirmDialog from "./ConfirmDialog"; import {MARGIN, PADDING} from './constants'
import { MARGIN, PADDING } from "./constants"; import {setRepo, settingsRepo} from './db'
import { setRepo, settingsRepo } from "./db"; import GymSet from './gym-set'
import GymSet from "./gym-set"; import {HomePageParams} from './home-page-params'
import { HomePageParams } from "./home-page-params"; import MassiveInput from './MassiveInput'
import Settings from "./settings"; import Settings from './settings'
import StackHeader from "./StackHeader"; import StackHeader from './StackHeader'
export default function EditSets() { export default function EditSets() {
const { params } = useRoute<RouteProp<HomePageParams, "EditSets">>(); const {params} = useRoute<RouteProp<HomePageParams, 'EditSets'>>()
const { ids } = params; const {ids} = params
const navigation = useNavigation(); const navigation = useNavigation()
const [settings, setSettings] = useState<Settings>({} as Settings); const [settings, setSettings] = useState<Settings>({} as Settings)
const [name, setName] = useState(""); const [name, setName] = useState('')
const [reps, setReps] = useState(""); const [reps, setReps] = useState('')
const [weight, setWeight] = useState(""); const [weight, setWeight] = useState('')
const [newImage, setNewImage] = useState(""); const [newImage, setNewImage] = useState('')
const [unit, setUnit] = useState(""); const [unit, setUnit] = useState('')
const [showRemove, setShowRemove] = useState(false); const [showRemove, setShowRemove] = useState(false)
const [names, setNames] = useState(""); const [names, setNames] = useState('')
const [oldReps, setOldReps] = useState(""); const [oldReps, setOldReps] = useState('')
const [weights, setWeights] = useState(""); const [weights, setWeights] = useState('')
const [units, setUnits] = useState(""); const [units, setUnits] = useState('')
const [selection, setSelection] = useState({ const [selection, setSelection] = useState({
start: 0, start: 0,
end: 1, end: 1,
}); })
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings); settingsRepo.findOne({where: {}}).then(setSettings)
setRepo.find({ where: { id: In(ids) } }).then((sets) => { setRepo.find({where: {id: In(ids)}}).then(sets => {
setNames(sets.map((set) => set.name).join(", ")); setNames(sets.map(set => set.name).join(', '))
setOldReps(sets.map((set) => set.reps).join(", ")); setOldReps(sets.map(set => set.reps).join(', '))
setWeights(sets.map((set) => set.weight).join(", ")); setWeights(sets.map(set => set.weight).join(', '))
setUnits(sets.map((set) => set.unit).join(", ")); setUnits(sets.map(set => set.unit).join(', '))
}); })
}, [ids]) }, [ids]),
); )
const handleSubmit = async () => { const handleSubmit = async () => {
console.log(`${EditSets.name}.handleSubmit:`, { uri: newImage, name }); console.log(`${EditSets.name}.handleSubmit:`, {uri: newImage, name})
const update: Partial<GymSet> = {}; const update: Partial<GymSet> = {}
if (name) update.name = name; if (name) update.name = name
if (reps) update.reps = Number(reps); if (reps) update.reps = Number(reps)
if (weight) update.weight = Number(weight); if (weight) update.weight = Number(weight)
if (unit) update.unit = unit; if (unit) update.unit = unit
if (newImage) update.image = newImage; if (newImage) update.image = newImage
if (Object.keys(update).length > 0) await setRepo.update(ids, update); if (Object.keys(update).length > 0) await setRepo.update(ids, update)
navigation.goBack(); navigation.goBack()
}; }
const changeImage = useCallback(async () => { const changeImage = useCallback(async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({ const {fileCopyUri} = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images, type: DocumentPicker.types.images,
copyTo: "documentDirectory", copyTo: 'documentDirectory',
}); })
if (fileCopyUri) setNewImage(fileCopyUri); if (fileCopyUri) setNewImage(fileCopyUri)
}, []); }, [])
const handleRemove = useCallback(async () => { const handleRemove = useCallback(async () => {
setNewImage(""); setNewImage('')
setShowRemove(false); setShowRemove(false)
}, []); }, [])
return ( return (
<> <>
<StackHeader title={`Edit ${ids.length} sets`} /> <StackHeader title={`Edit ${ids.length} sets`} />
<View style={{ padding: PADDING, flex: 1 }}> <View style={{padding: PADDING, flex: 1}}>
<AppInput <MassiveInput
label={`Names: ${names}`} label={`Names: ${names}`}
value={name} value={name}
onChangeText={setName} onChangeText={setName}
@ -89,60 +89,26 @@ export default function EditSets() {
autoFocus={!name} autoFocus={!name}
/> />
<View <MassiveInput
style={{ label={`Reps: ${oldReps}`}
flexDirection: "row", keyboardType="numeric"
marginBottom: MARGIN, value={reps}
}} onChangeText={setReps}
> selection={selection}
<AppInput onSelectionChange={e => setSelection(e.nativeEvent.selection)}
style={{ autoFocus={!!name}
flex: 1, />
}}
label={`Reps: ${oldReps}`}
keyboardType="numeric"
value={reps}
onChangeText={setReps}
selection={selection}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
autoFocus={!!name}
/>
<IconButton
icon="add"
onPress={() => setReps((Number(reps) + 1).toString())}
/>
<IconButton
icon="remove"
onPress={() => setReps((Number(reps) - 1).toString())}
/>
</View>
<View <MassiveInput
style={{ label={`Weights: ${weights}`}
flexDirection: "row", keyboardType="numeric"
marginBottom: MARGIN, value={weight}
}} onChangeText={setWeight}
> onSubmitEditing={handleSubmit}
<AppInput />
style={{ flex: 1 }}
label={`Weights: ${weights}`}
keyboardType="numeric"
value={weight}
onChangeText={setWeight}
onSubmitEditing={handleSubmit}
/>
<IconButton
icon="add"
onPress={() => setWeight((Number(weight) + 2.5).toString())}
/>
<IconButton
icon="remove"
onPress={() => setWeight((Number(weight) - 2.5).toString())}
/>
</View>
{settings.showUnit && ( {settings.showUnit && (
<AppInput <MassiveInput
autoCapitalize="none" autoCapitalize="none"
label={`Units: ${units}`} label={`Units: ${units}`}
value={unit} value={unit}
@ -152,41 +118,37 @@ export default function EditSets() {
{settings.images && newImage && ( {settings.images && newImage && (
<TouchableRipple <TouchableRipple
style={{ marginBottom: MARGIN }} style={{marginBottom: MARGIN}}
onPress={changeImage} onPress={changeImage}
onLongPress={() => setShowRemove(true)} onLongPress={() => setShowRemove(true)}>
> <Card.Cover source={{uri: newImage}} />
<Card.Cover source={{ uri: newImage }} />
</TouchableRipple> </TouchableRipple>
)} )}
<ConfirmDialog <ConfirmDialog
title="Remove image" title="Remove image"
onOk={handleRemove} onOk={handleRemove}
show={showRemove} show={showRemove}
setShow={setShowRemove} setShow={setShowRemove}>
>
Are you sure you want to remove the image? Are you sure you want to remove the image?
</ConfirmDialog> </ConfirmDialog>
{settings.images && !newImage && ( {settings.images && !newImage && (
<Button <Button
style={{ marginBottom: MARGIN }} style={{marginBottom: MARGIN}}
onPress={changeImage} onPress={changeImage}
icon="add-photo-alternate" icon="add-photo-alternate">
>
Image Image
</Button> </Button>
)} )}
</View> </View>
<Button <Button
mode="outlined" mode="contained"
icon="save" icon="save"
style={{ margin: MARGIN }} style={{margin: MARGIN}}
onPress={handleSubmit} onPress={handleSubmit}>
>
Save Save
</Button> </Button>
</> </>
); )
} }

View File

@ -3,72 +3,70 @@ import {
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
useRoute, useRoute,
} from "@react-navigation/native"; } from '@react-navigation/native'
import { useCallback, useRef, useState } from "react"; import {useCallback, useRef, useState} from 'react'
import { ScrollView, TextInput, View } from "react-native"; import {ScrollView, 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 AppInput from "./AppInput"; import ConfirmDialog from './ConfirmDialog'
import ConfirmDialog from "./ConfirmDialog"; import {MARGIN, PADDING} from './constants'
import { MARGIN, PADDING } from "./constants"; import {getNow, planRepo, setRepo, settingsRepo} from './db'
import { getNow, planRepo, setRepo, settingsRepo } from "./db"; import {defaultSet} from './gym-set'
import { fixNumeric } from "./fix-numeric"; import MassiveInput from './MassiveInput'
import { defaultSet } from "./gym-set"; import Settings from './settings'
import Settings from "./settings"; import StackHeader from './StackHeader'
import StackHeader from "./StackHeader"; import {WorkoutsPageParams} from './WorkoutsPage'
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 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, setSettings] = useState<Settings>()
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings); settingsRepo.findOne({where: {}}).then(setSettings)
}, []) }, []),
); )
const update = async () => { const update = async () => {
await setRepo.update( await setRepo.update(
{ name: params.value.name }, {name: params.value.name},
{ {
name: name || params.value.name, name: name || params.value.name,
sets: Number(sets), sets: Number(sets),
minutes: +minutes, minutes: +minutes,
seconds: +seconds, seconds: +seconds,
steps, steps,
image: removeImage ? "" : uri, image: removeImage ? '' : uri,
} },
); )
await planRepo.query( await planRepo.query(
`UPDATE plans `UPDATE plans
SET workouts = REPLACE(workouts, $1, $2) SET workouts = REPLACE(workouts, $1, $2)
WHERE workouts LIKE $3`, WHERE workouts LIKE $3`,
[params.value.name, name, `%${params.value.name}%`] [params.value.name, name, `%${params.value.name}%`],
); )
navigation.goBack(); navigation.goBack()
}; }
const add = async () => { const add = async () => {
const now = await getNow(); const [{now}] = await getNow()
await setRepo.save({ await setRepo.save({
...defaultSet, ...defaultSet,
name, name,
@ -79,40 +77,40 @@ export default function EditWorkout() {
sets: sets ? +sets : 3, sets: sets ? +sets : 3,
steps, steps,
created: now, 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: DocumentPicker.types.images, type: DocumentPicker.types.images,
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 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 (
<> <>
<StackHeader title={params.value.name ? "Edit workout" : "Add workout"} /> <StackHeader title="Edit workout" />
<View style={{ padding: PADDING, flex: 1 }}> <View style={{padding: PADDING, flex: 1}}>
<ScrollView style={{ flex: 1 }}> <ScrollView style={{flex: 1}}>
<AppInput <MassiveInput
autoFocus autoFocus
label="Name" label="Name"
value={name} value={name}
@ -120,7 +118,7 @@ export default function EditWorkout() {
onSubmitEditing={submitName} onSubmitEditing={submitName}
/> />
{settings?.steps && ( {settings?.steps && (
<AppInput <MassiveInput
innerRef={stepsRef} innerRef={stepsRef}
selectTextOnFocus={false} selectTextOnFocus={false}
value={steps} value={steps}
@ -130,35 +128,25 @@ export default function EditWorkout() {
onSubmitEditing={() => setsRef.current?.focus()} onSubmitEditing={() => setsRef.current?.focus()}
/> />
)} )}
<AppInput <MassiveInput
innerRef={setsRef} innerRef={setsRef}
value={sets} value={sets}
onChangeText={(newSets) => { onChangeText={setSets}
const fixed = fixNumeric(newSets);
setSets(fixed);
if (fixed.length !== newSets.length)
toast("Sets must be a number");
}}
label="Sets per workout" label="Sets per workout"
keyboardType="numeric" keyboardType="numeric"
onSubmitEditing={() => minutesRef.current?.focus()} onSubmitEditing={() => minutesRef.current?.focus()}
/> />
{settings?.alarm && ( {settings?.alarm && (
<> <>
<AppInput <MassiveInput
innerRef={minutesRef} innerRef={minutesRef}
onSubmitEditing={() => secondsRef.current?.focus()} onSubmitEditing={() => secondsRef.current?.focus()}
value={minutes} value={minutes}
onChangeText={(newMinutes) => { onChangeText={setMinutes}
const fixed = fixNumeric(newMinutes);
setMinutes(fixed);
if (fixed.length !== newMinutes.length)
toast("Reps must be a number");
}}
label="Rest minutes" label="Rest minutes"
keyboardType="numeric" keyboardType="numeric"
/> />
<AppInput <MassiveInput
innerRef={secondsRef} innerRef={secondsRef}
value={seconds} value={seconds}
onChangeText={setSeconds} onChangeText={setSeconds}
@ -170,35 +158,32 @@ export default function EditWorkout() {
)} )}
{settings?.images && uri && ( {settings?.images && uri && (
<TouchableRipple <TouchableRipple
style={{ marginBottom: MARGIN }} style={{marginBottom: MARGIN}}
onPress={changeImage} onPress={changeImage}
onLongPress={() => setShowRemove(true)} onLongPress={() => setShowRemove(true)}>
> <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}
icon="add-photo-alternate" icon="add-photo-alternate">
>
Image Image
</Button> </Button>
)} )}
</ScrollView> </ScrollView>
<Button disabled={!name} mode="outlined" icon="save" onPress={save}> <Button disabled={!name} mode="contained" icon="save" onPress={save}>
Save Save
</Button> </Button>
<ConfirmDialog <ConfirmDialog
title="Remove image" title="Remove image"
onOk={handleRemove} onOk={handleRemove}
show={showRemove} show={showRemove}
setShow={setShowRemove} setShow={setShowRemove}>
>
Are you sure you want to remove the image? Are you sure you want to remove the image?
</ConfirmDialog> </ConfirmDialog>
</View> </View>
</> </>
); )
} }

View File

@ -1,6 +1,6 @@
source 'https://rubygems.org' source 'https://rubygems.org'
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version # You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby ">= 2.6.10" ruby '2.7.5'
gem 'cocoapods', '~> 1.12' gem 'cocoapods', '~> 1.11', '>= 1.11.2'

View File

@ -1,102 +0,0 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from "@react-navigation/native";
import { useCallback, useState } from "react";
import { FlatList, Image } from "react-native";
import { List } from "react-native-paper";
import { getBestSets } from "./best.service";
import { LIMIT } from "./constants";
import { settingsRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
import { GraphsPageParams } from "./GraphsPage";
import GymSet from "./gym-set";
import Page from "./Page";
import Settings from "./settings";
export default function GraphsList() {
const [bests, setBests] = useState<GymSet[]>([]);
const [offset, setOffset] = useState(0);
const [end, setEnd] = useState(false);
const [term, setTerm] = useState("");
const navigation = useNavigation<NavigationProp<GraphsPageParams>>();
const [settings, setSettings] = useState<Settings>();
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
}, [])
);
const refresh = useCallback(async (value: string) => {
const result = await getBestSets({ term: value, offset: 0 });
setBests(result);
setOffset(0);
}, []);
useFocusEffect(
useCallback(() => {
refresh(term);
}, [refresh, term])
);
const next = useCallback(async () => {
if (end) return;
const newOffset = offset + LIMIT;
console.log(`${GraphsList.name}.next:`, { offset, newOffset, term });
const newBests = await getBestSets({ term, offset });
if (newBests.length === 0) return setEnd(true);
if (!bests) return;
setBests([...bests, ...newBests]);
if (newBests.length < LIMIT) return setEnd(true);
setOffset(newOffset);
}, [term, end, offset, bests]);
const search = useCallback(
(value: string) => {
setTerm(value);
refresh(value);
},
[refresh]
);
const renderItem = ({ item }: { item: GymSet }) => (
<List.Item
key={item.name}
title={item.name}
description={`${item.reps} x ${item.weight}${item.unit || "kg"}`}
onPress={() => navigation.navigate("ViewGraph", { best: item })}
left={() =>
(settings.images && item.image && (
<Image
source={{ uri: item.image }}
style={{ height: 75, width: 75 }}
/>
)) ||
null
}
/>
);
return (
<>
<DrawerHeader name="Graphs" />
<Page term={term} search={search}>
{bests?.length === 0 ? (
<List.Item
title="No exercises yet"
description="Once sets have been added, this will highlight your personal bests."
/>
) : (
<FlatList
style={{ flex: 1 }}
renderItem={renderItem}
data={bests}
onEndReached={next}
/>
)}
</Page>
</>
);
}

View File

@ -1,23 +0,0 @@
import { createStackNavigator } from "@react-navigation/stack";
import GraphsList from "./GraphsList";
import GymSet from "./gym-set";
import ViewGraph from "./ViewGraph";
const Stack = createStackNavigator<GraphsPageParams>();
export type GraphsPageParams = {
GraphsList: {};
ViewGraph: {
best: GymSet;
};
};
export default function GraphsPage() {
return (
<Stack.Navigator
screenOptions={{ headerShown: false, animationEnabled: false }}
>
<Stack.Screen name="GraphsList" component={GraphsList} />
<Stack.Screen name="ViewGraph" component={ViewGraph} />
</Stack.Navigator>
);
}

View File

@ -1,19 +1,18 @@
import { createStackNavigator } from "@react-navigation/stack"; import {createStackNavigator} from '@react-navigation/stack'
import EditSet from "./EditSet"; import EditSet from './EditSet'
import EditSets from "./EditSets"; import EditSets from './EditSets'
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 (
<Stack.Navigator <Stack.Navigator
screenOptions={{ headerShown: false, animationEnabled: false }} screenOptions={{headerShown: false, animationEnabled: false}}>
>
<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.Screen name="EditSets" component={EditSets} /> <Stack.Screen name="EditSets" component={EditSets} />
</Stack.Navigator> </Stack.Navigator>
); )
} }

25
LabelledButton.tsx Normal file
View File

@ -0,0 +1,25 @@
import {View} from 'react-native'
import {ITEM_PADDING} from './constants'
import {Button, Subheading} from 'react-native-paper'
export default function LabelledButton({
label,
onPress,
children,
}: {
label?: string
onPress: () => void
children: string
}) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingLeft: ITEM_PADDING,
}}>
<Subheading style={{width: 100}}>{label}</Subheading>
<Button onPress={onPress}>{children}</Button>
</View>
)
}

View File

@ -1,6 +1,7 @@
import { useState } from "react"; import {useState} from 'react'
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 useDark from './use-dark'
export default function ListMenu({ export default function ListMenu({
onEdit, onEdit,
@ -10,79 +11,85 @@ export default function ListMenu({
onSelect, onSelect,
ids, ids,
}: { }: {
onEdit: () => void; onEdit: () => void
onCopy: () => void; onCopy: () => void
onClear: () => void; onClear: () => void
onDelete: () => void; onDelete: () => void
onSelect: () => void; onSelect: () => void
ids?: number[]; ids?: number[]
}) { }) {
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false)
const [showRemove, setShowRemove] = useState(false); const [showRemove, setShowRemove] = useState(false)
const dark = useDark()
const edit = () => { const edit = () => {
setShowMenu(false); setShowMenu(false)
onEdit(); onEdit()
}; }
const copy = () => { const copy = () => {
setShowMenu(false); setShowMenu(false)
onCopy(); onCopy()
}; }
const clear = () => { const clear = () => {
setShowMenu(false); setShowMenu(false)
onClear(); onClear()
}; }
const remove = () => { const remove = () => {
setShowMenu(false); setShowMenu(false)
setShowRemove(false); setShowRemove(false)
onDelete(); onDelete()
}; }
const select = () => { const select = () => {
onSelect(); setShowMenu(false)
}; onSelect()
}
return ( return (
<Menu <Menu
visible={showMenu} visible={showMenu}
onDismiss={() => setShowMenu(false)} onDismiss={() => setShowMenu(false)}
anchor={<IconButton onPress={() => setShowMenu(true)} icon="more-vert" />} anchor={
> <IconButton
<Menu.Item leadingIcon="done-all" title="Select all" onPress={select} /> color={dark ? 'white' : 'white'}
onPress={() => setShowMenu(true)}
icon="more-vert"
/>
}>
<Menu.Item icon="done-all" title="Select all" onPress={select} />
<Menu.Item <Menu.Item
leadingIcon="clear" icon="clear"
title="Clear" title="Clear"
onPress={clear} onPress={clear}
disabled={ids?.length === 0} disabled={ids?.length === 0}
/> />
<Menu.Item <Menu.Item
leadingIcon="edit" icon="edit"
title="Edit" title="Edit"
onPress={edit} onPress={edit}
disabled={ids?.length === 0} disabled={ids?.length === 0}
/> />
<Menu.Item <Menu.Item
leadingIcon="content-copy" icon="content-copy"
title="Copy" title="Copy"
onPress={copy} onPress={copy}
disabled={ids?.length === 0} disabled={ids?.length === 0}
/> />
<Divider /> <Divider />
<Menu.Item <Menu.Item
leadingIcon="delete" icon="delete"
onPress={() => setShowRemove(true)} onPress={() => setShowRemove(true)}
title="Delete" title="Delete"
/> />
<ConfirmDialog <ConfirmDialog
title={ids?.length === 0 ? "Delete all" : "Delete selected"} title={ids?.length === 0 ? 'Delete all' : 'Delete selected'}
show={showRemove} show={showRemove}
setShow={setShowRemove} setShow={setShowRemove}
onOk={remove} onOk={remove}
onCancel={() => setShowMenu(false)} onCancel={() => setShowMenu(false)}>
>
{ids?.length === 0 ? ( {ids?.length === 0 ? (
<>This irreversibly deletes records from the app. Are you sure?</> <>This irreversibly deletes records from the app. Are you sure?</>
) : ( ) : (
@ -90,5 +97,5 @@ export default function ListMenu({
)} )}
</ConfirmDialog> </ConfirmDialog>
</Menu> </Menu>
); )
} }

30
MassiveFab.tsx Normal file
View File

@ -0,0 +1,30 @@
import {ComponentProps, useMemo} from 'react'
import {FAB, useTheme} from 'react-native-paper'
import {CombinedDarkTheme, CombinedDefaultTheme} from './App'
import {lightColors} from './colors'
export default function MassiveFab(props: Partial<ComponentProps<typeof FAB>>) {
const {colors} = useTheme()
const fabColor = useMemo(
() =>
lightColors.map(color => color.hex).includes(colors.primary)
? CombinedDarkTheme.colors.background
: CombinedDefaultTheme.colors.background,
[colors.primary],
)
return (
<FAB
icon="add"
color={fabColor}
style={{
position: 'absolute',
right: 20,
bottom: 20,
backgroundColor: colors.primary,
}}
{...props}
/>
)
}

25
MassiveInput.tsx Normal file
View File

@ -0,0 +1,25 @@
import {ComponentProps, Ref} from 'react'
import {TextInput} from 'react-native-paper'
import {CombinedDefaultTheme} from './App'
import {MARGIN} from './constants'
import useDark from './use-dark'
export default function MassiveInput(
props: Partial<ComponentProps<typeof TextInput>> & {
innerRef?: Ref<any>
},
) {
const dark = useDark()
return (
<TextInput
selectionColor={dark ? '#2A2A2A' : CombinedDefaultTheme.colors.border}
mode="outlined"
style={{marginBottom: MARGIN, minWidth: 100}}
selectTextOnFocus
ref={props.innerRef}
blurOnSubmit={false}
{...props}
/>
)
}

View File

@ -1,7 +1,7 @@
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import { Searchbar } from "react-native-paper"; import {Searchbar} from 'react-native-paper'
import AppFab from "./AppFab"; import {PADDING} from './constants'
import { PADDING } from "./constants"; import MassiveFab from './MassiveFab'
export default function Page({ export default function Page({
onAdd, onAdd,
@ -10,11 +10,11 @@ export default function Page({
search, search,
style, style,
}: { }: {
children: JSX.Element | JSX.Element[]; children: JSX.Element | JSX.Element[]
onAdd?: () => void; onAdd?: () => void
term: string; term: string
search: (value: string) => void; search: (value: string) => void
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>
}) { }) {
return ( return (
<View style={[styles.view, style]}> <View style={[styles.view, style]}>
@ -26,9 +26,9 @@ export default function Page({
clearIcon="clear" clearIcon="clear"
/> />
{children} {children}
{onAdd && <AppFab onPress={onAdd} />} {onAdd && <MassiveFab onPress={onAdd} />}
</View> </View>
); )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@ -36,4 +36,4 @@ const styles = StyleSheet.create({
padding: PADDING, padding: PADDING,
flexGrow: 1, flexGrow: 1,
}, },
}); })

View File

@ -2,98 +2,83 @@ import {
NavigationProp, NavigationProp,
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
} from "@react-navigation/native"; } from '@react-navigation/native'
import { useCallback, useMemo, useState } from "react"; import {useCallback, useMemo, useState} from 'react'
import { Text } from "react-native"; import {Text} from 'react-native'
import { List } from "react-native-paper"; import {List} from 'react-native-paper'
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants"; import {getBestSet} from './best.service'
import { setRepo } from "./db"; import {DARK_RIPPLE, LIGHT_RIPPLE} from './constants'
import { defaultSet } from "./gym-set"; import {defaultSet} from './gym-set'
import { Plan } from "./plan"; import {Plan} from './plan'
import { PlanPageParams } from "./plan-page-params"; import {PlanPageParams} from './plan-page-params'
import { DAYS } from "./time"; import {DAYS} from './time'
import useDark from "./use-dark"; import useDark from './use-dark'
export default function PlanItem({ export default function PlanItem({
item, item,
setIds, setIds,
ids, ids,
}: { }: {
item: Plan; item: Plan
ids: number[]; ids: number[]
setIds: (value: number[]) => void; setIds: (value: number[]) => void
}) { }) {
const [today, setToday] = useState<string>(); const [today, setToday] = useState<string>()
const dark = useDark(); const dark = useDark()
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 start = useCallback(async () => { const start = useCallback(async () => {
const workout = item.workouts.split(",")[0]; const workout = item.workouts.split(',')[0]
let first = await setRepo.findOne({ let first = await getBestSet(workout)
where: { name: workout }, if (!first) first = {...defaultSet, name: workout}
order: { created: "desc" }, delete first.id
}); if (ids.length === 0)
if (!first) first = { ...defaultSet, name: workout }; return navigation.navigate('StartPlan', {plan: item, first})
delete first.id; const removing = ids.find(id => id === item.id)
if (ids.length === 0) { if (removing) setIds(ids.filter(id => id !== item.id))
return navigation.navigate("StartPlan", { plan: item, first }); else setIds([...ids, item.id])
} }, [ids, setIds, item, navigation])
const removing = ids.find((id) => id === item.id);
if (removing) setIds(ids.filter((id) => id !== item.id));
else setIds([...ids, item.id]);
}, [ids, setIds, item, navigation]);
const longPress = useCallback(() => { const longPress = useCallback(() => {
if (ids.length > 0) return; if (ids.length > 0) return
setIds([item.id]); setIds([item.id])
}, [ids.length, item.id, setIds]); }, [ids.length, item.id, setIds])
const currentDays = days.map((day, index) => (
<Text key={day}>
{day === today ? (
<Text
style={{
fontWeight: "bold",
textDecorationLine: "underline",
}}
>
{day}
</Text>
) : (
day
)}
{index === days.length - 1 ? "" : ", "}
</Text>
));
const title = useMemo( const title = useMemo(
() => () =>
item.title ? ( days.map((day, index) => (
<Text style={{ fontWeight: "bold" }}>{item.title}</Text> <Text key={day}>
) : ( {day === today ? (
currentDays <Text style={{fontWeight: 'bold', textDecorationLine: 'underline'}}>
), {day}
[item.title, currentDays] </Text>
); ) : (
day
)}
{index === days.length - 1 ? '' : ', '}
</Text>
)),
[days, today],
)
const description = useMemo( const description = useMemo(
() => (item.title ? currentDays : item.workouts.replace(/,/g, ", ")), () => item.workouts.replace(/,/g, ', '),
[item.title, currentDays, item.workouts] [item.workouts],
); )
const backgroundColor = useMemo(() => { const backgroundColor = useMemo(() => {
if (!ids.includes(item.id)) return; if (!ids.includes(item.id)) return
if (dark) return DARK_RIPPLE; if (dark) return DARK_RIPPLE
return LIGHT_RIPPLE; return LIGHT_RIPPLE
}, [dark, ids, item.id]); }, [dark, ids, item.id])
return ( return (
<List.Item <List.Item
@ -101,7 +86,7 @@ export default function PlanItem({
title={title} title={title}
description={description} description={description}
onLongPress={longPress} onLongPress={longPress}
style={{ backgroundColor }} style={{backgroundColor}}
/> />
); )
} }

View File

@ -2,95 +2,89 @@ import {
NavigationProp, NavigationProp,
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
} from "@react-navigation/native"; } from '@react-navigation/native'
import { useCallback, 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 { Like } from "typeorm"; import {Like} from 'typeorm'
import { planRepo } from "./db"; import {planRepo} from './db'
import DrawerHeader from "./DrawerHeader"; import DrawerHeader from './DrawerHeader'
import ListMenu from "./ListMenu"; import ListMenu from './ListMenu'
import Page from "./Page"; import Page from './Page'
import { Plan } from "./plan"; import {Plan} from './plan'
import { PlanPageParams } from "./plan-page-params"; import {PlanPageParams} from './plan-page-params'
import PlanItem from "./PlanItem"; import PlanItem from './PlanItem'
export default function PlanList() { export default function PlanList() {
const [term, setTerm] = useState(""); const [term, setTerm] = useState('')
const [plans, setPlans] = useState<Plan[]>(); const [plans, setPlans] = useState<Plan[]>()
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([])
const navigation = useNavigation<NavigationProp<PlanPageParams>>(); const navigation = useNavigation<NavigationProp<PlanPageParams>>()
const refresh = useCallback(async (value: string) => { const refresh = useCallback(async (value: string) => {
planRepo planRepo
.find({ .find({
where: [ where: [{days: Like(`%${value}%`)}, {workouts: Like(`%${value}%`)}],
{ title: Like(`%${value.trim()}%`) },
{ days: Like(`%${value.trim()}%`) },
{ workouts: Like(`%${value.trim()}%`) },
],
}) })
.then(setPlans); .then(setPlans)
}, []); }, [])
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
refresh(term); refresh(term)
}, [refresh, term]) }, [refresh, term]),
); )
const search = useCallback( const search = useCallback(
(value: string) => { (value: string) => {
setTerm(value); setTerm(value)
refresh(value); refresh(value)
}, },
[refresh] [refresh],
); )
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: Plan }) => ( ({item}: {item: Plan}) => (
<PlanItem ids={ids} setIds={setIds} item={item} key={item.id} /> <PlanItem ids={ids} setIds={setIds} item={item} key={item.id} />
), ),
[ids] [ids],
); )
const onAdd = () => const onAdd = () =>
navigation.navigate("EditPlan", { navigation.navigate('EditPlan', {plan: {days: '', workouts: ''}})
plan: { title: "", days: "", workouts: "" },
});
const edit = useCallback(async () => { const edit = useCallback(async () => {
const plan = await planRepo.findOne({ where: { id: ids.pop() } }); const plan = await planRepo.findOne({where: {id: ids.pop()}})
navigation.navigate("EditPlan", { plan }); navigation.navigate('EditPlan', {plan})
setIds([]); setIds([])
}, [ids, navigation]); }, [ids, navigation])
const copy = useCallback(async () => { const copy = useCallback(async () => {
const plan = await planRepo.findOne({ const plan = await planRepo.findOne({
where: { id: ids.pop() }, where: {id: ids.pop()},
}); })
delete plan.id; delete plan.id
navigation.navigate("EditPlan", { plan }); navigation.navigate('EditPlan', {plan})
setIds([]); setIds([])
}, [ids, navigation]); }, [ids, navigation])
const clear = useCallback(() => { const clear = useCallback(() => {
setIds([]); setIds([])
}, []); }, [])
const remove = useCallback(async () => { const remove = useCallback(async () => {
await planRepo.delete(ids.length > 0 ? ids : {}); await planRepo.delete(ids.length > 0 ? ids : {})
await refresh(term); await refresh(term)
setIds([]); setIds([])
}, [ids, refresh, term]); }, [ids, refresh, term])
const select = useCallback(() => { const select = useCallback(() => {
setIds(plans.map((plan) => plan.id)); setIds(plans.map(plan => plan.id))
}, [plans]); }, [plans])
return ( return (
<> <>
<DrawerHeader name={ids.length > 0 ? `${ids.length} selected` : "Plans"}> <DrawerHeader name="Plans">
<ListMenu <ListMenu
onClear={clear} onClear={clear}
onCopy={copy} onCopy={copy}
@ -108,13 +102,13 @@ export default function PlanList() {
/> />
) : ( ) : (
<FlatList <FlatList
style={{ flex: 1 }} style={{flex: 1}}
data={plans} data={plans}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={(set) => set.id?.toString() || ""} keyExtractor={set => set.id?.toString() || ''}
/> />
)} )}
</Page> </Page>
</> </>
); )
} }

View File

@ -1,21 +1,20 @@
import { createStackNavigator } from "@react-navigation/stack"; import {createStackNavigator} from '@react-navigation/stack'
import EditPlan from "./EditPlan"; import EditPlan from './EditPlan'
import EditSet from "./EditSet"; 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 (
<Stack.Navigator <Stack.Navigator
screenOptions={{ headerShown: false, animationEnabled: false }} screenOptions={{headerShown: false, animationEnabled: false}}>
>
<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.Screen name="EditSet" component={EditSet} />
</Stack.Navigator> </Stack.Navigator>
); )
} }

BIN
README.md.pdf Normal file

Binary file not shown.

View File

@ -1,57 +1,57 @@
import { createDrawerNavigator } from "@react-navigation/drawer"; import {createDrawerNavigator} from '@react-navigation/drawer'
import { IconButton } from "react-native-paper"; import {useMemo} from 'react'
import GraphsPage from "./GraphsPage"; import {Platform} from 'react-native'
import { DrawerParamList } from "./drawer-param-list"; import {IconButton} from 'react-native-paper'
import HomePage from "./HomePage"; import BestPage from './BestPage'
import PlanPage from "./PlanPage"; import {DrawerParamList} from './drawer-param-list'
import SettingsPage from "./SettingsPage"; import HomePage from './HomePage'
import TimerPage from "./TimerPage"; import PlanPage from './PlanPage'
import useDark from "./use-dark"; import Route from './route'
import WorkoutsPage from "./WorkoutsPage"; import SettingsPage from './SettingsPage'
import TimerPage from './TimerPage'
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 routes: Route[] = useMemo(
() => [
{name: 'Home', component: HomePage, icon: 'home'},
{name: 'Plans', component: PlanPage, icon: 'event'},
{name: 'Best', component: BestPage, icon: 'insights'},
{name: 'Workouts', component: WorkoutsPage, icon: 'fitness-center'},
{name: 'Timer', component: TimerPage, icon: 'access-time'},
{name: 'Settings', component: SettingsPage, icon: 'settings'},
],
[],
)
return ( return (
<Drawer.Navigator <Drawer.Navigator
screenOptions={{ screenOptions={{
headerTintColor: dark ? "white" : "black", headerTintColor: dark ? 'white' : 'black',
swipeEdgeWidth: 1000, swipeEdgeWidth: 1000,
headerShown: false, headerShown: false,
}} }}>
> {}
<Drawer.Screen {routes
name="Home" .filter(route => {
component={HomePage} if (Platform.OS === 'ios' && route.name === 'Timer') return false
options={{ drawerIcon: () => <IconButton icon="home" /> }} return true
/> })
<Drawer.Screen .map(route => (
name="Plans" <Drawer.Screen
component={PlanPage} key={route.name}
options={{ drawerIcon: () => <IconButton icon="event" /> }} name={route.name}
/> component={route.component}
<Drawer.Screen options={{
name="Graphs" drawerIcon: () => <IconButton icon={route.icon} />,
component={GraphsPage} }}
options={{ drawerIcon: () => <IconButton icon="insights" /> }} />
/> ))}
<Drawer.Screen
name="Workouts"
component={WorkoutsPage}
options={{ drawerIcon: () => <IconButton icon="fitness-center" /> }}
/>
<Drawer.Screen
name="Timer"
component={TimerPage}
options={{ drawerIcon: () => <IconButton icon="access-time" /> }}
/>
<Drawer.Screen
name="Settings"
component={SettingsPage}
options={{ drawerIcon: () => <IconButton icon="settings" /> }}
/>
</Drawer.Navigator> </Drawer.Navigator>
); )
} }

View File

@ -1,50 +1,49 @@
import React, { useCallback, useMemo, useState } from "react"; import {useCallback, useMemo, useState} from 'react'
import { View } from "react-native"; import {View} from 'react-native'
import { Button, Menu, Subheading, useTheme } from "react-native-paper"; import {Button, Menu, Subheading, useTheme} from 'react-native-paper'
import { ITEM_PADDING } from "./constants"; import {ITEM_PADDING} from './constants'
export interface Item { export interface Item {
value: string; value: string
label: string; label: string
color?: string; color?: string
} }
function Select({ export default function Select({
value, value,
onChange, onChange,
items, items,
label, label,
}: { }: {
value: string; value: string
onChange: (value: string) => void; onChange: (value: string) => void
items: Item[]; items: Item[]
label?: string; label?: string
}) { }) {
const [show, setShow] = useState(false); const [show, setShow] = useState(false)
const { colors } = useTheme(); const {colors} = useTheme()
const selected = useMemo( const selected = useMemo(
() => items.find((item) => item.value === value) || items[0], () => items.find(item => item.value === value) || items[0],
[items, value] [items, value],
); )
const handlePress = useCallback( const handlePress = useCallback(
(newValue: string) => { (newValue: string) => {
onChange(newValue); onChange(newValue)
setShow(false); setShow(false)
}, },
[onChange] [onChange],
); )
return ( return (
<View <View
style={{ style={{
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
paddingLeft: ITEM_PADDING, paddingLeft: ITEM_PADDING,
}} }}>
> {label && <Subheading style={{width: 100}}>{label}</Subheading>}
{label && <Subheading style={{ width: 100 }}>{label}</Subheading>}
<Menu <Menu
visible={show} visible={show}
onDismiss={() => setShow(false)} onDismiss={() => setShow(false)}
@ -52,24 +51,20 @@ function Select({
<Button <Button
onPress={() => setShow(true)} onPress={() => setShow(true)}
style={{ style={{
alignSelf: "flex-start", alignSelf: 'flex-start',
}} }}>
>
{selected?.label} {selected?.label}
</Button> </Button>
} }>
> {items.map(item => (
{items.map((item) => (
<Menu.Item <Menu.Item
titleStyle={{ color: item.color || colors.onSurface }}
key={item.value} key={item.value}
titleStyle={{color: item.color || colors.text}}
title={item.label} title={item.label}
onPress={() => handlePress(item.value)} onPress={() => handlePress(item.value)}
/> />
))} ))}
</Menu> </Menu>
</View> </View>
); )
} }
export default React.memo(Select);

View File

@ -1,13 +1,13 @@
import { NavigationProp, useNavigation } from "@react-navigation/native"; import {NavigationProp, useNavigation} from '@react-navigation/native'
import { format } from "date-fns"; import {format} from 'date-fns'
import { useCallback, useMemo } from "react"; import {useCallback, useMemo} from 'react'
import { Image } from "react-native"; import {Image} from 'react-native'
import { List, Text } from "react-native-paper"; import {List, Text} from 'react-native-paper'
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants"; import {DARK_RIPPLE, LIGHT_RIPPLE} from './constants'
import GymSet from "./gym-set"; import GymSet from './gym-set'
import { HomePageParams } from "./home-page-params"; import {HomePageParams} from './home-page-params'
import Settings from "./settings"; import Settings from './settings'
import useDark from "./use-dark"; import useDark from './use-dark'
export default function SetItem({ export default function SetItem({
item, item,
@ -15,63 +15,61 @@ export default function SetItem({
ids, ids,
setIds, setIds,
}: { }: {
item: GymSet; item: GymSet
onRemove: () => void; onRemove: () => void
settings: Settings; settings: Settings
ids: number[]; ids: number[]
setIds: (value: number[]) => void; setIds: (value: number[]) => void
}) { }) {
const dark = useDark(); const dark = useDark()
const navigation = useNavigation<NavigationProp<HomePageParams>>(); const navigation = useNavigation<NavigationProp<HomePageParams>>()
const longPress = useCallback(() => { const longPress = useCallback(() => {
if (ids.length > 0) return; if (ids.length > 0) return
setIds([item.id]); setIds([item.id])
}, [ids.length, item.id, setIds]); }, [ids.length, item.id, setIds])
const press = useCallback(() => { const press = useCallback(() => {
if (ids.length === 0) return navigation.navigate("EditSet", { set: item }); if (ids.length === 0) return navigation.navigate('EditSet', {set: item})
const removing = ids.find((id) => id === item.id); const removing = ids.find(id => id === item.id)
if (removing) setIds(ids.filter((id) => id !== item.id)); if (removing) setIds(ids.filter(id => id !== item.id))
else setIds([...ids, item.id]); else setIds([...ids, item.id])
}, [ids, item, navigation, setIds]); }, [ids, item, navigation, setIds])
const backgroundColor = useMemo(() => { const backgroundColor = useMemo(() => {
if (!ids.includes(item.id)) return; if (!ids.includes(item.id)) return
if (dark) return DARK_RIPPLE; if (dark) return DARK_RIPPLE
return LIGHT_RIPPLE; return LIGHT_RIPPLE
}, [dark, ids, item.id]); }, [dark, ids, item.id])
const left = useCallback(() => {
if (!settings.images || !item.image) return null;
return (
<Image source={{ uri: item.image }} style={{ height: 75, width: 75 }} />
);
}, [item.image, settings.images]);
const right = useCallback(() => {
if (!settings.showDate) return null;
return (
<Text
style={{
alignSelf: "center",
color: dark ? "#909090ff" : "#717171ff",
}}
>
{format(new Date(item.created), settings.date || "P")}
</Text>
);
}, [settings.showDate, item.created, settings.date, dark]);
return ( return (
<List.Item <>
onPress={press} <List.Item
title={item.name} onPress={press}
description={`${item.reps} x ${item.weight}${item.unit || "kg"}`} title={item.name}
onLongPress={longPress} description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
style={{ backgroundColor }} onLongPress={longPress}
left={left} style={{backgroundColor}}
right={right} left={() =>
/> settings.images &&
); item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)
}
right={() => (
<>
{settings.showDate && (
<Text
style={{
alignSelf: 'center',
color: dark ? '#909090ff' : '#717171ff',
}}>
{format(new Date(item.created), settings.date || 'P')}
</Text>
)}
</>
)}
/>
</>
)
} }

View File

@ -2,52 +2,57 @@ import {
NavigationProp, NavigationProp,
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
} from "@react-navigation/native"; } from '@react-navigation/native'
import { useCallback, 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 { Like } from "typeorm"; import {Like} from 'typeorm'
import { LIMIT } from "./constants"; import {getNow, setRepo, settingsRepo} from './db'
import { getNow, setRepo, settingsRepo } from "./db"; import DrawerHeader from './DrawerHeader'
import DrawerHeader from "./DrawerHeader"; import GymSet, {defaultSet} from './gym-set'
import GymSet, { defaultSet } from "./gym-set"; import {HomePageParams} from './home-page-params'
import { HomePageParams } from "./home-page-params"; import ListMenu from './ListMenu'
import ListMenu from "./ListMenu"; import Page from './Page'
import Page from "./Page"; import SetItem from './SetItem'
import SetItem from "./SetItem"; import Settings from './settings'
import Settings from "./settings";
const limit = 15
export default function SetList() { export default function SetList() {
const [sets, setSets] = useState<GymSet[]>([]); const [sets, setSets] = useState<GymSet[]>([])
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0)
const [term, setTerm] = useState(""); const [term, setTerm] = useState('')
const [end, setEnd] = useState(false); const [end, setEnd] = useState(false)
const [settings, setSettings] = useState<Settings>(); const [settings, setSettings] = useState<Settings>()
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([])
const navigation = useNavigation<NavigationProp<HomePageParams>>(); const navigation = useNavigation<NavigationProp<HomePageParams>>()
const refresh = useCallback(async (value: string) => { const refresh = useCallback(async (value: string) => {
const newSets = await setRepo.find({ const newSets = await setRepo.find({
where: { name: Like(`%${value.trim()}%`), hidden: 0 as any }, where: {name: Like(`%${value}%`), hidden: 0 as any},
take: LIMIT, take: limit,
skip: 0, skip: 0,
order: { created: "DESC" }, order: {created: 'DESC'},
}); })
console.log(`${SetList.name}.refresh:`, { value }); console.log(`${SetList.name}.refresh:`, {
setSets(newSets); value,
setOffset(0); limit,
setEnd(false); length: newSets.length,
}, []); })
setSets(newSets)
setOffset(0)
setEnd(false)
}, [])
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
refresh(term); refresh(term)
settingsRepo.findOne({ where: {} }).then(setSettings); settingsRepo.findOne({where: {}}).then(setSettings)
}, [refresh, term]) }, [refresh, term]),
); )
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: GymSet }) => ( ({item}: {item: GymSet}) => (
<SetItem <SetItem
settings={settings} settings={settings}
item={item} item={item}
@ -57,75 +62,75 @@ export default function SetList() {
setIds={setIds} setIds={setIds}
/> />
), ),
[refresh, term, settings, ids] [refresh, term, settings, ids],
); )
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, term }); console.log(`${SetList.name}.next:`, {offset, newOffset, term})
const newSets = await setRepo.find({ const newSets = await setRepo.find({
where: { name: Like(`%${term}%`), hidden: 0 as any }, where: {name: Like(`%${term}%`), hidden: 0 as any},
take: LIMIT, take: limit,
skip: newOffset, skip: newOffset,
order: { created: "DESC" }, 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)
}, [term, end, offset, sets]); }, [term, end, offset, sets])
const onAdd = useCallback(async () => { const onAdd = useCallback(async () => {
const now = await getNow(); const [{now}] = await getNow()
let set = sets[0]; let set = sets[0]
if (!set) set = { ...defaultSet }; if (!set) set = {...defaultSet}
set.created = now; set.created = now
delete set.id; delete set.id
navigation.navigate("EditSet", { set }); navigation.navigate('EditSet', {set})
}, [navigation, sets]); }, [navigation, sets])
const search = useCallback( const search = useCallback(
(value: string) => { (value: string) => {
setTerm(value); setTerm(value)
refresh(value); refresh(value)
}, },
[refresh] [refresh],
); )
const edit = useCallback(() => { const edit = useCallback(() => {
navigation.navigate("EditSets", { ids }); navigation.navigate('EditSets', {ids})
setIds([]); setIds([])
}, [ids, navigation]); }, [ids, navigation])
const copy = useCallback(async () => { const copy = useCallback(async () => {
const set = await setRepo.findOne({ const set = await setRepo.findOne({
where: { id: ids.pop() }, where: {id: ids.pop()},
}); })
delete set.id; delete set.id
delete set.created; delete set.created
navigation.navigate("EditSet", { set }); navigation.navigate('EditSet', {set})
setIds([]); setIds([])
}, [ids, navigation]); }, [ids, navigation])
const clear = useCallback(() => { const clear = useCallback(() => {
setIds([]); setIds([])
}, []); }, [])
const remove = useCallback(async () => { const remove = useCallback(async () => {
setIds([]); setIds([])
await setRepo.delete(ids.length > 0 ? ids : {}); await setRepo.delete(ids.length > 0 ? ids : {})
await refresh(term); await refresh(term)
}, [ids, refresh, term]); }, [ids, refresh, term])
const select = useCallback(() => { const select = useCallback(() => {
setIds(sets.map((set) => set.id)); setIds(sets.map(set => set.id))
}, [sets]); }, [sets])
return ( return (
<> <>
<DrawerHeader name={ids.length > 0 ? `${ids.length} selected` : "Home"}> <DrawerHeader name="Home">
<ListMenu <ListMenu
onClear={clear} onClear={clear}
onCopy={copy} onCopy={copy}
@ -146,7 +151,7 @@ export default function SetList() {
settings && ( settings && (
<FlatList <FlatList
data={sets} data={sets}
style={{ flex: 1 }} style={{flex: 1}}
renderItem={renderItem} renderItem={renderItem}
onEndReached={next} onEndReached={next}
/> />
@ -154,5 +159,5 @@ export default function SetList() {
)} )}
</Page> </Page>
</> </>
); )
} }

View File

@ -1,33 +0,0 @@
import { View } from "react-native";
import { Button, Subheading } from "react-native-paper";
import { ITEM_PADDING } from "./constants";
export default function SettingButton({
name: text,
label,
onPress,
}: {
name: string;
label?: string;
onPress: () => void;
}) {
if (label) {
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingLeft: ITEM_PADDING,
}}
>
<Subheading style={{ width: 100 }}>{label}</Subheading>
<Button onPress={onPress}>{text}</Button>
</View>
);
}
return (
<Button style={{ alignSelf: "flex-start" }} onPress={onPress}>
{text}
</Button>
);
}

View File

@ -1,352 +1,230 @@
import { NavigationProp, useNavigation } from "@react-navigation/native"; import {
import { format } from "date-fns"; NavigationProp,
import { useCallback, useEffect, useMemo, useState } from "react"; useFocusEffect,
import { useForm } from "react-hook-form"; useNavigation,
import { NativeModules, ScrollView } from "react-native"; } from '@react-navigation/native'
import DocumentPicker from "react-native-document-picker"; import {format} from 'date-fns'
import { Dirs, FileSystem } from "react-native-file-access"; import {useCallback, useEffect, useMemo, useState} from 'react'
import ConfirmDialog from "./ConfirmDialog"; import {Controller, useForm} from 'react-hook-form'
import { MARGIN } from "./constants"; import {NativeModules, Platform, View} from 'react-native'
import { AppDataSource } from "./data-source"; import DocumentPicker from 'react-native-document-picker'
import { setRepo, settingsRepo } from "./db"; import {Dirs, FileSystem} from 'react-native-file-access'
import { DrawerParamList } from "./drawer-param-list"; import {Button, Subheading} from 'react-native-paper'
import DrawerHeader from "./DrawerHeader"; import ConfirmDialog from './ConfirmDialog'
import Input from "./input"; import {ITEM_PADDING, MARGIN, toSentenceCase} from './constants'
import { darkOptions, lightOptions, themeOptions } from "./options"; import {AppDataSource} from './data-source'
import Page from "./Page"; import {setRepo, settingsRepo} from './db'
import Select from "./Select"; import {DrawerParamList} from './drawer-param-list'
import SettingButton from "./SettingButton"; import DrawerHeader from './DrawerHeader'
import Settings from "./settings"; import LabelledButton from './LabelledButton'
import Switch from "./Switch"; import {darkOptions, lightOptions, themeOptions} from './options'
import { toast } from "./toast"; import Page from './Page'
import { useTheme } from "./use-theme"; import Select from './Select'
import Settings from './settings'
import Switch from './Switch'
import {toast} from './toast'
import {useTheme} from './use-theme'
const twelveHours = [ const defaultFormats = ['P', 'Pp', 'ccc p', 'p']
"dd/LL/yyyy",
"dd/LL/yyyy, p",
"ccc p",
"p",
"yyyy-MM-d",
"yyyy-MM-d, p",
"yyyy.MM.d",
];
const twentyFours = [
"dd/LL/yyyy",
"dd/LL/yyyy, k:m",
"ccc k:m",
"k:m",
"yyyy-MM-d",
"yyyy-MM-d, k:m",
"yyyy.MM.d",
];
export default function SettingsPage() { export default function SettingsPage() {
const [ignoring, setIgnoring] = useState(false); const {control, watch} = useForm<Settings>({
const [term, setTerm] = useState(""); defaultValues: async () => settingsRepo.findOne({where: {}}),
const [formatOptions, setFormatOptions] = useState<string[]>(twelveHours); })
const [importing, setImporting] = useState(false); const settings = watch()
const [deleting, setDeleting] = useState(false); const [term, setTerm] = useState('')
const { reset } = useNavigation<NavigationProp<DrawerParamList>>(); const [sound, setSound] = useState('')
const {setTheme, setLightColor, setDarkColor} = useTheme()
const { watch, setValue } = useForm<Settings>({ const [formatOptions, setFormatOptions] = useState<string[]>(defaultFormats)
defaultValues: () => settingsRepo.findOne({ where: {} }), const [importing, setImporting] = useState(false)
}); const [ignoring, setIgnoring] = useState(false)
const settings = watch(); const {reset} = useNavigation<NavigationProp<DrawerParamList>>()
const {
theme,
setTheme,
lightColor,
setLightColor,
darkColor,
setDarkColor,
} = useTheme();
useEffect(() => { useEffect(() => {
NativeModules.SettingsModule.ignoringBattery(setIgnoring); if (Object.keys(settings).length === 0) return
NativeModules.SettingsModule.is24().then((is24: boolean) => { console.log(`${SettingsPage.name}.update`, {settings})
console.log(`${SettingsPage.name}.focus:`, { is24 }); settingsRepo.update({}, settings)
if (is24) setFormatOptions(twentyFours); setLightColor(settings.lightColor)
else setFormatOptions(twelveHours); setDarkColor(settings.darkColor)
}); setTheme(settings.theme)
}, []); if (!settings.alarm || ignoring) return
NativeModules.SettingsModule.ignoreBattery()
setIgnoring(true)
}, [settings, setDarkColor, setLightColor, setTheme, ignoring])
const update = useCallback((key: keyof Settings, value: unknown) => { useFocusEffect(
return settingsRepo useCallback(() => {
.createQueryBuilder() if (Platform.OS !== 'android') return
.update() NativeModules.SettingsModule.ignoringBattery(setIgnoring)
.set({ [key]: value }) NativeModules.SettingsModule.is24().then((is24: boolean) => {
.printSql() console.log(`${SettingsPage.name}.focus:`, {is24})
.execute(); if (is24) setFormatOptions(['P', 'P, k:m', 'ccc k:m', 'k:m'])
}, []); else setFormatOptions(defaultFormats)
})
const soundString = useMemo(() => { }, []),
if (!settings.sound) return null; )
const split = settings.sound.split("/");
return split.pop();
}, [settings.sound]);
const changeSound = useCallback(async () => { const changeSound = useCallback(async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({ const {fileCopyUri} = await DocumentPicker.pickSingle({
type: DocumentPicker.types.audio, type: 'audio/*',
copyTo: "documentDirectory", copyTo: 'documentDirectory',
}); })
if (!fileCopyUri) return; if (!fileCopyUri) return
setValue("sound", fileCopyUri); settingsRepo.update({}, {sound: fileCopyUri})
await update("sound", fileCopyUri); setSound(fileCopyUri)
toast("Sound will play after rest timers."); toast('This song will now play after rest timers complete.')
}, [setValue, update]); }, [])
const switches: Input<boolean>[] = useMemo( const soundString = useMemo(() => {
() => [ if (!sound) return null
{ name: "Rest timers", value: settings.alarm, key: "alarm" }, const split = sound.split('/')
{ name: "Vibrate", value: settings.vibrate, key: "vibrate" }, return split.pop()
{ name: "Disable sound", value: settings.noSound, key: "noSound" }, }, [sound])
{ name: "Notifications", value: settings.notify, key: "notify" },
{ name: "Show images", value: settings.images, key: "images" },
{ name: "Show unit", value: settings.showUnit, key: "showUnit" },
{ name: "Show steps", value: settings.steps, key: "steps" },
{ name: "Show date", value: settings.showDate, key: "showDate" },
{ name: "Automatic backup", value: settings.backup, key: "backup" },
],
[settings]
);
const filter = useCallback(
({ name }) => name.toLowerCase().includes(term.toLowerCase()),
[term]
);
const changeBoolean = useCallback(
async (key: keyof Settings, value: boolean) => {
setValue(key, value);
await update(key, value);
switch (key) {
case "alarm":
if (value) toast("Timers will now run after each set.");
else toast("Stopped timers running after each set.");
if (value && !ignoring) NativeModules.SettingsModule.ignoreBattery();
return;
case "vibrate":
if (value) toast("Alarms will now vibrate.");
else toast("Alarms will no longer vibrate.");
return;
case "notify":
if (value) toast("Show notifications for new records.");
else toast("Stopped notifications for new records.");
return;
case "images":
if (value) toast("Show images for sets.");
else toast("Hid images for sets.");
return;
case "showUnit":
if (value) toast("Show option to select unit for sets.");
else toast("Hid unit option for sets.");
return;
case "steps":
if (value) toast("Show steps for a workout.");
else toast("Hid steps for workouts.");
return;
case "showDate":
if (value) toast("Show date for sets.");
else toast("Hid date on sets.");
return;
case "noSound":
if (value) toast("Disable sound on rest timer alarms.");
else toast("Enabled sound for rest timer alarms.");
return;
case "backup":
if (value) {
const result = await DocumentPicker.pickDirectory();
toast("Backup database daily.");
NativeModules.BackupModule.start(result.uri);
} else {
toast("Stopped backing up daily");
NativeModules.BackupModule.stop();
}
return;
}
},
[ignoring, setValue, update]
);
const renderSwitch = useCallback( const renderSwitch = useCallback(
(item: Input<boolean>) => ( (key: keyof Settings) => (
<Switch <Switch control={control} name={key}>
key={item.name} {toSentenceCase(key)}
value={item.value} </Switch>
onChange={(value) => changeBoolean(item.key, value)}
title={item.name}
/>
), ),
[changeBoolean] [control],
); )
const switchesMarkup = useMemo( const switches: (keyof Settings)[] = [
() => switches.filter(filter).map((s) => renderSwitch(s)), 'alarm',
[filter, switches, renderSwitch] 'vibrate',
); 'noSound',
'notify',
'images',
'showUnit',
'steps',
'showDate',
]
const changeString = useCallback( const selects: (keyof Settings)[] = [
async (key: keyof Settings, value: string) => { 'theme',
setValue(key, value); 'darkColor',
await update(key, value); 'lightColor',
'date',
]
const getItems = useCallback(
(key: keyof Settings) => {
const today = new Date()
switch (key) { switch (key) {
case "date": case 'theme':
return toast("Changed date format"); return themeOptions
case "darkColor": case 'darkColor':
setDarkColor(value); return lightOptions
return toast("Set primary color for dark mode."); case 'lightColor':
case "lightColor": return darkOptions
setLightColor(value); case 'date':
return toast("Set primary color for light mode."); return formatOptions.map(option => ({
case "vibrate": label: format(today, option),
return toast("Set primary color for light mode."); value: option,
case "sound": }))
return toast("Sound will play after rest timers."); default:
case "theme": return []
setTheme(value as string);
if (value === "dark") toast("Theme will always be dark.");
else if (value === "light") toast("Theme will always be light.");
else if (value === "system") toast("Theme will follow system.");
return;
} }
}, },
[update, setTheme, setDarkColor, setLightColor, setValue] [formatOptions],
); )
const selects: Input<string>[] = useMemo(() => {
const today = new Date();
return [
{ name: "Theme", value: theme, items: themeOptions, key: "theme" },
{
name: "Dark color",
value: darkColor,
items: lightOptions,
key: "darkColor",
},
{
name: "Light color",
value: lightColor,
items: darkOptions,
key: "lightColor",
},
{
name: "Date format",
value: settings.date,
items: formatOptions.map((option) => ({
label: format(today, option),
value: option,
})),
key: "date",
},
];
}, [settings, darkColor, formatOptions, theme, lightColor]);
const renderSelect = useCallback( const renderSelect = useCallback(
(input: Input<string>) => ( (key: keyof Settings) => (
<Select <Controller
key={input.name} key={key}
value={input.value} name={key}
onChange={(value) => changeString(input.key, value)} control={control}
label={input.name} render={({field: {onChange, value}}) => (
items={input.items} <Select
value={value as string}
onChange={onChange}
items={getItems(key)}
label={toSentenceCase(key)}
/>
)}
/> />
), ),
[changeString] [control, getItems],
); )
const selectsMarkup = useMemo(
() => selects.filter(filter).map(renderSelect),
[filter, selects, renderSelect]
);
const confirmDelete = useCallback(async () => {
setDeleting(false);
await AppDataSource.dropDatabase();
await AppDataSource.destroy();
await AppDataSource.initialize();
toast("Database deleted.");
}, []);
const confirmImport = useCallback(async () => { const confirmImport = useCallback(async () => {
setImporting(false); setImporting(false)
await AppDataSource.destroy(); await AppDataSource.destroy()
const file = await DocumentPicker.pickSingle(); const result = await DocumentPicker.pickSingle()
await FileSystem.cp(file.uri, Dirs.DatabaseDir + "/massive.db"); await FileSystem.cp(result.uri, Dirs.DatabaseDir + '/massive.db')
await AppDataSource.initialize(); await AppDataSource.initialize()
await setRepo.createQueryBuilder().update().set({ image: null }).execute(); await setRepo.createQueryBuilder().update().set({image: null}).execute()
await update("sound", null); await settingsRepo
const { alarm, backup } = await settingsRepo.findOne({ where: {} }); .createQueryBuilder()
console.log({ backup }); .update()
const directory = await DocumentPicker.pickDirectory(); .set({sound: null})
if (backup) NativeModules.BackupModule.start(directory.uri); .execute()
else NativeModules.BackupModule.stop(); reset({index: 0, routes: [{name: 'Settings'}]})
NativeModules.SettingsModule.ignoringBattery((isIgnoring: boolean) => { }, [reset])
if (alarm && !isIgnoring) NativeModules.SettingsModule.ignoreBattery();
reset({ index: 0, routes: [{ name: "Settings" }] });
});
}, [reset, update]);
const exportDatabase = useCallback(async () => { const exportDatabase = useCallback(async () => {
const path = Dirs.DatabaseDir + "/massive.db"; const path = Dirs.DatabaseDir + '/massive.db'
await FileSystem.cpExternal(path, "massive.db", "downloads"); await FileSystem.cpExternal(path, 'massive.db', 'downloads')
toast("Database exported. Check downloads."); toast('Database exported. Check downloads.')
}, []); }, [])
const buttons = useMemo( const buttons = [
() => [ {
{ name: 'Alarm sound',
name: soundString || "Default", element: (
onPress: changeSound, <LabelledButton label="Alarm sound" onPress={changeSound}>
label: "Alarm sound", {soundString || 'Default'}
}, </LabelledButton>
{ name: "Export database", onPress: exportDatabase }, ),
{ name: "Import database", onPress: () => setImporting(true) }, },
{ name: "Delete database", onPress: () => setDeleting(true) }, {
], name: 'Export database',
[changeSound, exportDatabase, soundString] element: (
); <Button style={{alignSelf: 'flex-start'}} onPress={exportDatabase}>
Export database
const buttonsMarkup = useMemo( </Button>
() => ),
buttons },
.filter(filter) {
.map((button) => <SettingButton {...button} key={button.name} />), name: 'Import database',
[buttons, filter] element: (
); <Button
style={{alignSelf: 'flex-start'}}
onPress={() => setImporting(true)}>
Import database
</Button>
),
},
]
return ( return (
<> <>
<DrawerHeader name="Settings" /> <DrawerHeader name="Settings" />
<Page term={term} search={setTerm} style={{ flexGrow: 1 }}> <Page term={term} search={setTerm} style={{flexGrow: 0}}>
<ScrollView style={{ marginTop: MARGIN, flex: 1 }}> <View style={{marginTop: MARGIN}}>
{switchesMarkup} {switches
{selectsMarkup} .filter(s => s.toLowerCase().includes(term.toLowerCase()))
{buttonsMarkup} .map(s => renderSwitch(s))}
</ScrollView> {selects
.filter(s => s.toLowerCase().includes(term.toLowerCase()))
.map(key => renderSelect(key))}
{buttons
.filter(b => b.name.includes(term.toLowerCase()))
.map(b => b.element)}
</View>
</Page> </Page>
<ConfirmDialog <ConfirmDialog
title="Are you sure?" title="Are you sure?"
onOk={confirmImport} onOk={confirmImport}
setShow={setImporting} setShow={setImporting}
show={importing} show={importing}>
>
Importing a database overwrites your current data. This action cannot be Importing a database overwrites your current data. This action cannot be
reversed! reversed!
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog
title="Are you sure?"
onOk={confirmDelete}
setShow={setDeleting}
show={deleting}
>
Deleting your database wipes your current data. This action cannot be
reversed!
</ConfirmDialog>
</> </>
); )
} }

View File

@ -1,20 +1,36 @@
import { useNavigation } from "@react-navigation/native"; import {useNavigation} from '@react-navigation/native'
import { Appbar, IconButton } from "react-native-paper"; import Share from 'react-native-share'
import {FileSystem} from 'react-native-file-access'
import {Appbar, IconButton} from 'react-native-paper'
import {captureScreen} from 'react-native-view-shot'
import useDark from './use-dark'
export default function StackHeader({ export default function StackHeader({title}: {title: string}) {
title, const navigation = useNavigation()
children, const dark = useDark()
}: {
title: string;
children?: JSX.Element | JSX.Element[];
}) {
const navigation = useNavigation();
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} />
{children} <IconButton
color={dark ? 'white' : 'white'}
onPress={() =>
captureScreen().then(async uri => {
const base64 = await FileSystem.readFile(uri, 'base64')
const url = `data:image/jpeg;base64,${base64}`
Share.open({
type: 'image/jpeg',
url,
})
})
}
icon="share"
/>
</Appbar.Header> </Appbar.Header>
); )
} }

View File

@ -1,50 +1,44 @@
import { import {RouteProp, useFocusEffect, useRoute} from '@react-navigation/native'
NavigationProp, import {useCallback, useMemo, useRef, useState} from 'react'
RouteProp, import {NativeModules, TextInput, View} from 'react-native'
useFocusEffect, import {FlatList} from 'react-native-gesture-handler'
useNavigation, import {Button, ProgressBar} from 'react-native-paper'
useRoute, import {getBestSet} from './best.service'
} from "@react-navigation/native"; import {PADDING} from './constants'
import { useCallback, useMemo, useRef, useState } from "react"; import CountMany from './count-many'
import { FlatList, NativeModules, TextInput, View } from "react-native"; import {AppDataSource} from './data-source'
import { Button, IconButton, ProgressBar } from "react-native-paper"; import {getNow, setRepo, settingsRepo} from './db'
import AppInput from "./AppInput"; import GymSet from './gym-set'
import { getBestSet } from "./best.service"; import MassiveInput from './MassiveInput'
import { MARGIN, PADDING } from "./constants"; import {PlanPageParams} from './plan-page-params'
import CountMany from "./count-many"; import Settings from './settings'
import { AppDataSource } from "./data-source"; import StackHeader from './StackHeader'
import { getNow, setRepo, settingsRepo } from "./db"; import StartPlanItem from './StartPlanItem'
import { fixNumeric } from "./fix-numeric"; import {toast} from './toast'
import GymSet from "./gym-set";
import { PlanPageParams } from "./plan-page-params";
import Settings from "./settings";
import StackHeader from "./StackHeader";
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 [reps, setReps] = useState(params.first?.reps.toString() || "0"); const [reps, setReps] = useState(params.first?.reps.toString() || '0')
const [weight, setWeight] = useState(params.first?.weight.toString() || "0"); const [weight, setWeight] = useState(params.first?.weight.toString() || '0')
const [unit, setUnit] = useState<string>(params.first?.unit || "kg"); const [unit, setUnit] = useState<string>(params.first?.unit || 'kg')
const [selected, setSelected] = useState(0); const [selected, setSelected] = useState(0)
const [settings, setSettings] = useState<Settings>(); const [settings, setSettings] = useState<Settings>()
const [counts, setCounts] = useState<CountMany[]>(); const [counts, setCounts] = useState<CountMany[]>()
const weightRef = useRef<TextInput>(null); const weightRef = useRef<TextInput>(null)
const repsRef = useRef<TextInput>(null); const repsRef = useRef<TextInput>(null)
const unitRef = useRef<TextInput>(null); const unitRef = useRef<TextInput>(null)
const workouts = useMemo(() => params.plan.workouts.split(","), [params]); const workouts = useMemo(() => params.plan.workouts.split(','), [params])
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
const [selection, setSelection] = useState({ const [selection, setSelection] = useState({
start: 0, start: 0,
end: 0, end: 0,
}); })
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const questions = workouts const questions = workouts
.map((workout, index) => `('${workout}',${index})`) .map((workout, index) => `('${workout}',${index})`)
.join(","); .join(',')
console.log({questions, workouts})
const select = ` const select = `
SELECT workouts.name, COUNT(sets.id) as total, sets.sets SELECT workouts.name, COUNT(sets.id) as total, sets.sets
FROM (select 0 as name, 0 as sequence union values ${questions}) as workouts FROM (select 0 as name, 0 as sequence union values ${questions}) as workouts
@ -55,45 +49,41 @@ export default function StartPlan() {
ORDER BY workouts.sequence ORDER BY workouts.sequence
LIMIT -1 LIMIT -1
OFFSET 1 OFFSET 1
`; `
const newCounts = await AppDataSource.manager.query(select); const newCounts = await AppDataSource.manager.query(select)
console.log(`${StartPlan.name}.focus:`, { newCounts }); console.log(`${StartPlan.name}.focus:`, {newCounts})
setCounts(newCounts); setCounts(newCounts)
}, [workouts]); }, [workouts])
const select = useCallback( const select = useCallback(
async (index: number, newCounts?: CountMany[]) => { async (index: number, newCounts?: CountMany[]) => {
setSelected(index); setSelected(index)
if (!counts && !newCounts) return; if (!counts && !newCounts) return
const workout = counts ? counts[index] : newCounts[index]; const workout = counts ? counts[index] : newCounts[index]
console.log(`${StartPlan.name}.next:`, { workout }); console.log(`${StartPlan.name}.next:`, {workout})
const last = await setRepo.findOne({ const newBest = await getBestSet(workout.name)
where: { name: workout.name }, if (!newBest) return
order: { created: "desc" }, delete newBest.id
}); console.log(`${StartPlan.name}.next:`, {newBest})
console.log({ last }); setReps(newBest.reps.toString())
if (!last) return; setWeight(newBest.weight.toString())
delete last.id; setUnit(newBest.unit)
console.log(`${StartPlan.name}.select:`, { last });
setReps(last.reps.toString());
setWeight(last.weight.toString());
setUnit(last.unit);
}, },
[counts] [counts],
); )
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings); settingsRepo.findOne({where: {}}).then(setSettings)
refresh(); refresh()
}, [refresh]) }, [refresh]),
); )
const handleSubmit = async () => { const handleSubmit = async () => {
const now = await getNow(); const [{now}] = await getNow()
const workout = counts[selected]; const workout = counts[selected]
const best = await getBestSet(workout.name); const best = await getBestSet(workout.name)
delete best.id; delete best.id
const newSet: GymSet = { const newSet: GymSet = {
...best, ...best,
weight: +weight, weight: +weight,
@ -101,96 +91,48 @@ export default function StartPlan() {
unit, unit,
created: now, created: now,
hidden: false, hidden: false,
}; }
await setRepo.save(newSet); await setRepo.save(newSet)
await refresh(); 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."); toast("Great work King! That's a new record.")
} if (!settings.alarm) return
if (!settings.alarm) return;
const milliseconds = const milliseconds =
Number(best.minutes) * 60 * 1000 + Number(best.seconds) * 1000; Number(best.minutes) * 60 * 1000 + Number(best.seconds) * 1000
NativeModules.AlarmModule.timer(milliseconds); const {vibrate, sound, noSound} = settings
}; const args = [milliseconds, vibrate, sound, noSound]
NativeModules.AlarmModule.timer(...args)
}
return ( return (
<> <>
<StackHeader title={params.plan.days.replace(/,/g, ", ")}> <StackHeader title={params.plan.days.replace(/,/g, ', ')} />
<IconButton <View style={{padding: PADDING, flex: 1, flexDirection: 'column'}}>
onPress={() => navigation.navigate("EditPlan", { plan: params.plan })} <View style={{flex: 1}}>
icon="edit" <MassiveInput
/> label="Reps"
</StackHeader> keyboardType="numeric"
<View style={{ padding: PADDING, flex: 1, flexDirection: "column" }}> value={reps}
<View style={{ flex: 1 }}> onChangeText={setReps}
<View onSubmitEditing={() => weightRef.current?.focus()}
style={{ selection={selection}
flexDirection: "row", onSelectionChange={e => setSelection(e.nativeEvent.selection)}
marginBottom: MARGIN, innerRef={repsRef}
}} />
> <MassiveInput
<AppInput label="Weight"
label="Reps" keyboardType="numeric"
style={{ flex: 1 }} value={weight}
keyboardType="numeric" onChangeText={setWeight}
value={reps} onSubmitEditing={handleSubmit}
onChangeText={(newReps) => { innerRef={weightRef}
const fixed = fixNumeric(newReps); blurOnSubmit
setReps(fixed); />
if (fixed.length !== newReps.length)
toast("Reps must be a number");
}}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
innerRef={repsRef}
/>
<IconButton
icon="add"
onPress={() => setReps((Number(reps) + 1).toString())}
/>
<IconButton
icon="remove"
onPress={() => setReps((Number(reps) - 1).toString())}
/>
</View>
<View
style={{
flexDirection: "row",
marginBottom: MARGIN,
}}
>
<AppInput
label="Weight"
style={{ flex: 1 }}
keyboardType="numeric"
value={weight}
onChangeText={(newWeight) => {
const fixed = fixNumeric(newWeight);
setWeight(fixed);
if (fixed.length !== newWeight.length)
toast("Weight must be a number");
}}
onSubmitEditing={handleSubmit}
innerRef={weightRef}
blurOnSubmit
/>
<IconButton
icon="add"
onPress={() => setWeight((Number(weight) + 2.5).toString())}
/>
<IconButton
icon="remove"
onPress={() => setWeight((Number(weight) - 2.5).toString())}
/>
</View>
{settings?.showUnit && ( {settings?.showUnit && (
<AppInput <MassiveInput
autoCapitalize="none" autoCapitalize="none"
label="Unit" label="Unit"
value={unit} value={unit}
@ -201,7 +143,7 @@ export default function StartPlan() {
{counts && ( {counts && (
<FlatList <FlatList
data={counts} data={counts}
renderItem={(props) => ( renderItem={props => (
<View> <View>
<StartPlanItem <StartPlanItem
{...props} {...props}
@ -217,10 +159,10 @@ export default function StartPlan() {
/> />
)} )}
</View> </View>
<Button mode="outlined" icon="save" onPress={handleSubmit}> <Button mode="contained" icon="save" onPress={handleSubmit}>
Save Save
</Button> </Button>
</View> </View>
</> </>
); )
} }

View File

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

View File

@ -1,42 +1,41 @@
import React from "react"; import {Control, Controller} from 'react-hook-form'
import { Platform, Pressable } from "react-native"; import {Platform, Pressable} from 'react-native'
import { Switch as PaperSwitch, Text, useTheme } from "react-native-paper"; import {Switch as PaperSwitch, Text, useTheme} from 'react-native-paper'
import { MARGIN } from "./constants"; import {MARGIN} from './constants'
function Switch({ export default function Switch({
value, control,
onChange, name,
title, children,
}: { }: {
value?: boolean; name: string
onChange: (value: boolean) => void; control: Control<any, any>
title: string; children: string
}) { }) {
const { colors } = useTheme(); const {colors} = useTheme()
return ( return (
<Pressable <Controller
onPress={() => onChange(!value)} name={name}
style={{ control={control}
flexDirection: "row", render={({field: {onChange, value}}) => (
flexWrap: "wrap", <Pressable
alignItems: "center", onPress={() => onChange(!value)}
marginBottom: Platform.OS === "ios" ? MARGIN : null, style={{
}} flexDirection: 'row',
> flexWrap: 'wrap',
<PaperSwitch alignItems: 'center',
color={colors.primary} marginBottom: Platform.OS === 'ios' ? MARGIN : null,
style={{ marginRight: MARGIN }} }}>
value={value} <PaperSwitch
onValueChange={onChange} color={colors.primary}
trackColor={{ style={{marginRight: MARGIN}}
true: colors.primary + "80", value={value}
false: colors.surfaceDisabled, onValueChange={onChange}
}} />
/> <Text>{children}</Text>
<Text>{title}</Text> </Pressable>
</Pressable> )}
); />
)
} }
export default React.memo(Switch);

View File

@ -1,75 +1,79 @@
import { useFocusEffect } from "@react-navigation/native"; import {useFocusEffect} from '@react-navigation/native'
import React, { useCallback, useMemo, useState } from "react"; import React, {useCallback, useMemo, useState} from 'react'
import { Dimensions, NativeModules, View } from "react-native"; import {Dimensions, NativeModules, View} from 'react-native'
import { Button, Text, useTheme } from "react-native-paper"; import {Button, Text, useTheme} from 'react-native-paper'
import { ProgressCircle } from "react-native-svg-charts"; import {ProgressCircle} from 'react-native-svg-charts'
import AppFab from "./AppFab"; import {MARGIN, PADDING} from './constants'
import { MARGIN, PADDING } from "./constants"; import {settingsRepo} from './db'
import { settingsRepo } from "./db"; import DrawerHeader from './DrawerHeader'
import DrawerHeader from "./DrawerHeader"; import MassiveFab from './MassiveFab'
import Settings from "./settings"; import Settings from './settings'
import useTimer from "./use-timer"; import useTimer from './use-timer'
export interface TickEvent { export interface TickEvent {
minutes: string; minutes: string
seconds: string; seconds: string
} }
export default function TimerPage() { export default function TimerPage() {
const { minutes, seconds } = useTimer(); const {minutes, seconds} = useTimer()
const [settings, setSettings] = useState<Settings>(); const [settings, setSettings] = useState<Settings>()
const { colors } = useTheme(); const {colors} = useTheme()
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings); settingsRepo.findOne({where: {}}).then(setSettings)
}, []) }, []),
); )
const stop = () => { const stop = () => {
NativeModules.AlarmModule.stop(); NativeModules.AlarmModule.stop()
}; }
const add = async () => { const add = async () => {
console.log(`${TimerPage.name}.add:`, settings); console.log(`${TimerPage.name}.add:`, settings)
NativeModules.AlarmModule.add(); const params = [settings.vibrate, settings.sound, settings.noSound]
}; NativeModules.AlarmModule.add(...params)
}
const progress = useMemo(() => { const progress = useMemo(() => {
return (Number(minutes) * 60 + Number(seconds)) / 210; return (Number(minutes) * 60 + Number(seconds)) / 210
}, [minutes, seconds]); }, [minutes, seconds])
const left = useMemo(() => { const left = useMemo(() => {
return Dimensions.get("screen").width * 0.5 - 60; return Dimensions.get('screen').width * 0.5 - 85
}, []); }, [])
return ( return (
<> <>
<DrawerHeader name="Timer" /> <DrawerHeader name="Timer" />
<View style={{ flexGrow: 1, padding: PADDING }}> <View style={{flexGrow: 1, padding: PADDING}}>
<View <View
style={{ style={{
flex: 1, flex: 1,
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
}} }}>
> <View>
<Text style={{ fontSize: 70, position: "absolute" }}> <Text style={{fontSize: 70, top: 150}}>
{minutes}:{seconds} {minutes}:{seconds}
</Text> </Text>
</View>
<ProgressCircle <ProgressCircle
style={{ height: 300, width: 300, marginBottom: MARGIN }} style={{height: 300, width: 500, marginBottom: MARGIN, top: -50}}
progress={progress} progress={progress}
strokeWidth={10} strokeWidth={10}
progressColor={colors.primary} progressColor={colors.text}
backgroundColor={colors.primary + "80"} backgroundColor={colors.placeholder}
/> />
</View> </View>
</View> </View>
<Button onPress={add} style={{ position: "absolute", top: "82%", left }}> <Button
onPress={add}
style={{position: 'absolute', top: '85%', left: left + 25}}>
Add 1 min Add 1 min
</Button> </Button>
<AppFab icon="stop" onPress={stop} /> <MassiveFab icon="stop" onPress={stop} />
</> </>
); )
} }

131
ViewBest.tsx Normal file
View File

@ -0,0 +1,131 @@
import {RouteProp, useRoute} from '@react-navigation/native'
import {format} from 'date-fns'
import {useEffect, useMemo, useState} from 'react'
import {View} from 'react-native'
import {List} from 'react-native-paper'
import {BestPageParams} from './BestPage'
import Chart from './Chart'
import {PADDING} from './constants'
import {setRepo} from './db'
import GymSet from './gym-set'
import {Metrics} from './metrics'
import {Periods} from './periods'
import Select from './Select'
import StackHeader from './StackHeader'
import Volume from './volume'
export default function ViewBest() {
const {params} = useRoute<RouteProp<BestPageParams, 'ViewBest'>>()
const [weights, setWeights] = useState<GymSet[]>([])
const [volumes, setVolumes] = useState<Volume[]>([])
const [metric, setMetric] = useState(Metrics.Weight)
const [period, setPeriod] = useState(Periods.Monthly)
useEffect(() => {
console.log(`${ViewBest.name}.useEffect`, {metric})
console.log(`${ViewBest.name}.useEffect`, {period})
let difference = '-7 days'
if (period === Periods.Monthly) difference = '-1 months'
else if (period === Periods.Yearly) difference = '-1 years'
let group = '%Y-%m-%d'
if (period === Periods.Yearly) group = '%Y-%m'
const builder = setRepo
.createQueryBuilder()
.select("STRFTIME('%Y-%m-%d', created)", 'created')
.addSelect('unit')
.where('name = :name', {name: params.best.name})
.andWhere('NOT hidden')
.andWhere("DATE(created) >= DATE('now', 'weekday 0', :difference)", {
difference,
})
.groupBy('name')
.addGroupBy(`STRFTIME('${group}', created)`)
switch (metric) {
case Metrics.Weight:
builder.addSelect('MAX(weight)', 'weight').getRawMany().then(setWeights)
break
case Metrics.Volume:
builder
.addSelect('SUM(weight * reps)', 'value')
.getRawMany()
.then(setVolumes)
break
default:
// 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])
const charts = useMemo(() => {
if (
(metric === Metrics.Volume && volumes.length === 0) ||
(metric === Metrics.Weight && weights.length === 0) ||
(metric === Metrics.OneRepMax && weights.length === 0)
)
return <List.Item title="No data yet." />
if (metric === Metrics.Volume)
return (
<Chart
yData={volumes.map(v => v.value)}
yFormat={(value: number) =>
`${value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${
volumes[0].unit || 'kg'
}`
}
xData={weights}
xFormat={(_value, index) =>
format(new Date(weights[index].created), 'd/M')
}
/>
)
return (
<Chart
yData={weights.map(set => set.weight)}
yFormat={value => `${value}${weights[0].unit}`}
xData={weights}
xFormat={(_value, index) =>
format(new Date(weights[index].created), 'd/M')
}
/>
)
}, [volumes, weights, metric])
return (
<>
<StackHeader title={params.best.name} />
<View style={{padding: PADDING}}>
<Select
label="Metric"
items={[
{value: Metrics.Volume, label: Metrics.Volume},
{value: Metrics.OneRepMax, label: Metrics.OneRepMax},
{
label: Metrics.Weight,
value: Metrics.Weight,
},
]}
onChange={value => setMetric(value as Metrics)}
value={metric}
/>
<Select
label="Period"
items={[
{value: Periods.Weekly, label: Periods.Weekly},
{value: Periods.Monthly, label: Periods.Monthly},
{value: Periods.Yearly, label: Periods.Yearly},
]}
onChange={value => setPeriod(value as Periods)}
value={period}
/>
{charts}
</View>
</>
)
}

View File

@ -1,154 +0,0 @@
import { RouteProp, useRoute } from "@react-navigation/native";
import { format } from "date-fns";
import { useEffect, useMemo, useState } from "react";
import { View } from "react-native";
import { FileSystem } from "react-native-file-access";
import { IconButton, List } from "react-native-paper";
import Share from "react-native-share";
import { captureScreen } from "react-native-view-shot";
import Chart from "./Chart";
import { GraphsPageParams } from "./GraphsPage";
import Select from "./Select";
import StackHeader from "./StackHeader";
import { PADDING } from "./constants";
import { setRepo } from "./db";
import GymSet from "./gym-set";
import { Metrics } from "./metrics";
import { Periods } from "./periods";
import Volume from "./volume";
export default function ViewGraph() {
const { params } = useRoute<RouteProp<GraphsPageParams, "ViewGraph">>();
const [weights, setWeights] = useState<GymSet[]>();
const [volumes, setVolumes] = useState<Volume[]>();
const [metric, setMetric] = useState(Metrics.Weight);
const [period, setPeriod] = useState(Periods.Monthly);
useEffect(() => {
let difference = "-7 days";
if (period === Periods.Monthly) difference = "-1 months";
else if (period === Periods.Yearly) difference = "-1 years";
let group = "%Y-%m-%d";
if (period === Periods.Yearly) group = "%Y-%m";
const builder = setRepo
.createQueryBuilder()
.select("STRFTIME('%Y-%m-%d', created)", "created")
.addSelect("unit")
.where("name = :name", { name: params.best.name })
.andWhere("NOT hidden")
.andWhere("DATE(created) >= DATE('now', 'weekday 0', :difference)", {
difference,
})
.groupBy("name")
.addGroupBy(`STRFTIME('${group}', created)`);
switch (metric) {
case Metrics.Weight:
builder
.addSelect("ROUND(MAX(weight), 2)", "weight")
.getRawMany()
.then(setWeights);
break;
case Metrics.Volume:
builder
.addSelect("ROUND(SUM(weight * reps), 2)", "value")
.getRawMany()
.then(setVolumes);
break;
default:
// Brzycki formula https://en.wikipedia.org/wiki/One-repetition_maximum#Brzycki
builder
.addSelect(
"ROUND(MAX(weight / (1.0278 - 0.0278 * reps)), 2)",
"weight"
)
.getRawMany()
.then((newWeights) => {
console.log({ weights: newWeights });
setWeights(newWeights);
});
}
}, [params.best.name, metric, period]);
const charts = useMemo(() => {
if (
(metric === Metrics.Volume && volumes?.length === 0) ||
(metric === Metrics.Weight && weights?.length === 0) ||
(metric === Metrics.OneRepMax && weights?.length === 0)
) {
return <List.Item title="No data yet." />;
}
if (metric === Metrics.Volume && volumes?.length && weights?.length) {
return (
<Chart
yData={volumes.map((v) => v.value)}
yFormat={(value: number) =>
`${value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}${
volumes[0].unit || "kg"
}`
}
xData={weights}
xFormat={(_value, index) =>
format(new Date(weights[index].created), "d/M")
}
/>
);
}
return (
<Chart
yData={weights?.map((set) => set.weight) || []}
yFormat={(value) => `${value}${weights?.[0].unit}`}
xData={weights || []}
xFormat={(_value, index) =>
format(new Date(weights?.[index].created), "d/M")
}
/>
);
}, [volumes, weights, metric]);
return (
<>
<StackHeader title={params.best.name}>
<IconButton
onPress={() =>
captureScreen().then(async (uri) => {
const base64 = await FileSystem.readFile(uri, "base64");
const url = `data:image/jpeg;base64,${base64}`;
Share.open({
type: "image/jpeg",
url,
});
})
}
icon="share"
/>
</StackHeader>
<View style={{ padding: PADDING }}>
<Select
label="Metric"
items={[
{ value: Metrics.Volume, label: Metrics.Volume },
{ value: Metrics.OneRepMax, label: Metrics.OneRepMax },
{
label: Metrics.Weight,
value: Metrics.Weight,
},
]}
onChange={(value) => setMetric(value as Metrics)}
value={metric}
/>
<Select
label="Period"
items={[
{ value: Periods.Weekly, label: Periods.Weekly },
{ value: Periods.Monthly, label: Periods.Monthly },
{ value: Periods.Yearly, label: Periods.Yearly },
]}
onChange={(value) => setPeriod(value as Periods)}
value={period}
/>
{charts}
</View>
</>
);
}

View File

@ -1,96 +1,87 @@
import { NavigationProp, useNavigation } from "@react-navigation/native"; import {NavigationProp, useNavigation} from '@react-navigation/native'
import { 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 { setRepo } from "./db"; import {setRepo} from './db'
import GymSet from "./gym-set"; import GymSet from './gym-set'
import { WorkoutsPageParams } from "./WorkoutsPage"; import {WorkoutsPageParams} from './WorkoutsPage'
export default function WorkoutItem({ export default function WorkoutItem({
item, item,
onRemove, onRemove,
images, images,
}: { }: {
item: GymSet; item: GymSet
onRemove: () => void; onRemove: () => void
images: boolean; 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 navigation = useNavigation<NavigationProp<WorkoutsPageParams>>(); const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>()
const remove = useCallback(async () => { const remove = useCallback(async () => {
await setRepo.delete({ name: item.name }); await setRepo.delete({name: item.name})
setShowMenu(false); setShowMenu(false)
onRemove(); onRemove()
}, [setShowMenu, onRemove, 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')
return `${item.sets} x ${item.minutes || 0}:${seconds}`; return `${item.sets} x ${item.minutes || 0}:${seconds}`
}, [item]); }, [item])
const left = useCallback(() => {
if (!images || !item.image) return null;
return (
<Image source={{ uri: item.image }} style={{ height: 75, width: 75 }} />
);
}, [item.image, images]);
const right = useCallback(() => {
return (
<Text
style={{
alignSelf: "center",
}}
>
<Menu
anchor={anchor}
visible={showMenu}
onDismiss={() => setShowMenu(false)}
>
<Menu.Item
leadingIcon="delete"
onPress={() => {
setShowRemove(item.name);
setShowMenu(false);
}}
title="Delete"
/>
</Menu>
</Text>
);
}, [anchor, showMenu, item.name]);
return ( return (
<> <>
<List.Item <List.Item
onPress={() => navigation.navigate("EditWorkout", { value: item })} onPress={() => navigation.navigate('EditWorkout', {value: item})}
title={item.name} title={item.name}
description={description} description={description}
onLongPress={longPress} onLongPress={longPress}
left={left} left={() =>
right={right} images &&
item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)
}
right={() => (
<Text
style={{
alignSelf: 'center',
}}>
<Menu
anchor={anchor}
visible={showMenu}
onDismiss={() => setShowMenu(false)}>
<Menu.Item
icon="delete"
onPress={() => {
setShowRemove(item.name)
setShowMenu(false)
}}
title="Delete"
/>
</Menu>
</Text>
)}
/> />
<ConfirmDialog <ConfirmDialog
title={`Delete ${showRemove}`} title={`Delete ${showRemove}`}
show={!!showRemove} show={!!showRemove}
setShow={(show) => (show ? null : setShowRemove(""))} setShow={show => (show ? null : setShowRemove(''))}
onOk={remove} onOk={remove}>
>
This irreversibly deletes ALL sets related to this workout. Are you This irreversibly deletes ALL sets related to this workout. Are you
sure? sure?
</ConfirmDialog> </ConfirmDialog>
</> </>
); )
} }

View File

@ -2,52 +2,53 @@ import {
NavigationProp, NavigationProp,
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
} from "@react-navigation/native"; } from '@react-navigation/native'
import { useCallback, 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 { LIMIT } from "./constants"; import DrawerHeader from './DrawerHeader'
import { setRepo, settingsRepo } from "./db"; import Page from './Page'
import DrawerHeader from "./DrawerHeader"; import GymSet from './gym-set'
import GymSet from "./gym-set"; import SetList from './SetList'
import Page from "./Page"; import WorkoutItem from './WorkoutItem'
import SetList from "./SetList"; import {WorkoutsPageParams} from './WorkoutsPage'
import Settings from "./settings"; import {setRepo, settingsRepo} from './db'
import WorkoutItem from "./WorkoutItem"; import Settings from './settings'
import { WorkoutsPageParams } from "./WorkoutsPage";
const limit = 15
export default function WorkoutList() { export default function WorkoutList() {
const [workouts, setWorkouts] = useState<GymSet[]>(); const [workouts, setWorkouts] = useState<GymSet[]>()
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0)
const [term, setTerm] = useState(""); const [term, setTerm] = useState('')
const [end, setEnd] = useState(false); const [end, setEnd] = useState(false)
const [settings, setSettings] = useState<Settings>(); const [settings, setSettings] = useState<Settings>()
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>(); const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>()
const refresh = useCallback(async (value: string) => { const refresh = useCallback(async (value: string) => {
const newWorkouts = await setRepo const newWorkouts = await setRepo
.createQueryBuilder() .createQueryBuilder()
.select() .select()
.where("name LIKE :name", { name: `%${value.trim()}%` }) .where('name LIKE :name', {name: `%${value}%`})
.groupBy("name") .groupBy('name')
.orderBy("name") .orderBy('name')
.limit(LIMIT) .limit(limit)
.getMany(); .getMany()
console.log(`${WorkoutList.name}`, { newWorkout: newWorkouts[0] }); console.log(`${WorkoutList.name}`, {newWorkout: newWorkouts[0]})
setWorkouts(newWorkouts); setWorkouts(newWorkouts)
setOffset(0); setOffset(0)
setEnd(false); setEnd(false)
}, []); }, [])
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
refresh(term); refresh(term)
settingsRepo.findOne({ where: {} }).then(setSettings); settingsRepo.findOne({where: {}}).then(setSettings)
}, [refresh, term]) }, [refresh, term]),
); )
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: GymSet }) => ( ({item}: {item: GymSet}) => (
<WorkoutItem <WorkoutItem
images={settings?.images} images={settings?.images}
item={item} item={item}
@ -55,47 +56,47 @@ export default function WorkoutList() {
onRemove={() => refresh(term)} onRemove={() => refresh(term)}
/> />
), ),
[refresh, term, settings?.images] [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, limit,
newOffset, newOffset,
term, term,
}); })
const newWorkouts = await setRepo const newWorkouts = await setRepo
.createQueryBuilder() .createQueryBuilder()
.select() .select()
.where("name LIKE :name", { name: `%${term.trim()}%` }) .where('name LIKE :name', {name: `%${term}%`})
.groupBy("name") .groupBy('name')
.orderBy("name") .orderBy('name')
.limit(LIMIT) .limit(limit)
.offset(newOffset) .offset(newOffset)
.getMany(); .getMany()
if (newWorkouts.length === 0) return setEnd(true); if (newWorkouts.length === 0) return setEnd(true)
if (!workouts) return; if (!workouts) return
setWorkouts([...workouts, ...newWorkouts]); setWorkouts([...workouts, ...newWorkouts])
if (newWorkouts.length < LIMIT) return setEnd(true); if (newWorkouts.length < limit) return setEnd(true)
setOffset(newOffset); setOffset(newOffset)
}, [term, end, offset, workouts]); }, [term, end, offset, workouts])
const onAdd = useCallback(async () => { const onAdd = useCallback(async () => {
navigation.navigate("EditWorkout", { navigation.navigate('EditWorkout', {
value: new GymSet(), value: new GymSet(),
}); })
}, [navigation]); }, [navigation])
const search = useCallback( const search = useCallback(
(value: string) => { (value: string) => {
setTerm(value); setTerm(value)
refresh(value); refresh(value)
}, },
[refresh] [refresh],
); )
return ( return (
<> <>
@ -109,13 +110,13 @@ export default function WorkoutList() {
) : ( ) : (
<FlatList <FlatList
data={workouts} data={workouts}
style={{ flex: 1 }} style={{flex: 1}}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={(w) => w.name} keyExtractor={w => w.name}
onEndReached={next} onEndReached={next}
/> />
)} )}
</Page> </Page>
</> </>
); )
} }

View File

@ -1,24 +1,23 @@
import { createStackNavigator } from "@react-navigation/stack"; import {createStackNavigator} from '@react-navigation/stack'
import EditWorkout from "./EditWorkout"; import EditWorkout from './EditWorkout'
import GymSet from "./gym-set"; import GymSet from './gym-set'
import WorkoutList from "./WorkoutList"; import WorkoutList from './WorkoutList'
export type WorkoutsPageParams = { export type WorkoutsPageParams = {
WorkoutList: {}; WorkoutList: {}
EditWorkout: { EditWorkout: {
value: GymSet; value: GymSet
}; }
}; }
const Stack = createStackNavigator<WorkoutsPageParams>(); const Stack = createStackNavigator<WorkoutsPageParams>()
export default function WorkoutsPage() { export default function WorkoutsPage() {
return ( return (
<Stack.Navigator <Stack.Navigator
screenOptions={{ headerShown: false, animationEnabled: false }} screenOptions={{headerShown: false, animationEnabled: false}}>
>
<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

@ -1,24 +1,24 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
CFPropertyList (3.0.6) CFPropertyList (3.0.5)
rexml rexml
addressable (2.8.4) addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15) artifactory (3.0.15)
atomos (0.1.3) atomos (0.1.3)
aws-eventstream (1.2.0) aws-eventstream (1.2.0)
aws-partitions (1.780.0) aws-partitions (1.657.0)
aws-sdk-core (3.175.0) aws-sdk-core (3.166.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.67.0) aws-sdk-kms (1.59.0)
aws-sdk-core (~> 3, >= 3.174.0) aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.126.0) aws-sdk-s3 (1.117.1)
aws-sdk-core (~> 3, >= 3.174.0) aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4) aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.2) aws-sigv4 (1.5.2)
@ -36,8 +36,8 @@ GEM
unf (>= 0.0.5, < 1.0.0) unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1) dotenv (2.8.1)
emoji_regex (3.2.3) emoji_regex (3.2.3)
excon (0.100.0) excon (0.93.1)
faraday (1.10.3) faraday (1.10.2)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0) faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1) faraday-excon (~> 1.1)
@ -65,8 +65,8 @@ GEM
faraday-retry (1.0.3) faraday-retry (1.0.3)
faraday_middleware (1.2.0) faraday_middleware (1.2.0)
faraday (~> 1.0) faraday (~> 1.0)
fastimage (2.2.7) fastimage (2.2.6)
fastlane (2.213.0) fastlane (2.210.1)
CFPropertyList (>= 2.3, < 4.0.0) CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0) addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0) artifactory (~> 3.0)
@ -90,7 +90,7 @@ GEM
json (< 3.0.0) json (< 3.0.0)
jwt (>= 2.1.0, < 3) jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0) mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0) multipart-post (~> 2.0.0)
naturally (~> 2.2) naturally (~> 2.2)
optparse (~> 0.1.1) optparse (~> 0.1.1)
plist (>= 3.1.0, < 4.0.0) plist (>= 3.1.0, < 4.0.0)
@ -106,9 +106,9 @@ GEM
xcpretty (~> 0.3.0) xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3) xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3) gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.43.0) google-apis-androidpublisher_v3 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a) google-apis-core (>= 0.9.1, < 2.a)
google-apis-core (0.11.0) google-apis-core (0.9.1)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a) httpclient (>= 2.8.1, < 3.a)
@ -117,10 +117,10 @@ GEM
retriable (>= 2.0, < 4.a) retriable (>= 2.0, < 4.a)
rexml rexml
webrick webrick
google-apis-iamcredentials_v1 (0.17.0) google-apis-iamcredentials_v1 (0.16.0)
google-apis-core (>= 0.11.0, < 2.a) google-apis-core (>= 0.9.1, < 2.a)
google-apis-playcustomapp_v1 (0.13.0) google-apis-playcustomapp_v1 (0.12.0)
google-apis-core (>= 0.11.0, < 2.a) google-apis-core (>= 0.9.1, < 2.a)
google-apis-storage_v1 (0.19.0) google-apis-storage_v1 (0.19.0)
google-apis-core (>= 0.9.0, < 2.a) google-apis-core (>= 0.9.0, < 2.a)
google-cloud-core (1.6.0) google-cloud-core (1.6.0)
@ -128,7 +128,7 @@ GEM
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0) google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0) faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1) google-cloud-errors (1.3.0)
google-cloud-storage (1.44.0) google-cloud-storage (1.44.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
@ -137,7 +137,7 @@ GEM
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
googleauth (1.5.2) googleauth (1.3.0)
faraday (>= 0.17.3, < 3.a) faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
memoist (~> 0.16) memoist (~> 0.16)
@ -148,20 +148,20 @@ GEM
http-cookie (1.0.5) http-cookie (1.0.5)
domain_name (~> 0.5) domain_name (~> 0.5)
httpclient (2.8.3) httpclient (2.8.3)
jmespath (1.6.2) jmespath (1.6.1)
json (2.6.3) json (2.6.2)
jwt (2.7.1) jwt (2.5.0)
memoist (0.16.2) memoist (0.16.2)
mini_magick (4.12.0) mini_magick (4.11.0)
mini_mime (1.1.2) mini_mime (1.1.2)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.3.0) multipart-post (2.0.0)
nanaimo (0.3.0) nanaimo (0.3.0)
naturally (2.2.1) naturally (2.2.1)
optparse (0.1.1) optparse (0.1.1)
os (1.1.4) os (1.1.4)
plist (3.7.0) plist (3.6.0)
public_suffix (5.0.1) public_suffix (5.0.0)
rake (13.0.6) rake (13.0.6)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
@ -178,7 +178,7 @@ GEM
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0) jwt (>= 1.5, < 3.0)
multi_json (~> 1.10) multi_json (~> 1.10)
simctl (1.6.10) simctl (1.6.8)
CFPropertyList CFPropertyList
naturally naturally
terminal-notifier (2.0.0) terminal-notifier (2.0.0)
@ -194,7 +194,7 @@ GEM
unf_ext unf_ext
unf_ext (0.0.8.2) unf_ext (0.0.8.2)
unicode-display_width (1.8.0) unicode-display_width (1.8.0)
webrick (1.8.1) webrick (1.7.0)
word_wrap (1.0.0) word_wrap (1.0.0)
xcodeproj (1.22.0) xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0) CFPropertyList (>= 2.3.3, < 4.0)

View File

@ -1,92 +1,115 @@
apply plugin: "com.android.application" apply plugin: "com.android.application"
apply plugin: "com.facebook.react"
apply plugin: "kotlin-android" apply plugin: "kotlin-android"
/** project.ext.react = [
* This is the configuration block to customize your React Native Android app. enableHermes: true, // clean and rebuild if changing
* By default you don't need to apply any configuration, just uncomment the lines you need. ]
*/
react {
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '..'
// root = file("../")
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
// reactNativeDir = file("../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen
// codegenDir = file("../node_modules/@react-native/codegen")
// The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js
// cliFile = file("../node_modules/react-native/cli.js")
/* Variants */ project.ext.vectoricons = [
// The list of variants to that are debuggable. For those we're going to iconFontNames: ['MaterialIcons.ttf']
// skip the bundling of the JS bundle and the assets. By default is just 'debug'. ]
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */ apply from: "../../node_modules/react-native/react.gradle"
// A list containing the node command and its flags. Default is just 'node'. apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
// nodeExecutableAndArgs = ["node"]
//
// The command to run when bundling. By default is 'bundle'
// bundleCommand = "ram-bundle"
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */ def enableSeparateBuildPerCPUArchitecture = true
// The hermes compiler command to run. By default it is 'hermesc' def enableProguardInReleaseBuilds = true
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" def jscFlavor = 'org.webkit:android-jsc:+'
// def enableHermes = project.ext.react.get("enableHermes", true);
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"] def reactNativeArchitectures() {
def value = project.getProperties().get("reactNativeArchitectures")
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
} }
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/
def enableProguardInReleaseBuilds = true
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'org.webkit:android-jsc:+'
android { android {
ndkVersion rootProject.ext.ndkVersion java {
sourceCompatibility = JavaVersion.VERSION_11
compileSdkVersion rootProject.ext.compileSdkVersion targetCompatibility = JavaVersion.VERSION_11
namespace "com.massive"
lintOptions {
checkReleaseBuilds false
abortOnError false
} }
packagingOptions {
jniLibs {
pickFirsts += ['**/armeabi-v7a/libfolly_runtime.so', '**/x86/libfolly_runtime.so', '**/arm64-v8a/libfolly_runtime.so', '**/x86_64/libfolly_runtime.so']
}
}
ndkVersion rootProject.ext.ndkVersion
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig { defaultConfig {
applicationId "com.massive" applicationId "com.massive"
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 36174 versionCode 36124
versionName "1.148" versionName "1.98"
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
if (isNewArchitectureEnabled()) {
externalNativeBuild {
ndkBuild {
arguments "APP_PLATFORM=android-21",
"APP_STL=c++_shared",
"NDK_TOOLCHAIN_VERSION=clang",
"GENERATED_SRC_DIR=$buildDir/generated/source",
"PROJECT_BUILD_DIR=$buildDir",
"REACT_ANDROID_DIR=$rootDir/../node_modules/react-native/ReactAndroid",
"REACT_ANDROID_BUILD_DIR=$rootDir/../node_modules/react-native/ReactAndroid/build",
"NODE_MODULES_DIR=$rootDir/../node_modules"
cFlags "-Wall", "-Werror", "-fexceptions", "-frtti", "-DWITH_INSPECTOR=1"
cppFlags "-std=c++17"
targets "massive_appmodules"
}
}
if (!enableSeparateBuildPerCPUArchitecture) {
ndk {
abiFilters(*reactNativeArchitectures())
}
}
}
}
if (isNewArchitectureEnabled()) {
externalNativeBuild {
ndkBuild {
path "$projectDir/src/main/jni/Android.mk"
}
}
def reactAndroidProjectDir = project(':ReactAndroid').projectDir
def packageReactNdkDebugLibs = tasks.register("packageReactNdkDebugLibs", Copy) {
dependsOn(":ReactAndroid:packageReactNdkDebugLibsForBuck")
from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib")
into("$buildDir/react-ndk/exported")
}
def packageReactNdkReleaseLibs = tasks.register("packageReactNdkReleaseLibs", Copy) {
dependsOn(":ReactAndroid:packageReactNdkReleaseLibsForBuck")
from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib")
into("$buildDir/react-ndk/exported")
}
afterEvaluate {
preDebugBuild.dependsOn(packageReactNdkDebugLibs)
preReleaseBuild.dependsOn(packageReactNdkReleaseLibs)
configureNdkBuildRelease.dependsOn(preReleaseBuild)
configureNdkBuildDebug.dependsOn(preDebugBuild)
reactNativeArchitectures().each { architecture ->
tasks.findByName("configureNdkBuildDebug[${architecture}]")?.configure {
dependsOn("preDebugBuild")
}
tasks.findByName("configureNdkBuildRelease[${architecture}]")?.configure {
dependsOn("preReleaseBuild")
}
}
}
}
splits {
abi {
reset()
enable enableSeparateBuildPerCPUArchitecture
universalApk false
include(*reactNativeArchitectures())
}
} }
signingConfigs { signingConfigs {
release { release {
@ -106,14 +129,15 @@ android {
} }
} }
} }
buildFeatures {
viewBinding true
}
buildTypes { buildTypes {
debug { debug {
signingConfig signingConfigs.debug signingConfig signingConfigs.debug
} }
release { release {
// Caution! In production, you need to generate your own keystore file. signingConfig signingConfigs.release
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
minifyEnabled enableProguardInReleaseBuilds minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
} }
@ -121,28 +145,65 @@ android {
} }
dependencies { dependencies {
// The version of react-native is set by the React Native Gradle Plugin implementation 'androidx.appcompat:appcompat:1.4.1'
implementation("com.facebook.react:react-android") implementation 'com.google.android.material:material:1.4.+'
implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3' implementation 'androidx.databinding:databinding-runtime:7.1.2'
implementation project(':react-native-sqlite-storage') def work_version = "2.7.1"
implementation project(':react-native-vector-icons')
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") implementation "androidx.work:work-runtime:$work_version"
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") { implementation "androidx.work:work-runtime-ktx:$work_version"
exclude group:'com.squareup.okhttp3', module:'okhttp' implementation "androidx.work:work-rxjava2:$work_version"
androidTestImplementation "androidx.work:work-testing:$work_version"
implementation "androidx.work:work-multiprocess:$work_version"
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "com.facebook.react:react-native:+" // From node_modules
implementation "androidx.core:core-ktx:1.8.0"
implementation project(':react-native-sqlite-storage')
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
exclude group: 'com.facebook.fbjni'
} }
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
if (hermesEnabled.toBoolean()) { exclude group: 'com.facebook.flipper'
implementation("com.facebook.react:hermes-android") exclude group: 'com.squareup.okhttp3', module: 'okhttp'
}
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") {
exclude group: 'com.facebook.flipper'
}
if (enableHermes) {
implementation("com.facebook.react:hermes-engine:+") { // From node_modules
exclude group: 'com.facebook.fbjni'
}
} else { } else {
implementation jscFlavor implementation jscFlavor
} }
} }
if (isNewArchitectureEnabled()) {
configurations.all {
resolutionStrategy.dependencySubstitution {
substitute(module("com.facebook.react:react-native"))
.using(project(":ReactAndroid"))
.because("On New Architecture we're building React Native from source")
substitute(module("com.facebook.react:hermes-engine"))
.using(project(":ReactAndroid:hermes-engine"))
.because("On New Architecture we're building Hermes from source")
}
}
}
task copyDownloadableDepsToLibs(type: Copy) {
from configurations.implementation
into 'libs'
}
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project) apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
project.ext.vectoricons = [
iconFontNames: ['MaterialIcons.ttf'] def isNewArchitectureEnabled() {
] return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle" }

View File

@ -17,6 +17,7 @@ import com.facebook.flipper.plugins.inspector.DescriptorMapping;
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin; import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor; import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin; import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
import com.facebook.flipper.plugins.react.ReactFlipperPlugin;
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin; import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
import com.facebook.react.ReactInstanceEventListener; import com.facebook.react.ReactInstanceEventListener;
import com.facebook.react.ReactInstanceManager; import com.facebook.react.ReactInstanceManager;
@ -24,16 +25,13 @@ import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.network.NetworkingModule; import com.facebook.react.modules.network.NetworkingModule;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
/**
* Class responsible of loading Flipper inside your React Native application. This is the debug
* flavor of it. Here you can add your own plugins and customize the Flipper setup.
*/
public class ReactNativeFlipper { public class ReactNativeFlipper {
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) { public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
if (FlipperUtils.shouldEnableFlipper(context)) { if (FlipperUtils.shouldEnableFlipper(context)) {
final FlipperClient client = AndroidFlipperClient.getInstance(context); final FlipperClient client = AndroidFlipperClient.getInstance(context);
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults())); client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
client.addPlugin(new ReactFlipperPlugin());
client.addPlugin(new DatabasesFlipperPlugin(context)); client.addPlugin(new DatabasesFlipperPlugin(context));
client.addPlugin(new SharedPreferencesFlipperPlugin(context)); client.addPlugin(new SharedPreferencesFlipperPlugin(context));
client.addPlugin(CrashReporterPlugin.getInstance()); client.addPlugin(CrashReporterPlugin.getInstance());

View File

@ -1,56 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
package="com.massive">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission <uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE" android:name="android.permission.ACCESS_NETWORK_STATE"
tools:node="remove" /> tools:node="remove" />
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
tools:node="remove"/>
<application <application
android:name=".MainApplication" android:name=".MainApplication"
android:label="@string/app_name" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:dataExtractionRules="@xml/data_extraction_rules"
android:roundIcon="@mipmap/ic_launcher_round" android:icon="@mipmap/ic_launcher"
android:allowBackup="false" android:label="@string/app_name"
android:theme="@style/AppTheme"> android:roundIcon="@mipmap/ic_launcher_round"
<activity android:theme="@style/AppTheme">
android:name=".MainActivity" <activity
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" android:name=".TimerDone"
android:launchMode="singleTask" android:exported="false">
android:windowSoftInputMode="adjustResize" <meta-data
android:exported="true"> android:name="android.app.lib_name"
<intent-filter> android:value="" />
<action android:name="android.intent.action.MAIN" /> </activity>
<category android:name="android.intent.category.LAUNCHER" /> <activity
</intent-filter> android:name=".MainActivity"
</activity> android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<activity <category android:name="android.intent.category.LAUNCHER" />
android:name=".TimerDone" </intent-filter>
android:exported="false"> </activity>
<meta-data <activity
android:name="android.app.lib_name" android:name=".StopAlarm"
android:value="" /> android:exported="true"
</activity> android:process=":remote" />
<service
<activity android:name=".AlarmService"
android:name=".StopAlarm" android:exported="false" />
android:exported="true"
android:process=":remote" />
<service
android:name=".AlarmService"
android:exported="false" />
</application> </application>
</manifest>
</manifest>

View File

@ -19,7 +19,7 @@ import kotlin.math.floor
class AlarmModule constructor(context: ReactApplicationContext?) : class AlarmModule constructor(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) { ReactContextBaseJavaModule(context) {
private var countdownTimer: CountDownTimer? = null var countdownTimer: CountDownTimer? = null
var currentMs: Long = 0 var currentMs: Long = 0
var running = false var running = false
@ -38,7 +38,11 @@ class AlarmModule constructor(context: ReactApplicationContext?) :
private val addReceiver = object : BroadcastReceiver() { private val addReceiver = object : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
add() val vibrate = intent?.extras?.getBoolean("vibrate") == true
val sound = intent?.extras?.getString("sound")
val noSound = intent?.extras?.getBoolean("noSound") == true
Log.d("AlarmModule", "vibrate=$vibrate,sound=$sound,noSound=$noSound")
add(vibrate, sound, noSound)
} }
} }
@ -55,15 +59,15 @@ class AlarmModule constructor(context: ReactApplicationContext?) :
@RequiresApi(api = Build.VERSION_CODES.O) @RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod @ReactMethod
fun add() { fun add(vibrate: Boolean, sound: String?, noSound: Boolean = false) {
Log.d("AlarmModule", "Add 1 min to alarm.") Log.d("AlarmModule", "Add 1 min to alarm.")
countdownTimer?.cancel() countdownTimer?.cancel()
val newMs = if (running) currentMs.toInt().plus(60000) else 60000 val newMs = if (running) currentMs.toInt().plus(60000) else 60000
countdownTimer = getTimer(newMs) countdownTimer = getTimer(newMs, vibrate, sound, noSound)
countdownTimer?.start() countdownTimer?.start()
running = true running = true
val manager = getManager() val manager = getManager()
manager.cancel(AlarmService.NOTIFICATION_ID_DONE) manager.cancel(NOTIFICATION_ID_DONE)
val intent = Intent(reactApplicationContext, AlarmService::class.java) val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.stopService(intent) reactApplicationContext.stopService(intent)
} }
@ -77,7 +81,7 @@ class AlarmModule constructor(context: ReactApplicationContext?) :
val intent = Intent(reactApplicationContext, AlarmService::class.java) val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext?.stopService(intent) reactApplicationContext?.stopService(intent)
val manager = getManager() val manager = getManager()
manager.cancel(AlarmService.NOTIFICATION_ID_DONE) manager.cancel(NOTIFICATION_ID_DONE)
manager.cancel(NOTIFICATION_ID_PENDING) manager.cancel(NOTIFICATION_ID_PENDING)
val params = Arguments.createMap().apply { val params = Arguments.createMap().apply {
putString("minutes", "00") putString("minutes", "00")
@ -90,14 +94,14 @@ class AlarmModule constructor(context: ReactApplicationContext?) :
@RequiresApi(api = Build.VERSION_CODES.O) @RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod @ReactMethod
fun timer(milliseconds: Int) { fun timer(milliseconds: Int, vibrate: Boolean, sound: String?, noSound: Boolean = false) {
Log.d("AlarmModule", "Queue alarm for $milliseconds delay") Log.d("AlarmModule", "Queue alarm for $milliseconds delay")
val manager = getManager() val manager = getManager()
manager.cancel(AlarmService.NOTIFICATION_ID_DONE) manager.cancel(NOTIFICATION_ID_DONE)
val intent = Intent(reactApplicationContext, AlarmService::class.java) val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.stopService(intent) reactApplicationContext.stopService(intent)
countdownTimer?.cancel() countdownTimer?.cancel()
countdownTimer = getTimer(milliseconds) countdownTimer = getTimer(milliseconds, vibrate, sound, noSound)
countdownTimer?.start() countdownTimer?.start()
running = true running = true
} }
@ -105,8 +109,11 @@ class AlarmModule constructor(context: ReactApplicationContext?) :
@RequiresApi(Build.VERSION_CODES.M) @RequiresApi(Build.VERSION_CODES.M)
private fun getTimer( private fun getTimer(
endMs: Int, endMs: Int,
vibrate: Boolean,
sound: String?,
noSound: Boolean
): CountDownTimer { ): CountDownTimer {
val builder = getBuilder() val builder = getBuilder(vibrate, sound, noSound)
return object : CountDownTimer(endMs.toLong(), 1000) { return object : CountDownTimer(endMs.toLong(), 1000) {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
override fun onTick(current: Long) { override fun onTick(current: Long) {
@ -133,8 +140,30 @@ class AlarmModule constructor(context: ReactApplicationContext?) :
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
override fun onFinish() { override fun onFinish() {
val context = reactApplicationContext val context = reactApplicationContext
context.startForegroundService(Intent(context, AlarmService::class.java)) val finishIntent = Intent(context, StopAlarm::class.java)
context val finishPending = PendingIntent.getActivity(
context, 0, finishIntent, PendingIntent.FLAG_IMMUTABLE
)
val fullIntent = Intent(context, TimerDone::class.java)
val fullPending = PendingIntent.getActivity(
context, 0, fullIntent, PendingIntent.FLAG_IMMUTABLE
)
builder.setContentText("Timer finished.").setProgress(0, 0, false)
.setAutoCancel(true).setOngoing(true).setFullScreenIntent(fullPending, true)
.setContentIntent(finishPending).setChannelId(CHANNEL_ID_DONE)
.setCategory(NotificationCompat.CATEGORY_ALARM).priority =
NotificationCompat.PRIORITY_HIGH
val manager = getManager()
manager.notify(NOTIFICATION_ID_DONE, builder.build())
manager.cancel(NOTIFICATION_ID_PENDING)
Log.d("AlarmModule", "Finished: vibrate=$vibrate,sound=$sound,noSound=$noSound")
val alarmIntent = Intent(context, AlarmService::class.java).apply {
putExtra("vibrate", vibrate)
putExtra("sound", sound)
putExtra("noSound", noSound)
}
context.startService(alarmIntent)
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("finish", Arguments.createMap().apply { .emit("finish", Arguments.createMap().apply {
putString("minutes", "00") putString("minutes", "00")
@ -146,18 +175,25 @@ class AlarmModule constructor(context: ReactApplicationContext?) :
@SuppressLint("UnspecifiedImmutableFlag") @SuppressLint("UnspecifiedImmutableFlag")
@RequiresApi(Build.VERSION_CODES.M) @RequiresApi(Build.VERSION_CODES.M)
private fun getBuilder(): NotificationCompat.Builder { private fun getBuilder(
vibrate: Boolean,
sound: String?,
noSound: Boolean
): NotificationCompat.Builder {
val context = reactApplicationContext val context = reactApplicationContext
val contentIntent = Intent(context, MainActivity::class.java) val contentIntent = Intent(context, MainActivity::class.java)
val pendingContent = val pendingContent =
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE) PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
val addBroadcast = Intent(ADD_BROADCAST).apply { val addBroadcast = Intent(ADD_BROADCAST).apply {
setPackage(context.packageName) setPackage(reactApplicationContext.packageName)
putExtra("vibrate", vibrate)
putExtra("sound", sound)
putExtra("noSound", noSound)
} }
val pendingAdd = val pendingAdd =
PendingIntent.getBroadcast(context, 0, addBroadcast, PendingIntent.FLAG_MUTABLE) PendingIntent.getBroadcast(context, 0, addBroadcast, PendingIntent.FLAG_MUTABLE)
val stopBroadcast = Intent(STOP_BROADCAST) val stopBroadcast = Intent(STOP_BROADCAST)
stopBroadcast.setPackage(context.packageName) stopBroadcast.setPackage(reactApplicationContext.packageName)
val pendingStop = val pendingStop =
PendingIntent.getBroadcast(context, 0, stopBroadcast, PendingIntent.FLAG_IMMUTABLE) PendingIntent.getBroadcast(context, 0, stopBroadcast, PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(context, CHANNEL_ID_PENDING) return NotificationCompat.Builder(context, CHANNEL_ID_PENDING)
@ -170,9 +206,16 @@ class AlarmModule constructor(context: ReactApplicationContext?) :
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun getManager(): NotificationManager { private fun getManager(): NotificationManager {
val alarmsChannel = NotificationChannel(
CHANNEL_ID_DONE, CHANNEL_ID_DONE, NotificationManager.IMPORTANCE_HIGH
)
alarmsChannel.description = "Alarms for rest timers."
alarmsChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
alarmsChannel.setSound(null, null)
val notificationManager = reactApplicationContext.getSystemService( val notificationManager = reactApplicationContext.getSystemService(
NotificationManager::class.java NotificationManager::class.java
) )
notificationManager.createNotificationChannel(alarmsChannel)
val timersChannel = NotificationChannel( val timersChannel = NotificationChannel(
CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, NotificationManager.IMPORTANCE_LOW CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, NotificationManager.IMPORTANCE_LOW
) )
@ -186,6 +229,8 @@ class AlarmModule constructor(context: ReactApplicationContext?) :
const val STOP_BROADCAST = "stop-timer-event" const val STOP_BROADCAST = "stop-timer-event"
const val ADD_BROADCAST = "add-timer-event" const val ADD_BROADCAST = "add-timer-event"
const val CHANNEL_ID_PENDING = "Timer" const val CHANNEL_ID_PENDING = "Timer"
const val CHANNEL_ID_DONE = "Alarm"
const val NOTIFICATION_ID_PENDING = 1 const val NOTIFICATION_ID_PENDING = 1
const val NOTIFICATION_ID_DONE = 2
} }
} }

View File

@ -1,65 +1,33 @@
package com.massive package com.massive
import android.annotation.SuppressLint import android.app.Service
import android.app.*
import android.content.Context import android.content.Context
import android.media.MediaPlayer.OnPreparedListener
import android.media.MediaPlayer
import androidx.annotation.RequiresApi
import android.content.Intent import android.content.Intent
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.MediaPlayer
import android.media.MediaPlayer.OnPreparedListener
import android.net.Uri import android.net.Uri
import android.os.* import android.os.*
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
class Settings(val sound: String?, val noSound: Boolean, val vibrate: Boolean)
@RequiresApi(Build.VERSION_CODES.O)
class AlarmService : Service(), OnPreparedListener { class AlarmService : Service(), OnPreparedListener {
private var mediaPlayer: MediaPlayer? = null var mediaPlayer: MediaPlayer? = null
private var vibrator: Vibrator? = null private var vibrator: Vibrator? = null
private fun getBuilder(): NotificationCompat.Builder { @RequiresApi(api = Build.VERSION_CODES.O)
val context = applicationContext override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
val contentIntent = Intent(context, MainActivity::class.java) if (intent.action == "stop") {
val pendingContent = onDestroy()
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE) return START_STICKY
val addBroadcast = Intent(AlarmModule.ADD_BROADCAST).apply {
setPackage(context.packageName)
} }
val pendingAdd = val sound = intent.extras?.getString("sound")
PendingIntent.getBroadcast(context, 0, addBroadcast, PendingIntent.FLAG_MUTABLE) val noSound = intent.extras?.getBoolean("noSound") == true
val stopBroadcast = Intent(AlarmModule.STOP_BROADCAST)
stopBroadcast.setPackage(context.packageName)
val pendingStop =
PendingIntent.getBroadcast(context, 0, stopBroadcast, PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(context, AlarmModule.CHANNEL_ID_PENDING)
.setSmallIcon(R.drawable.ic_baseline_hourglass_bottom_24).setContentTitle("Resting")
.setContentIntent(pendingContent)
.addAction(R.drawable.ic_baseline_stop_24, "Stop", pendingStop)
.addAction(R.drawable.ic_baseline_stop_24, "Add 1 min", pendingAdd)
.setDeleteIntent(pendingStop)
}
if (sound == null && !noSound) {
@SuppressLint("Range")
private fun getSettings(): Settings {
val db = DatabaseHelper(applicationContext).readableDatabase
val cursor = db.rawQuery("SELECT sound, noSound, vibrate FROM settings", null)
cursor.moveToFirst()
val sound = cursor.getString(cursor.getColumnIndex("sound"))
val noSound = cursor.getInt(cursor.getColumnIndex("noSound")) == 1
val vibrate = cursor.getInt(cursor.getColumnIndex("vibrate")) == 1
cursor.close()
return Settings(sound, noSound, vibrate)
}
private fun playSound(settings: Settings) {
if (settings.sound == null && !settings.noSound) {
mediaPlayer = MediaPlayer.create(applicationContext, R.raw.argon) mediaPlayer = MediaPlayer.create(applicationContext, R.raw.argon)
mediaPlayer?.start() mediaPlayer?.start()
mediaPlayer?.setOnCompletionListener { vibrator?.cancel() } mediaPlayer?.setOnCompletionListener { vibrator?.cancel() }
} else if (settings.sound != null && !settings.noSound) { } else if (sound != null && !noSound) {
mediaPlayer = MediaPlayer().apply { mediaPlayer = MediaPlayer().apply {
setAudioAttributes( setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
@ -67,56 +35,13 @@ class AlarmService : Service(), OnPreparedListener {
.setUsage(AudioAttributes.USAGE_MEDIA) .setUsage(AudioAttributes.USAGE_MEDIA)
.build() .build()
) )
setDataSource(applicationContext, Uri.parse(settings.sound)) setDataSource(applicationContext, Uri.parse(sound))
prepare() prepare()
start() start()
setOnCompletionListener { vibrator?.cancel() } setOnCompletionListener { vibrator?.cancel() }
} }
} }
}
private fun doNotify(): Notification {
val alarmsChannel = NotificationChannel(
CHANNEL_ID_DONE,
CHANNEL_ID_DONE,
NotificationManager.IMPORTANCE_HIGH
)
alarmsChannel.description = "Alarms for rest timers."
alarmsChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
alarmsChannel.setSound(null, null)
val manager = applicationContext.getSystemService(
NotificationManager::class.java
)
manager.createNotificationChannel(alarmsChannel)
val builder = getBuilder()
val context = applicationContext
val finishIntent = Intent(context, StopAlarm::class.java)
val finishPending = PendingIntent.getActivity(
context, 0, finishIntent, PendingIntent.FLAG_IMMUTABLE
)
val fullIntent = Intent(context, TimerDone::class.java)
val fullPending = PendingIntent.getActivity(
context, 0, fullIntent, PendingIntent.FLAG_IMMUTABLE
)
builder.setContentText("Timer finished.").setProgress(0, 0, false)
.setAutoCancel(true).setOngoing(true).setFullScreenIntent(fullPending, true)
.setContentIntent(finishPending).setChannelId(CHANNEL_ID_DONE)
.setCategory(NotificationCompat.CATEGORY_ALARM).priority =
NotificationCompat.PRIORITY_HIGH
val notification = builder.build()
manager.notify(NOTIFICATION_ID_DONE, notification)
manager.cancel(AlarmModule.NOTIFICATION_ID_PENDING)
return notification
}
@SuppressLint("Recycle")
@RequiresApi(api = Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
val notification = doNotify()
startForeground(NOTIFICATION_ID_DONE, notification)
val settings = getSettings()
playSound(settings)
if (!settings.vibrate) return START_STICKY
val pattern = longArrayOf(0, 300, 1300, 300, 1300, 300) val pattern = longArrayOf(0, 300, 1300, 300, 1300, 300)
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = val vibratorManager =
@ -130,7 +55,9 @@ class AlarmService : Service(), OnPreparedListener {
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ALARM) .setUsage(AudioAttributes.USAGE_ALARM)
.build() .build()
vibrator!!.vibrate(VibrationEffect.createWaveform(pattern, 1), audioAttributes) val vibrate = intent.extras!!.getBoolean("vibrate")
if (vibrate)
vibrator!!.vibrate(VibrationEffect.createWaveform(pattern, 1), audioAttributes)
return START_STICKY return START_STICKY
} }
@ -148,9 +75,4 @@ class AlarmService : Service(), OnPreparedListener {
mediaPlayer?.release() mediaPlayer?.release()
vibrator?.cancel() vibrator?.cancel()
} }
companion object {
const val CHANNEL_ID_DONE = "Alarm"
const val NOTIFICATION_ID_DONE = 2
}
} }

View File

@ -1,84 +0,0 @@
package com.massive
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.*
import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.documentfile.provider.DocumentFile
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import java.io.*
import java.util.*
class BackupModule constructor(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) {
val context: ReactApplicationContext = reactApplicationContext
private var targetDir: String? = null
private val copyReceiver = object : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onReceive(context: Context?, intent: Intent?) {
val treeUri: Uri = Uri.parse(targetDir)
val documentFile = context?.let { DocumentFile.fromTreeUri(it, treeUri) }
val file = documentFile?.createFile("application/octet-stream", "massive.db")
val output = context?.contentResolver?.openOutputStream(file!!.uri)
val sourceFile = File(context?.getDatabasePath("massive.db")!!.path)
val input = FileInputStream(sourceFile)
if (output != null) {
input.copyTo(output)
}
output?.flush()
output?.close()
}
}
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun start(baseUri: String) {
targetDir = baseUri
val alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(COPY_BROADCAST)
val pendingIntent =
PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
pendingIntent.send()
val calendar = Calendar.getInstance().apply {
timeInMillis = System.currentTimeMillis()
set(Calendar.HOUR_OF_DAY, 6)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
}
alarmMgr.setRepeating(
AlarmManager.RTC_WAKEUP,
calendar.timeInMillis,
AlarmManager.INTERVAL_DAY,
pendingIntent
)
}
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun stop() {
val alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(COPY_BROADCAST)
val pendingIntent =
PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
alarmMgr.cancel(pendingIntent)
}
init {
reactApplicationContext.registerReceiver(copyReceiver, IntentFilter(COPY_BROADCAST))
}
companion object {
const val COPY_BROADCAST = "copy-event"
}
override fun getName(): String {
return "BackupModule"
}
}

View File

@ -1,22 +0,0 @@
package com.massive
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
class DatabaseHelper(context: Context) :
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
companion object {
private const val DATABASE_NAME = "massive.db"
private const val DATABASE_VERSION = 1
}
override fun onCreate(db: SQLiteDatabase) {
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
}
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
}
}

View File

@ -0,0 +1,51 @@
package com.massive
import android.app.DownloadManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.NotificationManager.IMPORTANCE_DEFAULT
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import java.io.File
class DownloadModule internal constructor(context: ReactApplicationContext) :
ReactContextBaseJavaModule(context) {
override fun getName(): String {
return "DownloadModule"
}
@RequiresApi(Build.VERSION_CODES.O)
@ReactMethod
fun show(name: String) {
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_ID, IMPORTANCE_DEFAULT)
channel.description = "Notifications for downloaded files."
val manager =
reactApplicationContext.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
val intent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)
val pendingIntent =
PendingIntent.getActivity(reactApplicationContext, 0, intent, FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(reactApplicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_baseline_arrow_downward_24)
.setContentTitle("Downloaded $name")
.setContentIntent(pendingIntent)
.setAutoCancel(true)
manager.notify(NOTIFICATION_ID, builder.build())
}
companion object {
private const val CHANNEL_ID = "MassiveDownloads"
private const val NOTIFICATION_ID = 3
}
}

View File

@ -2,27 +2,32 @@ package com.massive
import com.facebook.react.ReactActivity import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled import com.facebook.react.ReactRootView
import com.facebook.react.defaults.DefaultReactActivityDelegate import android.os.Bundle;
class MainActivity : ReactActivity() { class MainActivity : ReactActivity() {
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String? { override fun getMainComponentName(): String? {
return "massive" return "massive"
} }
/**
* Returns the instance of the [ReactActivityDelegate]. Here we use a util class [ ] which allows you to easily enable Fabric and Concurrent React
* (aka React 18) with two boolean flags.
*/
override fun createReactActivityDelegate(): ReactActivityDelegate { override fun createReactActivityDelegate(): ReactActivityDelegate {
return DefaultReactActivityDelegate( return MainActivityDelegate(this, mainComponentName)
this,
mainComponentName!!, // If you opted-in for the New Architecture, we enable the Fabric Renderer.
fabricEnabled
)
} }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(null)
}
class MainActivityDelegate(activity: ReactActivity?, mainComponentName: String?) :
ReactActivityDelegate(activity, mainComponentName) {
override fun createRootView(): ReactRootView {
val reactRootView = ReactRootView(context)
reactRootView.setIsFabric(BuildConfig.IS_NEW_ARCHITECTURE_ENABLED)
return reactRootView
}
override fun isConcurrentRootEnabled(): Boolean {
return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
}
}
}

View File

@ -1,16 +1,15 @@
package com.massive package com.massive
import android.app.Application import android.app.Application
import com.facebook.react.PackageList import android.content.Context
import com.facebook.react.ReactApplication import com.facebook.react.*
import com.facebook.react.ReactNativeHost import com.facebook.react.config.ReactFeatureFlags
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.soloader.SoLoader import com.facebook.soloader.SoLoader
import com.massive.newarchitecture.MainApplicationReactNativeHost
import java.lang.reflect.InvocationTargetException
class MainApplication : Application(), ReactApplication { class MainApplication : Application(), ReactApplication {
private val mReactNativeHost: ReactNativeHost = object : DefaultReactNativeHost(this) { private val mReactNativeHost: ReactNativeHost = object : ReactNativeHost(this) {
override fun getUseDeveloperSupport(): Boolean { override fun getUseDeveloperSupport(): Boolean {
return BuildConfig.DEBUG return BuildConfig.DEBUG
} }
@ -24,24 +23,48 @@ class MainApplication : Application(), ReactApplication {
override fun getJSMainModuleName(): String { override fun getJSMainModuleName(): String {
return "index" return "index"
} }
override val isNewArchEnabled: Boolean
protected get() = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean
protected get() = BuildConfig.IS_HERMES_ENABLED
} }
private val mNewArchitectureNativeHost: ReactNativeHost = MainApplicationReactNativeHost(this)
override fun getReactNativeHost(): ReactNativeHost { override fun getReactNativeHost(): ReactNativeHost {
return mReactNativeHost return if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
mNewArchitectureNativeHost
} else {
mReactNativeHost
}
} }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
SoLoader.init(this, /* native exopackage */false) ReactFeatureFlags.useTurboModules = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { SoLoader.init(this, false)
// If you opted-in for the New Architecture, we load the native entry point for this app. initializeFlipper(this, reactNativeHost.reactInstanceManager)
load()
}
ReactNativeFlipper.initializeFlipper(this, reactNativeHost.reactInstanceManager)
} }
}
companion object {
private fun initializeFlipper(
context: Context, reactInstanceManager: ReactInstanceManager
) {
if (BuildConfig.DEBUG) {
try {
val aClass = Class.forName("com.massive.ReactNativeFlipper")
aClass
.getMethod(
"initializeFlipper",
Context::class.java,
ReactInstanceManager::class.java
)
.invoke(null, context, reactInstanceManager)
} catch (e: ClassNotFoundException) {
e.printStackTrace()
} catch (e: NoSuchMethodException) {
e.printStackTrace()
} catch (e: IllegalAccessException) {
e.printStackTrace()
} catch (e: InvocationTargetException) {
e.printStackTrace()
}
}
}
}
}

View File

@ -4,6 +4,7 @@ import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager import com.facebook.react.uimanager.ViewManager
import java.util.ArrayList
class MassivePackage : ReactPackage { class MassivePackage : ReactPackage {
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> { override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
@ -15,8 +16,8 @@ class MassivePackage : ReactPackage {
): List<NativeModule> { ): List<NativeModule> {
val modules: MutableList<NativeModule> = ArrayList() val modules: MutableList<NativeModule> = ArrayList()
modules.add(AlarmModule(reactContext)) modules.add(AlarmModule(reactContext))
modules.add(DownloadModule(reactContext))
modules.add(SettingsModule(reactContext)) modules.add(SettingsModule(reactContext))
modules.add(BackupModule(reactContext))
return modules return modules
} }
} }

View File

@ -23,7 +23,7 @@ class TimerDone : AppCompatActivity() {
Log.d("TimerDone", "Stopping...") Log.d("TimerDone", "Stopping...")
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java)) applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
val manager = getManager() val manager = getManager()
manager.cancel(AlarmService.NOTIFICATION_ID_DONE) manager.cancel(AlarmModule.NOTIFICATION_ID_DONE)
manager.cancel(AlarmModule.NOTIFICATION_ID_PENDING) manager.cancel(AlarmModule.NOTIFICATION_ID_PENDING)
val intent = Intent(applicationContext, MainActivity::class.java) val intent = Intent(applicationContext, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
@ -33,8 +33,8 @@ class TimerDone : AppCompatActivity() {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun getManager(): NotificationManager { fun getManager(): NotificationManager {
val alarmsChannel = NotificationChannel( val alarmsChannel = NotificationChannel(
AlarmService.CHANNEL_ID_DONE, AlarmModule.CHANNEL_ID_DONE,
AlarmService.CHANNEL_ID_DONE, AlarmModule.CHANNEL_ID_DONE,
NotificationManager.IMPORTANCE_HIGH NotificationManager.IMPORTANCE_HIGH
).apply { ).apply {
description = "Alarms for rest timers." description = "Alarms for rest timers."

View File

@ -1,20 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* <p>This source code is licensed under the MIT license found in the LICENSE file in the root
* directory of this source tree.
*/
package com.massive;
import android.content.Context;
import com.facebook.react.ReactInstanceManager;
/**
* Class responsible of loading Flipper inside your React Native application. This is the release
* flavor of it so it's empty as we don't want to load Flipper.
*/
public class ReactNativeFlipper {
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
// Do nothing as we don't want to initialize Flipper on Release.
}
}

View File

@ -1,22 +1,55 @@
import org.apache.tools.ant.taskdefs.condition.Os
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext { ext {
buildToolsVersion = "33.0.0" kotlin_version = '1.6.10'
buildToolsVersion = "31.0.0"
minSdkVersion = 21 minSdkVersion = 21
compileSdkVersion = 33 compileSdkVersion = 31
targetSdkVersion = 33 targetSdkVersion = 31
// We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP. if (System.properties['os.arch'] == "aarch64") {
ndkVersion = "23.1.7779620" // For M1 Users we need to use the NDK 24 which added support for aarch64
ndkVersion = "24.0.8215888"
} else {
// Otherwise we default to the side-by-side NDK version from AGP.
ndkVersion = "21.4.7075529"
}
} }
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath("com.android.tools.build:gradle") classpath('com.android.tools.build:gradle:7.2.1')
classpath("com.facebook.react:react-native-gradle-plugin") classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22") classpath("de.undercouch:gradle-download-task:5.0.1")
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url("$rootDir/../node_modules/react-native/android")
}
maven {
// Android JSC is installed from npm
url("$rootDir/../node_modules/jsc-android/dist")
}
mavenCentral {
// We don't want to fetch react-native from Maven Central as there are
// older versions over there.
content {
excludeGroup "com.facebook.react"
}
}
google()
maven { url 'https://www.jitpack.io' }
} }
} }

View File

@ -25,7 +25,7 @@ android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# Version of flipper SDK to use with React Native # Version of flipper SDK to use with React Native
FLIPPER_VERSION=0.182.0 FLIPPER_VERSION=0.125.0
# Use this property to specify which architecture you want to build. # Use this property to specify which architecture you want to build.
# You can also override it from the CLI using # You can also override it from the CLI using
@ -38,7 +38,3 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# to write custom TurboModules/Fabric components OR use libraries that # to write custom TurboModules/Fabric components OR use libraries that
# are providing them. # are providing them.
newArchEnabled=false newArchEnabled=false
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true

Binary file not shown.

View File

@ -1,6 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

18
android/gradlew vendored
View File

@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@ -80,11 +80,11 @@ do
esac esac
done done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
@ -143,16 +143,12 @@ fi
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #( case $MAX_FD in #(
max*) max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) || MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit" warn "Could not query maximum file descriptor limit"
esac esac
case $MAX_FD in #( case $MAX_FD in #(
'' | soft) :;; #( '' | soft) :;; #(
*) *)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" || ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD" warn "Could not set maximum file descriptor limit to $MAX_FD"
esac esac
@ -209,12 +205,6 @@ set -- \
org.gradle.wrapper.GradleWrapperMain \ org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args. # Use "xargs" to parse quoted args.
# #
# With -n1 it outputs one arg per line, with the quotes and backslashes removed. # With -n1 it outputs one arg per line, with the quotes and backslashes removed.

15
android/gradlew.bat vendored
View File

@ -14,7 +14,7 @@
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%" == "" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem Gradle startup script for Windows
@ -25,8 +25,7 @@
if "%OS%"=="Windows_NT" setlocal if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%" == "" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%
@ -41,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if "%ERRORLEVEL%" == "0" goto execute
echo. echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@ -76,15 +75,13 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd if "%ERRORLEVEL%"=="0" goto mainEnd
:fail :fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code! rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL% if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
if %EXIT_CODE% equ 0 set EXIT_CODE=1 exit /b 1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd :mainEnd
if "%OS%"=="Windows_NT" endlocal if "%OS%"=="Windows_NT" endlocal

View File

@ -1,6 +1,13 @@
rootProject.name = 'massive' rootProject.name = 'massive'
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
include ':app' include ':app'
includeBuild('../node_modules/@react-native/gradle-plugin') includeBuild('../node_modules/react-native-gradle-plugin')
include ':react-native-sqlite-storage' include ':react-native-sqlite-storage'
project(':react-native-sqlite-storage').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-sqlite-storage/platforms/android') project(':react-native-sqlite-storage').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-sqlite-storage/platforms/android')
if (settings.hasProperty("newArchEnabled") && settings.newArchEnabled == "true") {
include(":ReactAndroid")
project(":ReactAndroid").projectDir = file('../node_modules/react-native/ReactAndroid')
include(":ReactAndroid:hermes-engine")
project(":ReactAndroid:hermes-engine").projectDir = file('../node_modules/react-native/ReactAndroid/hermes-engine')
}

View File

@ -4,8 +4,8 @@ module.exports = {
'@babel/plugin-transform-flow-strip-types', '@babel/plugin-transform-flow-strip-types',
['@babel/plugin-proposal-decorators', {legacy: true}], ['@babel/plugin-proposal-decorators', {legacy: true}],
['@babel/plugin-proposal-class-properties', {loose: true}], ['@babel/plugin-proposal-class-properties', {loose: true}],
'react-native-paper/babel',
'react-native-reanimated/plugin', 'react-native-reanimated/plugin',
'react-native-paper/babel',
], ],
env: { env: {
production: { production: {

View File

@ -1,42 +1,15 @@
import { LIMIT } from "./constants"; import {setRepo} from './db'
import { setRepo } from "./db"; import GymSet from './gym-set'
import GymSet from "./gym-set";
export const getBestSet = async (name: string): Promise<GymSet> => { export const getBestSet = async (name: string): Promise<GymSet> => {
return setRepo return setRepo
.createQueryBuilder() .createQueryBuilder()
.select() .select()
.addSelect("MAX(weight)", "weight") .addSelect('MAX(weight)', 'weight')
.where("name = :name", { name }) .where('name = :name', {name})
.groupBy("name") .groupBy('name')
.addGroupBy("reps") .addGroupBy('reps')
.orderBy("weight", "DESC") .orderBy('weight', 'DESC')
.addOrderBy("reps", "DESC") .addOrderBy('reps', 'DESC')
.getOne(); .getOne()
}; }
export const getBestSets = ({
term: term,
offset,
}: {
term: string;
offset?: number;
}) => {
return setRepo
.createQueryBuilder("gym_set")
.select(["gym_set.name", "gym_set.reps", "gym_set.weight"])
.groupBy("gym_set.name")
.innerJoin(
(qb) =>
qb
.select(["gym_set2.name", "MAX(gym_set2.weight) AS max_weight"])
.from(GymSet, "gym_set2")
.where("gym_set2.name LIKE (:name)", { name: `%${term.trim()}%` })
.groupBy("gym_set2.name"),
"subquery",
"gym_set.name = subquery.gym_set2_name AND gym_set.weight = subquery.max_weight"
)
.limit(LIMIT)
.offset(offset || 0)
.getMany();
};

View File

@ -1,41 +1,40 @@
import { DefaultTheme, MD3DarkTheme } from "react-native-paper"; import {DarkTheme, DefaultTheme} from 'react-native-paper'
export const lightColors = [ export const lightColors = [
{ hex: MD3DarkTheme.colors.primary, name: "Purple" }, {hex: DarkTheme.colors.primary, name: 'Purple'},
{ hex: "#B3E5FC", name: "Blue" }, {hex: '#B3E5FC', name: 'Blue'},
{ hex: "#FA8072", name: "Salmon" }, {hex: '#FA8072', name: 'Salmon'},
{ hex: "#FFC0CB", name: "Pink" }, {hex: '#FFC0CB', name: 'Pink'},
{ hex: "#E9DCC9", name: "Linen" }, {hex: '#E9DCC9', name: 'Linen'},
]; ]
export const darkColors = [ export const darkColors = [
{ hex: DefaultTheme.colors.primary, name: "Purple" }, {hex: DefaultTheme.colors.primary, name: 'Purple'},
{ hex: "#0051a9", name: "Blue" }, {hex: '#0051a9', name: 'Blue'},
{ hex: "#000000", name: "Black" }, {hex: '#000000', name: 'Black'},
{ hex: "#863c3c", name: "Red" }, {hex: '#863c3c', name: 'Red'},
{ hex: "#1c6000", name: "Kermit" }, {hex: '#1c6000', name: 'Kermit'},
]; ]
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,6 +1,13 @@
export const MARGIN = 10; export const MARGIN = 10
export const PADDING = 10; export const PADDING = 10
export const ITEM_PADDING = 8; export const ITEM_PADDING = 8
export const DARK_RIPPLE = "#444444"; export const DARK_RIPPLE = '#444444'
export const LIGHT_RIPPLE = "#c2c2c2"; export const LIGHT_RIPPLE = '#c2c2c2'
export const LIMIT = 15;
export const toSentenceCase = (camelCase: string) => {
if (camelCase) {
const result = camelCase.replace(/([A-Z])/g, ' $1')
return result[0].toUpperCase() + result.substring(1).toLowerCase()
}
return ''
}

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
} }

View File

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

25
db.ts
View File

@ -1,15 +1,14 @@
import { AppDataSource } from "./data-source"; import {AppDataSource} from './data-source'
import GymSet from "./gym-set"; import GymSet from './gym-set'
import { Plan } from "./plan"; import {Plan} from './plan'
import Settings from "./settings"; import Settings from './settings'
export const setRepo = AppDataSource.manager.getRepository(GymSet); export const setRepo = AppDataSource.manager.getRepository(GymSet)
export const planRepo = AppDataSource.manager.getRepository(Plan); export const planRepo = AppDataSource.manager.getRepository(Plan)
export const settingsRepo = AppDataSource.manager.getRepository(Settings); export const settingsRepo = AppDataSource.manager.getRepository(Settings)
export const getNow = async (): Promise<string> => { export const getNow = (): Promise<{now: string}[]> => {
const query = await AppDataSource.manager.query( return AppDataSource.manager.query(
"SELECT STRFTIME('%Y-%m-%dT%H:%M:%S','now','localtime') AS now" "SELECT STRFTIME('%Y-%m-%dT%H:%M:%S','now','localtime') AS now",
); )
return query[0].now; }
};

View File

@ -1,11 +0,0 @@
{
"fmt": {
"useTabs": false,
"lineWidth": 80,
"semiColons": false,
"singleQuote": true,
"proseWrap": "preserve",
"include": ["src/"],
"exclude": ["src/testdata/", "data/fixtures/**/*.ts"]
}
}

View File

@ -2,6 +2,10 @@
set -ex set -ex
yarn tsc
yarn lint
git push origin HEAD
cd android || exit 1 cd android || exit 1
build=app/build.gradle build=app/build.gradle
@ -23,17 +27,16 @@ sed -i "s/\(^\s*\)versionCode [0-9]*$/\1versionCode $versionCode/" \
sed -i "s/\(^\s*\)versionName \"[0-9]*.[0-9]*\"$/\1versionName \"$major.$minor\"/" "$build" sed -i "s/\(^\s*\)versionName \"[0-9]*.[0-9]*\"$/\1versionName \"$major.$minor\"/" "$build"
sed -i "s/\"version\": \"[0-9]*.[0-9]*\"/\"version\": \"$major.$minor\"/" ../package.json sed -i "s/\"version\": \"[0-9]*.[0-9]*\"/\"version\": \"$major.$minor\"/" ../package.json
if [ "$1" != "-n" ]; then [ "$1" != "--nobundle" ] && ./gradlew bundleRelease
yarn tsc
yarn lint bundle install
./gradlew bundleRelease bundle exec fastlane supply --aab app/build/outputs/bundle/release/app-release.aab
bundle install
bundle exec fastlane supply --aab app/build/outputs/bundle/release/app-release.aab
fi
git add app/build.gradle ../package.json git add app/build.gradle ../package.json
git commit --amend --message \ git commit --no-verify --message "Set versionCode=$versionCode"
"$(git log -1 --pretty=%B | sed " 1 s/.*/& - $major.$minor/")"
git tag "$versionCode" git tag "$versionCode"
git push origin HEAD git push origin HEAD &
git push --tags git push --tags
cd ..
./install.sh

View File

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

View File

@ -1,11 +0,0 @@
export const fixNumeric = (text: string) => {
let newText = text.replace(/[^0-9.-]/g, "");
let parts = newText.split(".");
if (parts.length > 2) {
newText = parts[0] + "." + parts.slice(1).join("");
}
if (newText.startsWith("-")) {
newText = "-" + newText.slice(1).replace(/-/g, "");
}
return newText;
};

View File

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

View File

@ -1,11 +1,11 @@
import GymSet from "./gym-set"; import GymSet from './gym-set'
export type HomePageParams = { export type HomePageParams = {
Sets: {}; Sets: {}
EditSet: { EditSet: {
set: GymSet; set: GymSet
}; }
EditSets: { EditSets: {
ids: number[]; ids: number[]
}; }
}; }

View File

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

View File

@ -1,9 +1,7 @@
import { Item } from "./Select"; import {Item} from './Select'
import Settings from "./settings";
export default interface Input<T> { export default interface Input<T> {
name: string; key: keyof T
key: keyof Settings; name: string
value?: T; items?: Item[]
items?: Item[];
} }

View File

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

View File

@ -1,11 +0,0 @@
# This `.xcode.env` file is versioned and is used to source the environment
# used when running script phases inside Xcode.
# To customize your local environment, you can create an `.xcode.env.local`
# file that is not versioned.
# NODE_BINARY variable contains the PATH to the node executable.
#
# Customize the NODE_BINARY variable here.
# For example, to use nvm with brew, add the following line
# . "$(brew --prefix nvm)/nvm.sh" --no-use
export NODE_BINARY=$(command -v node)

View File

@ -1,29 +1,8 @@
# Resolve react_native_pods.rb with node to allow for hoisting require_relative '../node_modules/react-native/scripts/react_native_pods'
require Pod::Executable.execute_command('node', ['-p', require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
'require.resolve(
"react-native/scripts/react_native_pods.rb",
{paths: [process.argv[1]]},
)', __dir__]).strip
platform :ios, min_ios_version_supported platform :ios, '12.4'
prepare_react_native_project! install! 'cocoapods', :deterministic_uuids => false
# If you are using a `react-native-flipper` your iOS build will fail when `NO_FLIPPER=1` is set.
# because `react-native-flipper` depends on (FlipperKit,...) that will be excluded
#
# To fix this you can also exclude `react-native-flipper` using a `react-native.config.js`
# ```js
# module.exports = {
# dependencies: {
# ...(process.env.NO_FLIPPER ? { 'react-native-flipper': { platforms: { ios: null } } } : {}),
# ```
flipper_config = ENV['NO_FLIPPER'] == "1" ? FlipperConfiguration.disabled : FlipperConfiguration.enabled
linkage = ENV['USE_FRAMEWORKS']
if linkage != nil
Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green
use_frameworks! :linkage => linkage.to_sym
end
target 'massive' do target 'massive' do
config = use_native_modules! config = use_native_modules!
@ -33,14 +12,9 @@ target 'massive' do
use_react_native!( use_react_native!(
:path => config[:reactNativePath], :path => config[:reactNativePath],
# Hermes is now enabled by default. Disable by setting this flag to false. # to enable hermes on iOS, change `false` to `true` and then install pods
:hermes_enabled => flags[:hermes_enabled], :hermes_enabled => flags[:hermes_enabled],
:fabric_enabled => flags[:fabric_enabled], :fabric_enabled => flags[:fabric_enabled],
# Enables Flipper.
#
# Note that if you have use_frameworks! enabled, Flipper will not work and
# you should disable the next line.
:flipper_configuration => flipper_config,
# An absolute path to your application root. # An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/.." :app_path => "#{Pod::Config.instance.installation_root}/.."
) )
@ -50,13 +24,14 @@ target 'massive' do
# Pods for testing # Pods for testing
end end
# Enables Flipper.
#
# Note that if you have use_frameworks! enabled, Flipper will not work and
# you should disable the next line.
use_flipper!()
post_install do |installer| post_install do |installer|
# https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202 react_native_post_install(installer)
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false
)
__apply_Xcode_12_5_M1_post_install_workaround(installer) __apply_Xcode_12_5_M1_post_install_workaround(installer)
end end
end end

View File

@ -43,6 +43,7 @@
5DCACB8F33CDC322A6C60F78 /* libPods-massive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-massive.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 5DCACB8F33CDC322A6C60F78 /* libPods-massive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-massive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = massive/LaunchScreen.storyboard; sourceTree = "<group>"; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = massive/LaunchScreen.storyboard; sourceTree = "<group>"; };
89C6BE57DB24E9ADA2F236DE /* Pods-massive-massiveTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-massive-massiveTests.release.xcconfig"; path = "Target Support Files/Pods-massive-massiveTests/Pods-massive-massiveTests.release.xcconfig"; sourceTree = "<group>"; }; 89C6BE57DB24E9ADA2F236DE /* Pods-massive-massiveTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-massive-massiveTests.release.xcconfig"; path = "Target Support Files/Pods-massive-massiveTests/Pods-massive-massiveTests.release.xcconfig"; sourceTree = "<group>"; };
CA043791292233DB00942DF1 /* MaterialIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = MaterialIcons.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/MaterialIcons.ttf"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -116,6 +117,7 @@
83CBB9F61A601CBA00E9B192 = { 83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
CA043790292233A900942DF1 /* Fonts */,
13B07FAE1A68108700A75B9A /* massive */, 13B07FAE1A68108700A75B9A /* massive */,
832341AE1AAA6A7D00B99B32 /* Libraries */, 832341AE1AAA6A7D00B99B32 /* Libraries */,
00E356EF1AD99517003FC87E /* massiveTests */, 00E356EF1AD99517003FC87E /* massiveTests */,
@ -148,6 +150,14 @@
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
CA043790292233A900942DF1 /* Fonts */ = {
isa = PBXGroup;
children = (
CA043791292233DB00942DF1 /* MaterialIcons.ttf */,
);
path = Fonts;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -492,7 +502,6 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@ -518,7 +527,6 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@ -564,7 +572,7 @@
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386;
GCC_C_LANGUAGE_STANDARD = gnu99; GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@ -598,6 +606,7 @@
"-DFOLLY_MOBILE=1", "-DFOLLY_MOBILE=1",
"-DFOLLY_USE_LIBCPP=1", "-DFOLLY_USE_LIBCPP=1",
); );
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
}; };
name = Debug; name = Debug;
@ -635,7 +644,7 @@
COPY_PHASE_STRIP = YES; COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386;
GCC_C_LANGUAGE_STANDARD = gnu99; GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@ -661,6 +670,7 @@
"-DFOLLY_MOBILE=1", "-DFOLLY_MOBILE=1",
"-DFOLLY_USE_LIBCPP=1", "-DFOLLY_USE_LIBCPP=1",
); );
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };

View File

@ -1,6 +1,8 @@
#import <RCTAppDelegate.h> #import <React/RCTBridgeDelegate.h>
#import <UIKit/UIKit.h> #import <UIKit/UIKit.h>
@interface AppDelegate : RCTAppDelegate @interface AppDelegate : UIResponder <UIApplicationDelegate, RCTBridgeDelegate>
@property (nonatomic, strong) UIWindow *window;
@end @end

View File

@ -1,17 +1,85 @@
#import "AppDelegate.h" #import "AppDelegate.h"
#import <React/RCTBridge.h>
#import <React/RCTBundleURLProvider.h> #import <React/RCTBundleURLProvider.h>
#import <React/RCTRootView.h>
#import <React/RCTAppSetupUtils.h>
#if RCT_NEW_ARCH_ENABLED
#import <React/CoreModulesPlugins.h>
#import <React/RCTCxxBridgeDelegate.h>
#import <React/RCTFabricSurfaceHostingProxyRootView.h>
#import <React/RCTSurfacePresenter.h>
#import <React/RCTSurfacePresenterBridgeAdapter.h>
#import <ReactCommon/RCTTurboModuleManager.h>
#import <react/config/ReactNativeConfig.h>
static NSString *const kRNConcurrentRoot = @"concurrentRoot";
@interface AppDelegate () <RCTCxxBridgeDelegate, RCTTurboModuleManagerDelegate> {
RCTTurboModuleManager *_turboModuleManager;
RCTSurfacePresenterBridgeAdapter *_bridgeAdapter;
std::shared_ptr<const facebook::react::ReactNativeConfig> _reactNativeConfig;
facebook::react::ContextContainer::Shared _contextContainer;
}
@end
#endif
@implementation AppDelegate @implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{ {
self.moduleName = @"massive"; RCTAppSetupPrepareApp(application);
// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};
return [super application:application didFinishLaunchingWithOptions:launchOptions]; RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions];
#if RCT_NEW_ARCH_ENABLED
_contextContainer = std::make_shared<facebook::react::ContextContainer const>();
_reactNativeConfig = std::make_shared<facebook::react::EmptyReactNativeConfig const>();
_contextContainer->insert("ReactNativeConfig", _reactNativeConfig);
_bridgeAdapter = [[RCTSurfacePresenterBridgeAdapter alloc] initWithBridge:bridge contextContainer:_contextContainer];
bridge.surfacePresenter = _bridgeAdapter.surfacePresenter;
#endif
NSDictionary *initProps = [self prepareInitialProps];
UIView *rootView = RCTAppSetupDefaultRootView(bridge, @"massive", initProps);
if (@available(iOS 13.0, *)) {
rootView.backgroundColor = [UIColor systemBackgroundColor];
} else {
rootView.backgroundColor = [UIColor whiteColor];
}
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
UIViewController *rootViewController = [UIViewController new];
rootViewController.view = rootView;
self.window.rootViewController = rootViewController;
[self.window makeKeyAndVisible];
return YES;
}
/// This method controls whether the `concurrentRoot`feature of React18 is turned on or off.
///
/// @see: https://reactjs.org/blog/2022/03/29/react-v18.html
/// @note: This requires to be rendering on Fabric (i.e. on the New Architecture).
/// @return: `true` if the `concurrentRoot` feture is enabled. Otherwise, it returns `false`.
- (BOOL)concurrentRootEnabled
{
// Switch this bool to turn on and off the concurrent root
return true;
}
- (NSDictionary *)prepareInitialProps
{
NSMutableDictionary *initProps = [NSMutableDictionary new];
#ifdef RCT_NEW_ARCH_ENABLED
initProps[kRNConcurrentRoot] = @([self concurrentRootEnabled]);
#endif
return initProps;
} }
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
@ -23,4 +91,43 @@
#endif #endif
} }
#if RCT_NEW_ARCH_ENABLED
#pragma mark - RCTCxxBridgeDelegate
- (std::unique_ptr<facebook::react::JSExecutorFactory>)jsExecutorFactoryForBridge:(RCTBridge *)bridge
{
_turboModuleManager = [[RCTTurboModuleManager alloc] initWithBridge:bridge
delegate:self
jsInvoker:bridge.jsCallInvoker];
return RCTAppSetupDefaultJsExecutorFactory(bridge, _turboModuleManager);
}
#pragma mark RCTTurboModuleManagerDelegate
- (Class)getModuleClassFromName:(const char *)name
{
return RCTCoreModulesClassProvider(name);
}
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const std::string &)name
jsInvoker:(std::shared_ptr<facebook::react::CallInvoker>)jsInvoker
{
return nullptr;
}
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const std::string &)name
initParams:
(const facebook::react::ObjCTurboModule::InitParams &)params
{
return nullptr;
}
- (id<RCTTurboModule>)getModuleInstanceFromClass:(Class)moduleClass
{
return RCTAppSetupDefaultModuleFromClass(moduleClass);
}
#endif
@end @end

View File

@ -1,53 +1,53 @@
{ {
"images" : [ "images": [
{ {
"idiom" : "iphone", "idiom": "iphone",
"scale" : "2x", "scale": "2x",
"size" : "20x20" "size": "20x20"
}, },
{ {
"idiom" : "iphone", "idiom": "iphone",
"scale" : "3x", "scale": "3x",
"size" : "20x20" "size": "20x20"
}, },
{ {
"idiom" : "iphone", "idiom": "iphone",
"scale" : "2x", "scale": "2x",
"size" : "29x29" "size": "29x29"
}, },
{ {
"idiom" : "iphone", "idiom": "iphone",
"scale" : "3x", "scale": "3x",
"size" : "29x29" "size": "29x29"
}, },
{ {
"idiom" : "iphone", "idiom": "iphone",
"scale" : "2x", "scale": "2x",
"size" : "40x40" "size": "40x40"
}, },
{ {
"idiom" : "iphone", "idiom": "iphone",
"scale" : "3x", "scale": "3x",
"size" : "40x40" "size": "40x40"
}, },
{ {
"idiom" : "iphone", "idiom": "iphone",
"scale" : "2x", "scale": "2x",
"size" : "60x60" "size": "60x60"
}, },
{ {
"idiom" : "iphone", "idiom": "iphone",
"scale" : "3x", "scale": "3x",
"size" : "60x60" "size": "60x60"
}, },
{ {
"idiom" : "ios-marketing", "idiom": "ios-marketing",
"scale" : "1x", "scale": "1x",
"size" : "1024x1024" "size": "1024x1024"
} }
], ],
"info" : { "info": {
"author" : "xcode", "author": "xcode",
"version" : 1 "version": 1
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"info" : { "info": {
"version" : 1, "version": 1,
"author" : "xcode" "author": "xcode"
} }
} }

View File

@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>UIAppFonts</key>
<array>
<string>MaterialIcons.ttf</string>
</array>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@ -17,11 +21,11 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string> <string>1.0</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>1</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
@ -43,6 +47,10 @@
<array> <array>
<string>armv7</string> <string>armv7</string>
</array> </array>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>

View File

@ -1,21 +1,29 @@
import { NativeModules } from "react-native"; import 'react-native-gesture-handler/jestSetup'
import "react-native-gesture-handler/jestSetup"; import {NativeModules as RNNativeModules} from 'react-native'
NativeModules.RNViewShot = NativeModules.RNViewShot || { //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(), captureScreen: jest.fn(),
}; }
NativeModules.SettingsModule = NativeModules.SettingsModule || {
ignoringBattery: jest.fn(),
is24: jest.fn(() => Promise.resolve(true)),
};
jest.mock("react-native-file-access", () => jest.fn()); jest.mock('react-native-file-access', () => jest.fn())
jest.mock("react-native-share", () => jest.fn()); jest.mock('react-native-share', () => jest.fn())
jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper"); jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper')
jest.mock("react-native/Libraries/EventEmitter/NativeEventEmitter"); jest.useFakeTimers()
jest.mock('react-native-reanimated', () => {
jest.mock("react-native-reanimated", () => { const Reanimated = require('react-native-reanimated/mock')
const Reanimated = require("react-native-reanimated/mock"); Reanimated.default.call = () => {}
Reanimated.default.call = () => {}; return Reanimated
return Reanimated; })
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 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: 102 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@ -1,5 +1,5 @@
export enum Metrics { export enum Metrics {
Weight = "Best weight", Weight = 'Best weight',
Volume = "Volume", Volume = 'Volume',
OneRepMax = "One rep max", OneRepMax = 'One rep max',
} }

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