Compare commits

..

3 Commits

Author SHA1 Message Date
3ec685a76e Give up on hidden drawer navigation.
Auto focusing is all messed up here and I
don't think all this trouble is worth it.
2022-09-29 13:32:59 +13:00
8a74d750c4 Fix back logic 2022-09-29 12:50:56 +13:00
5fc4c758b6 Use hidden drawer routes instead of drawer + stack
Closes #68
2022-09-29 12:28:03 +13:00
193 changed files with 7006 additions and 12169 deletions

View File

@ -1,3 +0,0 @@
source "https://rubygems.org"
gem "fastlane"

View File

@ -1,22 +1,16 @@
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'],
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',
semi: 'off',
curly: 'off',
'react/react-in-jsx-scope': 'off',
'react-native/no-inline-styles': 'off',
'no-spaced-func': 'off',
}, },
}, },
], ],
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

7
.prettierrc.js Normal file
View File

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

View File

@ -1 +1 @@
2.7.5 2.7.4

12
.vscode/launch.json vendored
View File

@ -1,12 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Android Hermes - Experimental",
"request": "launch",
"type": "reactnativedirect",
"cwd": "${workspaceFolder}",
"platform": "android"
}
]
}

136
App.tsx
View File

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

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);

74
BestPage.tsx Normal file
View File

@ -0,0 +1,74 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import {default as React, useCallback, useEffect, useState} from 'react';
import {FlatList, Image} from 'react-native';
import {List} from 'react-native-paper';
import {getBestReps, getBestWeights} from './best.service';
import {DrawerParamList} from './drawer-param-list';
import Page from './Page';
import Set from './set';
import {settings} from './settings.service';
export default function BestPage() {
const [bests, setBests] = useState<Set[]>([]);
const [search, setSearch] = useState('');
const navigation = useNavigation<NavigationProp<DrawerParamList>>();
const refresh = useCallback(async () => {
const weights = await getBestWeights(search);
console.log(`${BestPage.name}.refresh:`, {length: weights.length});
let newBest: Set[] = [];
for (const set of weights) {
const reps = await getBestReps(set.name, set.weight);
newBest.push(...reps);
}
setBests(newBest);
}, [search]);
useFocusEffect(
useCallback(() => {
refresh();
navigation.getParent()?.setOptions({
headerRight: () => null,
});
}, [refresh, navigation]),
);
useEffect(() => {
refresh();
}, [search, refresh]);
const renderItem = ({item}: {item: Set}) => (
<List.Item
key={item.name}
title={item.name}
description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
onPress={() => navigation.navigate('View best', {best: item})}
left={() =>
(settings.images && item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)) ||
null
}
/>
);
return (
<Page search={search} setSearch={setSearch}>
<FlatList
style={{height: '99%'}}
ListEmptyComponent={
<List.Item
title="No exercises yet"
description="Once sets have been added, this will highlight your personal bests."
/>
}
renderItem={renderItem}
data={bests}
/>
</Page>
);
}

View File

@ -1,11 +1,10 @@
import { useTheme } from "@react-navigation/native"; import * as shape from 'd3-shape';
import * as shape from "d3-shape"; import React, {useContext} from 'react';
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 {CustomTheme} from './App';
import { MARGIN, PADDING } from "./constants"; import {MARGIN, PADDING} from './constants';
import GymSet from "./gym-set"; import Set from './set';
import useDark from "./use-dark";
export default function Chart({ export default function Chart({
yData, yData,
@ -14,53 +13,41 @@ export default function Chart({
yFormat, yFormat,
}: { }: {
yData: number[]; yData: number[];
xData: GymSet[]; xData: Set[];
xFormat: (value: any, index: number) => string; xFormat: (value: any, index: number) => string;
yFormat: (value: any) => string; yFormat: (value: any) => string;
}) { }) {
const { colors } = useTheme(); const {color} = useContext(CustomTheme);
const dark = useDark(); const axesSvg = {fontSize: 10, fill: 'grey'};
const axesSvg = { const verticalContentInset = {top: 10, bottom: 10};
fontSize: 10,
fill: dark
? CombinedDarkTheme.colors.text
: CombinedDefaultTheme.colors.text,
};
const verticalContentInset = { top: 10, bottom: 10 };
const xAxisHeight = 30; const xAxisHeight = 30;
return ( return (
<> <>
<View <View style={{height: 300, padding: PADDING, flexDirection: 'row'}}>
style={{
height: 300,
padding: PADDING,
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: color,
}} }}>
>
<Grid /> <Grid />
</LineChart> </LineChart>
<XAxis <XAxis
style={{marginHorizontal: -10, height: xAxisHeight}}
data={xData} data={xData}
formatLabel={xFormat} formatLabel={xFormat}
contentInset={{ left: 15, right: 16 }} contentInset={{left: 10, right: 10}}
svg={axesSvg} svg={axesSvg}
/> />
</View> </View>

View File

@ -1,4 +1,5 @@
import { Button, Dialog, Portal, Text } from "react-native-paper"; import React from 'react';
import {Button, Dialog, Portal, Text} from 'react-native-paper';
export default function ConfirmDialog({ export default function ConfirmDialog({
title, title,
@ -6,20 +7,13 @@ export default function ConfirmDialog({
onOk, onOk,
show, show,
setShow, setShow,
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;
}) { }) {
const cancel = () => {
setShow(false);
onCancel && onCancel();
};
return ( return (
<Portal> <Portal>
<Dialog visible={show} onDismiss={() => setShow(false)}> <Dialog visible={show} onDismiss={() => setShow(false)}>
@ -29,7 +23,7 @@ export default function ConfirmDialog({
</Dialog.Content> </Dialog.Content>
<Dialog.Actions> <Dialog.Actions>
<Button onPress={onOk}>OK</Button> <Button onPress={onOk}>OK</Button>
<Button onPress={cancel}>Cancel</Button> <Button onPress={() => setShow(false)}>Cancel</Button>
</Dialog.Actions> </Dialog.Actions>
</Dialog> </Dialog>
</Portal> </Portal>

View File

@ -1,22 +0,0 @@
import { DrawerNavigationProp } from "@react-navigation/drawer";
import { useNavigation } from "@react-navigation/native";
import { Appbar, IconButton } from "react-native-paper";
import { DrawerParamList } from "./drawer-param-list";
export default function DrawerHeader({
name,
children,
}: {
name: string;
children?: JSX.Element | JSX.Element[];
}) {
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
return (
<Appbar.Header>
<IconButton icon="menu" onPress={navigation.openDrawer} />
<Appbar.Content title={name} />
{children}
</Appbar.Header>
);
}

150
DrawerMenu.tsx Normal file
View File

@ -0,0 +1,150 @@
import {NavigationProp, useNavigation} from '@react-navigation/native';
import React, {useCallback, useContext, useState} from 'react';
import DocumentPicker from 'react-native-document-picker';
import {FileSystem} from 'react-native-file-access';
import {Divider, IconButton, Menu} from 'react-native-paper';
import ConfirmDialog from './ConfirmDialog';
import {DrawerParamList} from './drawer-param-list';
import {SnackbarContext} from './MassiveSnack';
import {Plan} from './plan';
import {addPlans, deletePlans, getAllPlans} from './plan.service';
import {addSets, deleteSets, getAllSets} from './set.service';
import {write} from './write';
const setFields =
'id,name,reps,weight,created,unit,hidden,sets,minutes,seconds';
const planFields = 'id,days,workouts';
export default function DrawerMenu({name}: {name: keyof DrawerParamList}) {
const [showMenu, setShowMenu] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const {toast} = useContext(SnackbarContext);
const {reset} = useNavigation<NavigationProp<DrawerParamList>>();
const exportSets = useCallback(async () => {
const sets = await getAllSets();
const data = [setFields]
.concat(
sets.map(
set =>
`${set.id},${set.name},${set.reps},${set.weight},${set.created},${set.unit},${set.hidden},${set.sets},${set.minutes},${set.seconds}`,
),
)
.join('\n');
console.log(`${DrawerMenu.name}.exportSets`, {length: sets.length});
await write('sets.csv', data);
}, []);
const exportPlans = useCallback(async () => {
const plans: Plan[] = await getAllPlans();
const data = [planFields]
.concat(plans.map(set => `"${set.id}","${set.days}","${set.workouts}"`))
.join('\n');
console.log(`${DrawerMenu.name}.exportPlans`, {length: plans.length});
await write('plans.csv', data);
}, []);
const download = useCallback(async () => {
setShowMenu(false);
if (name === 'Home') exportSets();
else if (name === 'Plans') exportPlans();
}, [name, exportSets, exportPlans]);
const uploadSets = useCallback(async () => {
const result = await DocumentPicker.pickSingle();
const file = await FileSystem.readFile(result.uri);
console.log(`${DrawerMenu.name}.${uploadSets.name}:`, file.length);
const lines = file.split('\n');
console.log(lines[0]);
if (!setFields.includes(lines[0])) return toast('Invalid csv.', 3000);
const values = lines
.slice(1)
.filter(line => line)
.map(set => {
const [
,
setName,
reps,
weight,
created,
unit,
hidden,
sets,
minutes,
seconds,
] = set.split(',');
return `('${setName}',${reps},${weight},'${created}','${unit}',${hidden},${
sets ?? 3
},${minutes ?? 3},${seconds ?? 30})`;
})
.join(',');
await addSets(setFields.split(',').slice(1).join(','), values);
toast('Data imported.', 3000);
reset({index: 0, routes: [{name}]});
}, [reset, name, toast]);
const uploadPlans = useCallback(async () => {
const result = await DocumentPicker.pickSingle();
const file = await FileSystem.readFile(result.uri);
console.log(`${DrawerMenu.name}.uploadPlans:`, file.length);
const lines = file.split('\n');
if (lines[0] != planFields) return toast('Invalid csv.', 3000);
const values = file
.split('\n')
.slice(1)
.filter(line => line)
.map(set => {
const [, days, workouts] = set
.split('","')
.map(cell => cell.replace(/"/g, ''));
return `('${days}','${workouts}')`;
})
.join(',');
await addPlans(values);
toast('Data imported.', 3000);
}, [toast]);
const upload = useCallback(async () => {
setShowMenu(false);
if (name === 'Home') await uploadSets();
else if (name === 'Plans') await uploadPlans();
reset({index: 0, routes: [{name}]});
}, [name, uploadPlans, uploadSets, reset]);
const remove = useCallback(async () => {
setShowMenu(false);
setShowRemove(false);
if (name === 'Home') await deleteSets();
else if (name === 'Plans') await deletePlans();
toast('All data has been deleted.', 4000);
reset({index: 0, routes: [{name}]});
}, [reset, name, toast]);
if (name === 'Home' || name === 'Plans')
return (
<Menu
visible={showMenu}
onDismiss={() => setShowMenu(false)}
anchor={
<IconButton onPress={() => setShowMenu(true)} icon="more-vert" />
}>
<Menu.Item icon="arrow-downward" onPress={download} title="Download" />
<Menu.Item icon="arrow-upward" onPress={upload} title="Upload" />
<Divider />
<Menu.Item
icon="delete"
onPress={() => setShowRemove(true)}
title="Delete"
/>
<ConfirmDialog
title="Delete all data"
show={showRemove}
setShow={setShowRemove}
onOk={remove}>
This irreversibly deletes all data from the app. Are you sure?
</ConfirmDialog>
</Menu>
);
return null;
}

View File

@ -1,69 +1,86 @@
import { import {
NavigationProp, NavigationProp,
RouteProp, RouteProp,
useFocusEffect,
useNavigation, useNavigation,
useRoute, useRoute,
} from "@react-navigation/native"; } from '@react-navigation/native';
import { useCallback, useEffect, useState } from "react"; import React, {useCallback, useEffect, useState} from 'react';
import { ScrollView, StyleSheet, View } from "react-native"; import {BackHandler, ScrollView, StyleSheet, View} from 'react-native';
import { Button, IconButton, Text } from "react-native-paper"; import {Button, IconButton, Text} from 'react-native-paper';
import { MARGIN, PADDING } from "./constants"; import {MARGIN, PADDING} from './constants';
import { planRepo, setRepo } from "./db"; import {DrawerParamList} from './drawer-param-list';
import { defaultSet } from "./gym-set"; import {addPlan, updatePlan} from './plan.service';
import { PlanPageParams } from "./plan-page-params"; import {getNames} from './set.service';
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<DrawerParamList, 'Edit plan'>>();
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>>();
useFocusEffect(
useCallback(() => {
console.log(`${EditPlan.name}.focus:`, {plan});
navigation.setOptions({
headerLeft: () => (
<IconButton
icon="arrow-back"
onPress={() => navigation.navigate('Plans', {})}
/>
),
headerRight: () => null,
title: plan.id ? 'Edit plan' : 'Create plan',
});
const onBack = () => {
navigation.navigate('Plans', {});
return true;
};
BackHandler.addEventListener('hardwareBackPress', onBack);
return () => BackHandler.removeEventListener('hardwareBackPress', onBack);
}, [navigation, plan]),
);
useEffect(() => { useEffect(() => {
setRepo getNames().then(n => {
.createQueryBuilder() console.log(EditPlan.name, {n});
.select("name") setNames(n);
.distinct(true) });
.orderBy("name")
.getRawMany()
.then((values) => {
console.log(EditPlan.name, { values });
setNames(values.map((value) => value.name));
});
}, []); }, []);
const save = useCallback(async () => { const save = useCallback(async () => {
console.log(`${EditPlan.name}.save`, { days, workouts, plan }); console.log(`${EditPlan.name}.save`, {days, workouts, plan});
if (!days || !workouts) return; if (!days || !workouts) return;
const newWorkouts = workouts.filter((workout) => workout).join(","); const newWorkouts = workouts.filter(workout => workout).join(',');
const newDays = days.filter((day) => day).join(","); const newDays = days.filter(day => day).join(',');
await planRepo.save({ if (typeof plan.id === 'undefined')
title: title, await addPlan({days: newDays, workouts: newWorkouts});
days: newDays, else
workouts: newWorkouts, await updatePlan({
id: plan.id, days: newDays,
}); workouts: newWorkouts,
}, [title, days, workouts, plan]); id: plan.id,
});
navigation.goBack();
}, [days, workouts, plan, navigation]);
const toggleWorkout = useCallback( const toggleWorkout = useCallback(
(on: boolean, name: string) => { (on: boolean, name: string) => {
if (on) { if (on) {
setWorkouts([...workouts, name]); setWorkouts([...workouts, name]);
} else { } else {
setWorkouts(workouts.filter((workout) => workout !== name)); setWorkouts(workouts.filter(workout => workout !== name));
} }
}, },
[setWorkouts, workouts] [setWorkouts, workouts],
); );
const toggleDay = useCallback( const toggleDay = useCallback(
@ -71,83 +88,66 @@ export default function EditPlan() {
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 (
<> <View style={{padding: PADDING}}>
<StackHeader <ScrollView style={{height: '90%'}}>
title={typeof plan.id === "number" ? "Edit plan" : "Add plan"} <Text style={styles.title}>Days</Text>
> {DAYS.map(day => (
{typeof plan.id === "number" && ( <Switch
<IconButton key={day}
onPress={async () => { onValueChange={value => toggleDay(value, day)}
await save(); onPress={() => toggleDay(!days.includes(day), day)}
const newPlan = await planRepo.findOne({ value={days.includes(day)}>
where: { id: plan.id }, {day}
}); </Switch>
let first = await setRepo.findOne({ ))}
where: { name: workouts[0] }, <Text style={[styles.title, {marginTop: MARGIN}]}>Workouts</Text>
order: { created: "desc" }, {names.length === 0 ? (
}); <View>
if (!first) first = { ...defaultSet, name: workouts[0] }; <Text>No workouts found.</Text>
delete first.id; </View>
navigation.navigate("StartPlan", { plan: newPlan, first }); ) : (
}} names.map(name => (
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>
{DAYS.map((day) => (
<Switch <Switch
key={day} key={name}
onChange={(value) => toggleDay(value, day)} onValueChange={value => toggleWorkout(value, name)}
value={days.includes(day)} value={workouts.includes(name)}
title={day} onPress={() => toggleWorkout(!workouts.includes(name), name)}>
/> {name}
))} </Switch>
<Text style={[styles.title, { marginTop: MARGIN }]}>Workouts</Text> ))
{names.length === 0 ? ( )}
<View> </ScrollView>
<Text>No workouts found.</Text> {names.length === 0 ? (
</View>
) : (
names.map((name) => (
<Switch
key={name}
onChange={(value) => toggleWorkout(value, name)}
value={workouts.includes(name)}
title={name}
/>
))
)}
</ScrollView>
<Button <Button
disabled={workouts.length === 0 && days.length === 0} disabled={workouts.length === 0 && days.length === 0}
style={styles.button} mode="contained"
mode="outlined" onPress={() => {
navigation.goBack();
navigation.navigate('Workouts', {
screen: 'EditWorkout',
params: {value: {name: ''}},
});
}}>
Add workout
</Button>
) : (
<Button
disabled={workouts.length === 0 && days.length === 0}
style={{marginTop: MARGIN}}
mode="contained"
icon="save" icon="save"
onPress={async () => { onPress={save}>
await save();
navigation.navigate("PlanList");
}}
>
Save Save
</Button> </Button>
</View> )}
</> </View>
); );
} }
@ -156,5 +156,4 @@ const styles = StyleSheet.create({
fontSize: 20, fontSize: 20,
marginBottom: MARGIN, marginBottom: MARGIN,
}, },
button: {},
}); });

View File

@ -1,277 +1,103 @@
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import { import {
NavigationProp,
RouteProp, RouteProp,
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
useRoute, useRoute,
} from "@react-navigation/native"; } from '@react-navigation/native';
import { format } from "date-fns"; import React, {useCallback, useContext} from 'react';
import { useCallback, useRef, useState } from "react"; import {BackHandler, NativeModules, View} from 'react-native';
import { NativeModules, TextInput, View } from "react-native"; import {IconButton} from 'react-native-paper';
import DocumentPicker from "react-native-document-picker"; import {PADDING} from './constants';
import { Button, Card, IconButton, TouchableRipple } from "react-native-paper"; import {DrawerParamList} from './drawer-param-list';
import AppInput from "./AppInput"; import {SnackbarContext} from './MassiveSnack';
import ConfirmDialog from "./ConfirmDialog"; import Set from './set';
import { MARGIN, PADDING } from "./constants"; import {addSet, updateSet} from './set.service';
import { getNow, setRepo, settingsRepo } from "./db"; import SetForm from './SetForm';
import GymSet from "./gym-set"; import {getSettings, settings, updateSettings} from './settings.service';
import { HomePageParams } from "./home-page-params";
import Settings from "./settings";
import StackHeader from "./StackHeader";
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<DrawerParamList, 'Edit set'>>();
const { set } = params; const {set, count, workouts} = params;
const navigation = useNavigation(); const navigation = useNavigation<NavigationProp<DrawerParamList>>();
const [settings, setSettings] = useState<Settings>({} as Settings); const {toast} = useContext(SnackbarContext);
const [name, setName] = useState(set.name);
const [reps, setReps] = useState(set.reps?.toString());
const [weight, setWeight] = useState(set.weight?.toString());
const [newImage, setNewImage] = useState(set.image);
const [unit, setUnit] = useState(set.unit);
const [created, setCreated] = useState<Date>(
set.created ? new Date(set.created) : new Date()
);
const [createdDirty, setCreatedDirty] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const [removeImage, setRemoveImage] = useState(false);
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const unitRef = useRef<TextInput>(null);
const [selection, setSelection] = useState({
start: 0,
end: set.reps?.toString().length,
});
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings); console.log(`${EditSet.name}.focus:`, set);
}, []) let title = 'Create set';
if (typeof set.id === 'number') title = 'Edit set';
else if (Number(set.sets) > 0)
title = `${set.name} (${count + 1} / ${set.sets})`;
navigation.setOptions({
title,
headerLeft: () => (
<IconButton
icon="arrow-back"
onPress={() => navigation.navigate('Home', {})}
/>
),
});
const onBack = () => {
navigation.navigate('Home', {});
return true;
};
BackHandler.addEventListener('hardwareBackPress', onBack);
return () => BackHandler.removeEventListener('hardwareBackPress', onBack);
}, [navigation, set, count]),
); );
const startTimer = useCallback( const startTimer = useCallback(async (_set: Set) => {
async (value: string) => { if (!settings.alarm) return;
if (!settings.alarm) return; const milliseconds =
const first = await setRepo.findOne({ where: { name: value } }); Number(_set.minutes) * 60 * 1000 + Number(_set.seconds) * 1000;
const milliseconds = NativeModules.AlarmModule.timer(
(first?.minutes ?? 3) * 60 * 1000 + (first?.seconds ?? 0) * 1000; milliseconds,
if (milliseconds) NativeModules.AlarmModule.timer(milliseconds); !!settings.vibrate,
settings.sound,
);
const next = new Date();
next.setTime(next.getTime() + milliseconds);
await updateSettings({...settings, nextAlarm: next.toISOString()});
await getSettings();
}, []);
const update = useCallback(
async (_set: Set) => {
console.log(`${EditSet.name}.update`, _set);
await updateSet(_set);
navigation.goBack();
}, },
[settings] [navigation],
); );
const added = useCallback( const add = useCallback(
async (value: GymSet) => { async (_set: Set) => {
startTimer(value.name); console.log(`${EditSet.name}.add`, {set: _set});
console.log(`${EditSet.name}.add`, { set: value }); startTimer(_set);
if (!settings.notify) return; await addSet(_set);
if (!settings.notify) return navigation.goBack();
if ( if (
value.weight > set.weight || _set.weight > set.weight ||
(value.reps > set.reps && value.weight === set.weight) (_set.reps > set.reps && _set.weight === set.weight)
) { )
toast("Great work King! That's a new record."); toast("Great work King, that's a new record!", 3000);
} navigation.goBack();
}, },
[startTimer, set, settings] [navigation, startTimer, set, toast],
); );
const handleSubmit = async () => { const save = useCallback(
if (!name) return; async (_set: Set) => {
if (typeof set.id === 'number') return update(_set);
const newSet: Partial<GymSet> = { return add(_set);
id: set.id, },
name, [update, add, set.id],
reps: Number(reps), );
weight: Number(weight),
unit,
minutes: Number(set.minutes ?? 3),
seconds: Number(set.seconds ?? 30),
sets: set.sets ?? 3,
hidden: false,
};
newSet.image = newImage;
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 { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: "documentDirectory",
});
if (fileCopyUri) setNewImage(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setNewImage("");
setRemoveImage(true);
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 (
<> <View style={{padding: PADDING}}>
<StackHeader <SetForm save={save} set={set} workouts={workouts} />
title={typeof set.id === "number" ? "Edit set" : "Add set"} </View>
/>
<View style={{ padding: PADDING, flex: 1 }}>
<AppInput
label="Name"
value={name}
onChangeText={setName}
autoCorrect={false}
autoFocus={!name}
onSubmitEditing={() => repsRef.current?.focus()}
/>
<View style={{ flexDirection: "row" }}>
<AppInput
style={{
flex: 1,
marginBottom: MARGIN,
}}
label="Reps"
keyboardType="numeric"
value={reps}
onChangeText={(newReps) => {
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
style={{
flexDirection: "row",
marginBottom: MARGIN,
}}
>
<AppInput
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 && (
<AppInput
autoCapitalize="none"
label="Unit"
value={unit}
onChangeText={setUnit}
innerRef={unitRef}
/>
)}
{settings.showDate && (
<AppInput
label="Created"
value={format(created, settings.date || "P")}
onPressOut={pickDate}
/>
)}
{settings.images && newImage && (
<TouchableRipple
style={{ marginBottom: MARGIN }}
onPress={changeImage}
onLongPress={() => setShowRemove(true)}
>
<Card.Cover source={{ uri: newImage }} />
</TouchableRipple>
)}
{settings.images && !newImage && (
<Button
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="add-photo-alternate"
>
Image
</Button>
)}
</View>
<Button
disabled={!name}
mode="outlined"
icon="save"
style={{ margin: MARGIN }}
onPress={handleSubmit}
>
Save
</Button>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemove}
setShow={setShowRemove}
>
Are you sure you want to remove the image?
</ConfirmDialog>
</>
); );
} }

View File

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

View File

@ -1,84 +1,86 @@
import { import {
NavigationProp,
RouteProp, RouteProp,
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
useRoute, useRoute,
} from "@react-navigation/native"; } from '@react-navigation/native';
import { useCallback, useRef, useState } from "react"; import React, {useCallback, useContext, useState} from 'react';
import { ScrollView, TextInput, View } from "react-native"; import {BackHandler, ScrollView, 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, IconButton, 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 {DrawerParamList} from './drawer-param-list';
import { getNow, planRepo, setRepo, settingsRepo } from "./db"; import MassiveInput from './MassiveInput';
import { fixNumeric } from "./fix-numeric"; import {SnackbarContext} from './MassiveSnack';
import { defaultSet } from "./gym-set"; import {updatePlanWorkouts} from './plan.service';
import Settings from "./settings"; import {addSet, updateManySet, updateSetImage} from './set.service';
import StackHeader from "./StackHeader"; import {settings} from './settings.service';
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<DrawerParamList, 'Edit workout'>>();
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 {toast} = useContext(SnackbarContext);
const setsRef = useRef<TextInput>(null); const navigation = useNavigation<NavigationProp<DrawerParamList>>();
const stepsRef = useRef<TextInput>(null);
const minutesRef = useRef<TextInput>(null);
const secondsRef = useRef<TextInput>(null);
const [settings, setSettings] = useState<Settings>();
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings); navigation.setOptions({
}, []) headerLeft: () => (
<IconButton
icon="arrow-back"
onPress={() => navigation.navigate('Workouts', {})}
/>
),
headerRight: null,
title: params.value.name || 'New workout',
});
const onBack = () => {
navigation.navigate('Workouts', {});
return true;
};
BackHandler.addEventListener('hardwareBackPress', onBack);
return () => BackHandler.removeEventListener('hardwareBackPress', onBack);
}, [navigation, params.value.name]),
); );
const update = async () => { const update = async () => {
await setRepo.update( await updateManySet({
{ name: params.value.name }, oldName: params.value.name,
{ newName: name || params.value.name,
name: name || params.value.name, sets: sets ?? '3',
sets: Number(sets), seconds: seconds?.toString() ?? '30',
minutes: +minutes, minutes: minutes?.toString() ?? '3',
seconds: +seconds, steps,
steps, });
image: removeImage ? "" : uri, await updatePlanWorkouts(params.value.name, name || params.value.name);
} if (uri || removeImage) await updateSetImage(params.value.name, uri || '');
);
await planRepo.query(
`UPDATE plans
SET workouts = REPLACE(workouts, $1, $2)
WHERE workouts LIKE $3`,
[params.value.name, name, `%${params.value.name}%`]
);
navigation.goBack(); navigation.goBack();
}; };
const add = async () => { const add = async () => {
const now = await getNow(); await addSet({
await setRepo.save({
...defaultSet,
name, name,
reps: 0,
weight: 0,
hidden: true, hidden: true,
image: uri, image: uri,
minutes: minutes ? +minutes : 3, minutes: minutes ? +minutes : 3,
seconds: seconds ? +seconds : 30, seconds: seconds ? +seconds : 30,
sets: sets ? +sets : 3, sets: sets ? +sets : 3,
steps, steps,
created: now,
}); });
navigation.goBack(); navigation.goBack();
}; };
@ -89,116 +91,93 @@ export default function EditWorkout() {
}; };
const changeImage = useCallback(async () => { const changeImage = useCallback(async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({ const {fileCopyUri} = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images, type: 'image/*',
copyTo: "documentDirectory", copyTo: 'documentDirectory',
}); });
if (fileCopyUri) setUri(fileCopyUri); if (fileCopyUri) setUri(fileCopyUri);
}, []); }, []);
const handleRemove = useCallback(async () => { const handleRemove = useCallback(async () => {
setUri(""); setUri('');
setRemoveImage(true); setRemoveImage(true);
setShowRemove(false); setShowRemove(false);
}, []); }, []);
const submitName = () => { const handleName = (value: string) => {
if (settings.steps) stepsRef.current?.focus(); setName(value.replace(/,|'/g, ''));
else setsRef.current?.focus(); if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
const handleSteps = (value: string) => {
setSteps(value.replace(/,|'/g, ''));
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
}; };
return ( return (
<> <View style={{padding: PADDING}}>
<StackHeader title={params.value.name ? "Edit workout" : "Add workout"} /> <ScrollView style={{height: '90%'}}>
<View style={{ padding: PADDING, flex: 1 }}> <MassiveInput
<ScrollView style={{ flex: 1 }}> autoFocus
<AppInput label="Name"
autoFocus value={name}
label="Name" onChangeText={handleName}
value={name} />
onChangeText={setName} {!!settings.steps && (
onSubmitEditing={submitName} <MassiveInput
selectTextOnFocus={false}
value={steps}
onChangeText={handleSteps}
label="Steps"
multiline
/> />
{settings?.steps && ( )}
<AppInput <MassiveInput
innerRef={stepsRef} value={sets}
selectTextOnFocus={false} onChangeText={setSets}
value={steps} label="Sets per workout"
onChangeText={setSteps} keyboardType="numeric"
label="Steps" />
multiline <MassiveInput
onSubmitEditing={() => setsRef.current?.focus()} value={minutes}
/> onChangeText={setMinutes}
)} label="Rest minutes"
<AppInput keyboardType="numeric"
innerRef={setsRef} />
value={sets} <MassiveInput
onChangeText={(newSets) => { value={seconds}
const fixed = fixNumeric(newSets); onChangeText={setSeconds}
setSets(fixed); label="Rest seconds"
if (fixed.length !== newSets.length) keyboardType="numeric"
toast("Sets must be a number"); />
}} {uri ? (
label="Sets per workout" <TouchableRipple
keyboardType="numeric" style={{marginBottom: MARGIN}}
onSubmitEditing={() => minutesRef.current?.focus()} onPress={changeImage}
/> onLongPress={() => setShowRemove(true)}>
{settings?.alarm && ( <Card.Cover source={{uri}} />
<> </TouchableRipple>
<AppInput ) : (
innerRef={minutesRef} <Button
onSubmitEditing={() => secondsRef.current?.focus()} style={{marginBottom: MARGIN}}
value={minutes} onPress={changeImage}
onChangeText={(newMinutes) => { icon="add-photo-alternate">
const fixed = fixNumeric(newMinutes); Image
setMinutes(fixed); </Button>
if (fixed.length !== newMinutes.length) )}
toast("Reps must be a number"); </ScrollView>
}} <Button disabled={!name} mode="contained" icon="save" onPress={save}>
label="Rest minutes" Save
keyboardType="numeric" </Button>
/> <ConfirmDialog
<AppInput title="Remove image"
innerRef={secondsRef} onOk={handleRemove}
value={seconds} show={showRemove}
onChangeText={setSeconds} setShow={setShowRemove}>
label="Rest seconds" Are you sure you want to remove the image?
keyboardType="numeric" </ConfirmDialog>
blurOnSubmit </View>
/>
</>
)}
{settings?.images && uri && (
<TouchableRipple
style={{ marginBottom: MARGIN }}
onPress={changeImage}
onLongPress={() => setShowRemove(true)}
>
<Card.Cover source={{ uri }} />
</TouchableRipple>
)}
{settings?.images && !uri && (
<Button
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="add-photo-alternate"
>
Image
</Button>
)}
</ScrollView>
<Button disabled={!name} mode="outlined" icon="save" onPress={save}>
Save
</Button>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemove}
setShow={setShowRemove}
>
Are you sure you want to remove the image?
</ConfirmDialog>
</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,100 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.5)
rexml
activesupport (6.1.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
atomos (0.1.3)
claide (1.1.0)
cocoapods (1.11.3)
addressable (~> 2.8)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.11.3)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 1.4.0, < 2.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.4.0, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
fourflusher (>= 2.3.0, < 3.0)
gh_inspector (~> 1.0)
molinillo (~> 0.8.0)
nap (~> 1.0)
ruby-macho (>= 1.0, < 3.0)
xcodeproj (>= 1.21.0, < 2.0)
cocoapods-core (1.11.3)
activesupport (>= 5.0, < 7)
addressable (~> 2.8)
algoliasearch (~> 1.0)
concurrent-ruby (~> 1.1)
fuzzy_match (~> 2.0.4)
nap (~> 1.0)
netrc (~> 0.11)
public_suffix (~> 4.0)
typhoeus (~> 1.0)
cocoapods-deintegrate (1.0.5)
cocoapods-downloader (1.6.3)
cocoapods-plugins (1.0.0)
nap
cocoapods-search (1.0.1)
cocoapods-trunk (1.6.0)
nap (>= 0.8, < 2.0)
netrc (~> 0.11)
cocoapods-try (1.2.0)
colored2 (3.1.2)
concurrent-ruby (1.1.10)
escape (0.0.4)
ethon (0.16.0)
ffi (>= 1.15.0)
ffi (1.15.5)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
httpclient (2.8.3)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
json (2.6.2)
minitest (5.16.3)
molinillo (0.8.0)
nanaimo (0.3.0)
nap (1.1.0)
netrc (0.11.0)
public_suffix (4.0.7)
rexml (3.2.5)
ruby-macho (2.5.1)
typhoeus (1.4.0)
ethon (>= 0.9.0)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
zeitwerk (2.6.6)
PLATFORMS
ruby
DEPENDENCIES
cocoapods (~> 1.11, >= 1.11.2)
RUBY VERSION
ruby 2.7.5p203
BUNDLED WITH
2.1.4

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,135 @@
import { createStackNavigator } from "@react-navigation/stack"; import {
import EditSet from "./EditSet"; NavigationProp,
import EditSets from "./EditSets"; useFocusEffect,
import { HomePageParams } from "./home-page-params"; useNavigation,
import SetList from "./SetList"; } from '@react-navigation/native';
import {default as React, useCallback, useEffect, useState} from 'react';
import {FlatList} from 'react-native';
import {List} from 'react-native-paper';
import {getBestSet} from './best.service';
import {DrawerParamList} from './drawer-param-list';
import Page from './Page';
import {getTodaysPlan} from './plan.service';
import Set from './set';
import {countToday, defaultSet, getSets, getToday} from './set.service';
import SetItem from './SetItem';
import {settings} from './settings.service';
const Stack = createStackNavigator<HomePageParams>(); const limit = 15;
export default function HomePage() { export default function HomePage() {
const [sets, setSets] = useState<Set[]>();
const [set, setSet] = useState<Set>();
const [count, setCount] = useState(0);
const [workouts, setWorkouts] = useState<string[]>([]);
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [end, setEnd] = useState(false);
const [dates, setDates] = useState(false);
const [images, setImages] = useState(true);
const navigation = useNavigation<NavigationProp<DrawerParamList>>();
const predict = useCallback(async () => {
setCount(0);
setSet({...defaultSet});
if (!settings.predict) return;
const todaysPlan = await getTodaysPlan();
console.log(`${HomePage.name}.predict:`, {todaysPlan});
if (todaysPlan.length === 0) return;
const todaysWorkouts = todaysPlan[0].workouts.split(',');
setWorkouts(todaysWorkouts);
let best = await getBestSet(todaysWorkouts[0]);
const todaysSet = await getToday();
if (!todaysSet || !todaysWorkouts.includes(todaysSet.name))
return setSet({...best});
let _count = await countToday(todaysSet.name);
best = await getBestSet(todaysSet.name);
const index = todaysWorkouts.indexOf(todaysSet.name) + 1;
if (_count >= Number(best.sets)) {
best = await getBestSet(todaysWorkouts[index]);
_count = 0;
}
if (best.name === '') setCount(0);
else setCount(_count);
setSet({...best});
}, []);
const refresh = useCallback(async () => {
predict();
const newSets = await getSets({search: `%${search}%`, limit, offset: 0});
console.log(`${HomePage.name}.refresh:`, {first: newSets[0]});
if (newSets.length === 0) return setSets([]);
setSets(newSets);
setOffset(0);
setEnd(false);
}, [search, predict]);
useFocusEffect(
useCallback(() => {
refresh();
setImages(!!settings.images);
}, [refresh]),
);
useEffect(() => {
refresh();
}, [search, refresh]);
const renderItem = useCallback(
({item}: {item: Set}) => (
<SetItem
dates={dates}
setDates={setDates}
images={images}
setImages={setImages}
item={item}
key={item.id}
onRemove={refresh}
/>
),
[refresh, dates, setDates, images, setImages],
);
const next = useCallback(async () => {
if (end) return;
const newOffset = offset + limit;
console.log(`${HomePage.name}.next:`, {offset, newOffset, search});
const newSets = await getSets({
search: `%${search}%`,
limit,
offset: newOffset,
});
if (newSets.length === 0) return setEnd(true);
if (!sets) return;
setSets([...sets, ...newSets]);
if (newSets.length < limit) return setEnd(true);
setOffset(newOffset);
}, [search, end, offset, sets]);
const onAdd = useCallback(async () => {
console.log(`${HomePage.name}.onAdd`, {set, workouts});
navigation.navigate('Edit set', {
set: set || {...defaultSet},
workouts,
count,
});
}, [navigation, set, workouts, count]);
return ( return (
<Stack.Navigator <Page onAdd={onAdd} search={search} setSearch={setSearch}>
screenOptions={{ headerShown: false, animationEnabled: false }} <FlatList
> data={sets}
<Stack.Screen name="Sets" component={SetList} /> style={{height: '99%'}}
<Stack.Screen name="EditSet" component={EditSet} /> ListEmptyComponent={
<Stack.Screen name="EditSets" component={EditSets} /> <List.Item
</Stack.Navigator> title="No sets yet"
description="A set is a group of repetitions. E.g. 8 reps of Squats."
/>
}
renderItem={renderItem}
keyExtractor={s => s.id!.toString()}
onEndReached={next}
/>
</Page>
); );
} }

View File

@ -1,94 +0,0 @@
import { useState } from "react";
import { Divider, IconButton, Menu } from "react-native-paper";
import ConfirmDialog from "./ConfirmDialog";
export default function ListMenu({
onEdit,
onCopy,
onClear,
onDelete,
onSelect,
ids,
}: {
onEdit: () => void;
onCopy: () => void;
onClear: () => void;
onDelete: () => void;
onSelect: () => void;
ids?: number[];
}) {
const [showMenu, setShowMenu] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const edit = () => {
setShowMenu(false);
onEdit();
};
const copy = () => {
setShowMenu(false);
onCopy();
};
const clear = () => {
setShowMenu(false);
onClear();
};
const remove = () => {
setShowMenu(false);
setShowRemove(false);
onDelete();
};
const select = () => {
onSelect();
};
return (
<Menu
visible={showMenu}
onDismiss={() => setShowMenu(false)}
anchor={<IconButton onPress={() => setShowMenu(true)} icon="more-vert" />}
>
<Menu.Item leadingIcon="done-all" title="Select all" onPress={select} />
<Menu.Item
leadingIcon="clear"
title="Clear"
onPress={clear}
disabled={ids?.length === 0}
/>
<Menu.Item
leadingIcon="edit"
title="Edit"
onPress={edit}
disabled={ids?.length === 0}
/>
<Menu.Item
leadingIcon="content-copy"
title="Copy"
onPress={copy}
disabled={ids?.length === 0}
/>
<Divider />
<Menu.Item
leadingIcon="delete"
onPress={() => setShowRemove(true)}
title="Delete"
/>
<ConfirmDialog
title={ids?.length === 0 ? "Delete all" : "Delete selected"}
show={showRemove}
setShow={setShowRemove}
onOk={remove}
onCancel={() => setShowMenu(false)}
>
{ids?.length === 0 ? (
<>This irreversibly deletes records from the app. Are you sure?</>
) : (
<>This will delete {ids?.length} record(s). Are you sure?</>
)}
</ConfirmDialog>
</Menu>
);
}

27
MassiveFab.tsx Normal file
View File

@ -0,0 +1,27 @@
import React, {useContext} from 'react';
import {FAB} from 'react-native-paper';
import {CustomTheme} from './App';
import {lightColors} from './colors';
export default function MassiveFab(
props: Partial<React.ComponentProps<typeof FAB>>,
) {
const {color} = useContext(CustomTheme);
const fabColor = lightColors.map(lightColor => lightColor.hex).includes(color)
? 'black'
: undefined;
return (
<FAB
icon="add"
color={fabColor}
style={{
position: 'absolute',
right: 10,
bottom: 60,
backgroundColor: color,
}}
{...props}
/>
);
}

24
MassiveInput.tsx Normal file
View File

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

44
MassiveSnack.tsx Normal file
View File

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

View File

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

View File

@ -1,107 +1,53 @@
import { import {NavigationProp, useNavigation} from '@react-navigation/native';
NavigationProp, import React, {useCallback, useState} from 'react';
useFocusEffect, import {GestureResponderEvent} from 'react-native';
useNavigation, import {List, Menu} from 'react-native-paper';
} from "@react-navigation/native"; import {DrawerParamList} from './drawer-param-list';
import { useCallback, useMemo, useState } from "react"; import {Plan} from './plan';
import { Text } from "react-native"; import {deletePlan} from './plan.service';
import { List } from "react-native-paper";
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants";
import { setRepo } from "./db";
import { defaultSet } from "./gym-set";
import { Plan } from "./plan";
import { PlanPageParams } from "./plan-page-params";
import { DAYS } from "./time";
import useDark from "./use-dark";
export default function PlanItem({ export default function PlanItem({
item, item,
setIds, onRemove,
ids,
}: { }: {
item: Plan; item: Plan;
ids: number[]; onRemove: () => void;
setIds: (value: number[]) => void;
}) { }) {
const [today, setToday] = useState<string>(); const [show, setShow] = useState(false);
const dark = useDark(); const [anchor, setAnchor] = useState({x: 0, y: 0});
const days = useMemo(() => item.days.split(","), [item.days]); const navigation = useNavigation<NavigationProp<DrawerParamList>>();
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
useFocusEffect( const remove = useCallback(async () => {
useCallback(() => { if (typeof item.id === 'number') await deletePlan(item.id);
const newToday = DAYS[new Date().getDay()]; setShow(false);
setToday(newToday); onRemove();
}, []) }, [setShow, item.id, onRemove]);
const longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
setShow(true);
},
[setAnchor, setShow],
); );
const start = useCallback(async () => {
const workout = item.workouts.split(",")[0];
let first = await setRepo.findOne({
where: { name: workout },
order: { created: "desc" },
});
if (!first) first = { ...defaultSet, name: workout };
delete first.id;
if (ids.length === 0) {
return navigation.navigate("StartPlan", { plan: item, first });
}
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(() => {
if (ids.length > 0) return;
setIds([item.id]);
}, [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(
() =>
item.title ? (
<Text style={{ fontWeight: "bold" }}>{item.title}</Text>
) : (
currentDays
),
[item.title, currentDays]
);
const description = useMemo(
() => (item.title ? currentDays : item.workouts.replace(/,/g, ", ")),
[item.title, currentDays, item.workouts]
);
const backgroundColor = useMemo(() => {
if (!ids.includes(item.id)) return;
if (dark) return DARK_RIPPLE;
return LIGHT_RIPPLE;
}, [dark, ids, item.id]);
return ( return (
<List.Item <>
onPress={start} <List.Item
title={title} onPress={() => navigation.navigate('Edit plan', {plan: item})}
description={description} title={
onLongPress={longPress} item.days
style={{ backgroundColor }} ? item.days.replace(/,/g, ', ')
/> : item.workouts.replace(/,/g, ', ')
}
description={item.days ? item.workouts.replace(/,/g, ', ') : null}
onLongPress={longPress}
right={() => (
<Menu anchor={anchor} visible={show} onDismiss={() => setShow(false)}>
<Menu.Item icon="delete" onPress={remove} title="Delete" />
</Menu>
)}
/>
</>
); );
} }

View File

@ -1,120 +0,0 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from "@react-navigation/native";
import { useCallback, useState } from "react";
import { FlatList } from "react-native";
import { List } from "react-native-paper";
import { Like } from "typeorm";
import { planRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
import ListMenu from "./ListMenu";
import Page from "./Page";
import { Plan } from "./plan";
import { PlanPageParams } from "./plan-page-params";
import PlanItem from "./PlanItem";
export default function PlanList() {
const [term, setTerm] = useState("");
const [plans, setPlans] = useState<Plan[]>();
const [ids, setIds] = useState<number[]>([]);
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
const refresh = useCallback(async (value: string) => {
planRepo
.find({
where: [
{ title: Like(`%${value.trim()}%`) },
{ days: Like(`%${value.trim()}%`) },
{ workouts: Like(`%${value.trim()}%`) },
],
})
.then(setPlans);
}, []);
useFocusEffect(
useCallback(() => {
refresh(term);
}, [refresh, term])
);
const search = useCallback(
(value: string) => {
setTerm(value);
refresh(value);
},
[refresh]
);
const renderItem = useCallback(
({ item }: { item: Plan }) => (
<PlanItem ids={ids} setIds={setIds} item={item} key={item.id} />
),
[ids]
);
const onAdd = () =>
navigation.navigate("EditPlan", {
plan: { title: "", days: "", workouts: "" },
});
const edit = useCallback(async () => {
const plan = await planRepo.findOne({ where: { id: ids.pop() } });
navigation.navigate("EditPlan", { plan });
setIds([]);
}, [ids, navigation]);
const copy = useCallback(async () => {
const plan = await planRepo.findOne({
where: { id: ids.pop() },
});
delete plan.id;
navigation.navigate("EditPlan", { plan });
setIds([]);
}, [ids, navigation]);
const clear = useCallback(() => {
setIds([]);
}, []);
const remove = useCallback(async () => {
await planRepo.delete(ids.length > 0 ? ids : {});
await refresh(term);
setIds([]);
}, [ids, refresh, term]);
const select = useCallback(() => {
setIds(plans.map((plan) => plan.id));
}, [plans]);
return (
<>
<DrawerHeader name={ids.length > 0 ? `${ids.length} selected` : "Plans"}>
<ListMenu
onClear={clear}
onCopy={copy}
onDelete={remove}
onEdit={edit}
ids={ids}
onSelect={select}
/>
</DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}>
{plans?.length === 0 ? (
<List.Item
title="No plans yet"
description="A plan is a list of workouts for certain days."
/>
) : (
<FlatList
style={{ flex: 1 }}
data={plans}
renderItem={renderItem}
keyExtractor={(set) => set.id?.toString() || ""}
/>
)}
</Page>
</>
);
}

View File

@ -1,21 +1,64 @@
import { createStackNavigator } from "@react-navigation/stack"; import {
import EditPlan from "./EditPlan"; NavigationProp,
import EditSet from "./EditSet"; useFocusEffect,
import { PlanPageParams } from "./plan-page-params"; useNavigation,
import PlanList from "./PlanList"; } from '@react-navigation/native';
import StartPlan from "./StartPlan"; import {default as React, useCallback, useEffect, useState} from 'react';
import {FlatList} from 'react-native';
const Stack = createStackNavigator<PlanPageParams>(); import {List} from 'react-native-paper';
import {DrawerParamList} from './drawer-param-list';
import DrawerMenu from './DrawerMenu';
import Page from './Page';
import {Plan} from './plan';
import {getPlans} from './plan.service';
import PlanItem from './PlanItem';
export default function PlanPage() { export default function PlanPage() {
const [search, setSearch] = useState('');
const [plans, setPlans] = useState<Plan[]>([]);
const navigation = useNavigation<NavigationProp<DrawerParamList>>();
const refresh = useCallback(async () => {
getPlans(search).then(setPlans);
}, [search]);
useFocusEffect(
useCallback(() => {
refresh();
navigation.getParent()?.setOptions({
headerRight: () => <DrawerMenu name="Plans" />,
});
}, [refresh, navigation]),
);
useEffect(() => {
refresh();
}, [search, refresh]);
const renderItem = useCallback(
({item}: {item: Plan}) => (
<PlanItem item={item} key={item.id} onRemove={refresh} />
),
[refresh],
);
const onAdd = () =>
navigation.navigate('Edit plan', {plan: {days: '', workouts: ''}});
return ( return (
<Stack.Navigator <Page onAdd={onAdd} search={search} setSearch={setSearch}>
screenOptions={{ headerShown: false, animationEnabled: false }} <FlatList
> style={{height: '99%'}}
<Stack.Screen name="PlanList" component={PlanList} /> data={plans}
<Stack.Screen name="EditPlan" component={EditPlan} /> renderItem={renderItem}
<Stack.Screen name="StartPlan" component={StartPlan} /> keyExtractor={set => set.id?.toString() || ''}
<Stack.Screen name="EditSet" component={EditSet} /> ListEmptyComponent={
</Stack.Navigator> <List.Item
title="No plans yet"
description="A plan is a list of workouts for certain days."
/>
}
/>
</Page>
); );
} }

View File

@ -26,7 +26,6 @@ Massive tracks your reps and sets at the gym. No internet connectivity or high s
<img src="metadata/en-US/images/phoneScreenshots/timer.png" width="318"/> <img src="metadata/en-US/images/phoneScreenshots/timer.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/plans.png" width="318"/> <img src="metadata/en-US/images/phoneScreenshots/plans.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/plan-edit.png" width="318"/> <img src="metadata/en-US/images/phoneScreenshots/plan-edit.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/plan-start.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/best-view.png" width="318"/> <img src="metadata/en-US/images/phoneScreenshots/best-view.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/settings.png" width="318"/> <img src="metadata/en-US/images/phoneScreenshots/settings.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/drawer.png" width="318"/> <img src="metadata/en-US/images/phoneScreenshots/drawer.png" width="318"/>

View File

@ -1,57 +1,83 @@
import { createDrawerNavigator } from "@react-navigation/drawer"; import {createDrawerNavigator} from '@react-navigation/drawer';
import { IconButton } from "react-native-paper"; import React, {useContext, useEffect, useState} from 'react';
import GraphsPage from "./GraphsPage"; import {useColorScheme} from 'react-native';
import { DrawerParamList } from "./drawer-param-list"; import {IconButton} from 'react-native-paper';
import HomePage from "./HomePage"; import {CustomTheme} from './App';
import PlanPage from "./PlanPage"; import BestPage from './BestPage';
import SettingsPage from "./SettingsPage"; import {runMigrations} from './db';
import TimerPage from "./TimerPage"; import {DrawerParamList} from './drawer-param-list';
import useDark from "./use-dark"; import DrawerMenu from './DrawerMenu';
import WorkoutsPage from "./WorkoutsPage"; import EditPlan from './EditPlan';
import EditSet from './EditSet';
import EditWorkout from './EditWorkout';
import HomePage from './HomePage';
import PlanPage from './PlanPage';
import Route from './route';
import {getSettings, settings} from './settings.service';
import SettingsPage from './SettingsPage';
import ViewBest from './ViewBest';
import WorkoutsPage from './WorkoutsPage';
const Drawer = createDrawerNavigator<DrawerParamList>(); const Drawer = createDrawerNavigator<DrawerParamList>();
export default function Routes() { export default function Routes() {
const dark = useDark(); const [migrated, setMigrated] = useState(false);
const dark = useColorScheme() === 'dark';
const {setColor} = useContext(CustomTheme);
useEffect(() => {
runMigrations()
.then(getSettings)
.then(() => {
setMigrated(true);
if (settings.color) setColor(settings.color);
});
}, [setColor]);
if (!migrated) return null;
const routes: Route[] = [
{name: 'Home', component: HomePage, icon: 'home'},
{name: 'Plans', component: PlanPage, icon: 'event'},
{name: 'Best', component: BestPage, icon: 'insights'},
{name: 'Workouts', component: WorkoutsPage, icon: 'fitness-center'},
{name: 'Settings', component: SettingsPage, icon: 'settings'},
];
const hiddenRoutes: Route[] = [
{name: 'Edit set', component: EditSet},
{name: 'Edit plan', component: EditPlan},
{name: 'Edit workout', component: EditWorkout},
{name: 'View best', component: ViewBest},
];
return ( return (
<Drawer.Navigator <Drawer.Navigator
screenOptions={{ screenOptions={{
headerTintColor: dark ? "white" : "black", headerTintColor: dark ? 'white' : 'black',
swipeEdgeWidth: 1000, swipeEdgeWidth: 1000,
headerShown: false, }}>
}} {routes.map(route => (
> <Drawer.Screen
<Drawer.Screen key={route.name}
name="Home" name={route.name}
component={HomePage} component={route.component}
options={{ drawerIcon: () => <IconButton icon="home" /> }} options={{
/> drawerIcon: () => <IconButton icon={route.icon || ''} />,
<Drawer.Screen headerRight: () => <DrawerMenu name={route.name} />,
name="Plans" }}
component={PlanPage} />
options={{ drawerIcon: () => <IconButton icon="event" /> }} ))}
/> {hiddenRoutes.map(route => (
<Drawer.Screen <Drawer.Screen
name="Graphs" key={route.name}
component={GraphsPage} name={route.name}
options={{ drawerIcon: () => <IconButton icon="insights" /> }} component={route.component}
/> options={{
<Drawer.Screen drawerItemStyle: {height: 0},
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,75 +0,0 @@
import React, { useCallback, useMemo, useState } from "react";
import { View } from "react-native";
import { Button, Menu, Subheading, useTheme } from "react-native-paper";
import { ITEM_PADDING } from "./constants";
export interface Item {
value: string;
label: string;
color?: string;
}
function Select({
value,
onChange,
items,
label,
}: {
value: string;
onChange: (value: string) => void;
items: Item[];
label?: string;
}) {
const [show, setShow] = useState(false);
const { colors } = useTheme();
const selected = useMemo(
() => items.find((item) => item.value === value) || items[0],
[items, value]
);
const handlePress = useCallback(
(newValue: string) => {
onChange(newValue);
setShow(false);
},
[onChange]
);
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingLeft: ITEM_PADDING,
}}
>
{label && <Subheading style={{ width: 100 }}>{label}</Subheading>}
<Menu
visible={show}
onDismiss={() => setShow(false)}
anchor={
<Button
onPress={() => setShow(true)}
style={{
alignSelf: "flex-start",
}}
>
{selected?.label}
</Button>
}
>
{items.map((item) => (
<Menu.Item
titleStyle={{ color: item.color || colors.onSurface }}
key={item.value}
title={item.label}
onPress={() => handlePress(item.value)}
/>
))}
</Menu>
</View>
);
}
export default React.memo(Select);

153
SetForm.tsx Normal file
View File

@ -0,0 +1,153 @@
import {useFocusEffect} from '@react-navigation/native';
import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import {ScrollView, View} from 'react-native';
import {Button, Text} from 'react-native-paper';
import MassiveInput from './MassiveInput';
import {SnackbarContext} from './MassiveSnack';
import Set from './set';
import {getSets} from './set.service';
import {settings} from './settings.service';
export default function SetForm({
save,
set,
workouts,
}: {
set: Set;
save: (set: Set) => void;
workouts: string[];
}) {
const [name, setName] = useState(set.name);
const [reps, setReps] = useState(set.reps.toString());
const [weight, setWeight] = useState(set.weight.toString());
const [unit, setUnit] = useState(set.unit);
const [uri, setUri] = useState(set.image);
const [selection, setSelection] = useState({
start: 0,
end: set.reps.toString().length,
});
const {toast} = useContext(SnackbarContext);
const weightRef = useRef<any>(null);
const repsRef = useRef<any>(null);
const unitRef = useRef<any>(null);
useFocusEffect(
useCallback(() => {
repsRef?.current.focus();
}, []),
);
useEffect(() => {
console.log('SetForm.useEffect:', {uri, set});
setName(set.name);
setReps(set.reps.toString());
setWeight(set.weight.toString());
setUnit(set.unit);
if (!set.image)
getSets({search: set.name, limit: 1, offset: 0}).then(([s]) =>
setUri(s?.image),
);
else setUri(set.image);
repsRef?.current.focus();
}, [uri, set]);
const handleSubmit = () => {
if (!name) return;
save({
name,
reps: Number(reps),
weight: Number(weight),
id: set.id,
unit,
image: uri,
minutes: Number(set.minutes ?? 3),
seconds: Number(set.seconds ?? 30),
sets: set.sets ?? 3,
});
};
const handleName = (value: string) => {
setName(value.replace(/,|'/g, ''));
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
const handleUnit = (value: string) => {
setUnit(value.replace(/,|'/g, ''));
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
return (
<>
<ScrollView style={{height: '90%'}}>
<MassiveInput
label="Name"
value={name}
onChangeText={handleName}
autoCorrect={false}
blurOnSubmit={false}
onSubmitEditing={() => repsRef.current?.focus()}
/>
<MassiveInput
label="Reps"
keyboardType="numeric"
value={reps}
onChangeText={setReps}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
blurOnSubmit={false}
innerRef={repsRef}
/>
<MassiveInput
label="Weight"
keyboardType="numeric"
value={weight}
onChangeText={setWeight}
onSubmitEditing={handleSubmit}
innerRef={weightRef}
/>
{!!settings.showUnit && (
<MassiveInput
autoCapitalize="none"
label="Unit"
value={unit}
onChangeText={handleUnit}
innerRef={unitRef}
/>
)}
{workouts.length > 0 && !!settings.workouts && (
<View style={{flexDirection: 'row'}}>
{workouts.map((workout, index) => (
<Text key={workout}>
<Text
style={
workout === name
? {textDecorationLine: 'underline', fontWeight: 'bold'}
: null
}>
{workout}
</Text>
{index === workouts.length - 1 ? '' : ', '}
</Text>
))}
</View>
)}
</ScrollView>
<Button
disabled={!name}
mode="contained"
icon="save"
onPress={handleSubmit}>
Save
</Button>
</>
);
}

View File

@ -1,77 +1,99 @@
import { NavigationProp, useNavigation } from "@react-navigation/native"; import {NavigationProp, useNavigation} from '@react-navigation/native';
import { format } from "date-fns"; import React, {useCallback, useState} from 'react';
import { useCallback, useMemo } from "react"; import {GestureResponderEvent, Image} from 'react-native';
import { Image } from "react-native"; import {Divider, List, Menu, Text} from 'react-native-paper';
import { List, Text } from "react-native-paper"; import {DrawerParamList} from './drawer-param-list';
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants"; import Set from './set';
import GymSet from "./gym-set"; import {deleteSet} from './set.service';
import { HomePageParams } from "./home-page-params";
import Settings from "./settings";
import useDark from "./use-dark";
export default function SetItem({ export default function SetItem({
item, item,
settings, onRemove,
ids, dates,
setIds, setDates,
images,
setImages,
}: { }: {
item: GymSet; item: Set;
onRemove: () => void; onRemove: () => void;
settings: Settings; dates: boolean;
ids: number[]; setDates: (value: boolean) => void;
setIds: (value: number[]) => void; images: boolean;
setImages: (value: boolean) => void;
}) { }) {
const dark = useDark(); const [showMenu, setShowMenu] = useState(false);
const navigation = useNavigation<NavigationProp<HomePageParams>>(); const [anchor, setAnchor] = useState({x: 0, y: 0});
const navigation = useNavigation<NavigationProp<DrawerParamList>>();
const longPress = useCallback(() => { const remove = useCallback(async () => {
if (ids.length > 0) return; if (typeof item.id === 'number') await deleteSet(item.id);
setIds([item.id]); setShowMenu(false);
}, [ids.length, item.id, setIds]); onRemove();
}, [setShowMenu, onRemove, item.id]);
const press = useCallback(() => { const copy = useCallback(() => {
if (ids.length === 0) return navigation.navigate("EditSet", { set: item }); const set: Set = {...item};
const removing = ids.find((id) => id === item.id); delete set.id;
if (removing) setIds(ids.filter((id) => id !== item.id)); setShowMenu(false);
else setIds([...ids, item.id]); navigation.navigate('Edit set', {set, workouts: [], count: 0});
}, [ids, item, navigation, setIds]); }, [navigation, item]);
const backgroundColor = useMemo(() => { const longPress = useCallback(
if (!ids.includes(item.id)) return; (e: GestureResponderEvent) => {
if (dark) return DARK_RIPPLE; setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
return LIGHT_RIPPLE; setShowMenu(true);
}, [dark, ids, item.id]); },
[setShowMenu, setAnchor],
);
const left = useCallback(() => { const toggleDates = useCallback(() => {
if (!settings.images || !item.image) return null; setDates(!dates);
return ( setShowMenu(false);
<Image source={{ uri: item.image }} style={{ height: 75, width: 75 }} /> }, [dates, setDates]);
);
}, [item.image, settings.images]);
const right = useCallback(() => { const toggleImages = useCallback(() => {
if (!settings.showDate) return null; setImages(!images);
return ( setShowMenu(false);
<Text }, [images, setImages]);
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={() =>
description={`${item.reps} x ${item.weight}${item.unit || "kg"}`} navigation.navigate('Edit set', {set: item, workouts: [], count: 0})
onLongPress={longPress} }
style={{ backgroundColor }} title={item.name}
left={left} description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
right={right} onLongPress={longPress}
/> left={() =>
images &&
item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)
}
right={() => (
<>
{dates && (
<Text
style={{
alignSelf: 'center',
}}>
{item.created?.replace('T', ' ')}
</Text>
)}
<Menu
anchor={anchor}
visible={showMenu}
onDismiss={() => setShowMenu(false)}>
<Menu.Item icon="content-copy" onPress={copy} title="Copy" />
<Menu.Item icon="image" onPress={toggleImages} title="Images" />
<Menu.Item icon="event" onPress={toggleDates} title="Dates" />
<Divider />
<Menu.Item icon="delete" onPress={remove} title="Delete" />
</Menu>
</>
)}
/>
</>
); );
} }

View File

@ -1,158 +0,0 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from "@react-navigation/native";
import { useCallback, useState } from "react";
import { FlatList } from "react-native";
import { List } from "react-native-paper";
import { Like } from "typeorm";
import { LIMIT } from "./constants";
import { getNow, setRepo, settingsRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
import GymSet, { defaultSet } from "./gym-set";
import { HomePageParams } from "./home-page-params";
import ListMenu from "./ListMenu";
import Page from "./Page";
import SetItem from "./SetItem";
import Settings from "./settings";
export default function SetList() {
const [sets, setSets] = useState<GymSet[]>([]);
const [offset, setOffset] = useState(0);
const [term, setTerm] = useState("");
const [end, setEnd] = useState(false);
const [settings, setSettings] = useState<Settings>();
const [ids, setIds] = useState<number[]>([]);
const navigation = useNavigation<NavigationProp<HomePageParams>>();
const refresh = useCallback(async (value: string) => {
const newSets = await setRepo.find({
where: { name: Like(`%${value.trim()}%`), hidden: 0 as any },
take: LIMIT,
skip: 0,
order: { created: "DESC" },
});
console.log(`${SetList.name}.refresh:`, { value });
setSets(newSets);
setOffset(0);
setEnd(false);
}, []);
useFocusEffect(
useCallback(() => {
refresh(term);
settingsRepo.findOne({ where: {} }).then(setSettings);
}, [refresh, term])
);
const renderItem = useCallback(
({ item }: { item: GymSet }) => (
<SetItem
settings={settings}
item={item}
key={item.id}
onRemove={() => refresh(term)}
ids={ids}
setIds={setIds}
/>
),
[refresh, term, settings, ids]
);
const next = useCallback(async () => {
if (end) return;
const newOffset = offset + LIMIT;
console.log(`${SetList.name}.next:`, { offset, newOffset, term });
const newSets = await setRepo.find({
where: { name: Like(`%${term}%`), hidden: 0 as any },
take: LIMIT,
skip: newOffset,
order: { created: "DESC" },
});
if (newSets.length === 0) return setEnd(true);
if (!sets) return;
setSets([...sets, ...newSets]);
if (newSets.length < LIMIT) return setEnd(true);
setOffset(newOffset);
}, [term, end, offset, sets]);
const onAdd = useCallback(async () => {
const now = await getNow();
let set = sets[0];
if (!set) set = { ...defaultSet };
set.created = now;
delete set.id;
navigation.navigate("EditSet", { set });
}, [navigation, sets]);
const search = useCallback(
(value: string) => {
setTerm(value);
refresh(value);
},
[refresh]
);
const edit = useCallback(() => {
navigation.navigate("EditSets", { ids });
setIds([]);
}, [ids, navigation]);
const copy = useCallback(async () => {
const set = await setRepo.findOne({
where: { id: ids.pop() },
});
delete set.id;
delete set.created;
navigation.navigate("EditSet", { set });
setIds([]);
}, [ids, navigation]);
const clear = useCallback(() => {
setIds([]);
}, []);
const remove = useCallback(async () => {
setIds([]);
await setRepo.delete(ids.length > 0 ? ids : {});
await refresh(term);
}, [ids, refresh, term]);
const select = useCallback(() => {
setIds(sets.map((set) => set.id));
}, [sets]);
return (
<>
<DrawerHeader name={ids.length > 0 ? `${ids.length} selected` : "Home"}>
<ListMenu
onClear={clear}
onCopy={copy}
onDelete={remove}
onEdit={edit}
ids={ids}
onSelect={select}
/>
</DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}>
{sets?.length === 0 ? (
<List.Item
title="No sets yet"
description="A set is a group of repetitions. E.g. 8 reps of Squats."
/>
) : (
settings && (
<FlatList
data={sets}
style={{ flex: 1 }}
renderItem={renderItem}
onEndReached={next}
/>
)
)}
</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 {Picker} from '@react-native-picker/picker';
import { format } from "date-fns"; import {useFocusEffect} from '@react-navigation/native';
import { useCallback, useEffect, useMemo, useState } from "react"; import React, {useCallback, useContext, useEffect, useState} from 'react';
import { useForm } from "react-hook-form"; import {NativeModules, ScrollView} from 'react-native';
import { NativeModules, ScrollView } from "react-native"; import DocumentPicker from 'react-native-document-picker';
import DocumentPicker from "react-native-document-picker"; import {Button} from 'react-native-paper';
import { Dirs, FileSystem } from "react-native-file-access"; import {CustomTheme} from './App';
import ConfirmDialog from "./ConfirmDialog"; import {darkColors, lightColors} from './colors';
import { MARGIN } from "./constants"; import ConfirmDialog from './ConfirmDialog';
import { AppDataSource } from "./data-source"; import {MARGIN} from './constants';
import { setRepo, settingsRepo } from "./db"; import Input from './input';
import { DrawerParamList } from "./drawer-param-list"; import {SnackbarContext} from './MassiveSnack';
import DrawerHeader from "./DrawerHeader"; import Page from './Page';
import Input from "./input"; import {getSettings, settings, updateSettings} from './settings.service';
import { darkOptions, lightOptions, themeOptions } from "./options"; import Switch from './Switch';
import Page from "./Page";
import Select from "./Select";
import SettingButton from "./SettingButton";
import Settings from "./settings";
import Switch from "./Switch";
import { toast } from "./toast";
import { useTheme } from "./use-theme";
const twelveHours = [
"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 [battery, setBattery] = useState(false);
const [ignoring, setIgnoring] = useState(false); const [ignoring, setIgnoring] = useState(false);
const [term, setTerm] = useState(""); const [search, setSearch] = useState('');
const [formatOptions, setFormatOptions] = useState<string[]>(twelveHours); const [vibrate, setVibrate] = useState(!!settings.vibrate);
const [importing, setImporting] = useState(false); const [alarm, setAlarm] = useState(!!settings.alarm);
const [deleting, setDeleting] = useState(false); const [predict, setPredict] = useState(!!settings.predict);
const { reset } = useNavigation<NavigationProp<DrawerParamList>>(); const [sound, setSound] = useState(settings.sound);
const [notify, setNotify] = useState(!!settings.notify);
const [images, setImages] = useState(!!settings.images);
const [showUnit, setShowUnit] = useState(!!settings.showUnit);
const [workouts, setWorkouts] = useState(!!settings.workouts);
const [steps, setSteps] = useState(!!settings.steps);
const [focus, setFocus] = useState(settings.focus);
const {color, setColor} = useContext(CustomTheme);
const {toast} = useContext(SnackbarContext);
const { watch, setValue } = useForm<Settings>({ useFocusEffect(
defaultValues: () => settingsRepo.findOne({ where: {} }), useCallback(() => {
}); NativeModules.AlarmModule.ignoringBattery(setIgnoring);
const settings = watch(); }, []),
);
const {
theme,
setTheme,
lightColor,
setLightColor,
darkColor,
setDarkColor,
} = useTheme();
useEffect(() => { useEffect(() => {
NativeModules.SettingsModule.ignoringBattery(setIgnoring); updateSettings({
NativeModules.SettingsModule.is24().then((is24: boolean) => { vibrate: +vibrate,
console.log(`${SettingsPage.name}.focus:`, { is24 }); alarm: +alarm,
if (is24) setFormatOptions(twentyFours); predict: +predict,
else setFormatOptions(twelveHours); sound,
notify: +notify,
images: +images,
showUnit: +showUnit,
color,
workouts: +workouts,
steps: +steps,
focus,
}); });
}, []); getSettings();
}, [
vibrate,
alarm,
predict,
sound,
notify,
images,
showUnit,
color,
workouts,
steps,
focus,
]);
const update = useCallback((key: keyof Settings, value: unknown) => { const changeAlarmEnabled = useCallback(
return settingsRepo (enabled: boolean) => {
.createQueryBuilder() setAlarm(enabled);
.update() if (enabled) toast('Timers will now run after each set.', 4000);
.set({ [key]: value }) else toast('Stopped timers running after each set.', 4000);
.printSql() if (enabled && !ignoring) setBattery(true);
.execute(); },
}, []); [setBattery, ignoring, toast],
);
const soundString = useMemo(() => { const changePredict = useCallback(
if (!settings.sound) return null; (enabled: boolean) => {
const split = settings.sound.split("/"); setPredict(enabled);
return split.pop(); if (enabled) toast('Predict your next set based on todays plan.', 4000);
}, [settings.sound]); else toast('New sets will always be empty.', 4000);
},
[setPredict, toast],
);
const changeVibrate = useCallback(
(enabled: boolean) => {
setVibrate(enabled);
if (enabled) toast('When a timer completes, vibrate your phone.', 4000);
else toast('Stop vibrating at the end of timers.', 4000);
},
[setVibrate, toast],
);
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); setSound(fileCopyUri);
await update("sound", fileCopyUri); toast('This song will now play after rest timers complete.', 4000);
toast("Sound will play after rest timers."); }, [toast]);
}, [setValue, update]);
const switches: Input<boolean>[] = useMemo( const changeNotify = useCallback(
() => [ (enabled: boolean) => {
{ name: "Rest timers", value: settings.alarm, key: "alarm" }, setNotify(enabled);
{ name: "Vibrate", value: settings.vibrate, key: "vibrate" }, if (enabled) toast('Show when a set is a new record.', 4000);
{ name: "Disable sound", value: settings.noSound, key: "noSound" }, else toast('Stopped showing notifications for new records.', 4000);
{ 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] [toast],
); );
const renderSwitch = useCallback( const changeImages = useCallback(
(item: Input<boolean>) => ( (enabled: boolean) => {
<Switch setImages(enabled);
key={item.name} if (enabled) toast('Show images for sets.', 4000);
value={item.value} else toast('Stopped showing images for sets.', 4000);
onChange={(value) => changeBoolean(item.key, value)}
title={item.name}
/>
),
[changeBoolean]
);
const switchesMarkup = useMemo(
() => switches.filter(filter).map((s) => renderSwitch(s)),
[filter, switches, renderSwitch]
);
const changeString = useCallback(
async (key: keyof Settings, value: string) => {
setValue(key, value);
await update(key, value);
switch (key) {
case "date":
return toast("Changed date format");
case "darkColor":
setDarkColor(value);
return toast("Set primary color for dark mode.");
case "lightColor":
setLightColor(value);
return toast("Set primary color for light mode.");
case "vibrate":
return toast("Set primary color for light mode.");
case "sound":
return toast("Sound will play after rest timers.");
case "theme":
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] [toast],
); );
const selects: Input<string>[] = useMemo(() => { const changeUnit = useCallback(
const today = new Date(); (enabled: boolean) => {
return [ setShowUnit(enabled);
{ name: "Theme", value: theme, items: themeOptions, key: "theme" }, if (enabled) toast('Show option to select unit for sets.', 4000);
{ else toast('Hid unit option for sets.', 4000);
name: "Dark color", },
value: darkColor, [toast],
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(
(input: Input<string>) => (
<Select
key={input.name}
value={input.value}
onChange={(value) => changeString(input.key, value)}
label={input.name}
items={input.items}
/>
),
[changeString]
); );
const selectsMarkup = useMemo( const changeWorkouts = useCallback(
() => selects.filter(filter).map(renderSelect), (enabled: boolean) => {
[filter, selects, renderSelect] setWorkouts(enabled);
if (enabled) toast('Show workout for sets.', 4000);
else toast('Stopped showing workout for sets.', 4000);
},
[toast],
); );
const confirmDelete = useCallback(async () => { const changeSteps = useCallback(
setDeleting(false); (enabled: boolean) => {
await AppDataSource.dropDatabase(); setSteps(enabled);
await AppDataSource.destroy(); if (enabled) toast('Show steps for a workout.', 4000);
await AppDataSource.initialize(); else toast('Stopped showing steps for workouts.', 4000);
toast("Database deleted."); },
}, []); [toast],
const confirmImport = useCallback(async () => {
setImporting(false);
await AppDataSource.destroy();
const file = await DocumentPicker.pickSingle();
await FileSystem.cp(file.uri, Dirs.DatabaseDir + "/massive.db");
await AppDataSource.initialize();
await setRepo.createQueryBuilder().update().set({ image: null }).execute();
await update("sound", null);
const { alarm, backup } = await settingsRepo.findOne({ where: {} });
console.log({ backup });
const directory = await DocumentPicker.pickDirectory();
if (backup) NativeModules.BackupModule.start(directory.uri);
else NativeModules.BackupModule.stop();
NativeModules.SettingsModule.ignoringBattery((isIgnoring: boolean) => {
if (alarm && !isIgnoring) NativeModules.SettingsModule.ignoreBattery();
reset({ index: 0, routes: [{ name: "Settings" }] });
});
}, [reset, update]);
const exportDatabase = useCallback(async () => {
const path = Dirs.DatabaseDir + "/massive.db";
await FileSystem.cpExternal(path, "massive.db", "downloads");
toast("Database exported. Check downloads.");
}, []);
const buttons = useMemo(
() => [
{
name: soundString || "Default",
onPress: changeSound,
label: "Alarm sound",
},
{ name: "Export database", onPress: exportDatabase },
{ name: "Import database", onPress: () => setImporting(true) },
{ name: "Delete database", onPress: () => setDeleting(true) },
],
[changeSound, exportDatabase, soundString]
); );
const buttonsMarkup = useMemo( const switches: Input<boolean>[] = [
() => {name: 'Rest timers', value: alarm, onChange: changeAlarmEnabled},
buttons {name: 'Vibrate', value: vibrate, onChange: changeVibrate},
.filter(filter) {name: 'Predict sets', value: predict, onChange: changePredict},
.map((button) => <SettingButton {...button} key={button.name} />), {name: 'Record notifications', value: notify, onChange: changeNotify},
[buttons, filter] {name: 'Show images', value: images, onChange: changeImages},
); {name: 'Show unit', value: showUnit, onChange: changeUnit},
{name: 'Show workouts', value: workouts, onChange: changeWorkouts},
{name: 'Show steps', value: steps, onChange: changeSteps},
];
return ( return (
<> <Page search={search} setSearch={setSearch}>
<DrawerHeader name="Settings" /> <ScrollView style={{marginTop: MARGIN}}>
{switches
<Page term={term} search={setTerm} style={{ flexGrow: 1 }}> .filter(input =>
<ScrollView style={{ marginTop: MARGIN, flex: 1 }}> input.name.toLowerCase().includes(search.toLowerCase()),
{switchesMarkup} )
{selectsMarkup} .map(input => (
{buttonsMarkup} <Switch
</ScrollView> onPress={() => input.onChange(!input.value)}
</Page> key={input.name}
value={input.value}
onValueChange={input.onChange}>
{input.name}
</Switch>
))}
{'focus'.includes(search.toLowerCase()) && (
<Picker
style={{color}}
dropdownIconColor={color}
selectedValue={focus}
onValueChange={value => setFocus(value)}>
<Picker.Item value="" label="Don't auto focus" />
<Picker.Item value="name" label="Auto focus Name" />
<Picker.Item value="reps" label="Auto focus Reps" />
<Picker.Item value="weight" label="Auto focus Weight" />
</Picker>
)}
{'theme'.includes(search.toLowerCase()) && (
<Picker
style={{color}}
dropdownIconColor={color}
selectedValue={color}
onValueChange={value => setColor(value)}>
{darkColors.concat(lightColors).map(colorOption => (
<Picker.Item
key={colorOption.hex}
value={colorOption.hex}
label={`${colorOption.name} theme`}
color={colorOption.hex}
/>
))}
</Picker>
)}
{'alarm sound'.includes(search.toLowerCase()) && (
<Button
style={{alignSelf: 'flex-start', marginTop: MARGIN}}
onPress={changeSound}>
Alarm sound
{sound
? ': ' + sound.split('/')[sound.split('/').length - 1]
: null}
</Button>
)}
</ScrollView>
<ConfirmDialog <ConfirmDialog
title="Are you sure?" title="Battery optimizations"
onOk={confirmImport} show={battery}
setShow={setImporting} setShow={setBattery}
show={importing} onOk={() => {
> NativeModules.AlarmModule.ignoreBattery();
Importing a database overwrites your current data. This action cannot be setBattery(false);
reversed! }}>
Disable battery optimizations for Massive to use rest timers.
</ConfirmDialog> </ConfirmDialog>
</Page>
<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 +0,0 @@
import { useNavigation } from "@react-navigation/native";
import { Appbar, IconButton } from "react-native-paper";
export default function StackHeader({
title,
children,
}: {
title: string;
children?: JSX.Element | JSX.Element[];
}) {
const navigation = useNavigation();
return (
<Appbar.Header>
<IconButton icon="arrow-back" onPress={navigation.goBack} />
<Appbar.Content title={title} />
{children}
</Appbar.Header>
);
}

View File

@ -1,226 +0,0 @@
import {
NavigationProp,
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from "@react-navigation/native";
import { useCallback, useMemo, useRef, useState } from "react";
import { FlatList, NativeModules, TextInput, View } from "react-native";
import { Button, IconButton, ProgressBar } from "react-native-paper";
import AppInput from "./AppInput";
import { getBestSet } from "./best.service";
import { MARGIN, PADDING } from "./constants";
import CountMany from "./count-many";
import { AppDataSource } from "./data-source";
import { getNow, setRepo, settingsRepo } from "./db";
import { fixNumeric } from "./fix-numeric";
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() {
const { params } = useRoute<RouteProp<PlanPageParams, "StartPlan">>();
const [reps, setReps] = useState(params.first?.reps.toString() || "0");
const [weight, setWeight] = useState(params.first?.weight.toString() || "0");
const [unit, setUnit] = useState<string>(params.first?.unit || "kg");
const [selected, setSelected] = useState(0);
const [settings, setSettings] = useState<Settings>();
const [counts, setCounts] = useState<CountMany[]>();
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const unitRef = useRef<TextInput>(null);
const workouts = useMemo(() => params.plan.workouts.split(","), [params]);
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
const [selection, setSelection] = useState({
start: 0,
end: 0,
});
const refresh = useCallback(async () => {
const questions = workouts
.map((workout, index) => `('${workout}',${index})`)
.join(",");
const select = `
SELECT workouts.name, COUNT(sets.id) as total, sets.sets
FROM (select 0 as name, 0 as sequence union values ${questions}) as workouts
LEFT JOIN sets ON sets.name = workouts.name
AND sets.created LIKE STRFTIME('%Y-%m-%d%%', 'now', 'localtime')
AND NOT sets.hidden
GROUP BY workouts.name
ORDER BY workouts.sequence
LIMIT -1
OFFSET 1
`;
const newCounts = await AppDataSource.manager.query(select);
console.log(`${StartPlan.name}.focus:`, { newCounts });
setCounts(newCounts);
}, [workouts]);
const select = useCallback(
async (index: number, newCounts?: CountMany[]) => {
setSelected(index);
if (!counts && !newCounts) return;
const workout = counts ? counts[index] : newCounts[index];
console.log(`${StartPlan.name}.next:`, { workout });
const last = await setRepo.findOne({
where: { name: workout.name },
order: { created: "desc" },
});
console.log({ last });
if (!last) return;
delete last.id;
console.log(`${StartPlan.name}.select:`, { last });
setReps(last.reps.toString());
setWeight(last.weight.toString());
setUnit(last.unit);
},
[counts]
);
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
refresh();
}, [refresh])
);
const handleSubmit = async () => {
const now = await getNow();
const workout = counts[selected];
const best = await getBestSet(workout.name);
delete best.id;
const newSet: GymSet = {
...best,
weight: +weight,
reps: +reps,
unit,
created: now,
hidden: false,
};
await setRepo.save(newSet);
await refresh();
if (
settings.notify &&
(+weight > best.weight || (+reps > best.reps && +weight === best.weight))
) {
toast("Great work King! That's a new record.");
}
if (!settings.alarm) return;
const milliseconds =
Number(best.minutes) * 60 * 1000 + Number(best.seconds) * 1000;
NativeModules.AlarmModule.timer(milliseconds);
};
return (
<>
<StackHeader title={params.plan.days.replace(/,/g, ", ")}>
<IconButton
onPress={() => navigation.navigate("EditPlan", { plan: params.plan })}
icon="edit"
/>
</StackHeader>
<View style={{ padding: PADDING, flex: 1, flexDirection: "column" }}>
<View style={{ flex: 1 }}>
<View
style={{
flexDirection: "row",
marginBottom: MARGIN,
}}
>
<AppInput
label="Reps"
style={{ flex: 1 }}
keyboardType="numeric"
value={reps}
onChangeText={(newReps) => {
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
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 && (
<AppInput
autoCapitalize="none"
label="Unit"
value={unit}
onChangeText={setUnit}
innerRef={unitRef}
/>
)}
{counts && (
<FlatList
data={counts}
renderItem={(props) => (
<View>
<StartPlanItem
{...props}
onUndo={refresh}
onSelect={select}
selected={selected}
/>
<ProgressBar
progress={(props.item.total || 0) / (props.item.sets || 3)}
/>
</View>
)}
/>
)}
</View>
<Button mode="outlined" icon="save" onPress={handleSubmit}>
Save
</Button>
</View>
</>
);
}

View File

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

View File

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

View File

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

110
ViewBest.tsx Normal file
View File

@ -0,0 +1,110 @@
import {Picker} from '@react-native-picker/picker';
import {
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {useColorScheme, View} from 'react-native';
import {FileSystem} from 'react-native-file-access';
import {IconButton} from 'react-native-paper';
import Share from 'react-native-share';
import {captureScreen} from 'react-native-view-shot';
import {getVolumes, getWeightsBy} from './best.service';
import Chart from './Chart';
import {PADDING} from './constants';
import {DrawerParamList} from './drawer-param-list';
import {Metrics} from './metrics';
import {Periods} from './periods';
import Set from './set';
import {formatMonth} from './time';
import Volume from './volume';
export default function ViewBest() {
const {params} = useRoute<RouteProp<DrawerParamList, 'View best'>>();
const dark = useColorScheme() === 'dark';
const [weights, setWeights] = useState<Set[]>([]);
const [volumes, setVolumes] = useState<Volume[]>([]);
const [metric, setMetric] = useState(Metrics.Weight);
const [period, setPeriod] = useState(Periods.Monthly);
const navigation = useNavigation();
useFocusEffect(
useCallback(() => {
console.log(`${ViewBest.name}.useFocusEffect`);
navigation.getParent()?.setOptions({
headerLeft: () => (
<IconButton icon="arrow-back" onPress={() => navigation.goBack()} />
),
headerRight: () => (
<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"
/>
),
title: params.best.name,
});
}, [navigation, params.best]),
);
useEffect(() => {
if (metric === Metrics.Weight)
getWeightsBy(params.best.name, period).then(setWeights);
else if (metric === Metrics.Volume)
getVolumes(params.best.name, period).then(setVolumes);
console.log(`${ViewBest.name}.useEffect`, {metric});
console.log(`${ViewBest.name}.useEffect`, {period});
}, [params.best.name, metric, period]);
return (
<View style={{padding: PADDING}}>
<Picker
style={{color: dark ? 'white' : 'black'}}
dropdownIconColor={dark ? 'white' : 'black'}
selectedValue={metric}
onValueChange={value => setMetric(value)}>
<Picker.Item value={Metrics.Volume} label={Metrics.Volume} />
<Picker.Item value={Metrics.Weight} label={Metrics.Weight} />
</Picker>
<Picker
style={{color: dark ? 'white' : 'black'}}
dropdownIconColor={dark ? 'white' : 'black'}
selectedValue={period}
onValueChange={value => setPeriod(value)}>
<Picker.Item value={Periods.Weekly} label={Periods.Weekly} />
<Picker.Item value={Periods.Monthly} label={Periods.Monthly} />
<Picker.Item value={Periods.Yearly} label={Periods.Yearly} />
</Picker>
{metric === Metrics.Volume && (
<Chart
yData={volumes.map(v => v.value)}
yFormat={(value: number) =>
`${value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${
volumes[0].unit
}`
}
xData={weights}
xFormat={(_value, index) => formatMonth(weights[index].created!)}
/>
)}
{metric === Metrics.Weight && (
<Chart
yData={weights.map(set => set.weight)}
yFormat={value => `${value}${weights[0].unit}`}
xData={weights}
xFormat={(_value, index) => formatMonth(weights[index].created!)}
/>
)}
</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,93 +1,79 @@
import { NavigationProp, useNavigation } from "@react-navigation/native"; import {NavigationProp, useNavigation} from '@react-navigation/native';
import { useCallback, useMemo, useState } from "react"; import React, {useCallback, 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 {DrawerParamList} from './drawer-param-list';
import GymSet from "./gym-set"; import Set from './set';
import { WorkoutsPageParams } from "./WorkoutsPage"; import {deleteSetsBy} from './set.service';
export default function WorkoutItem({ export default function WorkoutItem({
item, item,
onRemove, onRemoved,
images,
}: { }: {
item: GymSet; item: Set;
onRemove: () => void; onRemoved: () => void;
images: boolean;
}) { }) {
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [anchor, setAnchor] = useState({ x: 0, y: 0 }); const [anchor, setAnchor] = useState({x: 0, y: 0});
const [showRemove, setShowRemove] = useState(""); const [showRemove, setShowRemove] = useState('');
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>(); const navigation = useNavigation<NavigationProp<DrawerParamList>>();
const remove = useCallback(async () => { const remove = useCallback(async () => {
await setRepo.delete({ name: item.name }); await deleteSetsBy(item.name);
setShowMenu(false); setShowMenu(false);
onRemove(); onRemoved();
}, [setShowMenu, onRemove, item.name]); }, [setShowMenu, onRemoved, 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 minutes = item.minutes?.toString().padStart(2, '0');
const seconds = item.seconds?.toString().padStart(2, "0"); const seconds = item.seconds?.toString().padStart(2, '0');
return `${item.sets} x ${item.minutes || 0}:${seconds}`;
}, [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('Edit workout', {value: item})}
title={item.name} title={item.name}
description={description} description={`${item.sets} sets ${minutes}:${seconds} rest`}
onLongPress={longPress} onLongPress={longPress}
left={left} left={() =>
right={right} 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

@ -1,121 +0,0 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from "@react-navigation/native";
import { useCallback, useState } from "react";
import { FlatList } from "react-native";
import { List } from "react-native-paper";
import { LIMIT } from "./constants";
import { setRepo, settingsRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
import GymSet from "./gym-set";
import Page from "./Page";
import SetList from "./SetList";
import Settings from "./settings";
import WorkoutItem from "./WorkoutItem";
import { WorkoutsPageParams } from "./WorkoutsPage";
export default function WorkoutList() {
const [workouts, setWorkouts] = useState<GymSet[]>();
const [offset, setOffset] = useState(0);
const [term, setTerm] = useState("");
const [end, setEnd] = useState(false);
const [settings, setSettings] = useState<Settings>();
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
const refresh = useCallback(async (value: string) => {
const newWorkouts = await setRepo
.createQueryBuilder()
.select()
.where("name LIKE :name", { name: `%${value.trim()}%` })
.groupBy("name")
.orderBy("name")
.limit(LIMIT)
.getMany();
console.log(`${WorkoutList.name}`, { newWorkout: newWorkouts[0] });
setWorkouts(newWorkouts);
setOffset(0);
setEnd(false);
}, []);
useFocusEffect(
useCallback(() => {
refresh(term);
settingsRepo.findOne({ where: {} }).then(setSettings);
}, [refresh, term])
);
const renderItem = useCallback(
({ item }: { item: GymSet }) => (
<WorkoutItem
images={settings?.images}
item={item}
key={item.name}
onRemove={() => refresh(term)}
/>
),
[refresh, term, settings?.images]
);
const next = useCallback(async () => {
if (end) return;
const newOffset = offset + LIMIT;
console.log(`${SetList.name}.next:`, {
offset,
limit: LIMIT,
newOffset,
term,
});
const newWorkouts = await setRepo
.createQueryBuilder()
.select()
.where("name LIKE :name", { name: `%${term.trim()}%` })
.groupBy("name")
.orderBy("name")
.limit(LIMIT)
.offset(newOffset)
.getMany();
if (newWorkouts.length === 0) return setEnd(true);
if (!workouts) return;
setWorkouts([...workouts, ...newWorkouts]);
if (newWorkouts.length < LIMIT) return setEnd(true);
setOffset(newOffset);
}, [term, end, offset, workouts]);
const onAdd = useCallback(async () => {
navigation.navigate("EditWorkout", {
value: new GymSet(),
});
}, [navigation]);
const search = useCallback(
(value: string) => {
setTerm(value);
refresh(value);
},
[refresh]
);
return (
<>
<DrawerHeader name="Workouts" />
<Page onAdd={onAdd} term={term} search={search}>
{workouts?.length === 0 ? (
<List.Item
title="No workouts yet."
description="A workout is something you do at the gym. For example Deadlifts are a workout."
/>
) : (
<FlatList
data={workouts}
style={{ flex: 1 }}
renderItem={renderItem}
keyExtractor={(w) => w.name}
onEndReached={next}
/>
)}
</Page>
</>
);
}

View File

@ -1,24 +1,97 @@
import { createStackNavigator } from "@react-navigation/stack"; import {
import EditWorkout from "./EditWorkout"; NavigationProp,
import GymSet from "./gym-set"; useFocusEffect,
import WorkoutList from "./WorkoutList"; useNavigation,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {FlatList} from 'react-native';
import {List} from 'react-native-paper';
import {DrawerParamList} from './drawer-param-list';
import Page from './Page';
import Set from './set';
import {getDistinctSets} from './set.service';
import WorkoutItem from './WorkoutItem';
export type WorkoutsPageParams = { const limit = 15;
WorkoutList: {};
EditWorkout: {
value: GymSet;
};
};
const Stack = createStackNavigator<WorkoutsPageParams>();
export default function WorkoutsPage() { export default function WorkoutsPage() {
const [workouts, setWorkouts] = useState<Set[]>();
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [end, setEnd] = useState(false);
const navigation = useNavigation<NavigationProp<DrawerParamList>>();
const refresh = useCallback(async () => {
const newWorkouts = await getDistinctSets({
search: `%${search}%`,
limit,
offset: 0,
});
console.log(`${WorkoutsPage.name}`, {newWorkout: newWorkouts[0]});
setWorkouts(newWorkouts);
setOffset(0);
setEnd(false);
}, [search]);
useEffect(() => {
refresh();
}, [search, refresh]);
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh]),
);
const renderItem = useCallback(
({item}: {item: Set}) => (
<WorkoutItem item={item} key={item.name} onRemoved={refresh} />
),
[refresh],
);
const next = useCallback(async () => {
if (end) return;
const newOffset = offset + limit;
console.log(`${WorkoutsPage.name}.next:`, {
offset,
limit,
newOffset,
search,
});
const newWorkouts = await getDistinctSets({
search: `%${search}%`,
limit,
offset: newOffset,
});
if (newWorkouts.length === 0) return setEnd(true);
if (!workouts) return;
setWorkouts([...workouts, ...newWorkouts]);
if (newWorkouts.length < limit) return setEnd(true);
setOffset(newOffset);
}, [search, end, offset, workouts]);
const onAdd = useCallback(async () => {
navigation.navigate('Edit workout', {
value: {name: '', sets: 3, image: '', steps: '', reps: 0, weight: 0},
});
}, [navigation]);
return ( return (
<Stack.Navigator <Page onAdd={onAdd} search={search} setSearch={setSearch}>
screenOptions={{ headerShown: false, animationEnabled: false }} <FlatList
> data={workouts}
<Stack.Screen name="WorkoutList" component={WorkoutList} /> style={{height: '99%'}}
<Stack.Screen name="EditWorkout" component={EditWorkout} /> ListEmptyComponent={
</Stack.Navigator> <List.Item
title="No workouts yet."
description="A workout is something you do at the gym. For example Deadlifts are a workout."
/>
}
renderItem={renderItem}
keyExtractor={w => w.name}
onEndReached={next}
/>
</Page>
); );
} }

View File

@ -1,3 +0,0 @@
source "https://rubygems.org"
gem "fastlane"

View File

@ -1,219 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
rexml
addressable (2.8.4)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.780.0)
aws-sdk-core (3.175.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.67.0)
aws-sdk-core (~> 3, >= 3.174.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.126.0)
aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.100.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.7)
fastlane (2.213.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (>= 1.4.5, < 2.0.0)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.43.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.19.0)
google-apis-core (>= 0.9.0, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.19.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.5.2)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.5)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.6.3)
jwt (2.7.1)
memoist (0.16.2)
mini_magick (4.12.0)
mini_mime (1.1.2)
multi_json (1.15.0)
multipart-post (2.3.0)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.1.1)
os (1.1.4)
plist (3.7.0)
public_suffix (5.0.1)
rake (13.0.6)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.5)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
signet (0.17.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (1.8.0)
webrick (1.8.1)
word_wrap (1.0.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
x86_64-linux
DEPENDENCIES
fastlane
BUNDLED WITH
2.3.25

View File

@ -1,92 +1,117 @@
apply plugin: "com.android.application" apply plugin: "com.android.application"
apply plugin: "com.facebook.react"
apply plugin: "kotlin-android" apply plugin: "kotlin-android"
/** import com.android.build.OutputFile
* This is the configuration block to customize your React Native Android app.
* 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.react = [
// The list of variants to that are debuggable. For those we're going to enableHermes: false, // clean and rebuild if changing
// 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 */ project.ext.vectoricons = [
// A list containing the node command and its flags. Default is just 'node'. iconFontNames: ['MaterialIcons.ttf']
// 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 */ apply from: "../../node_modules/react-native/react.gradle"
// The hermes compiler command to run. By default it is 'hermesc' apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
// def enableSeparateBuildPerCPUArchitecture = true
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" def enableProguardInReleaseBuilds = true
// hermesFlags = ["-O", "-output-source-map"] def jscFlavor = 'org.webkit:android-jsc:+'
def enableHermes = project.ext.react.get("enableHermes", false);
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 36037
versionName "1.148" versionName "1.11"
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 {
@ -111,9 +136,7 @@ android {
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 +144,61 @@ android {
} }
dependencies { dependencies {
// The version of react-native is set by the React Native Gradle Plugin def work_version = "2.7.1"
implementation("com.facebook.react:react-android")
implementation 'androidx.documentfile:documentfile:1.0.1' implementation "androidx.work:work-runtime:$work_version"
implementation 'androidx.constraintlayout:constraintlayout:2.1.3' implementation "androidx.work:work-runtime-ktx:$work_version"
implementation project(':react-native-sqlite-storage') implementation "androidx.work:work-rxjava2:$work_version"
implementation project(':react-native-vector-icons') 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:${FLIPPER_VERSION}")
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") { debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
exclude group:'com.facebook.flipper'
exclude group:'com.squareup.okhttp3', module:'okhttp' exclude group:'com.squareup.okhttp3', module:'okhttp'
} }
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") {
if (hermesEnabled.toBoolean()) { exclude group:'com.facebook.flipper'
implementation("com.facebook.react:hermes-android") }
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

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

View File

@ -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,35 @@
<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 tools:node="remove" android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE"
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:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/app_name"
android:allowBackup="false" android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme"
<activity android:dataExtractionRules="@xml/data_extraction_rules">
android:name=".MainActivity" <activity
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" android:name=".MainActivity"
android:launchMode="singleTask" android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:windowSoftInputMode="adjustResize" android:exported="true"
android:exported="true"> android:launchMode="singleTask"
<intent-filter> android:windowSoftInputMode="adjustResize">
<action android:name="android.intent.action.MAIN" /> <intent-filter>
<category android:name="android.intent.category.LAUNCHER" /> <action android:name="android.intent.action.MAIN" />
</intent-filter> <category android:name="android.intent.category.LAUNCHER" />
</activity> </intent-filter>
</activity>
<activity <activity android:exported="true" android:process=":remote" android:name=".StopAlarm" />
android:name=".TimerDone" <service android:name=".StopTimer" android:exported="true" android:process=":remote" />
android:exported="false"> <service android:name=".AlarmService" android:exported="true" />
<meta-data <service android:name=".TimerService" android:exported="true" />
android:name="android.app.lib_name"
android:value="" />
</activity>
<activity
android:name=".StopAlarm"
android:exported="true"
android:process=":remote" />
<service
android:name=".AlarmService"
android:exported="false" />
</application> </application>
</manifest> </manifest>

View File

@ -1,191 +1,90 @@
package com.massive package com.massive
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.* import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.net.Uri
import android.os.Build import android.os.Build
import android.os.CountDownTimer import android.os.PowerManager
import android.provider.Settings
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import com.facebook.react.bridge.Callback
import com.facebook.react.bridge.* import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.bridge.ReactContextBaseJavaModule
import kotlin.math.floor import com.facebook.react.bridge.ReactMethod
class AlarmModule constructor(context: ReactApplicationContext?) : class AlarmModule internal constructor(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) { ReactContextBaseJavaModule(context) {
private var countdownTimer: CountDownTimer? = null
var currentMs: Long = 0
var running = false
override fun getName(): String { override fun getName(): String {
return "AlarmModule" return "AlarmModule"
} }
private val stopReceiver = object : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onReceive(context: Context?, intent: Intent?) {
Log.d("AlarmModule", "Received stop broadcast intent")
stop()
}
}
private val addReceiver = object : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onReceive(context: Context?, intent: Intent?) {
add()
}
}
init {
reactApplicationContext.registerReceiver(stopReceiver, IntentFilter(STOP_BROADCAST))
reactApplicationContext.registerReceiver(addReceiver, IntentFilter(ADD_BROADCAST))
}
override fun onCatalystInstanceDestroy() {
reactApplicationContext.unregisterReceiver(stopReceiver)
reactApplicationContext.unregisterReceiver(addReceiver)
super.onCatalystInstanceDestroy()
}
@RequiresApi(api = Build.VERSION_CODES.O) @RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod @ReactMethod
fun add() { fun add(milliseconds: Int, vibrate: Boolean, sound: String?) {
Log.d("AlarmModule", "Add 1 min to alarm.") Log.d("AlarmModule", "Add 1 min to alarm.")
countdownTimer?.cancel() val addIntent = Intent(reactApplicationContext, TimerService::class.java)
val newMs = if (running) currentMs.toInt().plus(60000) else 60000 addIntent.action = "add"
countdownTimer = getTimer(newMs) addIntent.putExtra("vibrate", vibrate)
countdownTimer?.start() addIntent.putExtra("sound", sound)
running = true addIntent.data = Uri.parse("$milliseconds")
val manager = getManager() reactApplicationContext.startService(addIntent)
manager.cancel(AlarmService.NOTIFICATION_ID_DONE)
val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.stopService(intent)
} }
@RequiresApi(api = Build.VERSION_CODES.O) @RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod @ReactMethod
fun stop() { fun stop() {
Log.d("AlarmModule", "Stop alarm.") Log.d("AlarmModule", "Stop alarm.")
countdownTimer?.cancel() val timerIntent = Intent(reactApplicationContext, TimerService::class.java)
running = false reactApplicationContext.stopService(timerIntent)
val intent = Intent(reactApplicationContext, AlarmService::class.java) val alarmIntent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext?.stopService(intent) reactApplicationContext.stopService(alarmIntent)
val manager = getManager()
manager.cancel(AlarmService.NOTIFICATION_ID_DONE)
manager.cancel(NOTIFICATION_ID_PENDING)
val params = Arguments.createMap().apply {
putString("minutes", "00")
putString("seconds", "00")
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("tick", params)
} }
@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?) {
Log.d("AlarmModule", "Queue alarm for $milliseconds delay") Log.d("AlarmModule", "Queue alarm for $milliseconds delay")
val manager = getManager() val intent = Intent(reactApplicationContext, TimerService::class.java)
manager.cancel(AlarmService.NOTIFICATION_ID_DONE) intent.putExtra("milliseconds", milliseconds)
val intent = Intent(reactApplicationContext, AlarmService::class.java) intent.putExtra("vibrate", vibrate)
reactApplicationContext.stopService(intent) intent.putExtra("sound", sound)
countdownTimer?.cancel() reactApplicationContext.startService(intent)
countdownTimer = getTimer(milliseconds)
countdownTimer?.start()
running = true
} }
@RequiresApi(Build.VERSION_CODES.M) @RequiresApi(Build.VERSION_CODES.M)
private fun getTimer( @ReactMethod
endMs: Int, fun ignoringBattery(callback: Callback) {
): CountDownTimer { val packageName = reactApplicationContext.packageName
val builder = getBuilder() val pm =
return object : CountDownTimer(endMs.toLong(), 1000) { reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
@RequiresApi(Build.VERSION_CODES.O) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
override fun onTick(current: Long) { callback.invoke(pm.isIgnoringBatteryOptimizations(packageName))
currentMs = current } else {
val seconds = callback.invoke(true)
floor((current / 1000).toDouble() % 60).toInt().toString().padStart(2, '0')
val minutes =
floor((current / 1000).toDouble() / 60).toInt().toString().padStart(2, '0')
builder.setContentText("$minutes:$seconds").setAutoCancel(false).setDefaults(0)
.setProgress(endMs, current.toInt(), false)
.setCategory(NotificationCompat.CATEGORY_PROGRESS).priority =
NotificationCompat.PRIORITY_LOW
val manager = getManager()
manager.notify(NOTIFICATION_ID_PENDING, builder.build())
val params = Arguments.createMap().apply {
putString("minutes", minutes)
putString("seconds", seconds)
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("tick", params)
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onFinish() {
val context = reactApplicationContext
context.startForegroundService(Intent(context, AlarmService::class.java))
context
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("finish", Arguments.createMap().apply {
putString("minutes", "00")
putString("seconds", "00")
})
}
} }
} }
@SuppressLint("UnspecifiedImmutableFlag") @SuppressLint("BatteryLife")
@RequiresApi(Build.VERSION_CODES.M) @RequiresApi(Build.VERSION_CODES.M)
private fun getBuilder(): NotificationCompat.Builder { @ReactMethod
val context = reactApplicationContext fun ignoreBattery() {
val contentIntent = Intent(context, MainActivity::class.java) val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
val pendingContent = intent.data = Uri.parse("package:" + reactApplicationContext.packageName)
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
val addBroadcast = Intent(ADD_BROADCAST).apply { try {
setPackage(context.packageName) reactApplicationContext.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(
reactApplicationContext,
"Requests to ignore battery optimizations are disabled on your device.",
Toast.LENGTH_LONG
).show()
} }
val pendingAdd =
PendingIntent.getBroadcast(context, 0, addBroadcast, PendingIntent.FLAG_MUTABLE)
val stopBroadcast = Intent(STOP_BROADCAST)
stopBroadcast.setPackage(context.packageName)
val pendingStop =
PendingIntent.getBroadcast(context, 0, stopBroadcast, PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(context, 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)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun getManager(): NotificationManager {
val notificationManager = reactApplicationContext.getSystemService(
NotificationManager::class.java
)
val timersChannel = NotificationChannel(
CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, NotificationManager.IMPORTANCE_LOW
)
timersChannel.setSound(null, null)
timersChannel.description = "Progress on rest timers."
notificationManager.createNotificationChannel(timersChannel)
return notificationManager
}
companion object {
const val STOP_BROADCAST = "stop-timer-event"
const val ADD_BROADCAST = "add-timer-event"
const val CHANNEL_ID_PENDING = "Timer"
const val NOTIFICATION_ID_PENDING = 1
} }
} }

View File

@ -1,65 +1,31 @@
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) if (sound == null) {
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)
}
@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 {
mediaPlayer = MediaPlayer().apply { mediaPlayer = MediaPlayer().apply {
setAudioAttributes( setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
@ -67,56 +33,12 @@ 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 +52,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 +72,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

@ -0,0 +1,15 @@
package com.massive
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
class MassiveHelper(context: Context) : SQLiteOpenHelper(context, "massive.db", null, 1) {
override fun onCreate(db: SQLiteDatabase) {
return
}
override fun onUpgrade(p0: SQLiteDatabase?, p1: Int, p2: Int) {
return
}
}

View File

@ -4,6 +4,8 @@ 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 com.massive.AlarmModule
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 +17,7 @@ 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(SettingsModule(reactContext)) modules.add(DownloadModule(reactContext))
modules.add(BackupModule(reactContext))
return modules return modules
} }
} }

View File

@ -1,58 +0,0 @@
package com.massive
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresApi
import com.facebook.react.bridge.*
class SettingsModule constructor(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) {
override fun getName(): String {
return "SettingsModule"
}
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun ignoringBattery(callback: Callback) {
val packageName = reactApplicationContext.packageName
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
callback.invoke(pm.isIgnoringBatteryOptimizations(packageName))
} else {
callback.invoke(true)
}
}
@SuppressLint("BatteryLife")
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun ignoreBattery() {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.parse("package:" + reactApplicationContext.packageName)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
try {
reactApplicationContext.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(
reactApplicationContext,
"Requests to ignore battery optimizations are disabled on your device.",
Toast.LENGTH_LONG
).show()
}
}
@ReactMethod
fun is24(promise: Promise) {
val is24 = android.text.format.DateFormat.is24HourFormat(reactApplicationContext)
Log.d("SettingsModule", "is24=$is24")
promise.resolve(is24)
}
}

View File

@ -0,0 +1,18 @@
package com.massive
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.util.Log
class StopTimer : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
applicationContext.stopService(Intent(applicationContext, TimerService::class.java))
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
return super.onStartCommand(intent, flags, startId)
}
override fun onBind(p0: Intent?): IBinder? {
return null
}
}

View File

@ -1,58 +0,0 @@
package com.massive
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
class TimerDone : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_timer_done)
}
@RequiresApi(Build.VERSION_CODES.O)
@Suppress("UNUSED_PARAMETER")
fun stop(view: View) {
Log.d("TimerDone", "Stopping...")
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
val manager = getManager()
manager.cancel(AlarmService.NOTIFICATION_ID_DONE)
manager.cancel(AlarmModule.NOTIFICATION_ID_PENDING)
val intent = Intent(applicationContext, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
applicationContext.startActivity(intent)
}
@RequiresApi(Build.VERSION_CODES.O)
fun getManager(): NotificationManager {
val alarmsChannel = NotificationChannel(
AlarmService.CHANNEL_ID_DONE,
AlarmService.CHANNEL_ID_DONE,
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Alarms for rest timers."
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
}
val timersChannel = NotificationChannel(
AlarmModule.CHANNEL_ID_PENDING,
AlarmModule.CHANNEL_ID_PENDING,
NotificationManager.IMPORTANCE_LOW
).apply {
setSound(null, null)
description = "Progress on rest timers."
}
val notificationManager = applicationContext.getSystemService(
NotificationManager::class.java
)
notificationManager.createNotificationChannel(alarmsChannel)
notificationManager.createNotificationChannel(timersChannel)
return notificationManager
}
}

View File

@ -0,0 +1,167 @@
package com.massive
import android.annotation.SuppressLint
import android.app.*
import android.app.NotificationManager.IMPORTANCE_HIGH
import android.app.NotificationManager.IMPORTANCE_LOW
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.CountDownTimer
import android.os.IBinder
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import kotlin.math.floor
class TimerService() : Service() {
private var manager: NotificationManager? = null
private var countdownTimer: CountDownTimer? = null
private var endMs: Int = 0
private var currentMs: Long = 0
private var vibrate: Boolean = true
private var sound: String? = null
@RequiresApi(Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
vibrate = intent!!.extras!!.getBoolean("vibrate")
sound = intent.extras?.getString("sound")
manager?.cancel(NOTIFICATION_ID_DONE)
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
if (intent.action == "add") {
endMs = currentMs.toInt().plus(60000)
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
} else {
endMs = intent.extras!!.getInt("milliseconds")
}
Log.d("TimerService", "endMs=$endMs,currentMs=$currentMs,vibrate=$vibrate,sound=$sound")
manager = getManager(applicationContext)
val builder = getBuilder(applicationContext)
countdownTimer?.cancel()
countdownTimer = getTimer(builder)
countdownTimer?.start()
return super.onStartCommand(intent, flags, startId)
}
private fun getTimer(builder: NotificationCompat.Builder): CountDownTimer {
return object : CountDownTimer(endMs.toLong(), 1000) {
override fun onTick(current: Long) {
currentMs = current
val seconds = floor((current / 1000).toDouble() % 60)
.toInt().toString().padStart(2, '0')
val minutes = floor((current / 1000).toDouble() / 60)
.toInt().toString().padStart(2, '0')
builder.setContentText("$minutes:$seconds")
.setAutoCancel(false)
.setDefaults(0)
.setProgress(endMs, current.toInt(), false)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.priority = NotificationCompat.PRIORITY_LOW
manager!!.notify(NOTIFICATION_ID_PENDING, builder.build())
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onFinish() {
val finishIntent = Intent(applicationContext, StopAlarm::class.java)
val finishPending =
PendingIntent.getActivity(
applicationContext,
0,
finishIntent,
PendingIntent.FLAG_IMMUTABLE
)
val stopIntent = Intent(applicationContext, StopTimer::class.java)
val pendingStop =
PendingIntent.getService(
applicationContext,
0,
stopIntent,
PendingIntent.FLAG_IMMUTABLE
)
builder.setContentText("Timer finished.")
.setAutoCancel(true)
.setProgress(0, 0, false)
.setOngoing(false)
.setContentIntent(finishPending)
.setChannelId(CHANNEL_ID_DONE)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setDeleteIntent(pendingStop)
.priority = NotificationCompat.PRIORITY_HIGH
manager!!.notify(NOTIFICATION_ID_DONE, builder.build())
manager!!.cancel(NOTIFICATION_ID_PENDING)
val alarmIntent = Intent(applicationContext, AlarmService::class.java)
alarmIntent.putExtra("vibrate", vibrate)
alarmIntent.putExtra("sound", sound)
applicationContext.startService(alarmIntent)
}
}
}
override fun onBind(p0: Intent?): IBinder? {
return null
}
override fun onDestroy() {
Log.d("TimerService", "Destroying...")
countdownTimer?.cancel()
manager?.cancel(NOTIFICATION_ID_PENDING)
manager?.cancel(NOTIFICATION_ID_DONE)
super.onDestroy()
}
@SuppressLint("UnspecifiedImmutableFlag")
@RequiresApi(Build.VERSION_CODES.M)
private fun getBuilder(context: Context): NotificationCompat.Builder {
val contentIntent = Intent(context, MainActivity::class.java)
val pendingContent =
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
val stopIntent = Intent(context, StopTimer::class.java)
val pendingStop =
PendingIntent.getService(context, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
val addIntent = Intent(context, TimerService::class.java)
addIntent.action = "add"
addIntent.putExtra("vibrate", vibrate)
addIntent.putExtra("sound", sound)
addIntent.data = Uri.parse("$currentMs")
val pendingAdd = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getService(context, 0, addIntent, PendingIntent.FLAG_MUTABLE)
} else {
PendingIntent.getService(context, 0, addIntent, PendingIntent.FLAG_UPDATE_CURRENT)
}
return NotificationCompat.Builder(context, 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)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun getManager(context: Context): NotificationManager {
val alarmsChannel = NotificationChannel(
CHANNEL_ID_DONE,
CHANNEL_ID_DONE,
IMPORTANCE_HIGH
)
alarmsChannel.description = "Alarms for rest timers."
alarmsChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
val notificationManager = context.getSystemService(
NotificationManager::class.java
)
notificationManager.createNotificationChannel(alarmsChannel)
val timersChannel =
NotificationChannel(CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, IMPORTANCE_LOW)
timersChannel.setSound(null, null)
timersChannel.description = "Progress on rest timers."
notificationManager.createNotificationChannel(timersChannel)
return notificationManager
}
companion object {
private const val CHANNEL_ID_PENDING = "Timer"
private const val CHANNEL_ID_DONE = "Alarm"
private const val NOTIFICATION_ID_PENDING = 1
private const val NOTIFICATION_ID_DONE = 2
}
}

View File

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".TimerDone">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Timer up"
android:textSize="28sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Stop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
android:onClick="stop" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,7 +0,0 @@
<resources>
<style name="ThemeOverlay.Massive.FullscreenContainer" parent="">
<item name="fullscreenBackgroundColor">@color/light_blue_900</item>
<item name="fullscreenTextColor">@color/light_blue_A400</item>
</style>
</resources>

View File

@ -1,6 +0,0 @@
<resources>
<declare-styleable name="FullscreenAttrs">
<attr name="fullscreenBackgroundColor" format="color" />
<attr name="fullscreenTextColor" format="color" />
</declare-styleable>
</resources>

View File

@ -1,7 +0,0 @@
<resources>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="light_blue_A200">#FF40C4FF</color>
<color name="light_blue_A400">#FF00B0FF</color>
<color name="black_overlay">#66000000</color>
</resources>

View File

@ -1,8 +1,3 @@
<resources> <resources>
<string name="app_name">Massive</string> <string name="app_name">Massive</string>
<string name="title_activity_fullscreen">FullscreenActivity</string>
<string name="dummy_button">Dummy Button</string>
<string name="dummy_content">DUMMY\nCONTENT</string>
<string name="rest_timer_up">Rest timer up</string>
<string name="stop">STOP</string>
</resources> </resources>

View File

@ -6,13 +6,4 @@
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item> <item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
</style> </style>
<style name="Widget.AppTheme.ActionBar.Fullscreen" parent="Widget.AppCompat.ActionBar">
<item name="android:background">@color/black_overlay</item>
</style>
<style name="Widget.AppTheme.ButtonBar.Fullscreen" parent="">
<item name="android:background">@color/black_overlay</item>
<item name="android:buttonBarStyle">?android:attr/buttonBarStyle</item>
</style>
</resources> </resources>

View File

@ -1,13 +0,0 @@
<resources>
<style name="AppTheme.Fullscreen" parent="AppTheme">
<item name="android:actionBarStyle">@style/Widget.AppTheme.ActionBar.Fullscreen</item>
<item name="android:windowActionBarOverlay">true</item>
<item name="android:windowBackground">@null</item>
</style>
<style name="ThemeOverlay.Massive.FullscreenContainer" parent="">
<item name="fullscreenBackgroundColor">@color/light_blue_600</item>
<item name="fullscreenTextColor">@color/light_blue_A200</item>
</style>
</resources>

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

@ -1,2 +0,0 @@
json_key_file("~/.config/googlePlay.json") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one
package_name("com.massive") # e.g. com.krausefx.app

View File

@ -1,38 +0,0 @@
# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
# https://docs.fastlane.tools/actions
#
# For a list of all available plugins, check out
#
# https://docs.fastlane.tools/plugins/available-plugins
#
# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane
default_platform(:android)
platform :android do
desc "Runs all the tests"
lane :test do
gradle(task: "test")
end
desc "Submit a new Beta Build to Crashlytics Beta"
lane :beta do
gradle(task: "clean assembleRelease")
crashlytics
# sh "your_script.sh"
# You can also use other beta testing services here
end
desc "Deploy a new version to the Google Play"
lane :deploy do
gradle(task: "clean assembleRelease")
upload_to_play_store
end
end

View File

@ -1,48 +0,0 @@
fastlane documentation
----
# Installation
Make sure you have the latest version of the Xcode command line tools installed:
```sh
xcode-select --install
```
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
# Available Actions
## Android
### android test
```sh
[bundle exec] fastlane android test
```
Runs all the tests
### android beta
```sh
[bundle exec] fastlane android beta
```
Submit a new Beta Build to Crashlytics Beta
### android deploy
```sh
[bundle exec] fastlane android deploy
```
Deploy a new version to the Google Play
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).

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

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

View File

@ -1,42 +1,90 @@
import { LIMIT } from "./constants"; import {db} from './db';
import { setRepo } from "./db"; import {Periods} from './periods';
import GymSet from "./gym-set"; import Set from './set';
import {defaultSet} from './set.service';
import Volume from './volume';
export const getBestSet = async (name: string): Promise<GymSet> => { export const getBestSet = async (name: string): Promise<Set> => {
return setRepo const bestWeight = `
.createQueryBuilder() SELECT name, reps, unit, MAX(weight) AS weight
.select() FROM sets
.addSelect("MAX(weight)", "weight") WHERE name = ? AND NOT hidden
.where("name = :name", { name }) GROUP BY name;
.groupBy("name") `;
.addGroupBy("reps") const bestReps = `
.orderBy("weight", "DESC") SELECT name, MAX(reps) as reps, unit, weight, sets, minutes, seconds
.addOrderBy("reps", "DESC") FROM sets
.getOne(); WHERE name = ? AND weight = ? AND NOT hidden
GROUP BY name;
`;
const [weightResult] = await db.executeSql(bestWeight, [name]);
if (!weightResult.rows.length) return {...defaultSet};
const [repsResult] = await db.executeSql(bestReps, [
name,
weightResult.rows.item(0).weight,
]);
return repsResult.rows.item(0);
}; };
export const getBestSets = ({ export const getWeightsBy = async (
term: term, name: string,
offset, period: Periods,
}: { ): Promise<Set[]> => {
term: string; const select = `
offset?: number; SELECT max(weight) AS weight,
}) => { STRFTIME('%Y-%m-%d', created) as created, unit
return setRepo FROM sets
.createQueryBuilder("gym_set") WHERE name = ? AND NOT hidden
.select(["gym_set.name", "gym_set.reps", "gym_set.weight"]) AND DATE(created) >= DATE('now', 'weekday 0', ?)
.groupBy("gym_set.name") GROUP BY name, STRFTIME('%Y-%m-%d', created)
.innerJoin( `;
(qb) => let difference = '-7 days';
qb if (period === Periods.Monthly) difference = '-1 months';
.select(["gym_set2.name", "MAX(gym_set2.weight) AS max_weight"]) else if (period === Periods.Yearly) difference = '-1 years';
.from(GymSet, "gym_set2") const [result] = await db.executeSql(select, [name, difference]);
.where("gym_set2.name LIKE (:name)", { name: `%${term.trim()}%` }) return result.rows.raw();
.groupBy("gym_set2.name"), };
"subquery",
"gym_set.name = subquery.gym_set2_name AND gym_set.weight = subquery.max_weight" export const getVolumes = async (
) name: string,
.limit(LIMIT) period: Periods,
.offset(offset || 0) ): Promise<Volume[]> => {
.getMany(); const select = `
SELECT sum(weight * reps) AS value,
STRFTIME('%Y-%m-%d', created) as created, unit
FROM sets
WHERE name = ? AND NOT hidden
AND DATE(created) >= DATE('now', 'weekday 0', ?)
GROUP BY name, STRFTIME('%Y-%m-%d', created)
`;
let difference = '-7 days';
if (period === Periods.Monthly) difference = '-1 months';
else if (period === Periods.Yearly) difference = '-1 years';
const [result] = await db.executeSql(select, [name, difference]);
return result.rows.raw();
};
export const getBestWeights = async (search: string): Promise<Set[]> => {
const select = `
SELECT name, reps, unit, MAX(weight) AS weight
FROM sets
WHERE name LIKE ? AND NOT hidden
GROUP BY name;
`;
const [result] = await db.executeSql(select, [`%${search}%`]);
return result.rows.raw();
};
export const getBestReps = async (
name: string,
weight: number,
): Promise<Set[]> => {
const select = `
SELECT name, MAX(reps) as reps, unit, weight, image
FROM sets
WHERE name = ? AND weight = ? AND NOT hidden
GROUP BY name;
`;
const [result] = await db.executeSql(select, [name, weight]);
return result.rows.raw();
}; };

View File

@ -1,41 +1,13 @@
import { DefaultTheme, MD3DarkTheme } from "react-native-paper";
export const lightColors = [ export const lightColors = [
{ hex: MD3DarkTheme.colors.primary, name: "Purple" }, {hex: '#FA8072', name: 'Salmon'},
{ hex: "#B3E5FC", name: "Blue" }, {hex: '#B3E5FC', name: 'Cyan'},
{ 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: '#8156A7', name: 'Purple'},
{ hex: "#0051a9", name: "Blue" }, {hex: '#007AFF', name: 'Blue'},
{ hex: "#000000", name: "Black" }, {hex: '#000000', name: 'Black'},
{ hex: "#863c3c", name: "Red" }, {hex: '#CD5C5C', name: 'Red'},
{ hex: "#1c6000", name: "Kermit" },
]; ];
export const colorShade = (color: any, amount: number) => {
color = color.replace(/^#/, "");
if (color.length === 3) {
color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2];
}
let [r, g, b] = color.match(/.{2}/g);
[r, g, b] = [
parseInt(r, 16) + amount,
parseInt(g, 16) + amount,
parseInt(b, 16) + amount,
];
r = Math.max(Math.min(255, r), 0).toString(16);
g = Math.max(Math.min(255, g), 0).toString(16);
b = Math.max(Math.min(255, b), 0).toString(16);
const rr = (r.length < 2 ? "0" : "") + r;
const gg = (g.length < 2 ? "0" : "") + g;
const bb = (b.length < 2 ? "0" : "") + b;
return `#${rr}${gg}${bb}`;
};

View File

@ -1,6 +1,2 @@
export const MARGIN = 10; export const MARGIN = 10;
export const PADDING = 10; export const PADDING = 10;
export const ITEM_PADDING = 8;
export const DARK_RIPPLE = "#444444";
export const LIGHT_RIPPLE = "#c2c2c2";
export const LIMIT = 15;

View File

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

View File

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

140
db.ts
View File

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

View File

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

View File

@ -1,39 +1,28 @@
#!/bin/bash #!/bin/sh
set -ex set -ex
git push origin HEAD > /dev/null &
cd android || exit 1 cd android || exit 1
build=app/build.gradle
build=app/build.gradle
versionCode=$( versionCode=$(
grep '^\s*versionCode [0-9]*$' "$build" | awk '{print $2+1}' grep '^\s*versionCode [0-9]*$' "$build" | awk '{print $2+1}'
) )
major=$( major=$(
grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" | grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" \
sed 's/"//g' | cut -d '.' -f 1 | awk '{print $2}' | sed 's/"//g' | cut -d '.' -f 1 | awk '{print $2}'
) )
minor=$( minor=$(
grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" | grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" \
sed 's/"//g' | cut -d '.' -f 2 | sed 's/"//g' | cut -d '.' -f 2
) )
minor=$((minor + 1)) minor=$((minor+1))
sed -i "s/\(^\s*\)versionCode [0-9]*$/\1versionCode $versionCode/" \ sed -i "s/\(^\s*\)versionCode [0-9]*$/\1versionCode $versionCode/" \
"$build" "$build"
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
[ "$1" != "--nobundle" ] && ./gradlew bundleRelease
if [ "$1" != "-n" ]; then
yarn tsc
yarn lint
./gradlew bundleRelease
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 "$major.$minor"
git tag "$versionCode" git push origin HEAD & git push --tags
git push origin HEAD cd ..
git push --tags

View File

@ -1,8 +1,24 @@
import {Plan} from './plan';
import Set from './set';
export type DrawerParamList = { export type DrawerParamList = {
Home: {}; Home: {};
Settings: {}; Settings: {};
Graphs: {}; Best: {};
Plans: {}; Plans: {};
Workouts: {}; Workouts: {};
Timer: {}; 'Edit set': {
set: Set;
workouts: string[];
count: number;
};
'Edit plan': {
plan: Plan;
};
'Edit workout': {
value: Set;
};
'View best': {
best: Set;
};
}; };

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

View File

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

View File

@ -1,8 +1,6 @@
/**
* @format
*/
import {AppRegistry} from 'react-native'; import {AppRegistry} from 'react-native';
import 'react-native-gesture-handler';
import 'react-native-sqlite-storage';
import App from './App'; import App from './App';
import {name as appName} from './app.json'; import {name as appName} from './app.json';

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