Compare commits

..

3 Commits

Author SHA1 Message Date
Brandon Presley 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
Brandon Presley 8a74d750c4 Fix back logic 2022-09-29 12:50:56 +13:00
Brandon Presley 5fc4c758b6 Use hidden drawer routes instead of drawer + stack
Closes #68
2022-09-29 12:28:03 +13:00
216 changed files with 13610 additions and 20464 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'], };
}

2
.gitignore vendored
View File

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

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

141
App.tsx
View File

@ -1,22 +1,19 @@
import { import {
NavigationContainer,
DarkTheme as NavigationDarkTheme, DarkTheme as NavigationDarkTheme,
DefaultTheme as NavigationDefaultTheme, DefaultTheme as NavigationDefaultTheme,
} from "@react-navigation/native"; NavigationContainer,
import React, { useEffect, useMemo, useState } from "react"; } from '@react-navigation/native';
import { useColorScheme } from "react-native"; import React, {useState} from 'react';
import {useColorScheme} from 'react-native';
import { import {
MD3DarkTheme as PaperDarkTheme, DarkTheme as PaperDarkTheme,
MD3LightTheme as PaperDefaultTheme, DefaultTheme as PaperDefaultTheme,
Provider as PaperProvider, Provider,
} from "react-native-paper"; } from 'react-native-paper';
import MaterialIcon from "react-native-vector-icons/MaterialCommunityIcons"; import Ionicon from 'react-native-vector-icons/MaterialIcons';
import AppSnack from "./AppSnack"; import {lightColors} from './colors';
import AppStack from "./AppStack"; import MassiveSnack from './MassiveSnack';
import FatalError from "./FatalError"; import Routes from './Routes';
import { AppDataSource } from "./data-source";
import { settingsRepo } from "./db";
import { ThemeContext } from "./use-theme";
export const CombinedDefaultTheme = { export const CombinedDefaultTheme = {
...NavigationDefaultTheme, ...NavigationDefaultTheme,
@ -26,99 +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 = () => {
console.log("Re rendered app"); const dark = useColorScheme() === 'dark';
const systemTheme = useColorScheme(); const [color, setColor] = useState(
dark
const [appSettings, setAppSettings] = useState({ ? CombinedDarkTheme.colors.primary.toUpperCase()
startup: undefined, : CombinedDefaultTheme.colors.primary.toUpperCase(),
theme: "system", );
lightColor: CombinedDefaultTheme.colors.primary, const theme = dark
darkColor: CombinedDarkTheme.colors.primary, ? {
}); ...CombinedDarkTheme,
const [error, setError] = useState(""); colors: {...CombinedDarkTheme.colors, primary: color},
}
useEffect(() => { : {
(async () => { ...CombinedDefaultTheme,
if (!AppDataSource.isInitialized) colors: {...CombinedDefaultTheme.colors, primary: color},
await AppDataSource.initialize().catch((e) => setError(e.toString())); };
const gotSettings = await settingsRepo.findOne({ where: {} });
console.log(`${App.name}.mount`, { gotSettings });
setAppSettings({
startup: gotSettings.startup,
theme: gotSettings.theme,
lightColor:
gotSettings.lightColor || CombinedDefaultTheme.colors.primary,
darkColor: gotSettings.darkColor || CombinedDarkTheme.colors.primary,
});
})();
}, []);
const paperTheme = useMemo(() => {
const darkTheme = {
...CombinedDarkTheme,
colors: {
...CombinedDarkTheme.colors,
primary: appSettings.darkColor,
},
};
const lightTheme = {
...CombinedDefaultTheme,
colors: {
...CombinedDefaultTheme.colors,
primary: appSettings.lightColor,
},
};
let theme = systemTheme === "dark" ? darkTheme : lightTheme;
if (appSettings.theme === "dark") theme = darkTheme;
else if (appSettings.theme === "light") theme = lightTheme;
return theme;
}, [systemTheme, appSettings]);
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}>
{error && ( <MassiveSnack>
<FatalError <Routes />
message={error} </MassiveSnack>
setAppSettings={setAppSettings} </NavigationContainer>
setError={setError} </Provider>
/> </CustomTheme.Provider>
)}
{appSettings.startup !== undefined && (
<ThemeContext.Provider
value={{
theme: appSettings.theme,
setTheme: (theme) => setAppSettings({ ...appSettings, theme }),
lightColor: appSettings.lightColor,
setLightColor: (color) =>
setAppSettings({ ...appSettings, lightColor: color }),
darkColor: appSettings.darkColor,
setDarkColor: (color) =>
setAppSettings({ ...appSettings, darkColor: color }),
}}
>
<AppStack startup={appSettings.startup} />
</ThemeContext.Provider>
)}
</NavigationContainer>
<AppSnack textColor={paperTheme.colors.background} />
</PaperProvider>
); );
}; };

View File

@ -1,82 +0,0 @@
import { createDrawerNavigator } from "@react-navigation/drawer";
import { StackScreenProps } from "@react-navigation/stack";
import { IconButton, useTheme, Banner } from "react-native-paper";
import { DrawerParams } from "./drawer-params";
import ExerciseList from "./ExerciseList";
import GraphsList from "./GraphList";
import InsightsPage from "./InsightsPage";
import PlanList from "./PlanList";
import SetList from "./SetList";
import SettingsPage from "./SettingsPage";
import WeightList from "./WeightList";
import Daily from "./Daily";
const Drawer = createDrawerNavigator<DrawerParams>();
interface AppDrawerParams {
startup: string;
}
export default function AppDrawer({
route,
}: StackScreenProps<{ startup: AppDrawerParams }>) {
const { dark } = useTheme();
return (
<Drawer.Navigator
screenOptions={{
headerTintColor: dark ? "white" : "black",
swipeEdgeWidth: 1000,
headerShown: false,
}}
initialRouteName={
(route.params.startup as keyof DrawerParams) || "History"
}
>
<Drawer.Screen
name="History"
component={SetList}
options={{ drawerIcon: () => <IconButton icon="history" /> }}
/>
<Drawer.Screen
name="Exercises"
component={ExerciseList}
options={{ drawerIcon: () => <IconButton icon="dumbbell" /> }}
/>
<Drawer.Screen
name="Daily"
component={Daily}
options={{ drawerIcon: () => <IconButton icon="calendar-outline" /> }}
/>
<Drawer.Screen
name="Plans"
component={PlanList}
options={{ drawerIcon: () => <IconButton icon="checkbox-multiple-marked-outline" /> }}
/>
<Drawer.Screen
name="Graphs"
component={GraphsList}
options={{
drawerIcon: () => <IconButton icon="chart-bell-curve-cumulative" />,
}}
/>
<Drawer.Screen
name="Weight"
component={WeightList}
options={{ drawerIcon: () => <IconButton icon="scale-bathroom" /> }}
/>
<Drawer.Screen
name="Insights"
component={InsightsPage}
options={{
drawerIcon: () => <IconButton icon="lightbulb-on-outline" />,
}}
/>
<Drawer.Screen
name="Settings"
component={SettingsPage}
options={{ drawerIcon: () => <IconButton icon="cog-outline" /> }}
/>
</Drawer.Navigator>
);
}

View File

@ -1,21 +0,0 @@
import { ComponentProps } from "react";
import { FAB, useTheme } from "react-native-paper";
export default function AppFab(props: Partial<ComponentProps<typeof FAB>>) {
const { colors } = useTheme();
return (
<FAB
icon="plus"
testID="add"
color={colors.background}
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, useTheme } from "react-native-paper";
import { CombinedDefaultTheme } from "./App";
import { MARGIN } from "./constants";
function AppInput(
props: Partial<ComponentProps<typeof TextInput>> & {
innerRef?: Ref<any>;
}
) {
const { dark } = useTheme();
return (
<TextInput
selectionColor={dark ? "#2A2A2A" : CombinedDefaultTheme.colors.border}
style={{ marginBottom: MARGIN, minWidth: 100 }}
selectTextOnFocus
ref={props.innerRef}
blurOnSubmit={false}
mode="outlined"
{...props}
/>
);
}
export default React.memo(AppInput);

View File

@ -1,50 +0,0 @@
import { useMemo } from "react";
import { useWindowDimensions } from "react-native";
import { LineChart } from "react-native-chart-kit";
import { AbstractChartConfig } from "react-native-chart-kit/dist/AbstractChart";
import { useTheme } from "react-native-paper";
interface ChartProps {
labels: string[];
data: number[];
}
export default function AppLineChart({ labels, data }: ChartProps) {
const { width } = useWindowDimensions();
const { colors } = useTheme();
const config: AbstractChartConfig = {
backgroundGradientFrom: colors.background,
backgroundGradientTo: colors.elevation.level1,
color: () => colors.primary,
};
const pruned = useMemo(() => {
if (labels.length < 3) return labels;
const newPruned = [labels[0]];
const centerIndex = Math.floor(labels.length / 2);
for (let i = 1; i < labels.length - 1; i++) {
if (i === centerIndex) newPruned[i] = labels[i];
else newPruned[i] = "";
}
newPruned.push(labels[labels.length - 1]);
return newPruned;
}, [labels]);
return (
<LineChart
height={400}
width={width - 20}
data={{
labels: pruned,
datasets: [
{
data,
},
],
}}
bezier
chartConfig={config}
/>
);
}

View File

@ -1,57 +0,0 @@
import { useWindowDimensions } from "react-native";
import { PieChart } from "react-native-chart-kit";
import { useTheme } from "react-native-paper";
export interface Option {
value: number;
label: string;
}
export default function AppPieChart({ options }: { options: Option[] }) {
const { width } = useWindowDimensions();
const { colors } = useTheme();
const pieChartColors = [
"#FF7F50", // Coral
"#1E90FF", // Dodger Blue
"#32CD32", // Lime Green
"#BA55D3", // Medium Orchid
"#FFD700", // Gold
"#48D1CC", // Medium Turquoise
"#FF69B4", // Hot Pink
];
const data = options.map((option, index) => ({
name: option.label,
value: option.value,
color: pieChartColors[index],
legendFontColor: colors.onSurface,
legendFontSize: 15,
}));
return (
<PieChart
data={data}
paddingLeft="0"
width={width}
height={220}
chartConfig={{
backgroundColor: "#e26a00",
backgroundGradientFrom: "#fb8c00",
backgroundGradientTo: "#ffa726",
color: (opacity = 1) => `rgba(255, 255, 255, ${opacity})`,
labelColor: (opacity = 1) => `rgba(255, 255, 255, ${opacity})`,
style: {
borderRadius: 16,
},
propsForDots: {
r: "6",
strokeWidth: "2",
stroke: "#ffa726",
},
}}
accessor={"value"}
backgroundColor={"transparent"}
/>
);
}

View File

@ -1,33 +0,0 @@
import React, { useEffect, useState } from "react";
import { Snackbar } from "react-native-paper";
import { emitter } from "./emitter";
import { TOAST } from "./toast";
export default function AppSnack({ textColor }: { textColor: string }) {
const [snackbar, setSnackbar] = useState("");
useEffect(() => {
const description = emitter.addListener(
TOAST,
({ value }: { value: string }) => {
setSnackbar(value);
}
);
return description.remove;
}, []);
return (
<Snackbar
duration={3000}
onDismiss={() => setSnackbar("")}
visible={!!snackbar}
action={{
label: "Close",
onPress: () => setSnackbar(""),
textColor,
}}
>
{snackbar}
</Snackbar>
);
}

View File

@ -1,101 +0,0 @@
import { createStackNavigator } from "@react-navigation/stack";
import AppDrawer from "./AppDrawer";
import EditExercise from "./EditExercise";
import EditExercises from "./EditExercises";
import EditPlan from "./EditPlan";
import EditSet from "./EditSet";
import EditSets from "./EditSets";
import EditWeight from "./EditWeight";
import GymSet from "./gym-set";
import { Plan } from "./plan";
import StartPlan from "./StartPlan";
import ViewGraph from "./ViewGraph";
import ViewSetList from "./ViewSetList";
import ViewWeightGraph from "./ViewWeightGraph";
import Weight from "./weight";
import { View, Text, StyleSheet } from "react-native";
export type StackParams = {
Drawer: {};
EditSet: {
set: Partial<GymSet>;
};
EditSets: {
ids: number[];
};
EditPlan: {
plan: Partial<Plan>;
};
StartPlan: {
plan: Plan;
first: Partial<GymSet>;
};
ViewGraph: {
name: string;
};
EditWeight: {
weight: Partial<Weight>;
};
ViewWeightGraph: {};
EditExercise: {
gymSet: Partial<GymSet>;
};
EditExercises: {
names: string[];
};
ViewSetList: {
name: string;
};
};
const Stack = createStackNavigator<StackParams>();
export default function AppStack({ startup }: { startup: string }) {
return (
<>
{__DEV__ && (
<View style={styles.debugBanner}>
<Text style={styles.debugText}>DEBUG</Text>
</View>
)}
<Stack.Navigator
screenOptions={{ headerShown: false, animationEnabled: false }}
>
<Stack.Screen
name="Drawer"
component={AppDrawer}
initialParams={{ startup }}
/>
<Stack.Screen name="EditSet" component={EditSet} />
<Stack.Screen name="EditSets" component={EditSets} />
<Stack.Screen name="EditPlan" component={EditPlan} />
<Stack.Screen name="StartPlan" component={StartPlan} />
<Stack.Screen name="ViewGraph" component={ViewGraph} />
<Stack.Screen name="EditWeight" component={EditWeight} />
<Stack.Screen name="ViewWeightGraph" component={ViewWeightGraph} />
<Stack.Screen name="EditExercise" component={EditExercise} />
<Stack.Screen name="EditExercises" component={EditExercises} />
<Stack.Screen name="ViewSetList" component={ViewSetList} />
</Stack.Navigator>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
debugBanner: {
position: 'absolute',
top: 20,
right: 100,
backgroundColor: 'red',
zIndex: 1000,
borderRadius: 5,
},
debugText: {
color: 'white',
padding: 5,
fontSize: 10,
},
});

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

57
Chart.tsx Normal file
View File

@ -0,0 +1,57 @@
import * as shape from 'd3-shape';
import React, {useContext} from 'react';
import {View} from 'react-native';
import {Grid, LineChart, XAxis, YAxis} from 'react-native-svg-charts';
import {CustomTheme} from './App';
import {MARGIN, PADDING} from './constants';
import Set from './set';
export default function Chart({
yData,
xFormat,
xData,
yFormat,
}: {
yData: number[];
xData: Set[];
xFormat: (value: any, index: number) => string;
yFormat: (value: any) => string;
}) {
const {color} = useContext(CustomTheme);
const axesSvg = {fontSize: 10, fill: 'grey'};
const verticalContentInset = {top: 10, bottom: 10};
const xAxisHeight = 30;
return (
<>
<View style={{height: 300, padding: PADDING, flexDirection: 'row'}}>
<YAxis
data={yData}
style={{marginBottom: xAxisHeight}}
contentInset={verticalContentInset}
svg={axesSvg}
formatLabel={yFormat}
/>
<View style={{flex: 1, marginLeft: MARGIN}}>
<LineChart
style={{flex: 1}}
data={yData}
contentInset={verticalContentInset}
curve={shape.curveBasis}
svg={{
stroke: color,
}}>
<Grid />
</LineChart>
<XAxis
style={{marginHorizontal: -10, height: xAxisHeight}}
data={xData}
formatLabel={xFormat}
contentInset={{left: 10, right: 10}}
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>

109
Daily.tsx
View File

@ -1,109 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { FlatList, View } from "react-native";
import { Button, IconButton, List } from "react-native-paper";
import AppFab from "./AppFab";
import DrawerHeader from "./DrawerHeader";
import { LIMIT, PADDING } from "./constants";
import GymSet, { defaultSet } from "./gym-set";
import { getNow, setRepo, settingsRepo } from "./db";
import { NavigationProp, useFocusEffect, useNavigation } from "@react-navigation/native";
import { Like } from "typeorm";
import Settings from "./settings";
import { format } from "date-fns";
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import SetItem from "./SetItem";
import { StackParams } from "./AppStack";
export default function Daily() {
const [sets, setSets] = useState<GymSet[]>();
const [day, setDay] = useState<Date>()
const [settings, setSettings] = useState<Settings>();
const navigation = useNavigation<NavigationProp<StackParams>>();
const mounted = async () => {
const now = await getNow();
let created = now.split('T')[0];
setDay(new Date(created));
}
useEffect(() => {
mounted();
}, [])
const refresh = async () => {
if (!day) return;
const created = day.toISOString().split('T')[0]
const newSets = await setRepo.find({
where: { hidden: 0 as any, created: Like(`${created}%`) },
take: LIMIT,
skip: 0,
order: { created: "DESC" },
});
setSets(newSets);
settingsRepo.findOne({ where: {} }).then(setSettings)
}
useEffect(() => {
refresh();
}, [day])
useFocusEffect(useCallback(() => {
refresh();
}, [day]))
const onAdd = async () => {
const now = await getNow();
let set: Partial<GymSet> = { ...sets[0] };
if (!set) set = { ...defaultSet };
set.created = now;
delete set.id;
navigation.navigate("EditSet", { set });
}
const onRight = () => {
const newDay = new Date(day)
newDay.setDate(newDay.getDate() + 1)
setDay(newDay)
}
const onLeft = () => {
const newDay = new Date(day)
newDay.setDate(newDay.getDate() - 1)
setDay(newDay)
}
const onDate = () => {
DateTimePickerAndroid.open({
value: new Date(day),
onChange: (event, date) => {
if (event.type === 'dismissed') return;
setDay(date)
},
mode: 'date',
})
}
return (
<>
<DrawerHeader name="Daily" />
<View style={{ padding: PADDING, flexGrow: 1 }}>
<View style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center' }}>
<IconButton style={{ marginRight: 'auto' }} icon="chevron-double-left" onPress={onLeft} />
<Button onPress={onDate}>{format(day ? new Date(day) : new Date(), "PPPP")}</Button>
<IconButton style={{ marginLeft: 'auto' }} icon="chevron-double-right" onPress={onRight} />
</View>
{settings && (
<FlatList ListEmptyComponent={<List.Item title="No sets yet" />} style={{ flex: 1 }} data={sets} renderItem={({ item }) => <SetItem ids={[]} setIds={() => { }} item={item} settings={settings} />} />
)}
<AppFab onPress={onAdd} />
</View>
</>
)
}

View File

@ -1,28 +0,0 @@
import { DrawerNavigationProp } from "@react-navigation/drawer";
import { useNavigation } from "@react-navigation/native";
import { Appbar, IconButton } from "react-native-paper";
import { DrawerParams } from "./drawer-params";
export default function DrawerHeader({
name,
children,
ids,
unSelect,
}: {
name: string;
children?: JSX.Element | JSX.Element[];
ids?: unknown[],
unSelect?: () => void,
}) {
const navigation = useNavigation<DrawerNavigationProp<DrawerParams>>();
return (
<Appbar.Header>
{ids && ids.length > 0 ? (<IconButton icon="arrow-left" onPress={unSelect} />) : (
<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,231 +0,0 @@
import {
NavigationProp,
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from "@react-navigation/native";
import { useCallback, useRef, useState } from "react";
import { ScrollView, TextInput, View } from "react-native";
import DocumentPicker from "react-native-document-picker";
import { Button, Card, IconButton, TouchableRipple } from "react-native-paper";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import ConfirmDialog from "./ConfirmDialog";
import PrimaryButton from "./PrimaryButton";
import StackHeader from "./StackHeader";
import { MARGIN, PADDING } from "./constants";
import { getNow, planRepo, setRepo, settingsRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import { fixNumeric } from "./fix-numeric";
import GymSet, { defaultSet } from "./gym-set";
import Settings from "./settings";
import { toast } from "./toast";
export default function EditExercise() {
const { params } = useRoute<RouteProp<StackParams, "EditExercise">>();
const [removeImage, setRemoveImage] = useState(false);
const [showRemoveImage, setShowRemoveImage] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [name, setName] = useState(params.gymSet.name);
const [steps, setSteps] = useState(params.gymSet.steps);
const [uri, setUri] = useState(params.gymSet.image);
const [minutes, setMinutes] = useState(
params.gymSet.minutes?.toString() ?? "3"
);
const [seconds, setSeconds] = useState(
params.gymSet.seconds?.toString() ?? "30"
);
const [sets, setSets] = useState(params.gymSet.sets?.toString() ?? "3");
const { navigate } = useNavigation<NavigationProp<DrawerParams>>();
const setsRef = useRef<TextInput>(null);
const stepsRef = useRef<TextInput>(null);
const minutesRef = useRef<TextInput>(null);
const secondsRef = useRef<TextInput>(null);
const [settings, setSettings] = useState<Settings>();
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then((gotSettings) => {
setSettings(gotSettings);
if (params.gymSet.id) return;
setSets(gotSettings.defaultSets?.toString() ?? "3");
setMinutes(gotSettings.defaultMinutes?.toString() ?? "3");
setSeconds(gotSettings.defaultSeconds?.toString() ?? "30");
});
}, [params.gymSet.id])
);
const update = async () => {
const newExercise = {
name: name || params.gymSet.name,
sets: Number(sets),
minutes: Number(minutes),
seconds: Number(seconds),
steps,
image: removeImage ? "" : uri,
} as GymSet;
await setRepo.update({ name: params.gymSet.name }, newExercise);
await planRepo.query(
`UPDATE plans
SET exercises = REPLACE(exercises, $1, $2)
WHERE exercises LIKE $3`,
[params.gymSet.name, name, `%${params.gymSet.name}%`]
);
navigate("Exercises", { update: newExercise });
};
const add = async () => {
const now = await getNow();
await setRepo.save({
...defaultSet,
name,
hidden: true,
image: uri,
minutes: minutes ? Number(minutes) : 3,
seconds: seconds ? Number(seconds) : 30,
sets: sets ? Number(sets) : 3,
steps,
created: now,
});
navigate("Exercises");
};
const remove = async () => {
await setRepo.delete({ name: params.gymSet.name });
navigate("Exercises");
};
const save = async () => {
if (params.gymSet.name) return update();
return add();
};
const changeImage = useCallback(async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: "documentDirectory",
});
if (fileCopyUri) setUri(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setUri("");
setRemoveImage(true);
setShowRemoveImage(false);
}, []);
const submitName = () => {
if (settings.steps) stepsRef.current?.focus();
else setsRef.current?.focus();
};
return (
<>
<StackHeader
title={params.gymSet.name ? "Edit exercise" : "Add exercise"}
>
{typeof params.gymSet.id === "number" ? (
<IconButton onPress={() => setShowDelete(true)} icon="delete" />
) : null}
</StackHeader>
<View style={{ padding: PADDING, flex: 1 }}>
<ScrollView style={{ flex: 1 }}>
<AppInput
autoFocus
label="Name"
value={name}
onChangeText={setName}
onSubmitEditing={submitName}
/>
<AppInput
innerRef={stepsRef}
selectTextOnFocus={false}
value={steps}
onChangeText={setSteps}
label="Steps"
multiline
onSubmitEditing={() => setsRef.current?.focus()}
/>
<AppInput
innerRef={setsRef}
value={sets}
onChangeText={(newSets) => {
const fixed = fixNumeric(newSets);
setSets(fixed);
if (fixed.length !== newSets.length)
toast("Sets must be a number");
}}
label="Sets per exercise"
keyboardType="numeric"
onSubmitEditing={() => minutesRef.current?.focus()}
/>
{settings?.alarm && (
<>
<AppInput
innerRef={minutesRef}
onSubmitEditing={() => secondsRef.current?.focus()}
value={minutes}
onChangeText={(newMinutes) => {
const fixed = fixNumeric(newMinutes);
setMinutes(fixed);
if (fixed.length !== newMinutes.length)
toast("Reps must be a number");
}}
label="Rest minutes"
keyboardType="numeric"
/>
<AppInput
innerRef={secondsRef}
value={seconds}
onChangeText={setSeconds}
label="Rest seconds"
keyboardType="numeric"
blurOnSubmit
/>
</>
)}
{settings?.images && uri && (
<TouchableRipple
style={{ marginBottom: MARGIN }}
onPress={changeImage}
onLongPress={() => setShowRemoveImage(true)}
>
<Card.Cover source={{ uri }} />
</TouchableRipple>
)}
{settings?.images && !uri && (
<Button
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="image-plus"
>
Image
</Button>
)}
</ScrollView>
<PrimaryButton disabled={!name} icon="content-save" onPress={save}>
Save
</PrimaryButton>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemoveImage}
setShow={setShowRemoveImage}
>
Are you sure you want to remove the image?
</ConfirmDialog>
<ConfirmDialog
title="Delete set"
show={showDelete}
onOk={remove}
setShow={setShowDelete}
>
<>Are you sure you want to delete {name}</>
</ConfirmDialog>
</View>
</>
);
}

View File

@ -1,203 +0,0 @@
import {
NavigationProp,
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from "@react-navigation/native";
import { useCallback, useRef, useState } from "react";
import { ScrollView, TextInput, View } from "react-native";
import DocumentPicker from "react-native-document-picker";
import { Button, Card, TouchableRipple } from "react-native-paper";
import { In } from "typeorm";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import ConfirmDialog from "./ConfirmDialog";
import { MARGIN, PADDING } from "./constants";
import { planRepo, setRepo, settingsRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import { fixNumeric } from "./fix-numeric";
import Settings from "./settings";
import StackHeader from "./StackHeader";
import { toast } from "./toast";
import PrimaryButton from "./PrimaryButton";
export default function EditExercises() {
const { params } = useRoute<RouteProp<StackParams, "EditExercises">>();
const [removeImage, setRemoveImage] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const [name, setName] = useState("");
const [oldNames, setOldNames] = useState(params.names.join(", "));
const [steps, setSteps] = useState("");
const [oldSteps, setOldSteps] = useState("");
const [uri, setUri] = useState("");
const [minutes, setMinutes] = useState("");
const [oldMinutes, setOldMinutes] = useState("");
const [seconds, setSeconds] = useState("");
const [oldSeconds, setOldSeconds] = useState("");
const [sets, setSets] = useState("");
const [oldSets, setOldSets] = useState("");
const navigation = useNavigation<NavigationProp<DrawerParams>>();
const setsRef = useRef<TextInput>(null);
const stepsRef = useRef<TextInput>(null);
const minutesRef = useRef<TextInput>(null);
const secondsRef = useRef<TextInput>(null);
const [settings, setSettings] = useState<Settings>();
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
setRepo
.createQueryBuilder()
.select()
.where("name IN (:...names)", { names: params.names })
.groupBy("name")
.getMany()
.then((gymSets) => {
console.log(`${EditExercises.name}.focus:`, { gymSets });
setOldNames(gymSets.map((set) => set.name).join(", "));
setOldSteps(gymSets.map((set) => set.steps).join(", "));
setOldMinutes(gymSets.map((set) => set.minutes).join(", "));
setOldSeconds(gymSets.map((set) => set.seconds).join(", "));
setOldSets(gymSets.map((set) => set.sets).join(", "));
});
}, [params.names])
);
const update = async () => {
await setRepo.update(
{ name: In(params.names) },
{
name: name || undefined,
sets: sets ? Number(sets) : undefined,
minutes: minutes ? Number(minutes) : undefined,
seconds: seconds ? Number(seconds) : undefined,
steps: steps || undefined,
image: removeImage ? "" : uri,
}
);
for (const oldName of params.names) {
await planRepo
.createQueryBuilder()
.update()
.set({
exercises: () => `REPLACE(exercises, '${oldName}', '${name}')`,
})
.where("exercises LIKE :name", { name: `%${oldName}%` })
.execute();
}
navigation.navigate("Exercises");
};
const changeImage = useCallback(async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: "documentDirectory",
});
if (fileCopyUri) setUri(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setUri("");
setRemoveImage(true);
setShowRemove(false);
}, []);
const submitName = () => {
if (settings.steps) stepsRef.current?.focus();
else setsRef.current?.focus();
};
return (
<>
<StackHeader title={`Edit ${params.names.length} exercises`} />
<View style={{ padding: PADDING, flex: 1 }}>
<ScrollView style={{ flex: 1 }}>
<AppInput
autoFocus
label={`Names: ${oldNames}`}
value={name}
onChangeText={setName}
onSubmitEditing={submitName}
/>
<AppInput
innerRef={stepsRef}
selectTextOnFocus={false}
value={steps}
onChangeText={setSteps}
label={`Steps: ${oldSteps}`}
multiline
onSubmitEditing={() => setsRef.current?.focus()}
/>
<AppInput
innerRef={setsRef}
value={sets}
onChangeText={(newSets) => {
const fixed = fixNumeric(newSets);
setSets(fixed);
if (fixed.length !== newSets.length)
toast("Sets must be a number");
}}
label={`Sets: ${oldSets}`}
keyboardType="numeric"
onSubmitEditing={() => minutesRef.current?.focus()}
/>
{settings?.alarm && (
<>
<AppInput
innerRef={minutesRef}
onSubmitEditing={() => secondsRef.current?.focus()}
value={minutes}
onChangeText={(newMinutes) => {
const fixed = fixNumeric(newMinutes);
setMinutes(fixed);
if (fixed.length !== newMinutes.length)
toast("Reps must be a number");
}}
label={`Rest minutes: ${oldMinutes}`}
keyboardType="numeric"
/>
<AppInput
innerRef={secondsRef}
value={seconds}
onChangeText={setSeconds}
label={`Rest seconds: ${oldSeconds}`}
keyboardType="numeric"
blurOnSubmit
/>
</>
)}
{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="image-plus"
>
Image
</Button>
)}
</ScrollView>
<PrimaryButton disabled={!name} icon="content-save" onPress={update}>
Save
</PrimaryButton>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemove}
setShow={setShowRemove}
>
Are you sure you want to remove the image?
</ConfirmDialog>
</View>
</>
);
}

View File

@ -1,91 +1,86 @@
import { import {
NavigationProp, NavigationProp,
RouteProp, RouteProp,
useFocusEffect,
useNavigation, useNavigation,
useRoute, useRoute,
} from "@react-navigation/native"; } from '@react-navigation/native';
import React, { useCallback, useEffect, useState } from "react"; import React, {useCallback, useEffect, useState} from 'react';
import { import {BackHandler, ScrollView, StyleSheet, View} from 'react-native';
FlatList, import {Button, IconButton, Text} from 'react-native-paper';
Pressable, import {MARGIN, PADDING} from './constants';
ScrollView, import {DrawerParamList} from './drawer-param-list';
StyleSheet, import {addPlan, updatePlan} from './plan.service';
View, import {getNames} from './set.service';
} from "react-native"; import Switch from './Switch';
import { import {DAYS} from './time';
Button,
IconButton,
Switch as PaperSwitch,
Text,
} from "react-native-paper";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import PrimaryButton from "./PrimaryButton";
import StackHeader from "./StackHeader";
import Switch from "./Switch";
import { MARGIN, PADDING } from "./constants";
import { DAYS } from "./days";
import { planRepo, setRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import GymSet, { defaultSet } from "./gym-set";
import { toast } from "./toast";
export default function EditPlan() { export default function EditPlan() {
const { params } = useRoute<RouteProp<StackParams, "EditPlan">>(); const {params} = useRoute<RouteProp<DrawerParamList, 'Edit plan'>>();
const { plan } = params; const {plan} = params;
const [title, setTitle] = useState<string>(plan?.title);
const [names, setNames] = useState<string[]>();
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 [exercises, setExercises] = useState<string[]>( plan.workouts ? plan.workouts.split(',') : [],
plan.exercises ? plan.exercises.split(",") : []
); );
const [names, setNames] = useState<string[]>([]);
const navigation = useNavigation<NavigationProp<DrawerParamList>>();
const { navigate: drawerNavigate } = useFocusEffect(
useNavigation<NavigationProp<DrawerParams>>(); useCallback(() => {
const { navigate: stackNavigate } = console.log(`${EditPlan.name}.focus:`, {plan});
useNavigation<NavigationProp<StackParams>>(); 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) => {
const newNames = values.map((value) => value.name);
console.log(EditPlan.name, { newNames });
setNames(newNames);
});
}, []); }, []);
const save = useCallback(async () => { const save = useCallback(async () => {
console.log(`${EditPlan.name}.save`, { days, exercises, plan }); console.log(`${EditPlan.name}.save`, {days, workouts, plan});
if (!days || !exercises) return; if (!days || !workouts) return;
const newExercises = exercises.filter((exercise) => exercise).join(","); const newWorkouts = workouts.filter(workout => workout).join(',');
const newDays = days.filter((day) => day).join(","); const newDays = days.filter(day => day).join(',');
const saved = await planRepo.save({ if (typeof plan.id === 'undefined')
title: title, await addPlan({days: newDays, workouts: newWorkouts});
days: newDays, else
exercises: newExercises, await updatePlan({
id: plan.id, days: newDays,
}); workouts: newWorkouts,
if (saved.id === 1) toast("Tap your plan again to begin using it"); id: plan.id,
}, [title, days, exercises, plan]); });
navigation.goBack();
}, [days, workouts, plan, navigation]);
const toggleExercise = useCallback( const toggleWorkout = useCallback(
(on: boolean, name: string) => { (on: boolean, name: string) => {
if (on) { if (on) {
setExercises([...exercises, name]); setWorkouts([...workouts, name]);
} else { } else {
setExercises(exercises.filter((exercise) => exercise !== name)); setWorkouts(workouts.filter(workout => workout !== name));
} }
}, },
[setExercises, exercises] [setWorkouts, workouts],
); );
const toggleDay = useCallback( const toggleDay = useCallback(
@ -93,137 +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],
); );
const renderDay = (day: string) => (
<Switch
key={day}
onChange={(value) => toggleDay(value, day)}
value={days.includes(day)}
title={day}
/>
);
const renderExercise = (name: string, index: number, movable: boolean) => (
<Pressable
onPress={() => toggleExercise(!exercises.includes(name), name)}
style={{ flexDirection: "row", alignItems: "center" }}
key={name}
>
<PaperSwitch
value={exercises.includes(name)}
style={{ marginRight: MARGIN }}
onValueChange={(value) => toggleExercise(value, name)}
/>
<Text>{name}</Text>
{movable && (
<>
<IconButton
icon="arrow-up"
style={{ marginLeft: "auto" }}
onPressIn={() => moveUp(index)}
/>
<IconButton icon="arrow-down" onPressIn={() => moveDown(index)} />
</>
)}
</Pressable>
);
const moveDown = (from: number) => {
if (from === exercises.length - 1) return;
const to = from + 1;
const newExercises = [...exercises];
const copy = newExercises[from];
newExercises[from] = newExercises[to];
newExercises[to] = copy;
setExercises(newExercises);
};
const moveUp = (from: number) => {
if (from === 0) return;
const to = from - 1;
const newExercises = [...exercises];
const copy = newExercises[from];
newExercises[from] = newExercises[to];
newExercises[to] = copy;
setExercises(newExercises);
};
return ( return (
<> <View style={{padding: PADDING}}>
<StackHeader <ScrollView style={{height: '90%'}}>
title={typeof plan.id === "number" ? "Edit plan" : "Add plan"}
>
{typeof plan.id === "number" && (
<IconButton
onPress={async () => {
await save();
const newPlan = await planRepo.findOne({
where: { id: plan.id },
});
let first: Partial<GymSet> = await setRepo.findOne({
where: { name: exercises[0] },
order: { created: "desc" },
});
if (!first) first = { ...defaultSet, name: exercises[0] };
delete first.id;
stackNavigate("StartPlan", { plan: newPlan, first });
}}
icon="play"
/>
)}
</StackHeader>
<ScrollView style={{ padding: PADDING, flex: 1 }}>
<AppInput
label="Title"
value={title}
placeholder={days.join(", ")}
onChangeText={(value) => setTitle(value)}
/>
<Text style={styles.title}>Days</Text> <Text style={styles.title}>Days</Text>
{DAYS.map((day) => renderDay(day))} {DAYS.map(day => (
<Switch
<Text style={[styles.title, { marginTop: MARGIN }]}>Exercises</Text> key={day}
{exercises.map((exercise, index) => onValueChange={value => toggleDay(value, day)}
renderExercise(exercise, index, true) onPress={() => toggleDay(!days.includes(day), day)}
value={days.includes(day)}>
{day}
</Switch>
))}
<Text style={[styles.title, {marginTop: MARGIN}]}>Workouts</Text>
{names.length === 0 ? (
<View>
<Text>No workouts found.</Text>
</View>
) : (
names.map(name => (
<Switch
key={name}
onValueChange={value => toggleWorkout(value, name)}
value={workouts.includes(name)}
onPress={() => toggleWorkout(!workouts.includes(name), name)}>
{name}
</Switch>
))
)} )}
{names?.length === 0 && (
<>
<Text>No exercises yet.</Text>
<Button
onPress={() =>
stackNavigate("EditExercise", { gymSet: defaultSet })
}
style={{ alignSelf: "flex-start" }}
>
Add some?
</Button>
</>
)}
{names !== undefined &&
names
.filter((name) => !exercises.includes(name))
.map((name, index) => renderExercise(name, index, false))}
<View style={{ marginBottom: MARGIN }}></View>
</ScrollView> </ScrollView>
{names.length === 0 ? (
<PrimaryButton <Button
disabled={exercises.length === 0 && days.length === 0} disabled={workouts.length === 0 && days.length === 0}
icon="content-save" mode="contained"
onPress={async () => { onPress={() => {
await save(); navigation.goBack();
drawerNavigate("Plans"); navigation.navigate('Workouts', {
}} screen: 'EditWorkout',
style={{ margin: MARGIN }} params: {value: {name: ''}},
> });
Save }}>
</PrimaryButton> Add workout
</> </Button>
) : (
<Button
disabled={workouts.length === 0 && days.length === 0}
style={{marginTop: MARGIN}}
mode="contained"
icon="save"
onPress={save}>
Save
</Button>
)}
</View>
); );
} }

View File

@ -1,370 +1,103 @@
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import { import {
NavigationProp, 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 { import {DrawerParamList} from './drawer-param-list';
Button, import {SnackbarContext} from './MassiveSnack';
Card, import Set from './set';
IconButton, import {addSet, updateSet} from './set.service';
Menu, import SetForm from './SetForm';
TouchableRipple, import {getSettings, settings, updateSettings} from './settings.service';
} from "react-native-paper";
import { check, PERMISSIONS, request, RESULTS } from "react-native-permissions";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import ConfirmDialog from "./ConfirmDialog";
import { MARGIN, PADDING } from "./constants";
import { convert } from "./conversions";
import { getNow, setRepo, settingsRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import { fixNumeric } from "./fix-numeric";
import GymSet from "./gym-set";
import Select from "./Select";
import Settings from "./settings";
import StackHeader from "./StackHeader";
import { toast } from "./toast";
import PrimaryButton from "./PrimaryButton";
export default function EditSet() { export default function EditSet() {
const { params } = useRoute<RouteProp<StackParams, "EditSet">>(); const {params} = useRoute<RouteProp<DrawerParamList, 'Edit set'>>();
const { set } = params; const {set, count, workouts} = params;
const { navigate } = useNavigation<NavigationProp<DrawerParams>>(); 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 [showDelete, setShowDelete] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [created, setCreated] = useState<Date>(
set.created ? new Date(set.created) : new Date()
);
const [createdDirty, setCreatedDirty] = useState(false);
const [showRemoveImage, setShowRemoveImage] = useState(false);
const [removeImage, setRemoveImage] = useState(false);
const [setOptions, setSets] = useState<GymSet[]>([]);
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const [selection, setSelection] = useState({
start: 0,
end: set.reps?.toString().length,
});
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
settingsRepo.findOne({ where: {} }).then(gotSettings => { console.log(`${EditSet.name}.focus:`, set);
setSettings(gotSettings); let title = 'Create set';
console.log(`${EditSet.name}.focus:`, { gotSettings }) 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,
console.log(`${EditSet.name}.timer:`, { milliseconds }); !!settings.vibrate,
const canNotify = await check(PERMISSIONS.ANDROID.POST_NOTIFICATIONS); settings.sound,
if (canNotify === RESULTS.DENIED) );
await request(PERMISSIONS.ANDROID.POST_NOTIFICATIONS); const next = new Date();
if (milliseconds) NativeModules.AlarmModule.timer(milliseconds, `${first.name}`); 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 notify = (value: Partial<GymSet>) => { const add = useCallback(
if (!settings.notify) return navigate("History"); async (_set: Set) => {
if ( console.log(`${EditSet.name}.add`, {set: _set});
value.weight > set.weight || startTimer(_set);
(value.reps > set.reps && value.weight === set.weight) await addSet(_set);
) { if (!settings.notify) return navigation.goBack();
toast("Great work King! That's a new record."); if (
} _set.weight > set.weight ||
}; (_set.reps > set.reps && _set.weight === set.weight)
)
toast("Great work King, that's a new record!", 3000);
navigation.goBack();
},
[navigation, startTimer, set, toast],
);
const added = async (value: GymSet) => { const save = useCallback(
console.log(`${EditSet.name}.added:`, value); async (_set: Set) => {
startTimer(value.name); if (typeof set.id === 'number') return update(_set);
}; return add(_set);
},
const handleSubmit = async () => { [update, add, set.id],
if (!name) return; );
let newWeight = Number(weight || 0);
let newUnit = unit;
if (settings.autoConvert && unit !== settings.autoConvert) {
newUnit = settings.autoConvert;
newWeight = convert(newWeight, unit, settings.autoConvert);
}
const newSet: Partial<GymSet> = {
id: set.id,
name,
reps: Number(reps || 0),
weight: newWeight,
unit: newUnit,
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);
notify(newSet);
if (typeof set.id !== "number") added(saved);
navigate("History");
};
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);
setShowRemoveImage(false);
}, []);
const pickDate = useCallback(() => {
DateTimePickerAndroid.open({
value: created,
onChange: (event, date) => {
if (event.type === 'dismissed') return;
if (date === created) return;
setCreated(date);
setCreatedDirty(true);
DateTimePickerAndroid.open({
value: date,
onChange: (__, time) => setCreated(time),
mode: "time",
});
},
mode: "date",
});
}, [created]);
const remove = async () => {
await setRepo.delete(set.id);
navigate("History");
};
const openMenu = async () => {
if (setOptions.length > 0) return setShowMenu(true);
const latestSets = await setRepo
.createQueryBuilder()
.select()
.addSelect("MAX(created) as created")
.groupBy("name")
.getMany();
setSets(latestSets);
setShowMenu(true);
};
const select = (setOption: GymSet) => {
setName(setOption.name);
setReps(setOption.reps.toString());
setWeight(setOption.weight.toString());
setNewImage(setOption.image);
setUnit(setOption.unit);
setSelection({
start: 0,
end: setOption.reps.toString().length,
});
setShowMenu(false);
};
return ( return (
<> <View style={{padding: PADDING}}>
<StackHeader title={typeof set.id === "number" ? "Edit set" : "Add set"}> <SetForm save={save} set={set} workouts={workouts} />
{typeof set.id === "number" ? ( </View>
<IconButton onPress={() => setShowDelete(true)} icon="delete" />
) : null}
</StackHeader>
<View style={{ padding: PADDING, flex: 1 }}>
<View>
<AppInput
label="Name"
value={name}
onChangeText={setName}
autoCorrect={false}
autoFocus={!name}
onSubmitEditing={() => repsRef.current?.focus()}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<Menu
visible={showMenu}
onDismiss={() => setShowMenu(false)}
anchor={<IconButton icon="menu-down" onPress={openMenu} />}
>
{setOptions.map((setOption) => (
<Menu.Item
title={setOption.name}
key={setOption.id}
onPress={() => select(setOption)}
/>
))}
</Menu>
</View>
</View>
<View>
<AppInput
label="Reps"
keyboardType="numeric"
value={reps}
onChangeText={(newReps) => {
const fixed = fixNumeric(newReps);
setReps(fixed.replace(/-/g, ''))
if (fixed.length !== newReps.length)
toast("Reps must be a number");
else if (fixed.includes('-'))
toast("Reps must be a positive value")
}}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
innerRef={repsRef}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setReps((Number(reps) + 1).toString())}
/>
<IconButton
icon="minus"
onPress={() => setReps((Number(reps) - 1).toString())}
/>
</View>
</View>
<View>
<AppInput
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}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setWeight((Number(weight) + 2.5).toString())}
/>
<IconButton
icon="minus"
onPress={() => setWeight((Number(weight) - 2.5).toString())}
/>
</View>
</View>
{settings.showUnit && (
<Select
value={unit}
onChange={setUnit}
items={[
{ label: "kg", value: "kg" },
{ label: "lb", value: "lb" },
{ label: "stone", value: "stone" },
]}
label="Unit"
/>
)}
{settings.showDate && (
<AppInput
label="Created"
value={format(created, settings.date || "Pp")}
onPressOut={pickDate}
/>
)}
{settings.images && newImage && (
<TouchableRipple
style={{ marginBottom: MARGIN }}
onPress={changeImage}
onLongPress={() => setShowRemoveImage(true)}
>
<Card.Cover source={{ uri: newImage }} />
</TouchableRipple>
)}
{settings.images && !newImage && (
<Button
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="image-plus"
>
Image
</Button>
)}
</View>
<PrimaryButton
disabled={!name}
icon="content-save"
style={{ margin: MARGIN }}
onPress={handleSubmit}
>
Save
</PrimaryButton>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemoveImage}
setShow={setShowRemoveImage}
>
Are you sure you want to remove the image?
</ConfirmDialog>
<ConfirmDialog
title="Delete set"
show={showDelete}
onOk={remove}
setShow={setShowDelete}
>
<>Are you sure you want to delete {name}</>
</ConfirmDialog>
</>
); );
} }

View File

@ -1,203 +0,0 @@
import {
NavigationProp,
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 { StackParams } from "./AppStack";
import ConfirmDialog from "./ConfirmDialog";
import Select from "./Select";
import StackHeader from "./StackHeader";
import { MARGIN, PADDING } from "./constants";
import { setRepo, settingsRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import GymSet from "./gym-set";
import Settings from "./settings";
import PrimaryButton from "./PrimaryButton";
import { fixNumeric } from "./fix-numeric";
import { toast } from "./toast";
export default function EditSets() {
const { params } = useRoute<RouteProp<StackParams, "EditSets">>();
const { ids } = params;
const { navigate } = useNavigation<NavigationProp<DrawerParams>>();
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 save = async () => {
console.log(`${EditSets.name}.save:`, { 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);
navigate("History");
};
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>
<AppInput
label={`Reps: ${oldReps}`}
keyboardType="numeric"
value={reps}
onChangeText={(newReps) => {
const fixed = fixNumeric(newReps);
setReps(fixed.replace(/-/g, ''))
if (fixed.length !== newReps.length)
toast("Reps must be a number");
else if (fixed.includes('-'))
toast("Reps must be a positive value")
}}
selection={selection}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
autoFocus={!!name}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setReps((Number(reps) + 1).toString())}
/>
<IconButton
icon="minus"
onPress={() => setReps((Number(reps) - 1).toString())}
/>
</View>
</View>
<View>
<AppInput
label={`Weights: ${weights}`}
keyboardType="numeric"
value={weight}
onChangeText={setWeight}
onSubmitEditing={save}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setWeight((Number(weight) + 2.5).toString())}
/>
<IconButton
icon="minus"
onPress={() => setWeight((Number(weight) - 2.5).toString())}
/>
</View>
</View>
{settings.showUnit && (
<Select
value={unit}
onChange={setUnit}
items={[
{ label: "", value: "" },
{ label: "kg", value: "kg" },
{ label: "lb", value: "lb" },
{ label: "stone", value: "stone" },
]}
label={`Units: ${units}`}
/>
)}
{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="image-plus"
>
Image
</Button>
)}
</View>
<PrimaryButton
icon="content-save"
style={{ margin: MARGIN }}
onPress={save}
>
Save
</PrimaryButton>
</>
);
}

View File

@ -1,165 +0,0 @@
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import {
NavigationProp,
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from "@react-navigation/native";
import { format } from "date-fns";
import { useCallback, useRef, useState } from "react";
import { TextInput, View } from "react-native";
import { IconButton } from "react-native-paper";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import ConfirmDialog from "./ConfirmDialog";
import PrimaryButton from "./PrimaryButton";
import Select from "./Select";
import StackHeader from "./StackHeader";
import { MARGIN, PADDING } from "./constants";
import { AppDataSource } from "./data-source";
import { getNow, settingsRepo, weightRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import Settings from "./settings";
import { toast } from "./toast";
import Weight from "./weight";
export default function EditWeight() {
const { params } = useRoute<RouteProp<StackParams, "EditWeight">>();
const { weight } = params;
const { navigate } = useNavigation<NavigationProp<DrawerParams>>();
const { navigate: stackNavigate, goBack } = useNavigation<NavigationProp<StackParams>>();
const [settings, setSettings] = useState<Settings>({} as Settings);
const [value, setValue] = useState(weight.value?.toString());
const [unit, setUnit] = useState(weight.unit);
const [created, setCreated] = useState<Date>(
weight.created ? new Date(weight.created) : new Date()
);
const [showDelete, setShowDelete] = useState(false);
const [createdDirty, setCreatedDirty] = useState(false);
const unitRef = useRef<TextInput>(null);
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
}, [])
);
const submit = async () => {
if (!value) return;
const newWeight: Partial<Weight> = {
id: weight.id,
value: Number(value),
unit,
};
if (createdDirty) newWeight.created = created.toISOString();
else if (typeof weight.id !== "number") newWeight.created = await getNow();
await weightRepo.save(newWeight);
if (settings.notify) await checkWeekly();
goBack();
stackNavigate("ViewWeightGraph");
};
const checkWeekly = async () => {
const select = `
WITH weekly_weights AS (
SELECT
strftime('%W', created) AS week_number,
AVG(value) AS weekly_average
FROM weights
WHERE strftime('%W', created) = strftime('%W', 'now')
GROUP BY week_number
)
SELECT
((SELECT value FROM weights WHERE strftime('%W', created) = strftime('%W', 'now') ORDER BY created LIMIT 1) - weekly_weights.weekly_average) / (SELECT value FROM weights WHERE strftime('%W', created) = strftime('%W', 'now') ORDER BY created LIMIT 1) * 100 AS loss
FROM weekly_weights
WHERE week_number = strftime('%W', 'now')
`;
const result = await AppDataSource.manager.query(select);
console.log(`${EditWeight.name}.checkWeekly:`, result);
if (result.length && result[0].loss > 1)
toast("Weight loss should be <= 1% per week.");
};
const pickDate = useCallback(() => {
DateTimePickerAndroid.open({
value: created,
onChange: (_, date) => {
if (date === created) return;
setCreated(date);
setCreatedDirty(true);
},
mode: "date",
});
}, [created]);
const remove = async () => {
if (!weight.id) return;
await weightRepo.delete(weight.id);
navigate("Weight");
};
return (
<>
<StackHeader
title={typeof weight.id === "number" ? "Edit weight" : "Add weight"}
>
{typeof weight.id === "number" ? (
<IconButton onPress={() => setShowDelete(true)} icon="delete" />
) : null}
</StackHeader>
<ConfirmDialog
title="Delete weight"
show={showDelete}
onOk={remove}
setShow={setShowDelete}
>
<>Are you sure you want to delete {value}</>
</ConfirmDialog>
<View style={{ padding: PADDING, flex: 1 }}>
<AppInput
label="Weight"
value={value}
onChangeText={setValue}
keyboardType="numeric"
onSubmitEditing={submit}
autoFocus
/>
{settings.showUnit && (
<Select
value={unit}
onChange={setUnit}
items={[
{ label: "kg", value: "kg" },
{ label: "lb", value: "lb" },
{ label: "stone", value: "stone" },
]}
label="Unit"
/>
)}
{settings.showDate && (
<AppInput
label="Created"
value={format(created, settings.date || "Pp")}
onPressOut={pickDate}
/>
)}
</View>
<PrimaryButton
disabled={!value}
icon="content-save"
style={{ margin: MARGIN }}
onPress={submit}
>
Save
</PrimaryButton>
</>
);
}

183
EditWorkout.tsx Normal file
View File

@ -0,0 +1,183 @@
import {
NavigationProp,
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native';
import React, {useCallback, useContext, useState} from 'react';
import {BackHandler, ScrollView, View} from 'react-native';
import DocumentPicker from 'react-native-document-picker';
import {Button, Card, IconButton, TouchableRipple} from 'react-native-paper';
import ConfirmDialog from './ConfirmDialog';
import {MARGIN, PADDING} from './constants';
import {DrawerParamList} from './drawer-param-list';
import MassiveInput from './MassiveInput';
import {SnackbarContext} from './MassiveSnack';
import {updatePlanWorkouts} from './plan.service';
import {addSet, updateManySet, updateSetImage} from './set.service';
import {settings} from './settings.service';
export default function EditWorkout() {
const {params} = useRoute<RouteProp<DrawerParamList, 'Edit workout'>>();
const [removeImage, setRemoveImage] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const [name, setName] = useState(params.value.name);
const [steps, setSteps] = useState(params.value.steps);
const [uri, setUri] = useState(params.value.image);
const [minutes, setMinutes] = useState(
params.value.minutes?.toString() ?? '3',
);
const [seconds, setSeconds] = useState(
params.value.seconds?.toString() ?? '30',
);
const [sets, setSets] = useState(params.value.sets?.toString() ?? '3');
const {toast} = useContext(SnackbarContext);
const navigation = useNavigation<NavigationProp<DrawerParamList>>();
useFocusEffect(
useCallback(() => {
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 () => {
await updateManySet({
oldName: params.value.name,
newName: name || params.value.name,
sets: sets ?? '3',
seconds: seconds?.toString() ?? '30',
minutes: minutes?.toString() ?? '3',
steps,
});
await updatePlanWorkouts(params.value.name, name || params.value.name);
if (uri || removeImage) await updateSetImage(params.value.name, uri || '');
navigation.goBack();
};
const add = async () => {
await addSet({
name,
reps: 0,
weight: 0,
hidden: true,
image: uri,
minutes: minutes ? +minutes : 3,
seconds: seconds ? +seconds : 30,
sets: sets ? +sets : 3,
steps,
});
navigation.goBack();
};
const save = async () => {
if (params.value.name) return update();
return add();
};
const changeImage = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
type: 'image/*',
copyTo: 'documentDirectory',
});
if (fileCopyUri) setUri(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setUri('');
setRemoveImage(true);
setShowRemove(false);
}, []);
const handleName = (value: string) => {
setName(value.replace(/,|'/g, ''));
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 (
<View style={{padding: PADDING}}>
<ScrollView style={{height: '90%'}}>
<MassiveInput
autoFocus
label="Name"
value={name}
onChangeText={handleName}
/>
{!!settings.steps && (
<MassiveInput
selectTextOnFocus={false}
value={steps}
onChangeText={handleSteps}
label="Steps"
multiline
/>
)}
<MassiveInput
value={sets}
onChangeText={setSets}
label="Sets per workout"
keyboardType="numeric"
/>
<MassiveInput
value={minutes}
onChangeText={setMinutes}
label="Rest minutes"
keyboardType="numeric"
/>
<MassiveInput
value={seconds}
onChangeText={setSeconds}
label="Rest seconds"
keyboardType="numeric"
/>
{uri ? (
<TouchableRipple
style={{marginBottom: MARGIN}}
onPress={changeImage}
onLongPress={() => setShowRemove(true)}>
<Card.Cover source={{uri}} />
</TouchableRipple>
) : (
<Button
style={{marginBottom: MARGIN}}
onPress={changeImage}
icon="add-photo-alternate">
Image
</Button>
)}
</ScrollView>
<Button disabled={!name} mode="contained" 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,69 +0,0 @@
import { NavigationProp, useNavigation } from "@react-navigation/native";
import { useCallback, useMemo } from "react";
import { Image } from "react-native";
import { List, useTheme } from "react-native-paper";
import { StackParams } from "./AppStack";
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants";
import GymSet from "./gym-set";
export default function ExerciseItem({
item,
setNames,
names,
images,
alarm,
}: {
item: GymSet;
images: boolean;
setNames: (value: string[]) => void;
names: string[];
alarm: boolean;
}) {
const navigation = useNavigation<NavigationProp<StackParams>>();
const { dark } = useTheme();
const description = useMemo(() => {
const seconds = item.seconds?.toString().padStart(2, "0");
const time = ` x ${item.minutes || 0}:${seconds}`;
if (alarm) return item.sets.toString() + time;
return item.sets.toString();
}, [item.sets, item.minutes, item.seconds, alarm]);
const left = useCallback(() => {
if (!images || !item.image) return null;
return (
<Image source={{ uri: item.image }} style={{ height: 75, width: 75 }} />
);
}, [item.image, images]);
const long = () => {
if (names.length > 0) return;
setNames([item.name]);
};
const backgroundColor = useMemo(() => {
if (!names.includes(item.name)) return;
if (dark) return DARK_RIPPLE;
return LIGHT_RIPPLE;
}, [dark, names, item.name]);
const press = () => {
console.log(`${ExerciseItem.name}.press:`, { names });
if (names.length === 0)
return navigation.navigate("EditExercise", { gymSet: item });
const removing = names.find((name) => name === item.name);
if (removing) setNames(names.filter((name) => name !== item.name));
else setNames([...names, item.name]);
};
return (
<List.Item
onPress={press}
title={item.name}
description={description}
onLongPress={long}
left={left}
style={{ backgroundColor }}
/>
);
}

View File

@ -1,162 +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 { In } from "typeorm";
import { StackParams } from "./AppStack";
import { LIMIT } from "./constants";
import { setRepo, settingsRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
import ExerciseItem from "./ExerciseItem";
import GymSet, { defaultSet } from "./gym-set";
import ListMenu from "./ListMenu";
import Page from "./Page";
import SetList from "./SetList";
import Settings from "./settings";
export default function ExerciseList() {
const [exercises, setExercises] = useState<GymSet[]>();
const [offset, setOffset] = useState(0);
const [term, setTerm] = useState("");
const [end, setEnd] = useState(false);
const [settings, setSettings] = useState<Settings>();
const [names, setNames] = useState<string[]>([]);
const [refreshing, setRefreshing] = useState(false);
const navigation = useNavigation<NavigationProp<StackParams>>();
const reset = async (value: string) => {
console.log(`${ExerciseList.name}.reset`, value);
const newExercises = await setRepo
.createQueryBuilder()
.select()
.where("name LIKE :name", { name: `%${value.trim()}%` })
.groupBy("name")
.orderBy("name")
.limit(LIMIT)
.getMany();
setOffset(0);
console.log(`${ExerciseList.name}.reset`, { length: newExercises.length });
setEnd(newExercises.length < LIMIT);
setExercises(newExercises);
};
useFocusEffect(
useCallback(() => {
reset(term);
settingsRepo.findOne({ where: {} }).then(setSettings);
}, [term])
);
const renderItem = useCallback(
({ item }: { item: GymSet }) => (
<ExerciseItem
images={settings?.images}
alarm={settings?.alarm}
item={item}
key={item.name}
names={names}
setNames={setNames}
/>
),
[settings?.images, names, settings?.alarm]
);
const next = async () => {
console.log(`${SetList.name}.next:`, {
offset,
limit: LIMIT,
term,
end,
});
if (end) return;
const newOffset = offset + LIMIT;
const newExercises = await setRepo
.createQueryBuilder()
.select()
.where("name LIKE :name", { name: `%${term.trim()}%` })
.groupBy("name")
.orderBy("name")
.limit(LIMIT)
.offset(newOffset)
.getMany();
if (newExercises.length === 0) return setEnd(true);
if (!exercises) return;
setExercises([...exercises, ...newExercises]);
if (newExercises.length < LIMIT) return setEnd(true);
setOffset(newOffset);
};
const onAdd = useCallback(async () => {
navigation.navigate("EditExercise", {
gymSet: defaultSet,
});
}, [navigation]);
const search = (value: string) => {
setTerm(value);
reset(value);
};
const clear = useCallback(() => {
setNames([]);
}, []);
const remove = async () => {
setNames([]);
if (names.length > 0) await setRepo.delete({ name: In(names) });
await reset(term);
};
const select = () => {
if (!exercises) return;
if (names.length === exercises.length) return setNames([]);
setNames(exercises.map((exercise) => exercise.name));
};
const edit = () => {
navigation.navigate("EditExercises", { names });
};
return (
<>
<DrawerHeader
name={names.length > 0 ? `${names.length} selected` : "Exercises"}
ids={names}
unSelect={() => setNames([])}
>
<ListMenu
onClear={clear}
onDelete={remove}
onEdit={edit}
ids={names}
onSelect={select}
/>
</DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}>
{exercises?.length === 0 ? (
<List.Item
title="No exercises yet."
description="An exercise is something you do at the gym. E.g. Deadlifts"
/>
) : (
<FlatList
data={exercises}
style={{ flex: 1 }}
renderItem={renderItem}
keyExtractor={(w) => w.name}
onEndReached={next}
refreshing={refreshing}
onRefresh={() => {
setRefreshing(true);
reset("").finally(() => setRefreshing(false));
}}
/>
)}
</Page>
</>
);
}

View File

@ -1,55 +0,0 @@
import { View, useColorScheme } from "react-native";
import { useCallback } from "react";
import { Dirs, FileSystem } from "react-native-file-access";
import { Button, Text } from "react-native-paper";
import { CombinedDarkTheme, CombinedDefaultTheme } from "./App";
import { MARGIN } from "./constants";
import { AppDataSource } from "./data-source";
import { settingsRepo } from "./db";
export default function FatalError({
message,
setAppSettings,
setError,
}: {
message: string;
setAppSettings: (settings: {
startup: any;
theme: string;
lightColor: string;
darkColor: string;
}) => void;
setError: (message: string) => void;
}) {
const systemTheme = useColorScheme();
const resetDatabase = useCallback(async () => {
await FileSystem.cp("/dev/null", Dirs.DatabaseDir + "/massive.db");
await AppDataSource.initialize();
const gotSettings = await settingsRepo.findOne({ where: {} });
setAppSettings({
startup: gotSettings.startup,
theme: gotSettings.theme,
lightColor: gotSettings.lightColor || CombinedDefaultTheme.colors.primary,
darkColor: gotSettings.darkColor || CombinedDarkTheme.colors.primary,
});
setError("");
}, [setAppSettings, setError]);
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text
style={{
color: systemTheme === "dark" ? "white" : "black",
margin: MARGIN,
}}
>
Database failed to initialize: {message}
</Text>
<Button mode="contained" onPress={resetDatabase}>
Reset database
</Button>
</View>
);
}

View File

@ -1,9 +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'
# Cocoapods 1.15 introduced a bug which break the build. We will remove the upper gem 'cocoapods', '~> 1.11', '>= 1.11.2'
# bound in the template on Cocoapods with next React Native release.
gem 'cocoapods', '>= 1.13', '< 1.15'
gem 'activesupport', '>= 6.1.7.5', '< 7.1.0'

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,109 +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 { StackParams } from "./AppStack";
import { getBestSets } from "./best.service";
import { LIMIT } from "./constants";
import { settingsRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
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<StackParams>>();
const [settings, setSettings] = useState<Settings>();
const [refreshing, setRefreshing] = useState(false);
const refresh = useCallback(
async (value: string) => {
if (refreshing) return;
const result = await getBestSets({ term: value, offset: 0 });
setBests(result);
setOffset(0);
},
[refreshing]
);
useFocusEffect(
useCallback(() => {
refresh(term);
settingsRepo.findOne({ where: {} }).then(setSettings);
// eslint-disable-next-line
}, [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: newOffset });
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", { name: item.name })}
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}
keyExtractor={(set) => set.name}
onEndReached={next}
refreshing={refreshing}
onRefresh={() => {
setRefreshing(true);
refresh(term).finally(() => setRefreshing(false));
}}
/>
)}
</Page>
</>
);
}

135
HomePage.tsx Normal file
View File

@ -0,0 +1,135 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} 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 limit = 15;
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 (
<Page onAdd={onAdd} search={search} setSearch={setSearch}>
<FlatList
data={sets}
style={{height: '99%'}}
ListEmptyComponent={
<List.Item
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,216 +0,0 @@
import { useFocusEffect } from "@react-navigation/native";
import { useCallback, useMemo, useState } from "react";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { IconButton, Text } from "react-native-paper";
import AppPieChart from "./AppPieChart";
import AppLineChart from "./AppLineChart";
import ConfirmDialog from "./ConfirmDialog";
import { MARGIN, PADDING } from "./constants";
import { AppDataSource } from "./data-source";
import { DAYS } from "./days";
import DrawerHeader from "./DrawerHeader";
import { Periods } from "./periods";
import Select from "./Select";
interface WeekCount {
week: string;
count: number;
}
interface HourCount {
hour: string;
count: number;
}
export default function InsightsPage() {
const [weekCounts, setWeekCounts] = useState<WeekCount[]>();
const [hourCounts, setHourCounts] = useState<HourCount[]>();
const [loadingWeeks, setLoadingWeeks] = useState(true);
const [loadingHours, setLoadingHours] = useState(true);
const [period, setPeriod] = useState(Periods.Monthly);
const [showWeek, setShowWeek] = useState(false);
const [showHour, setShowHour] = useState(false);
useFocusEffect(
useCallback(() => {
let difference = "-1 months";
if (period === Periods.TwoMonths) difference = "-2 months";
if (period === Periods.ThreeMonths) difference = "-3 months";
if (period === Periods.SixMonths) difference = "-6 months";
const selectWeeks = `
SELECT strftime('%w', created) as week, COUNT(*) as count
FROM sets
WHERE DATE(created) >= DATE('now', 'weekday 0', '${difference}')
GROUP BY week
HAVING week IS NOT NULL
ORDER BY count DESC;
`;
const selectHours = `
SELECT strftime('%H', created) AS hour, COUNT(*) AS count
FROM sets
WHERE DATE(created) >= DATE('now', 'weekday 0', '${difference}')
GROUP BY hour
having hour is not null
ORDER BY hour
`;
setLoadingWeeks(true);
setLoadingHours(true);
setTimeout(
() =>
AppDataSource.manager
.query(selectWeeks)
.then(setWeekCounts)
.then(() => setLoadingWeeks(false))
.then(() =>
AppDataSource.manager.query(selectHours).then(setHourCounts)
)
.finally(() => {
setLoadingWeeks(false);
setLoadingHours(false);
}),
400
);
}, [period])
);
const hourLabel = (hour: string) => {
let twelveHour = Number(hour);
if (twelveHour === 0) return "12AM";
let amPm = "AM";
if (twelveHour >= 12) amPm = "PM";
if (twelveHour > 12) twelveHour -= 12;
return `${twelveHour} ${amPm}`;
};
const hourCharts = useMemo(() => {
if (loadingHours) return <ActivityIndicator />
if (hourCounts?.length === 0) return (<Text style={{ marginBottom: MARGIN }}>
No entries yet! Start recording sets to see your most active days of
the week.
</Text>)
return <AppLineChart
data={hourCounts.map((hc) => hc.count)}
labels={hourCounts.map((hc) => hourLabel(hc.hour))}
/>
}, [hourCounts, loadingHours])
const weekCharts = useMemo(() => {
if (loadingWeeks) return <ActivityIndicator />
if (weekCounts?.length === 0) return (<Text style={{ marginBottom: MARGIN }}>
No entries yet! Start recording sets to see your most active days of
the week.
</Text>)
return <AppPieChart
options={weekCounts.map((weekCount) => ({
label: DAYS[weekCount.week],
value: weekCount.count,
}))}
/>
}, [weekCounts, loadingWeeks])
return (
<>
<DrawerHeader name="Insights" />
<View
style={{
paddingLeft: PADDING,
paddingTop: PADDING,
paddingRight: PADDING,
}}
>
<Select
label="Period"
items={[
{ value: Periods.Monthly, label: Periods.Monthly },
{ value: Periods.TwoMonths, label: Periods.TwoMonths },
{ value: Periods.ThreeMonths, label: Periods.ThreeMonths },
{ value: Periods.SixMonths, label: Periods.SixMonths },
]}
value={period}
onChange={(value) => setPeriod(value as Periods)}
/>
</View>
<ScrollView
style={{
padding: PADDING,
flexGrow: 1,
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
alignContent: "center",
}}
>
<Text
variant="titleLarge"
style={{
marginBottom: MARGIN,
}}
>
Most active days of the week
</Text>
<IconButton
icon="help-circle-outline"
size={25}
style={{ padding: 0, margin: 0, paddingBottom: 10 }}
onPress={() => setShowWeek(true)}
/>
</View>
{weekCharts}
<View
style={{
flexDirection: "row",
alignItems: "center",
alignContent: "center",
}}
>
<Text
variant="titleLarge"
style={{
marginBottom: MARGIN,
}}
>
Most active hours of the day
</Text>
<IconButton
icon="help-circle-outline"
size={25}
style={{ padding: 0, margin: 0, paddingBottom: 10 }}
onPress={() => setShowHour(true)}
/>
</View>
{hourCharts}
<View style={{ marginBottom: MARGIN }} />
</ScrollView>
<ConfirmDialog
title="Most active days of the week"
show={showWeek}
setShow={setShowWeek}
onOk={() => setShowWeek(false)}
>
Are mondays your weak-spot? Find out here. This counts the # of sets you
tend to do based on the day of the week.
</ConfirmDialog>
<ConfirmDialog
title="Most active hours of the day"
show={showHour}
setShow={setShowHour}
onOk={() => setShowHour(false)}
>
If you find yourself giving up on the gym after 5pm, consider starting
earlier! Or vice-versa. This counts the # of sets you tend to do, based
on what time of day you began your workout.
</ConfirmDialog>
</>
);
}

View File

@ -1,104 +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?: unknown[];
}) {
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 = () => {
setShowMenu(false);
onSelect();
};
return (
<>
{ids.length > 0 && (
<IconButton icon="delete" onPress={() => setShowRemove(true)} />
)}
<Menu
visible={showMenu}
onDismiss={() => setShowMenu(false)}
anchor={
<IconButton onPress={() => setShowMenu(true)} icon="dots-vertical" />
}
>
<Menu.Item leadingIcon="check-all" title="Select all" onPress={select} />
<Menu.Item
leadingIcon="close"
title="Clear"
onPress={clear}
disabled={ids?.length === 0}
/>
<Menu.Item
leadingIcon="pencil"
title="Edit"
onPress={edit}
disabled={ids?.length === 0}
/>
{onCopy && (
<Menu.Item
leadingIcon="content-copy"
title="Copy"
onPress={copy}
disabled={ids?.length === 0}
/>
)}
<Divider />
<Menu.Item
leadingIcon="delete"
onPress={() => setShowRemove(true)}
title="Delete"
/>
</Menu>
<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} {ids?.length > 1 ? "records" : "record"}. Are you sure?</>
)}
</ConfirmDialog>
</>
);
}

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="magnify" icon="search"
clearIcon="close" 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,106 +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, useTheme } from "react-native-paper";
import { StackParams } from "./AppStack";
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants";
import { DAYS } from "./days";
import { setRepo } from "./db";
import GymSet, { defaultSet } from "./gym-set";
import { Plan } from "./plan";
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 } = useTheme(); 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<StackParams>>();
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 exercise = item.exercises.split(",")[0];
let first: Partial<GymSet> = await setRepo.findOne({
where: { name: exercise },
order: { created: "desc" },
});
if (!first) first = { ...defaultSet, name: exercise };
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.exercises.replace(/,/g, ", ")),
[item.title, currentDays, item.exercises]
);
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,127 +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 { StackParams } from "./AppStack";
import { planRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
import ListMenu from "./ListMenu";
import Page from "./Page";
import { defaultPlan, Plan } from "./plan";
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<StackParams>>();
const refresh = useCallback(async (value: string) => {
console.log(`${PlanList.name}.refresh:`, value);
planRepo
.find({
where: [
{ title: Like(`%${value.trim()}%`) },
{ days: Like(`%${value.trim()}%`) },
{ exercises: Like(`%${value.trim()}%`) },
],
})
.then(setPlans);
}, []);
useFocusEffect(
useCallback(() => {
refresh(term);
// eslint-disable-next-line
}, [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: defaultPlan,
});
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(() => {
if (!plans) return;
if (ids.length === plans.length) return setIds([]);
setIds(plans.map((plan) => plan.id));
}, [plans, ids.length]);
return (
<>
<DrawerHeader name={ids.length > 0 ? `${ids.length} selected` : "Plans"}
ids={ids}
unSelect={() => setIds([])}
>
<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 exercises for certain days."
/>
) : (
<FlatList
style={{ flex: 1 }}
data={plans}
renderItem={renderItem}
keyExtractor={(set) => set.id?.toString() || ""}
/>
)}
</Page>
</>
);
}

64
PlanPage.tsx Normal file
View File

@ -0,0 +1,64 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} 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 {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() {
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 (
<Page onAdd={onAdd} search={search} setSearch={setSearch}>
<FlatList
style={{height: '99%'}}
data={plans}
renderItem={renderItem}
keyExtractor={set => set.id?.toString() || ''}
ListEmptyComponent={
<List.Item
title="No plans yet"
description="A plan is a list of workouts for certain days."
/>
}
/>
</Page>
);
}

View File

@ -1,14 +0,0 @@
import { ComponentProps } from "react";
import { Button, useTheme } from "react-native-paper";
type PrimaryButtonProps = Omit<Partial<ComponentProps<typeof Button>>, "mode">;
export default function PrimaryButton(props: PrimaryButtonProps) {
const { colors } = useTheme();
return (
<Button mode="contained" textColor={colors.background} {...props}>
{props.children}
</Button>
);
}

View File

@ -23,14 +23,12 @@ Massive tracks your reps and sets at the gym. No internet connectivity or high s
<img src="metadata/en-US/images/phoneScreenshots/home.png" width="318"/> <img src="metadata/en-US/images/phoneScreenshots/home.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/edit.png" width="318"/> <img src="metadata/en-US/images/phoneScreenshots/edit.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"/>
<img src="metadata/en-US/images/phoneScreenshots/exercises.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/exercise-edit.png" width="318"/>
# Building from Source # Building from Source
@ -41,29 +39,43 @@ cd android
./gradlew assembleRelease ./gradlew assembleRelease
``` ```
The apk file can be found at `android/app/build/outputs/apk/release/app-release.apk` The apk file can be found at `android/app/build/outputs/apk/release/app-*-release.apk`
The APKs are separated by architecture, for example we have:
- `app-arm64-v8a-release.apk`
- `app-armeabi-v7a-release.apk`
- `app-x86_64-release.apk`
- `app-x86-release.apk`
Your phone is probably `app-arm64-v8a-release.apk`.
# Running in Development # Running in Development
First ensure Node.js dependencies are installed: First ensure Node.js dependencies are installed:
``` ```
npm install yarn install
``` ```
Then start the metro server: Then start the metro server:
``` ```
npm start yarn start
``` ```
Then (in a separate terminal) run the `android` script: Then (in a separate terminal) run the `android` script:
``` ```
npm run android yarn android
``` ```
# Fdroid Metadata # Fdroid Metadata
You can find the metadata yaml file in the fdroiddata repository: You can find the metadata yaml file in the fdroiddata repository:
https://gitlab.com/fdroid/fdroiddata/-/blob/master/metadata/com.massive.yml https://gitlab.com/fdroid/fdroiddata/-/blob/master/metadata/com.massive.yml
# Relevant Documentation
- Android https://developer.android.com/docs
- TypeScript https://www.typescriptlang.org/docs/
- JavaScript https://developer.mozilla.org/en-US/docs/Web/JavaScript
- SQLite https://sqlite.org/docs.html

83
Routes.tsx Normal file
View File

@ -0,0 +1,83 @@
import {createDrawerNavigator} from '@react-navigation/drawer';
import React, {useContext, useEffect, useState} from 'react';
import {useColorScheme} from 'react-native';
import {IconButton} from 'react-native-paper';
import {CustomTheme} from './App';
import BestPage from './BestPage';
import {runMigrations} from './db';
import {DrawerParamList} from './drawer-param-list';
import DrawerMenu from './DrawerMenu';
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>();
export default function Routes() {
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 (
<Drawer.Navigator
screenOptions={{
headerTintColor: dark ? 'white' : 'black',
swipeEdgeWidth: 1000,
}}>
{routes.map(route => (
<Drawer.Screen
key={route.name}
name={route.name}
component={route.component}
options={{
drawerIcon: () => <IconButton icon={route.icon || ''} />,
headerRight: () => <DrawerMenu name={route.name} />,
}}
/>
))}
{hiddenRoutes.map(route => (
<Drawer.Screen
key={route.name}
name={route.name}
component={route.component}
options={{
drawerItemStyle: {height: 0},
}}
/>
))}
</Drawer.Navigator>
);
}

View File

@ -1,75 +0,0 @@
import React, { useCallback, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
import { IconButton, Menu, useTheme } from "react-native-paper";
import AppInput from "./AppInput";
export interface Item {
value: string;
label: string;
color?: string;
icon?: 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();
let menuButton: React.Ref<View> = null;
const selected = useMemo(
() => items.find((item) => item.value === value) || items[0],
[items, value]
);
const press = useCallback(
(newValue: string) => {
onChange(newValue);
setShow(false);
},
[onChange]
);
return (
<Menu
visible={show}
onDismiss={() => setShow(false)}
anchor={
<View>
<Pressable onPress={() => setShow(true)}>
<AppInput label={label} value={selected.label} editable={false} />
</Pressable>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
ref={menuButton}
icon="menu-down"
onPress={() => setShow(true)}
/>
</View>
</View>
}
>
{items.map((item) => (
<Menu.Item
title={item.label}
key={item.value}
onPress={() => press(item.value)}
titleStyle={{ color: item.color || colors.onSurface }}
leadingIcon={item.icon}
/>
))}
</Menu>
);
}
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,91 +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 React, { 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, useTheme } from "react-native-paper"; import {DrawerParamList} from './drawer-param-list';
import { StackParams } from "./AppStack"; import Set from './set';
import { import {deleteSet} from './set.service';
DARK_RIPPLE,
DARK_SUBDUED,
LIGHT_RIPPLE,
LIGHT_SUBDUED,
} from "./constants";
import GymSet from "./gym-set";
import Settings from "./settings";
const SetItem = React.memo( export default function SetItem({
({ item,
item, onRemove,
settings, dates,
ids, setDates,
setIds, images,
disablePress, setImages,
customBg, }: {
}: { item: Set;
item: GymSet; onRemove: () => void;
settings: Settings; dates: boolean;
ids: number[]; setDates: (value: boolean) => void;
setIds: (value: number[]) => void; images: boolean;
disablePress?: boolean; setImages: (value: boolean) => void;
customBg?: string; }) {
}) => { const [showMenu, setShowMenu] = useState(false);
const { dark } = useTheme(); const [anchor, setAnchor] = useState({x: 0, y: 0});
const navigation = useNavigation<NavigationProp<StackParams>>(); 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 (disablePress) return; const set: Set = {...item};
if (ids.length === 0) delete set.id;
return navigation.navigate("EditSet", { set: item }); setShowMenu(false);
const removing = ids.find((id) => id === item.id); navigation.navigate('Edit set', {set, workouts: [], count: 0});
if (removing) setIds(ids.filter((id) => id !== item.id)); }, [navigation, item]);
else setIds([...ids, item.id]);
}, [ids, item, navigation, setIds, disablePress]);
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 image = 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]);
return ( const toggleImages = useCallback(() => {
setImages(!images);
setShowMenu(false);
}, [images, setImages]);
return (
<>
<List.Item <List.Item
onPress={press} onPress={() =>
title={item.name} navigation.navigate('Edit set', {set: item, workouts: [], count: 0})
description={
settings.showDate ? (
<Text style={{ color: dark ? DARK_SUBDUED : LIGHT_SUBDUED }}>
{format(new Date(item.created), settings.date || "Pp")}
</Text>
) : null
} }
title={item.name}
description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
onLongPress={longPress} onLongPress={longPress}
style={{ backgroundColor: customBg || backgroundColor }} left={() =>
left={image} images &&
item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)
}
right={() => ( right={() => (
<Text <>
style={{ {dates && (
alignSelf: "center", <Text
color: dark ? DARK_SUBDUED : LIGHT_SUBDUED, style={{
}} alignSelf: 'center',
> }}>
{`${item.reps} x ${item.weight}${item.unit || "kg"}`} {item.created?.replace('T', ' ')}
</Text> </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>
</>
)} )}
/> />
); </>
} );
); }
export default SetItem;

View File

@ -1,185 +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 { StackParams } from "./AppStack";
import DrawerHeader from "./DrawerHeader";
import ListMenu from "./ListMenu";
import Page from "./Page";
import SetItem from "./SetItem";
import { LIMIT } from "./constants";
import { getNow, setRepo, settingsRepo } from "./db";
import GymSet, { defaultSet } from "./gym-set";
import Settings from "./settings";
export default function SetList() {
const [refreshing, setRefreshing] = useState(false);
const [sets, setSets] = useState<GymSet[]>();
const [offset, setOffset] = useState(0);
const [end, setEnd] = useState(false);
const [settings, setSettings] = useState<Settings>();
const [ids, setIds] = useState<number[]>([]);
const navigation = useNavigation<NavigationProp<StackParams>>();
const [term, setTerm] = useState("");
const reset = 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" },
});
setSets(newSets);
console.log(`${SetList.name}.reset:`, { value, offset });
setEnd(false);
},
[offset]
);
useFocusEffect(
useCallback(() => {
console.log(`${SetList.name}.focus:`, { term });
settingsRepo.findOne({ where: {} }).then(setSettings);
reset(term);
// eslint-disable-next-line
}, [term])
);
const search = (value: string) => {
console.log(`${SetList.name}.search:`, value);
setTerm(value);
setOffset(0);
reset(value);
};
const renderItem = useCallback(
({ item }: { item: GymSet }) => (
<SetItem
settings={settings}
item={item}
key={item.id}
ids={ids}
setIds={setIds}
/>
),
[settings, ids]
);
const next = async () => {
console.log(`${SetList.name}.next:`, { end, refreshing });
if (end || refreshing) 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;
const map = new Map<number, GymSet>();
for (const set of sets) map.set(set.id, set);
for (const set of newSets) map.set(set.id, set);
const unique = Array.from(map.values());
setSets(unique);
if (newSets.length < LIMIT) return setEnd(true);
setOffset(newOffset);
};
const onAdd = useCallback(async () => {
const now = await getNow();
let set: Partial<GymSet> = { ...sets[0] };
if (!set) set = { ...defaultSet };
set.created = now;
delete set.id;
navigation.navigate("EditSet", { set });
}, [navigation, sets]);
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 = async () => {
setIds([]);
await setRepo.delete(ids.length > 0 ? ids : {});
return reset(term);
};
const select = useCallback(() => {
if (!sets) return;
if (ids.length === sets.length) return setIds([]);
setIds(sets.map((set) => set.id));
}, [sets, ids]);
const getContent = () => {
if (!settings || sets === undefined) return null;
if (sets.length === 0)
return (
<List.Item
title="No sets yet"
description="A set is a group of repetitions. E.g. 8 reps of Squats."
/>
);
return (
<FlatList
data={sets ?? []}
style={{ flex: 1 }}
renderItem={renderItem}
onEndReached={next}
onEndReachedThreshold={0.5}
refreshing={refreshing}
keyExtractor={(set) => set.id.toString()}
onRefresh={() => {
setOffset(0);
setRefreshing(true);
reset(term).finally(() => setRefreshing(false));
}}
/>
);
};
return (
<>
<DrawerHeader
name={ids.length > 0 ? `${ids.length} selected` : "History"}
ids={ids}
unSelect={() => setIds([])}
>
<ListMenu
onClear={clear}
onCopy={copy}
onDelete={remove}
onEdit={edit}
ids={ids}
onSelect={select}
/>
</DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}>
{getContent()}
</Page>
</>
);
}

View File

@ -1,605 +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 { FlatList, NativeModules } 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 { Button } from "react-native-paper"; import {darkColors, lightColors} from './colors';
import AppInput from "./AppInput"; import ConfirmDialog from './ConfirmDialog';
import ConfirmDialog from "./ConfirmDialog"; import {MARGIN} from './constants';
import { PADDING } from "./constants"; import Input from './input';
import { AppDataSource } from "./data-source"; import {SnackbarContext} from './MassiveSnack';
import { setRepo, settingsRepo } from "./db"; import Page from './Page';
import { DrawerParams } from "./drawer-params"; import {getSettings, settings, updateSettings} from './settings.service';
import DrawerHeader from "./DrawerHeader"; import Switch from './Switch';
import { darkOptions, lightOptions, themeOptions } from "./options";
import Page from "./Page";
import Select from "./Select";
import Settings from "./settings";
import Switch from "./Switch";
import { toast } from "./toast";
import { useAppTheme } from "./use-theme";
const twelveHours = [
"dd/LL/yyyy",
"dd/LL/yyyy, p",
"ccc p",
"p",
"yyyy-MM-dd",
"yyyy-MM-dd, p",
"yyyy.MM.dd",
];
const twentyFours = [
"dd/LL/yyyy",
"dd/LL/yyyy, k:mm",
"ccc k:mm",
"k:mm",
"yyyy-MM-dd",
"yyyy-MM-dd, k:mm",
"yyyy.MM.dd",
];
interface Item {
name: string;
renderItem: (name: string) => React.JSX.Element;
}
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 [error, setError] = useState(""); const [sound, setSound] = useState(settings.sound);
const { reset } = useNavigation<NavigationProp<DrawerParams>>(); 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,
} = useAppTheme();
useEffect(() => { useEffect(() => {
NativeModules.SettingsModule.ignoringBattery().then(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 backupString = useMemo(() => { const changeAlarmEnabled = useCallback(
if (!settings.backupDir) return null; (enabled: boolean) => {
const split = decodeURIComponent(settings.backupDir).split(":"); setAlarm(enabled);
return split.pop(); if (enabled) toast('Timers will now run after each set.', 4000);
}, [settings.backupDir]); else toast('Stopped timers running after each set.', 4000);
if (enabled && !ignoring) setBattery(true);
},
[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 confirmDelete = useCallback(async () => { const changeVibrate = useCallback(
setDeleting(false); (enabled: boolean) => {
await AppDataSource.dropDatabase(); setVibrate(enabled);
await AppDataSource.destroy(); if (enabled) toast('When a timer completes, vibrate your phone.', 4000);
await AppDataSource.initialize(); else toast('Stop vibrating at the end of timers.', 4000);
toast("Database deleted."); },
}, []); [setVibrate, toast],
);
const confirmImport = useCallback(async () => { const changeSound = useCallback(async () => {
setImporting(false); const {fileCopyUri} = await DocumentPicker.pickSingle({
await FileSystem.cp( type: 'audio/*',
Dirs.DatabaseDir + "/massive.db", copyTo: 'documentDirectory',
Dirs.DatabaseDir + "/massive-backup.db" });
); if (!fileCopyUri) return;
await AppDataSource.destroy(); setSound(fileCopyUri);
const file = await DocumentPicker.pickSingle(); toast('This song will now play after rest timers complete.', 4000);
if (!file.uri.endsWith('.db')) }, [toast]);
return toast("File name must end with .db")
await FileSystem.cp(file.uri, Dirs.DatabaseDir + "/massive.db");
try { const changeNotify = useCallback(
await AppDataSource.initialize(); (enabled: boolean) => {
} catch (e) { setNotify(enabled);
setError(e.toString()); if (enabled) toast('Show when a set is a new record.', 4000);
await FileSystem.cp( else toast('Stopped showing notifications for new records.', 4000);
Dirs.DatabaseDir + "/massive-backup.db", },
Dirs.DatabaseDir + "/massive.db" [toast],
); );
await AppDataSource.initialize();
return;
}
await setRepo.update({}, { image: null }); const changeImages = useCallback(
await settingsRepo.update({}, { sound: null, backup: false }); (enabled: boolean) => {
reset({ index: 0, routes: [{ name: "Settings" }] }); setImages(enabled);
toast("Imported database successfully.") if (enabled) toast('Show images for sets.', 4000);
}, [reset]); else toast('Stopped showing images for sets.', 4000);
},
[toast],
);
const today = new Date(); const changeUnit = useCallback(
(enabled: boolean) => {
setShowUnit(enabled);
if (enabled) toast('Show option to select unit for sets.', 4000);
else toast('Hid unit option for sets.', 4000);
},
[toast],
);
const data: Item[] = [ const changeWorkouts = useCallback(
{ (enabled: boolean) => {
name: "Start up page", setWorkouts(enabled);
renderItem: (name: string) => ( if (enabled) toast('Show workout for sets.', 4000);
<Select else toast('Stopped showing workout for sets.', 4000);
label={name}
items={[
{ label: "History", value: "History", icon: 'history' },
{ label: "Exercises", value: "Exercises", icon: 'dumbbell' },
{ label: "Daily", value: "Daily", icon: 'calendar-outline' },
{ label: "Plans", value: "Plans", icon: 'checkbox-multiple-marked-outline' },
{ label: "Graphs", value: "Graphs", icon: 'chart-bell-curve-cumulative' },
{ label: "Timer", value: "Timer", icon: 'timer-outline' },
{ label: "Weight", value: "Weight", icon: 'scale-bathroom' },
{ label: "Insights", value: "Insights", icon: 'lightbulb-on-outline' },
{ label: "Settings", value: "Settings", icon: 'cog-outline' },
]}
value={settings.startup}
onChange={async (value) => {
setValue("startup", value);
await settingsRepo.update({}, { startup: value });
toast(`App will always start on ${value}`);
}}
/>
),
}, },
{ [toast],
name: "Theme", );
renderItem: (name: string) => (
<Select const changeSteps = useCallback(
label={name} (enabled: boolean) => {
items={themeOptions} setSteps(enabled);
value={theme} if (enabled) toast('Show steps for a workout.', 4000);
onChange={async (value) => { else toast('Stopped showing steps for workouts.', 4000);
setValue("theme", value);
setTheme(value);
await settingsRepo.update({}, { theme: value });
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.");
}}
/>
),
},
{
name: "Date format",
renderItem: (name: string) => (
<Select
label={name}
items={formatOptions.map((option) => ({
label: format(today, option),
value: option,
}))}
value={settings.date}
onChange={async (value) => {
setValue("date", value);
await settingsRepo.update({}, { date: value });
toast("Changed date format.");
}}
/>
),
},
{
name: "Auto convert",
renderItem: (name: string) => (
<Select
label={name}
items={[
{ label: "Off", value: "", icon: 'scale-off' },
{ label: "Kilograms", value: "kg", icon: 'weight-kilogram' },
{ label: "Pounds", value: "lb", icon: 'weight-pound' },
{ label: "Stone", value: "stone", icon: 'weight' },
]}
value={settings.autoConvert}
onChange={async (value) => {
setValue("autoConvert", value);
await settingsRepo.update({}, { autoConvert: value });
if (value) toast(`Sets now automatically convert to ${value}`);
else toast("Stopped automatically converting sets.");
}}
/>
),
},
{
name: "Vibration duration (ms)",
renderItem: (name: string) => (
<AppInput
value={settings.duration?.toString() ?? "300"}
label={name}
onChangeText={(value) => setValue("duration", Number(value))}
onSubmitEditing={async (e) => {
const value = Number(e.nativeEvent.text);
setValue("duration", value);
await settingsRepo.update({}, { duration: value });
toast("Changed duration of alarm vibrations.");
}}
keyboardType="numeric"
blurOnSubmit
/>
),
},
{
name: "Default sets",
renderItem: (name: string) => (
<AppInput
value={settings.defaultSets?.toString() ?? "3"}
label={name}
onChangeText={(value) => setValue("defaultSets", Number(value))}
onSubmitEditing={async (e) => {
const value = Number(e.nativeEvent.text);
setValue("defaultSets", value);
await settingsRepo.update({}, { defaultSets: value });
toast(`New exercises now have ${value} sets by default.`);
}}
keyboardType="numeric"
blurOnSubmit
/>
),
},
{
name: "Default minutes",
renderItem: (name: string) => (
<AppInput
value={settings.defaultMinutes?.toString() ?? "3"}
label={name}
onChangeText={(value) => setValue("defaultMinutes", Number(value))}
onSubmitEditing={async (e) => {
const value = Number(e.nativeEvent.text);
setValue("defaultMinutes", value);
await settingsRepo.update({}, { defaultMinutes: value });
toast(`New exercises now wait ${value} minutes by default.`);
}}
keyboardType="numeric"
blurOnSubmit
/>
),
},
{
name: "Default seconds",
renderItem: (name: string) => (
<AppInput
value={settings.defaultSeconds?.toString() ?? "30"}
label={name}
onChangeText={(value) => setValue("defaultSeconds", Number(value))}
onSubmitEditing={async (e) => {
const value = Number(e.nativeEvent.text);
setValue("defaultSeconds", value);
await settingsRepo.update({}, { defaultSeconds: value });
toast(`New exercises now wait ${value} seconds by default.`);
}}
keyboardType="numeric"
blurOnSubmit
/>
),
},
{
name: "Dark color",
renderItem: (name: string) => (
<Select
label={name}
items={lightOptions}
value={darkColor}
onChange={async (value) => {
setValue("darkColor", value);
setDarkColor(value);
await settingsRepo.update({}, { darkColor: value });
toast("Set primary color for dark mode.");
}}
/>
),
},
{
name: "Light color",
renderItem: (name: string) => (
<Select
label={name}
items={darkOptions}
value={lightColor}
onChange={async (value) => {
setValue("lightColor", value);
setLightColor(value);
await settingsRepo.update({}, { lightColor: value });
toast("Set primary color for light mode.");
}}
/>
),
},
{
name: "Rest timers",
renderItem: (name: string) => (
<Switch
value={settings.alarm}
onChange={async (value) => {
setValue("alarm", value);
if (value && !ignoring) {
NativeModules.SettingsModule.ignoreBattery();
}
await settingsRepo.update({}, { alarm: value });
if (value) toast("Timers will now run after each set.");
else toast("Stopped timers running after each set.");
}}
title={name}
/>
),
},
{
name: "Vibrate",
renderItem: (name: string) => (
<Switch
value={settings.vibrate}
onChange={async (value) => {
setValue("vibrate", value);
await settingsRepo.update({}, { vibrate: value });
if (value) toast("Alarms will vibrate.");
else toast("Stopped alarms from vibrating.");
}}
title={name}
/>
),
},
{
name: "Sound",
renderItem: (name: string) => (
<Switch
value={!settings.noSound}
onChange={async (value) => {
setValue("noSound", !value);
await settingsRepo.update({}, { noSound: !value });
if (!value) toast("Alarms will no longer make a sound.");
else toast("Enabled sound for alarms.");
}}
title={name}
/>
),
},
{
name: "Notifications",
renderItem: (name: string) => (
<Switch
value={settings.notify}
onChange={async (value) => {
setValue("notify", value);
await settingsRepo.update({}, { notify: value });
if (value) toast("Show notifications for new records.");
else toast("Stopped notifications for new records.");
}}
title={name}
/>
),
},
{
name: "Show images",
renderItem: (name: string) => (
<Switch
value={settings.images}
onChange={async (value) => {
setValue("images", value);
await settingsRepo.update({}, { images: value });
if (value) toast("Show images for sets.");
else toast("Hid images for sets.");
}}
title={name}
/>
),
},
{
name: "Show unit",
renderItem: (name: string) => (
<Switch
value={settings.showUnit}
onChange={async (value) => {
setValue("showUnit", value);
await settingsRepo.update({}, { showUnit: value });
if (value) toast("Show option to select unit for sets.");
else toast("Hid unit option for sets.");
}}
title={name}
/>
),
},
{
name: "Show date",
renderItem: (name: string) => (
<Switch
value={settings.showDate}
onChange={async (value) => {
setValue("showDate", value);
await settingsRepo.update({}, { showDate: value });
if (value) toast("Show date for sets.");
else toast("Hid date on sets.");
}}
title={name}
/>
),
},
{
name: "Automatic backup",
renderItem: (name: string) => (
<Switch
value={settings.backup}
onChange={async (value) => {
setValue("backup", value);
await settingsRepo.update({}, { backup: value });
if (value) {
const result = await DocumentPicker.pickDirectory();
setValue("backupDir", result.uri);
await settingsRepo.update({}, { backupDir: result.uri });
console.log(`${SettingsPage.name}.backup:`, { result });
toast("Backup database daily.");
NativeModules.BackupModule.start(result.uri);
} else {
toast("Stopped backing up daily");
NativeModules.BackupModule.stop();
}
}}
title={name}
/>
),
},
{
name: `Backup directory: ${backupString || "Not set yet!"}`,
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const result = await DocumentPicker.pickDirectory();
setValue("backupDir", result.uri);
await settingsRepo.update({}, { backupDir: result.uri });
toast("Changed backup directory.");
if (!settings.backup) return;
NativeModules.BackupModule.stop();
NativeModules.BackupModule.start(result.uri);
}}
>
{name}
</Button>
),
},
{
name: `Alarm sound: ${soundString || "Default"}`,
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.audio,
copyTo: "documentDirectory",
});
if (!fileCopyUri) return;
setValue("sound", fileCopyUri);
await settingsRepo.update({}, { sound: fileCopyUri });
toast("Sound will play after rest timers.");
}}
>
{name}
</Button>
),
},
{
name: "Export database",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const result = await DocumentPicker.pickDirectory();
const error = await NativeModules.BackupModule.once(result.uri);
if (error) toast(error);
else toast("Database exported.");
}}
>
{name}
</Button>
),
},
{
name: "Export sets as CSV",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const result = await DocumentPicker.pickDirectory();
await NativeModules.BackupModule.exportSets(result.uri);
toast("Exported sets as CSV.");
}}
>
{name}
</Button>
),
},
{
name: "Export plans as CSV",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const result = await DocumentPicker.pickDirectory();
await NativeModules.BackupModule.exportPlans(result.uri);
toast("Exported plans as CSV.");
}}
>
{name}
</Button>
),
},
{
name: "Import database",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={() => setImporting(true)}
>
{name}
</Button>
),
},
{
name: "Delete database",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={() => setDeleting(true)}
>
{name}
</Button>
),
}, },
[toast],
);
const switches: Input<boolean>[] = [
{name: 'Rest timers', value: alarm, onChange: changeAlarmEnabled},
{name: 'Vibrate', value: vibrate, onChange: changeVibrate},
{name: 'Predict sets', value: predict, onChange: changePredict},
{name: 'Record notifications', value: notify, onChange: changeNotify},
{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}> .filter(input =>
<FlatList input.name.toLowerCase().includes(search.toLowerCase()),
data={data.filter((item) => )
item.name.toLowerCase().includes(term.toLowerCase()) .map(input => (
)} <Switch
renderItem={({ item }) => item.renderItem(item.name)} onPress={() => input.onChange(!input.value)}
style={{ flex: 1, paddingTop: PADDING }} key={input.name}
/> value={input.value}
</Page> 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="Failed to import database" title="Battery optimizations"
onOk={() => setError("")} show={battery}
setShow={() => setError("")} setShow={setBattery}
show={!!error} onOk={() => {
> NativeModules.AlarmModule.ignoreBattery();
{error} setBattery(false);
}}>
Disable battery optimizations for Massive to use rest timers.
</ConfirmDialog> </ConfirmDialog>
</Page>
<ConfirmDialog
title="Are you sure?"
onOk={confirmImport}
setShow={setImporting}
show={importing}
>
Importing a database overwrites your current data. This action cannot be
reversed!
</ConfirmDialog>
<ConfirmDialog
title="Are you sure?"
onOk={confirmDelete}
setShow={setDeleting}
show={deleting}
>
Deleting your database wipes your current data. This action cannot be
reversed!
</ConfirmDialog>
</>
); );
} }

View File

@ -1,20 +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-left" onPress={navigation.goBack} />
<Appbar.Content title={title} />
{children}
</Appbar.Header>
);
}

View File

@ -1,247 +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 { IconButton, ProgressBar } from "react-native-paper";
import { PERMISSIONS, RESULTS, check, request } from "react-native-permissions";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import PrimaryButton from "./PrimaryButton";
import Select from "./Select";
import StackHeader from "./StackHeader";
import StartPlanItem from "./StartPlanItem";
import { getBestSet } from "./best.service";
import { PADDING } from "./constants";
import { convert } from "./conversions";
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 Settings from "./settings";
import { toast } from "./toast";
export default function StartPlan() {
const { params } = useRoute<RouteProp<StackParams, "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<number>(0);
const [settings, setSettings] = useState<Settings>();
const [counts, setCounts] = useState<CountMany[]>();
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const exercises = useMemo(() => params.plan.exercises.split(","), [params]);
const navigation = useNavigation<NavigationProp<StackParams>>();
const [selection, setSelection] = useState({
start: 0,
end: 0,
});
const refresh = useCallback(async () => {
const questions = exercises
.map((exercise, index) => `('${exercise}',${index})`)
.join(",");
const select = `
SELECT exercises.name, COUNT(sets.id) as total, sets.sets
FROM (select 0 as name, 0 as sequence union values ${questions}) as exercises
LEFT JOIN sets ON sets.name = exercises.name
AND sets.created LIKE STRFTIME('%Y-%m-%d%%', 'now', 'localtime')
AND NOT sets.hidden
GROUP BY exercises.name
ORDER BY exercises.sequence
LIMIT -1
OFFSET 1
`;
const newCounts = await AppDataSource.manager.query(select);
console.log(`${StartPlan.name}.focus:`, { newCounts });
setCounts(newCounts);
}, [exercises]);
const select = useCallback(
async (index: number, newCounts?: CountMany[]) => {
setSelected(index);
if (!counts && !newCounts) return;
const exercise = counts ? counts[index] : newCounts[index];
console.log(`${StartPlan.name}.next:`, { exercise });
const last = await setRepo.findOne({
where: { name: exercise.name },
order: { created: "desc" },
});
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();
// eslint-disable-next-line
}, [])
);
const handleSubmit = async () => {
const now = await getNow();
const exercise = counts[selected];
const best = await getBestSet(exercise.name);
delete best.id;
let newWeight = Number(weight);
let newUnit = unit;
if (settings.autoConvert && unit !== settings.autoConvert) {
newUnit = settings.autoConvert;
newWeight = convert(newWeight, unit, settings.autoConvert);
}
const newSet: GymSet = {
...best,
weight: newWeight,
reps: Number(reps),
unit: newUnit,
created: now,
hidden: false,
};
await setRepo.save(newSet);
await refresh();
if (
settings.notify &&
(Number(weight) > best.weight ||
(Number(reps) > best.reps && Number(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;
const canNotify = await check(PERMISSIONS.ANDROID.POST_NOTIFICATIONS);
if (canNotify === RESULTS.DENIED)
await request(PERMISSIONS.ANDROID.POST_NOTIFICATIONS);
if (isNaN(exercise.total) ? 0 : exercise.total === best.sets - 1 && selected === exercises.length - 1)
return
NativeModules.AlarmModule.timer(milliseconds, `${exercise.name} (${exercise.total + 1}/${best.sets})`);
};
return (
<>
<StackHeader
title={params.plan.title || params.plan.days.replace(/,/g, ", ")}
>
<IconButton
onPress={() => navigation.navigate("EditPlan", { plan: params.plan })}
icon="pencil"
/>
</StackHeader>
<View style={{ padding: PADDING, flex: 1, flexDirection: "column" }}>
<View style={{ flex: 1 }}>
<View>
<AppInput
label="Reps"
keyboardType="numeric"
value={reps}
onChangeText={(newReps) => {
const fixed = fixNumeric(newReps);
setReps(fixed.replace(/-/g, ''))
if (fixed.length !== newReps.length)
toast("Reps must be a number");
else if (fixed.includes('-'))
toast("Reps must be a positive value")
}}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
innerRef={repsRef}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setReps((Number(reps) + 1).toString())}
/>
<IconButton
icon="minus"
onPress={() => setReps((Number(reps) - 1).toString())}
/>
</View>
</View>
<View>
<AppInput
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}
blurOnSubmit
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setWeight((Number(weight) + 2.5).toString())}
/>
<IconButton
icon="minus"
onPress={() => setWeight((Number(weight) - 2.5).toString())}
/>
</View>
</View>
{settings?.showUnit && (
<Select
value={unit}
onChange={setUnit}
items={[
{ label: "kg", value: "kg" },
{ label: "lb", value: "lb" },
{ label: "stone", value: "stone" },
]}
label="Unit"
/>
)}
{counts !== undefined && (
<FlatList
data={counts}
keyExtractor={(count) => count.name}
renderItem={(props) => (
<View>
<StartPlanItem
{...props}
onUndo={refresh}
onSelect={select}
selected={selected}
/>
<ProgressBar
progress={(props.item.total || 0) / (props.item.sets || 3)}
/>
</View>
)}
/>
)}
</View>
<PrimaryButton icon="content-save" onPress={handleSubmit}>
Save
</PrimaryButton>
</View>
</>
);
}

View File

@ -1,134 +0,0 @@
import { NavigationProp, useNavigation } from "@react-navigation/native";
import React, { useCallback, useState } from "react";
import {
GestureResponderEvent,
ListRenderItemInfo,
NativeModules,
View,
} from "react-native";
import { List, Menu, RadioButton, useTheme } from "react-native-paper";
import { Like } from "typeorm";
import { StackParams } from "./AppStack";
import CountMany from "./count-many";
import { getNow, setRepo } from "./db";
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: stackNavigate } =
useNavigation<NavigationProp<StackParams>>();
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.");
stackNavigate("EditSet", { set: first });
}, [item.name, stackNavigate]);
const view = useCallback(() => {
setShowMenu(false);
stackNavigate("ViewSetList", { name: item.name });
}, [item.name, stackNavigate]);
const graph = useCallback(() => {
setShowMenu(false);
stackNavigate("ViewGraph", { name: item.name });
}, [item.name, stackNavigate]);
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="eye-outline" onPress={view} title="Peek" />
<Menu.Item
leadingIcon="chart-bell-curve-cumulative"
onPress={graph}
title="Graph"
/>
<Menu.Item leadingIcon="pencil" onPress={edit} title="Edit" />
<Menu.Item leadingIcon="undo" onPress={undo} title="Undo" />
</Menu>
</View>
),
[anchor, showMenu, edit, undo, view, graph]
);
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,38 +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}
/> />
<Text>{title}</Text> <Text>{children}</Text>
</Pressable> </Pressable>
); );
} }
export default React.memo(Switch);

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,267 +0,0 @@
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import { RouteProp, useFocusEffect, useRoute } from "@react-navigation/native";
import { format } from "date-fns";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Keyboard, ScrollView, 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 AppInput from "./AppInput";
import AppLineChart from "./AppLineChart";
import { StackParams } from "./AppStack";
import Select from "./Select";
import StackHeader from "./StackHeader";
import { MARGIN, PADDING } from "./constants";
import { setRepo, settingsRepo } from "./db";
import GymSet from "./gym-set";
import { Metrics } from "./metrics";
import { Periods } from "./periods";
import Settings from "./settings";
import Volume from "./volume";
import { convert } from "./conversions";
export default function ViewGraph() {
const { params } = useRoute<RouteProp<StackParams, "ViewGraph">>();
const [weights, setWeights] = useState<GymSet[]>();
const [volumes, setVolumes] = useState<Volume[]>();
const [metric, setMetric] = useState(Metrics.OneRepMax);
const [period, setPeriod] = useState(Periods.Monthly);
const [unit, setUnit] = useState("kg");
const [start, setStart] = useState<Date | null>(null);
const [end, setEnd] = useState<Date | null>(null);
const [settings, setSettings] = useState<Settings>({} as Settings);
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
}, [])
);
useEffect(() => {
let difference = "-7 days";
if (period === Periods.Monthly) difference = "-1 months";
else if (period === Periods.Yearly) difference = "-1 years";
else if (period === Periods.TwoMonths) difference = "-2 months";
else if (period === Periods.ThreeMonths) difference = "-3 months";
else if (period === Periods.SixMonths) difference = "-6 months";
else if (period === Periods.AllTime) difference = null;
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.name })
.andWhere("NOT hidden");
if (start) builder.andWhere("DATE(created) >= :start", { start });
if (end) builder.andWhere("DATE(created) <= :end", { end });
if (difference)
builder.andWhere(
"DATE(created) >= DATE('now', 'weekday 0', :difference)",
{
difference,
}
);
builder.groupBy("name").addGroupBy(`STRFTIME('${group}', created)`);
switch (metric) {
case Metrics.Best:
builder
.addSelect("ROUND(MAX(weight), 2)", "weight")
.getRawMany()
.then((newWeights) =>
newWeights.map((set) => {
let weight = convert(set.weight, set.unit, unit);
if (isNaN(weight)) weight = 0;
return ({
...set,
weight: weight
});
})
)
.then(setWeights);
break;
case Metrics.Volume:
builder
.addSelect("ROUND(SUM(weight * reps), 2)", "value")
.getRawMany()
.then((newWeights) =>
newWeights.map((set) => {
let weight = convert(set.value, set.unit, unit);
if (isNaN(weight)) weight = 0;
return ({
...set,
value: weight
});
})
)
.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) =>
newWeights.map((set) => {
let weight = convert(set.weight, set.unit, unit);
if (isNaN(weight)) weight = 0;
return ({
...set,
weight: weight,
});
})
)
.then((newWeights) => {
console.log(`${ViewGraph.name}.oneRepMax:`, {
weights: newWeights,
});
setWeights(newWeights);
});
}
}, [params.name, metric, period, unit, start, end]);
const weightChart = useMemo(() => {
if (weights === undefined) return null;
if (weights.length === 0) return <List.Item title="No data yet." />;
return (
<AppLineChart
data={weights.map((set) => set.weight)}
labels={weights.map((set) =>
format(new Date(set.created), "yyyy-MM-d")
)}
/>
);
}, [weights]);
const volumeChart = useMemo(() => {
if (volumes === undefined) return null;
if (volumes.length === 0) return <List.Item title="No data yet." />;
return (
<AppLineChart
data={volumes.map((volume) => volume.value)}
labels={volumes.map((volume) =>
format(new Date(volume.created), "yyyy-MM-d")
)}
/>
);
}, [volumes]);
const pickStart = useCallback(() => {
DateTimePickerAndroid.open({
value: start || new Date(),
onChange: (event, date) => {
if (event.type === "dismissed") return;
if (date === start) return;
setStart(date);
setPeriod(Periods.AllTime);
Keyboard.dismiss();
},
mode: "date",
});
}, [start]);
const pickEnd = useCallback(() => {
DateTimePickerAndroid.open({
value: end || new Date(),
onChange: (event, date) => {
if (event.type === "dismissed") return;
if (date === end) return;
setEnd(date);
setPeriod(Periods.AllTime);
Keyboard.dismiss();
},
mode: "date",
});
}, [end]);
return (
<>
<StackHeader title={params.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>
<ScrollView style={{ padding: PADDING }}>
<Select
label="Metric"
items={[
{ value: Metrics.OneRepMax, label: Metrics.OneRepMax },
{ label: Metrics.Best, value: Metrics.Best },
{ value: Metrics.Volume, label: Metrics.Volume },
]}
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.TwoMonths, label: Periods.TwoMonths },
{ value: Periods.ThreeMonths, label: Periods.ThreeMonths },
{ value: Periods.SixMonths, label: Periods.SixMonths },
{ value: Periods.Yearly, label: Periods.Yearly },
{ value: Periods.AllTime, label: Periods.AllTime },
]}
onChange={(value) => {
setPeriod(value as Periods);
setStart(null);
setEnd(null);
}}
value={period}
/>
<View style={{ flexDirection: "row", marginBottom: MARGIN }}>
<AppInput
label="Start date"
value={start ? format(start, settings.date || "Pp") : null}
onPressOut={pickStart}
style={{ flex: 1, marginRight: MARGIN }}
/>
<AppInput
label="End date"
value={end ? format(end, settings.date || "Pp") : null}
onPressOut={pickEnd}
style={{ flex: 1 }}
/>
</View>
<Select
label="Unit"
value={unit}
onChange={setUnit}
items={[
{ label: "Pounds (lb)", value: "lb" },
{ label: "Kilograms (kg)", value: "kg" },
{ label: "Stone", value: "stone" },
]}
/>
<View style={{ paddingTop: PADDING }}>
{metric === Metrics.Volume ? volumeChart : weightChart}
</View>
</ScrollView>
</>
);
}

View File

@ -1,96 +0,0 @@
import { RouteProp, useRoute } from "@react-navigation/native";
import { format } from "date-fns";
import { useEffect, useState } from "react";
import { FlatList } from "react-native";
import { List, Text, useTheme } from "react-native-paper";
import { Like } from "typeorm";
import { StackParams } from "./AppStack";
import StackHeader from "./StackHeader";
import { LIMIT } from "./constants";
import { setRepo, settingsRepo } from "./db";
import GymSet from "./gym-set";
import Settings from "./settings";
interface ColorSet extends GymSet {
color?: string;
}
export default function ViewSetList() {
const [sets, setSets] = useState<ColorSet[]>();
const [settings, setSettings] = useState<Settings>();
const { colors } = useTheme();
const { params } = useRoute<RouteProp<StackParams, "ViewSetList">>();
useEffect(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
const reset = async () => {
const newSets: ColorSet[] = await setRepo.find({
where: { name: Like(`%${params.name}%`), hidden: 0 as any },
take: LIMIT,
skip: 0,
order: { created: "DESC" },
});
let prevDate = null;
const elevate = colors.elevation.level2;
const transparent = colors.elevation.level0;
let color = elevate;
for (let i = 0; i < newSets.length; i++) {
let currDate = new Date(newSets[i].created).toDateString();
if (currDate !== prevDate)
color = color === elevate ? transparent : elevate;
newSets[i].color = color;
prevDate = currDate;
}
setSets(newSets);
};
reset();
}, [params.name, colors]);
const renderItem = ({ item }: { item: ColorSet; index: number }) => (
<List.Item
title={format(new Date(item.created), settings.date || "Pp")}
style={{ backgroundColor: item.color }}
right={() => (
<Text
style={{
alignSelf: "center",
}}
>
{`${item.reps} x ${item.weight}${item.unit || "kg"}`}
</Text>
)}
/>
);
const getContent = () => {
if (!settings) return null;
if (sets?.length === 0)
return (
<List.Item
title="No sets yet"
description="A set is a group of repetitions. E.g. 8 reps of Squats."
/>
);
return (
<FlatList
data={sets ?? []}
style={{ flex: 1 }}
renderItem={renderItem}
keyExtractor={(set) => set.id?.toString()}
/>
);
};
return (
<>
<StackHeader title={params.name} />
{getContent()}
</>
);
}

View File

@ -1,167 +0,0 @@
import { format } from "date-fns";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Keyboard, ScrollView, 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 AppLineChart from "./AppLineChart";
import { MARGIN, PADDING } from "./constants";
import { settingsRepo, weightRepo } from "./db";
import { Periods } from "./periods";
import Select from "./Select";
import StackHeader from "./StackHeader";
import Weight from "./weight";
import { useFocusEffect } from "@react-navigation/native";
import Settings from "./settings";
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import AppInput from "./AppInput";
export default function ViewWeightGraph() {
const [weights, setWeights] = useState<Weight[]>();
const [period, setPeriod] = useState(Periods.TwoMonths);
const [start, setStart] = useState<Date | null>(null)
const [end, setEnd] = useState<Date | null>(null)
const [settings, setSettings] = useState<Settings>({} as Settings);
useFocusEffect(useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings)
}, []))
useEffect(() => {
let difference = "-7 days";
if (period === Periods.Monthly) difference = "-1 months";
else if (period === Periods.TwoMonths) difference = "-2 months";
else if (period === Periods.ThreeMonths) difference = "-3 months";
else if (period === Periods.SixMonths) difference = "-6 months";
else if (period === Periods.Yearly) difference = "-1 years";
else if (period === Periods.AllTime) difference = null;
let group = "%Y-%m-%d";
if (period === Periods.Yearly) group = "%Y-%m";
const builder = weightRepo
.createQueryBuilder()
.select("STRFTIME('%Y-%m-%d', created)", "created")
.addSelect("AVG(value) as value")
.addSelect("unit")
.groupBy(`STRFTIME('${group}', created)`)
if (difference)
builder.where("DATE(created) >= DATE('now', 'weekday 0', :difference)", {
difference,
})
if (start)
builder.andWhere("DATE(created) >= :start", { start });
if (end)
builder.andWhere("DATE(created) <= :end", { end });
builder
.getRawMany()
.then(setWeights);
}, [period, start, end]);
const pickStart = useCallback(() => {
DateTimePickerAndroid.open({
value: start || new Date(),
onChange: (event, date) => {
if (event.type === 'dismissed') return;
if (date === start) return;
setStart(date);
setPeriod(Periods.AllTime);
Keyboard.dismiss();
},
mode: "date",
});
}, [start]);
const pickEnd = useCallback(() => {
DateTimePickerAndroid.open({
value: end || new Date(),
onChange: (event, date) => {
if (event.type === 'dismissed') return;
if (date === end) return;
setEnd(date);
setPeriod(Periods.AllTime);
Keyboard.dismiss();
},
mode: "date",
});
}, [end]);
const charts = useMemo(() => {
if (!weights) return;
if (weights?.length === 0) {
return <List.Item title="No data yet." />;
}
return (
<AppLineChart
data={weights.map((set) => set.value)}
labels={weights.map((weight) =>
format(new Date(weight.created), "yyyy-MM-d")
)}
/>
);
}, [weights]);
return (
<>
<StackHeader title="Weight graph">
<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>
<ScrollView style={{ padding: PADDING }}>
<Select
label="Period"
items={[
{ value: Periods.Weekly, label: Periods.Weekly },
{ value: Periods.Monthly, label: Periods.Monthly },
{ value: Periods.TwoMonths, label: Periods.TwoMonths },
{ value: Periods.ThreeMonths, label: Periods.ThreeMonths },
{ value: Periods.SixMonths, label: Periods.SixMonths },
{ value: Periods.Yearly, label: Periods.Yearly },
{ value: Periods.AllTime, label: Periods.AllTime },
]}
onChange={(value) => {
setPeriod(value as Periods);
if (value === Periods.AllTime) return;
setStart(null);
setEnd(null);
}}
value={period}
/>
<View style={{ flexDirection: 'row', marginBottom: MARGIN }}>
<AppInput
label="Start date"
value={start ? format(start, settings.date || "Pp") : null}
onPressOut={pickStart}
style={{ flex: 1, marginRight: MARGIN }}
/>
<AppInput
label="End date"
value={end ? format(end, settings.date || "Pp") : null}
onPressOut={pickEnd}
style={{ flex: 1 }}
/>
</View>
{charts}
</ScrollView>
</>
);
}

View File

@ -1,49 +0,0 @@
import { NavigationProp, useNavigation } from "@react-navigation/native";
import { format } from "date-fns";
import { useCallback, useMemo } from "react";
import { List, Text } from "react-native-paper";
import { StackParams } from "./AppStack";
import Settings from "./settings";
import Weight from "./weight";
export default function WeightItem({
item,
settings,
}: {
item: Weight;
settings: Settings;
}) {
const navigation = useNavigation<NavigationProp<StackParams>>();
const press = useCallback(() => {
navigation.navigate("EditWeight", { weight: item });
}, [item, navigation]);
const today = useMemo(() => {
const now = new Date();
const created = new Date(item.created);
return (
now.getFullYear() === created.getFullYear() &&
now.getMonth() === created.getMonth() &&
now.getDate() === created.getDate()
);
}, [item.created]);
return (
<List.Item
onPress={press}
title={`${item.value}${item.unit || "kg"}`}
right={() => (
<Text
style={{
alignSelf: "center",
textDecorationLine: today ? "underline" : "none",
fontWeight: today ? "bold" : "normal",
}}
>
{format(new Date(item.created), settings.date || "Pp")}
</Text>
)}
/>
);
}

View File

@ -1,150 +0,0 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from "@react-navigation/native";
import { useCallback, useState } from "react";
import { FlatList } from "react-native";
import { IconButton, List } from "react-native-paper";
import { Like } from "typeorm";
import { StackParams } from "./AppStack";
import { LIMIT } from "./constants";
import { getNow, settingsRepo, weightRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
import Page from "./Page";
import Settings from "./settings";
import { default as Weight, defaultWeight } from "./weight";
import WeightItem from "./WeightItem";
export default function WeightList() {
const [refreshing, setRefreshing] = useState(false);
const [weights, setWeights] = useState<Weight[]>();
const [offset, setOffset] = useState(0);
const [end, setEnd] = useState(false);
const [settings, setSettings] = useState<Settings>();
const { navigate } = useNavigation<NavigationProp<StackParams>>();
const [term, setTerm] = useState("");
const reset = useCallback(
async (value: string) => {
const newWeights = await weightRepo.find({
where: [
{
value: isNaN(Number(term)) ? undefined : Number(term),
},
{
created: Like(`%${term}%`),
},
],
take: LIMIT,
skip: 0,
order: { created: "DESC" },
});
console.log(`${WeightList.name}.reset:`, { value, offset });
setWeights(newWeights);
setEnd(false);
},
[offset, term]
);
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
reset(term);
// eslint-disable-next-line
}, [term])
);
const search = (value: string) => {
console.log(`${WeightList.name}.search:`, value);
setTerm(value);
setOffset(0);
reset(value);
};
const renderItem = useCallback(
({ item }: { item: Weight }) => (
<WeightItem settings={settings} item={item} key={item.id} />
),
[settings]
);
const next = async () => {
console.log(`${WeightList.name}.next:`, { end, refreshing });
if (end || refreshing) return;
const newOffset = offset + LIMIT;
console.log(`${WeightList.name}.next:`, { offset, newOffset, term });
const newWeights = await weightRepo.find({
where: [
{
value: Number(term),
},
{
created: Like(`%${term}%`),
},
],
take: LIMIT,
skip: newOffset,
order: { created: "DESC" },
});
if (newWeights.length === 0) return setEnd(true);
if (!weights) return;
const map = new Map<number, Weight>();
for (const weight of weights) map.set(weight.id, weight);
for (const weight of newWeights) map.set(weight.id, weight);
const unique = Array.from(map.values());
setWeights(unique);
if (newWeights.length < LIMIT) return setEnd(true);
setOffset(newOffset);
};
const onAdd = useCallback(async () => {
const now = await getNow();
let weight: Partial<Weight> = { ...weights[0] };
if (!weight) weight = { ...defaultWeight };
weight.created = now;
delete weight.id;
navigate("EditWeight", { weight });
}, [navigate, weights]);
const getContent = () => {
if (!settings) return null;
if (weights?.length === 0)
return (
<List.Item
title="No sets yet"
description="A set is a group of repetitions. E.g. 8 reps of Squats."
/>
);
return (
<FlatList
data={weights ?? []}
style={{ flex: 1 }}
renderItem={renderItem}
onEndReached={next}
refreshing={refreshing}
keyExtractor={(set) => set.id?.toString()}
onRefresh={() => {
setOffset(0);
setRefreshing(true);
reset(term).finally(() => setRefreshing(false));
}}
/>
);
};
return (
<>
<DrawerHeader name="Weight">
<IconButton
onPress={() => navigate("ViewWeightGraph")}
icon="chart-bell-curve-cumulative"
/>
</DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}>
{getContent()}
</Page>
</>
);
}

82
WorkoutItem.tsx Normal file
View File

@ -0,0 +1,82 @@
import {NavigationProp, useNavigation} from '@react-navigation/native';
import React, {useCallback, useState} from 'react';
import {GestureResponderEvent, Image} from 'react-native';
import {List, Menu, Text} from 'react-native-paper';
import ConfirmDialog from './ConfirmDialog';
import {DrawerParamList} from './drawer-param-list';
import Set from './set';
import {deleteSetsBy} from './set.service';
export default function WorkoutItem({
item,
onRemoved,
}: {
item: Set;
onRemoved: () => void;
}) {
const [showMenu, setShowMenu] = useState(false);
const [anchor, setAnchor] = useState({x: 0, y: 0});
const [showRemove, setShowRemove] = useState('');
const navigation = useNavigation<NavigationProp<DrawerParamList>>();
const remove = useCallback(async () => {
await deleteSetsBy(item.name);
setShowMenu(false);
onRemoved();
}, [setShowMenu, onRemoved, item.name]);
const longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
setShowMenu(true);
},
[setShowMenu, setAnchor],
);
const minutes = item.minutes?.toString().padStart(2, '0');
const seconds = item.seconds?.toString().padStart(2, '0');
return (
<>
<List.Item
onPress={() => navigation.navigate('Edit workout', {value: item})}
title={item.name}
description={`${item.sets} sets ${minutes}:${seconds} rest`}
onLongPress={longPress}
left={() =>
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
title={`Delete ${showRemove}`}
show={!!showRemove}
setShow={show => (show ? null : setShowRemove(''))}
onOk={remove}>
This irreversibly deletes ALL sets related to this workout. Are you
sure?
</ConfirmDialog>
</>
);
}

97
WorkoutsPage.tsx Normal file
View File

@ -0,0 +1,97 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {FlatList} from 'react-native';
import {List} from 'react-native-paper';
import {DrawerParamList} from './drawer-param-list';
import Page from './Page';
import Set from './set';
import {getDistinctSets} from './set.service';
import WorkoutItem from './WorkoutItem';
const limit = 15;
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 (
<Page onAdd={onAdd} search={search} setSearch={setSearch}>
<FlatList
data={workouts}
style={{height: '99%'}}
ListEmptyComponent={
<List.Item
title="No workouts yet."
description="A workout is something you do at the gym. For example Deadlifts are a workout."
/>
}
renderItem={renderItem}
keyExtractor={w => w.name}
onEndReached={next}
/>
</Page>
);
}

View File

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

View File

@ -1,214 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
rexml
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.888.0)
aws-sdk-core (3.191.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.77.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.143.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0)
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.5)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.109.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.3.0)
fastlane (2.219.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-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
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 (~> 3)
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.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
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
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.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a)
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.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
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.7.1)
jwt (2.7.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.4.0)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.4.0)
os (1.1.4)
plist (3.7.1)
public_suffix (5.0.4)
rake (13.1.0)
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.6)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
signet (0.18.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 (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.5.0)
word_wrap (1.0.0)
xcodeproj (1.24.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
x64-mingw-ucrt
x86_64-linux
DEPENDENCIES
fastlane
BUNDLED WITH
2.3.25

View File

@ -1,94 +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"
apply plugin: "org.jetbrains.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
buildToolsVersion rootProject.ext.buildToolsVersion targetCompatibility = JavaVersion.VERSION_11
compileSdk rootProject.ext.compileSdkVersion
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 36250 versionCode 36037
versionName "2.35" 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 {
@ -113,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"
} }
@ -123,24 +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.constraintlayout:constraintlayout:2.1.4'
implementation 'com.opencsv:opencsv:5.5.2'
implementation project(':react-native-sqlite-storage')
implementation project(':react-native-vector-icons')
implementation("com.facebook.react:flipper-integration")
if (hermesEnabled.toBoolean()) { implementation "androidx.work:work-runtime:$work_version"
implementation("com.facebook.react:hermes-android") implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.work:work-rxjava2:$work_version"
androidTestImplementation "androidx.work:work-testing:$work_version"
implementation "androidx.work:work-multiprocess:$work_version"
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "com.facebook.react:react-native:+" // From node_modules
implementation "androidx.core:core-ktx:1.8.0"
implementation project(':react-native-sqlite-storage')
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
exclude group:'com.facebook.fbjni'
}
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
exclude group:'com.facebook.flipper'
exclude group:'com.squareup.okhttp3', module:'okhttp'
}
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") {
exclude group:'com.facebook.flipper'
}
if (enableHermes) {
implementation("com.facebook.react:hermes-engine:+") { // From node_modules
exclude group:'com.facebook.fbjni'
}
} else { } else {
implementation jscFlavor implementation jscFlavor
} }
} }
if (isNewArchitectureEnabled()) {
configurations.all {
resolutionStrategy.dependencySubstitution {
substitute(module("com.facebook.react:react-native"))
.using(project(":ReactAndroid"))
.because("On New Architecture we're building React Native from source")
substitute(module("com.facebook.react:hermes-engine"))
.using(project(":ReactAndroid:hermes-engine"))
.because("On New Architecture we're building Hermes from source")
}
}
}
task copyDownloadableDepsToLibs(type: Copy) {
from configurations.implementation
into 'libs'
}
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project) apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
project.ext.vectoricons = [
iconFontNames: ['MaterialCommunityIcons.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

@ -7,5 +7,7 @@
<application <application
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:targetApi="28" tools:targetApi="28"
tools:ignore="GoogleAppIndexingWarning"/> tools:ignore="GoogleAppIndexingWarning">
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" android:exported="false" />
</application>
</manifest> </manifest>

View File

@ -0,0 +1,73 @@
/**
* 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.flipper.android.AndroidFlipperClient;
import com.facebook.flipper.android.utils.FlipperUtils;
import com.facebook.flipper.core.FlipperClient;
import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin;
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin;
import com.facebook.flipper.plugins.inspector.DescriptorMapping;
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
import com.facebook.flipper.plugins.react.ReactFlipperPlugin;
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
import com.facebook.react.ReactInstanceEventListener;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.network.NetworkingModule;
import okhttp3.OkHttpClient;
public class ReactNativeFlipper {
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
if (FlipperUtils.shouldEnableFlipper(context)) {
final FlipperClient client = AndroidFlipperClient.getInstance(context);
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
client.addPlugin(new ReactFlipperPlugin());
client.addPlugin(new DatabasesFlipperPlugin(context));
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
client.addPlugin(CrashReporterPlugin.getInstance());
NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin();
NetworkingModule.setCustomClientBuilder(
new NetworkingModule.CustomClientBuilder() {
@Override
public void apply(OkHttpClient.Builder builder) {
builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin));
}
});
client.addPlugin(networkFlipperPlugin);
client.start();
// Fresco Plugin needs to ensure that ImagePipelineFactory is initialized
// Hence we run if after all native modules have been initialized
ReactContext reactContext = reactInstanceManager.getCurrentReactContext();
if (reactContext == null) {
reactInstanceManager.addReactInstanceEventListener(
new ReactInstanceEventListener() {
@Override
public void onReactContextInitialized(ReactContext reactContext) {
reactInstanceManager.removeReactInstanceEventListener(this);
reactContext.runOnNativeModulesQueueThread(
new Runnable() {
@Override
public void run() {
client.addPlugin(new FrescoFlipperPlugin());
}
});
}
});
} else {
client.addPlugin(new FrescoFlipperPlugin());
}
}
}
}

View File

@ -1,28 +1,21 @@
<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.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.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.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<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:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
@ -34,27 +27,9 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:exported="true" android:process=":remote" android:name=".StopAlarm" />
<activity <service android:name=".StopTimer" android:exported="true" android:process=":remote" />
android:name=".TimerDone" <service android:name=".AlarmService" android:exported="true" />
android:exported="false"> <service android:name=".TimerService" android:exported="true" />
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<activity
android:name=".StopAlarm"
android:exported="true"
android:process=":remote" />
<service
android:name=".TimerService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="App does not require SCHEDULE_EXACT_ALARM or USE_EXACT_ALARM, but needs foreground service for foreground timer."/>
</service>
</application> </application>
</manifest> </manifest>

View File

@ -1,25 +1,90 @@
package com.massive package com.massive
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
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 com.facebook.react.bridge.* import com.facebook.react.bridge.Callback
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
@RequiresApi(Build.VERSION_CODES.O)
class AlarmModule(context: ReactApplicationContext?) : class AlarmModule internal constructor(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) { ReactContextBaseJavaModule(context) {
override fun getName(): String { override fun getName(): String {
return "AlarmModule" return "AlarmModule"
} }
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod @ReactMethod
fun timer(milliseconds: Int, description: String) { fun add(milliseconds: Int, vibrate: Boolean, sound: String?) {
Log.d("AlarmModule", "Add 1 min to alarm.")
val addIntent = Intent(reactApplicationContext, TimerService::class.java)
addIntent.action = "add"
addIntent.putExtra("vibrate", vibrate)
addIntent.putExtra("sound", sound)
addIntent.data = Uri.parse("$milliseconds")
reactApplicationContext.startService(addIntent)
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
fun stop() {
Log.d("AlarmModule", "Stop alarm.")
val timerIntent = Intent(reactApplicationContext, TimerService::class.java)
reactApplicationContext.stopService(timerIntent)
val alarmIntent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.stopService(alarmIntent)
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
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 intent = Intent(reactApplicationContext, TimerService::class.java) val intent = Intent(reactApplicationContext, TimerService::class.java)
intent.putExtra("milliseconds", milliseconds) intent.putExtra("milliseconds", milliseconds)
intent.putExtra("description", description) intent.putExtra("vibrate", vibrate)
reactApplicationContext.startForegroundService(intent) intent.putExtra("sound", sound)
reactApplicationContext.startService(intent)
}
@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()
}
} }
} }

View File

@ -0,0 +1,75 @@
package com.massive
import android.app.Service
import android.content.Context
import android.media.MediaPlayer.OnPreparedListener
import android.media.MediaPlayer
import androidx.annotation.RequiresApi
import android.content.Intent
import android.media.AudioAttributes
import android.net.Uri
import android.os.*
class AlarmService : Service(), OnPreparedListener {
var mediaPlayer: MediaPlayer? = null
private var vibrator: Vibrator? = null
@RequiresApi(api = Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.action == "stop") {
onDestroy()
return START_STICKY
}
val sound = intent.extras?.getString("sound")
if (sound == null) {
mediaPlayer = MediaPlayer.create(applicationContext, R.raw.argon)
mediaPlayer?.start()
mediaPlayer?.setOnCompletionListener { vibrator?.cancel() }
} else {
mediaPlayer = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setDataSource(applicationContext, Uri.parse(sound))
prepare()
start()
setOnCompletionListener { vibrator?.cancel() }
}
}
val pattern = longArrayOf(0, 300, 1300, 300, 1300, 300)
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager =
getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(VIBRATOR_SERVICE) as Vibrator
}
val audioAttributes = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ALARM)
.build()
val vibrate = intent.extras!!.getBoolean("vibrate")
if (vibrate)
vibrator!!.vibrate(VibrationEffect.createWaveform(pattern, 1), audioAttributes)
return START_STICKY
}
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onPrepared(player: MediaPlayer) {
player.start()
}
override fun onDestroy() {
super.onDestroy()
mediaPlayer?.stop()
mediaPlayer?.release()
vibrator?.cancel()
}
}

View File

@ -1,138 +0,0 @@
package com.massive
import android.annotation.SuppressLint
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.*
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import java.io.*
import java.util.*
@SuppressLint("UnspecifiedRegisterReceiverFlag")
class BackupModule(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) {
val context: ReactApplicationContext = reactApplicationContext
private val copyReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val targetDir = intent?.getStringExtra("targetDir")
Log.d("BackupModule", "onReceive $targetDir")
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()
}
}
@ReactMethod
fun once(target: String, promise: Promise) {
Log.d("BackupModule", "once $target")
try {
val treeUri: Uri = Uri.parse(target)
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()
promise.resolve(0)
}
catch (error: Exception) {
promise.reject("ERROR", error)
}
}
@ReactMethod
fun start(baseUri: String) {
Log.d("BackupModule", "start $baseUri")
val alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(COPY_BROADCAST)
intent.putExtra("targetDir", baseUri)
val pendingIntent =
PendingIntent.getBroadcast(context, baseUri.hashCode(), 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
)
}
@ReactMethod(isBlockingSynchronousMethod = true)
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)
}
@ReactMethod
fun exportPlans(target: String, promise: Promise) {
try {
val db = DatabaseHelper(reactApplicationContext)
db.exportPlans(target, reactApplicationContext)
promise.resolve("Export successful!")
}
catch (e: Exception) {
promise.reject("ERROR", e)
}
}
@ReactMethod
fun exportSets(target: String, promise: Promise) {
try {
val db = DatabaseHelper(reactApplicationContext)
db.exportSets(target, reactApplicationContext)
promise.resolve("Export successful!")
}
catch (e: Exception) {
promise.reject("ERROR", e)
}
}
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
reactApplicationContext.registerReceiver(copyReceiver, IntentFilter(COPY_BROADCAST),
Context.RECEIVER_NOT_EXPORTED)
}
else {
reactApplicationContext.registerReceiver(copyReceiver, IntentFilter(COPY_BROADCAST))
}
}
companion object {
const val COPY_BROADCAST = "copy-event"
}
override fun getName(): String {
return "BackupModule"
}
}

View File

@ -1,94 +0,0 @@
package com.massive
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.opencsv.CSVWriter
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
}
fun exportSets(target: String, context: Context) {
Log.d("DatabaseHelper", "exportSets $target")
val treeUri: Uri = Uri.parse(target)
val documentFile = context.let { DocumentFile.fromTreeUri(it, treeUri) }
val file = documentFile?.createFile("application/octet-stream", "sets.csv") ?: return
context.contentResolver.openOutputStream(file.uri).use { outputStream ->
val csvWrite = CSVWriter(outputStream?.writer())
val db = this.readableDatabase
val setCursor = db.rawQuery("SELECT * FROM sets", null)
csvWrite.writeNext(setCursor.columnNames)
var lastId = 0
while(setCursor.moveToNext()) {
val arrStr = arrayOfNulls<String>(setCursor.columnCount)
for(i in 0 until setCursor.columnCount) {
arrStr[i] = setCursor.getString(i)
}
val id = arrStr[0]?.toInt()
if (id != null && id > lastId) lastId = id
csvWrite.writeNext(arrStr)
}
val weightCursor = db.rawQuery("SELECT * FROM weights", null)
while (weightCursor.moveToNext()) {
val arrStr = arrayOfNulls<String>(setCursor.columnCount)
arrStr[0] = lastId++.toString()
arrStr[1] = "Weight"
arrStr[2] = "1"
arrStr[3] = weightCursor.getString(1)
arrStr[4] = weightCursor.getString(2)
arrStr[5] = "kg"
csvWrite.writeNext(arrStr)
}
csvWrite.close()
setCursor.close()
weightCursor.close()
}
}
fun exportPlans(target: String, context: Context) {
Log.d("DatabaseHelper", "exportPlans $target")
val treeUri: Uri = Uri.parse(target)
val documentFile = context.let { DocumentFile.fromTreeUri(it, treeUri) }
val file = documentFile?.createFile("application/octet-stream", "plans.csv") ?: return
context.contentResolver.openOutputStream(file.uri).use { outputStream ->
val csvWrite = CSVWriter(outputStream?.writer())
val db = this.readableDatabase
val cursor = db.rawQuery("SELECT * FROM plans", null)
csvWrite.writeNext(cursor.columnNames)
while(cursor.moveToNext()) {
val arrStr = arrayOfNulls<String>(cursor.columnCount)
for(i in 0 until cursor.columnCount) {
arrStr[i] = cursor.getString(i)
}
csvWrite.writeNext(arrStr)
}
csvWrite.close()
cursor.close()
}
}
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

@ -1,31 +1,33 @@
package com.massive package com.massive
import android.os.Bundle
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() {
/** override fun getMainComponentName(): String? {
* Returns the name of the main component registered from JavaScript. This is used to schedule return "massive"
* rendering of the component. }
*/
override fun getMainComponentName(): String = "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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(null) 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,45 +1,70 @@
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.ReactHost import com.facebook.react.config.ReactFeatureFlags
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.flipper.ReactNativeFlipper
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 {
override val reactNativeHost: ReactNativeHost = private val mReactNativeHost: ReactNativeHost = object : ReactNativeHost(this) {
object : DefaultReactNativeHost(this) { override fun getUseDeveloperSupport(): Boolean {
override fun getPackages(): List<ReactPackage> = return BuildConfig.DEBUG
PackageList(this).packages.apply {
add(MassivePackage())
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
override fun getJSMainModuleName(): String = "index"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
} }
override val reactHost: ReactHost override fun getPackages(): List<ReactPackage> {
get() = getDefaultReactHost(this.applicationContext, reactNativeHost) val packages: MutableList<ReactPackage> = PackageList(this).packages
packages.add(MassivePackage())
return packages
}
override fun getJSMainModuleName(): String {
return "index"
}
}
private val mNewArchitectureNativeHost: ReactNativeHost = MainApplicationReactNativeHost(this)
override fun getReactNativeHost(): ReactNativeHost {
return if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
mNewArchitectureNativeHost
} else {
mReactNativeHost
}
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ReactFeatureFlags.useTurboModules = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
SoLoader.init(this, false) SoLoader.init(this, false)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { initializeFlipper(this, reactNativeHost.reactInstanceManager)
// If you opted-in for the New Architecture, we load the native entry point for this app.
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(promise: Promise) {
val packageName = reactApplicationContext.packageName
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
promise.resolve(pm.isIgnoringBatteryOptimizations(packageName))
} else {
promise.resolve(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

@ -1,11 +1,17 @@
package com.massive package com.massive
import android.app.Activity import android.app.Activity
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.AttributeSet
import android.util.Log import android.util.Log
import android.view.View
import android.view.WindowManager
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.massive.AlarmService
import com.massive.MainActivity
class StopAlarm : Activity() { class StopAlarm : Activity() {
@RequiresApi(Build.VERSION_CODES.O_MR1) @RequiresApi(Build.VERSION_CODES.O_MR1)
@ -13,7 +19,7 @@ class StopAlarm : Activity() {
Log.d("AlarmActivity", "Call to AlarmActivity") Log.d("AlarmActivity", "Call to AlarmActivity")
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val context = applicationContext val context = applicationContext
context.stopService(Intent(context, TimerService::class.java)) context.stopService(Intent(context, AlarmService::class.java))
savedInstanceState.apply { setShowWhenLocked(true) } savedInstanceState.apply { setShowWhenLocked(true) }
val intent = Intent(context, MainActivity::class.java) val intent = Intent(context, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK

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,171 +0,0 @@
package com.massive
import android.app.AlarmManager
import android.app.AlarmManager.ELAPSED_REALTIME_WAKEUP
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.SystemClock
import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
import android.widget.Toast
import androidx.annotation.RequiresApi
@RequiresApi(Build.VERSION_CODES.O)
class Timer(private var msTimerDuration: Long) {
enum class State {
Running,
Paused,
Expired
}
fun start(context: Context) {
if (state != State.Paused) return
endTime = SystemClock.elapsedRealtime() + msTimerDuration
registerPendingIntent(context)
state = State.Running
}
fun stop(context: Context) {
if (state != State.Running) return
msTimerDuration = endTime - SystemClock.elapsedRealtime()
unregisterPendingIntent(context)
state = State.Paused
}
fun expire() {
state = State.Expired
msTimerDuration = 0
totalTimerDuration = 0
}
fun getRemainingSeconds(): Int {
return (getRemainingMillis() / 1000).toInt()
}
fun increaseDuration(context: Context, milli: Long) {
val wasRunning = isRunning()
if (wasRunning) stop(context)
msTimerDuration += milli
totalTimerDuration += milli
if (wasRunning) start(context)
}
fun isExpired(): Boolean {
return state == State.Expired
}
fun getDurationSeconds(): Int {
return (totalTimerDuration / 1000).toInt()
}
fun getRemainingMillis(): Long {
return if (state == State.Running) endTime - SystemClock.elapsedRealtime()
else
msTimerDuration
}
private fun isRunning(): Boolean {
return state == State.Running
}
private fun requestPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
val intent = Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
intent.data = Uri.parse("package:" + context.packageName)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
return try {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context2: Context?, intent: Intent?) {
context.unregisterReceiver(this)
registerPendingIntent(context)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(
receiver,
IntentFilter(AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED),
Context.RECEIVER_NOT_EXPORTED
)
} else {
context.registerReceiver(
receiver,
IntentFilter(AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED)
)
}
context.startActivity(intent)
false
} catch (e: ActivityNotFoundException) {
Toast.makeText(
context,
"Request for SCHEDULE_EXACT_ALARM rejected on your device",
Toast.LENGTH_LONG
).show()
false
}
}
private fun incorrectPermissions(context: Context, alarmManager: AlarmManager): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& !alarmManager.canScheduleExactAlarms()
&& !requestPermission(context)
}
private fun getAlarmManager(context: Context): AlarmManager {
return context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
}
private fun unregisterPendingIntent(context: Context) {
val intent = Intent(context, TimerService::class.java)
.setAction(TimerService.TIMER_EXPIRED)
val pendingIntent = PendingIntent.getService(
context,
0,
intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = getAlarmManager(context)
if (incorrectPermissions(context, alarmManager)) return
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
}
private fun registerPendingIntent(context: Context) {
val intent = Intent(context, TimerService::class.java)
.setAction(TimerService.TIMER_EXPIRED)
val pendingIntent = PendingIntent.getService(
context,
0,
intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = getAlarmManager(context)
if (incorrectPermissions(context, alarmManager)) return
alarmManager.setExactAndAllowWhileIdle(
ELAPSED_REALTIME_WAKEUP,
endTime,
pendingIntent
)
}
private var endTime: Long = 0
private var totalTimerDuration: Long = msTimerDuration
private var state: State = State.Paused
companion object {
fun emptyTimer(): Timer {
return Timer(0)
}
const val ONE_MINUTE_MILLI: Long = 60000
}
}

View File

@ -1,30 +0,0 @@
package com.massive
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
import androidx.core.app.NotificationManagerCompat
class TimerDone : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_timer_done)
Log.d("TimerDone", "Rendered.")
}
@RequiresApi(Build.VERSION_CODES.O)
@Suppress("UNUSED_PARAMETER")
fun stop(view: View) {
Log.d("TimerDone", "Stopping...")
applicationContext.stopService(Intent(applicationContext, TimerService::class.java))
val manager = NotificationManagerCompat.from(this)
manager.cancel(TimerService.ONGOING_ID)
val intent = Intent(applicationContext, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
applicationContext.startActivity(intent)
}
}

View File

@ -1,358 +1,167 @@
package com.massive package com.massive
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.* import android.app.*
import android.content.ActivityNotFoundException import android.app.NotificationManager.IMPORTANCE_HIGH
import android.content.BroadcastReceiver import android.app.NotificationManager.IMPORTANCE_LOW
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri import android.net.Uri
import android.os.* import android.os.Build
import android.os.CountDownTimer
import android.os.IBinder
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import kotlin.math.floor
class Settings(val sound: String?, val noSound: Boolean, val vibrate: Boolean, val duration: Long) class TimerService() : Service() {
private var manager: NotificationManager? = null
@RequiresApi(Build.VERSION_CODES.O) private var countdownTimer: CountDownTimer? = null
class TimerService : Service() { private var endMs: Int = 0
private var currentMs: Long = 0
private lateinit var timerHandler: Handler private var vibrate: Boolean = true
private var timerRunnable: Runnable? = null private var sound: String? = null
private var timer: Timer = Timer.emptyTimer()
private var mediaPlayer: MediaPlayer? = null
private var vibrator: Vibrator? = null
private var currentDescription = ""
private val stopReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Log.d("TimerService", "Received stop broadcast intent")
timer.stop(applicationContext)
timer.expire()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private val addReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
timer.increaseDuration(applicationContext, Timer.ONE_MINUTE_MILLI)
updateNotification(timer.getRemainingSeconds())
mediaPlayer?.stop()
vibrator?.cancel()
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
override fun onCreate() {
super.onCreate()
timerHandler = Handler(Looper.getMainLooper())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
applicationContext.registerReceiver(
stopReceiver, IntentFilter(STOP_BROADCAST),
Context.RECEIVER_NOT_EXPORTED
)
applicationContext.registerReceiver(
addReceiver, IntentFilter(ADD_BROADCAST),
Context.RECEIVER_NOT_EXPORTED
)
} else {
applicationContext.registerReceiver(stopReceiver, IntentFilter(STOP_BROADCAST))
applicationContext.registerReceiver(addReceiver, IntentFilter(ADD_BROADCAST))
}
}
private fun onTimerStart(intent: Intent?) {
timerRunnable?.let { timerHandler.removeCallbacks(it) }
currentDescription = intent?.getStringExtra("description").toString()
timer.stop(applicationContext)
timer = Timer((intent?.getIntExtra("milliseconds", 0) ?: 0).toLong())
timer.start(applicationContext)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
ONGOING_ID,
getProgress(timer.getRemainingSeconds()).build(),
FOREGROUND_SERVICE_TYPE_SPECIAL_USE
)
} else {
startForeground(ONGOING_ID, getProgress(timer.getRemainingSeconds()).build())
}
battery()
Log.d("TimerService", "onTimerStart seconds=${timer.getDurationSeconds()}")
timerRunnable = object : Runnable {
override fun run() {
val startTime = SystemClock.elapsedRealtime()
if (timer.isExpired()) return
updateNotification(timer.getRemainingSeconds())
val delay = timer.getRemainingMillis() % 1000
timerHandler.postDelayed(this, if (SystemClock.elapsedRealtime() - startTime + delay > 980) 20 else delay)
}
}
timerHandler.postDelayed(timerRunnable!!, 20)
}
private fun onTimerExpired() {
Log.d("TimerService", "onTimerExpired duration=${timer.getDurationSeconds()}")
timer.expire()
val settings = getSettings()
vibrate(settings)
playSound(settings)
notifyFinished()
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null && intent.action == TIMER_EXPIRED) onTimerExpired() vibrate = intent!!.extras!!.getBoolean("vibrate")
else onTimerStart(intent) sound = intent.extras?.getString("sound")
return START_STICKY 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)
} }
override fun onDestroy() { private fun getTimer(builder: NotificationCompat.Builder): CountDownTimer {
super.onDestroy() return object : CountDownTimer(endMs.toLong(), 1000) {
timerRunnable?.let { timerHandler.removeCallbacks(it) } override fun onTick(current: Long) {
applicationContext.unregisterReceiver(stopReceiver) currentMs = current
applicationContext.unregisterReceiver(addReceiver) val seconds = floor((current / 1000).toDouble() % 60)
mediaPlayer?.stop() .toInt().toString().padStart(2, '0')
mediaPlayer?.release() val minutes = floor((current / 1000).toDouble() / 60)
vibrator?.cancel() .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(intent: Intent?): IBinder? { override fun onBind(p0: Intent?): IBinder? {
return null return null
} }
@SuppressLint("BatteryLife") override fun onDestroy() {
fun battery() { Log.d("TimerService", "Destroying...")
val powerManager = countdownTimer?.cancel()
applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager manager?.cancel(NOTIFICATION_ID_PENDING)
val ignoring = manager?.cancel(NOTIFICATION_ID_DONE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) super.onDestroy()
powerManager.isIgnoringBatteryOptimizations(
applicationContext.packageName
)
else true
if (ignoring) return
val intent = Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.parse("package:" + applicationContext.packageName)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
try {
applicationContext.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(
applicationContext,
"Requests to ignore battery optimizations are disabled on your device.",
Toast.LENGTH_LONG
).show()
}
} }
@SuppressLint("Range") @SuppressLint("UnspecifiedImmutableFlag")
private fun getSettings(): Settings { @RequiresApi(Build.VERSION_CODES.M)
val db = DatabaseHelper(applicationContext).readableDatabase private fun getBuilder(context: Context): NotificationCompat.Builder {
val cursor = db.rawQuery("SELECT sound, noSound, vibrate, duration FROM settings", null) val contentIntent = Intent(context, MainActivity::class.java)
cursor.moveToFirst() val pendingContent =
val sound = cursor.getString(cursor.getColumnIndex("sound")) PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
val noSound = cursor.getInt(cursor.getColumnIndex("noSound")) == 1 val stopIntent = Intent(context, StopTimer::class.java)
val vibrate = cursor.getInt(cursor.getColumnIndex("vibrate")) == 1
var duration = cursor.getLong(cursor.getColumnIndex("duration"))
if (duration.toInt() == 0) duration = 300
cursor.close()
return Settings(sound, noSound, vibrate, duration)
}
private fun playSound(settings: Settings) {
if (settings.noSound) return
if (settings.sound == null) {
mediaPlayer = MediaPlayer.create(applicationContext, R.raw.argon)
mediaPlayer?.start()
mediaPlayer?.setOnCompletionListener { vibrator?.cancel() }
} else {
mediaPlayer = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setDataSource(applicationContext, Uri.parse(settings.sound))
prepare()
start()
setOnCompletionListener { vibrator?.cancel() }
}
}
}
private fun getProgress(timeLeftInSeconds: Int): NotificationCompat.Builder {
val notificationText = formatTime(timeLeftInSeconds)
val notificationChannelId = "timer_channel"
val notificationIntent = Intent(this, MainActivity::class.java)
val contentPending = PendingIntent.getActivity(
this,
0,
notificationIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val stopBroadcast = Intent(STOP_BROADCAST)
stopBroadcast.setPackage(applicationContext.packageName)
val stopPending =
PendingIntent.getBroadcast(
applicationContext,
0,
stopBroadcast,
PendingIntent.FLAG_IMMUTABLE
)
val addBroadcast =
Intent(ADD_BROADCAST).apply { setPackage(applicationContext.packageName) }
val addPending =
PendingIntent.getBroadcast(
applicationContext,
0,
addBroadcast,
PendingIntent.FLAG_MUTABLE
)
val notificationBuilder = NotificationCompat.Builder(this, notificationChannelId)
.setContentTitle(currentDescription)
.setContentText(notificationText)
.setSmallIcon(R.drawable.ic_baseline_timer_24)
.setProgress(timer.getDurationSeconds(), timeLeftInSeconds, false)
.setContentIntent(contentPending)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setAutoCancel(false)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setDeleteIntent(stopPending)
.addAction(R.drawable.ic_baseline_stop_24, "Stop", stopPending)
.addAction(R.drawable.ic_baseline_stop_24, "Add 1 min", addPending)
val notificationManager = NotificationManagerCompat.from(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
notificationChannelId,
"Timer Channel",
NotificationManager.IMPORTANCE_LOW
)
notificationManager.createNotificationChannel(channel)
}
return notificationBuilder
}
private fun vibrate(settings: Settings) {
if (!settings.vibrate) return
val pattern =
longArrayOf(0, settings.duration, 1000, settings.duration, 1000, settings.duration)
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager =
getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(VIBRATOR_SERVICE) as Vibrator
}
vibrator!!.vibrate(VibrationEffect.createWaveform(pattern, 2))
}
private fun notifyFinished() {
val channelId = "finished_channel"
val notificationManager = NotificationManagerCompat.from(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
channelId,
"Timer Finished Channel",
NotificationManager.IMPORTANCE_HIGH
)
channel.setSound(null, null)
channel.setBypassDnd(true)
channel.enableVibration(false)
channel.description = "Plays an alarm when a rest timer completes."
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
notificationManager.createNotificationChannel(channel)
}
val fullIntent = Intent(applicationContext, TimerDone::class.java)
val fullPending = PendingIntent.getActivity(
applicationContext, 0, fullIntent, PendingIntent.FLAG_MUTABLE
)
val finishIntent = Intent(applicationContext, StopAlarm::class.java)
val finishPending = PendingIntent.getActivity(
applicationContext, 0, finishIntent, PendingIntent.FLAG_IMMUTABLE
)
val stopBroadcast = Intent(STOP_BROADCAST)
stopBroadcast.setPackage(applicationContext.packageName)
val pendingStop = val pendingStop =
PendingIntent.getBroadcast( PendingIntent.getService(context, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
applicationContext, val addIntent = Intent(context, TimerService::class.java)
0, addIntent.action = "add"
stopBroadcast, addIntent.putExtra("vibrate", vibrate)
PendingIntent.FLAG_IMMUTABLE addIntent.putExtra("sound", sound)
) addIntent.data = Uri.parse("$currentMs")
val pendingAdd = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val builder = NotificationCompat.Builder(this, channelId) PendingIntent.getService(context, 0, addIntent, PendingIntent.FLAG_MUTABLE)
.setContentTitle("Timer finished") } else {
.setContentText(currentDescription) PendingIntent.getService(context, 0, addIntent, PendingIntent.FLAG_UPDATE_CURRENT)
.setSmallIcon(R.drawable.ic_baseline_timer_24)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(finishPending)
.setFullScreenIntent(fullPending, true)
.setAutoCancel(true)
.setDeleteIntent(pendingStop)
if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
} }
notificationManager.notify(FINISHED_ID, builder.build()) 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)
} }
private fun updateNotification(seconds: Int) { @RequiresApi(Build.VERSION_CODES.O)
val notificationManager = NotificationManagerCompat.from(this) private fun getManager(context: Context): NotificationManager {
val notification = getProgress(seconds) val alarmsChannel = NotificationChannel(
if (ActivityCompat.checkSelfPermission( CHANNEL_ID_DONE,
this, CHANNEL_ID_DONE,
Manifest.permission.POST_NOTIFICATIONS IMPORTANCE_HIGH
) != PackageManager.PERMISSION_GRANTED )
) { alarmsChannel.description = "Alarms for rest timers."
return alarmsChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
} val notificationManager = context.getSystemService(
notificationManager.notify(ONGOING_ID, notification.build()) NotificationManager::class.java
} )
notificationManager.createNotificationChannel(alarmsChannel)
private fun formatTime(timeInSeconds: Int): String { val timersChannel =
val minutes = timeInSeconds / 60 NotificationChannel(CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, IMPORTANCE_LOW)
val seconds = timeInSeconds % 60 timersChannel.setSound(null, null)
return String.format("%02d:%02d", minutes, seconds) timersChannel.description = "Progress on rest timers."
notificationManager.createNotificationChannel(timersChannel)
return notificationManager
} }
companion object { companion object {
const val STOP_BROADCAST = "stop-timer-event" private const val CHANNEL_ID_PENDING = "Timer"
const val ADD_BROADCAST = "add-timer-event" private const val CHANNEL_ID_DONE = "Alarm"
const val TIMER_EXPIRED = "timer-expired-event" private const val NOTIFICATION_ID_PENDING = 1
const val ONGOING_ID = 1 private const val NOTIFICATION_ID_DONE = 2
const val FINISHED_ID = 1
} }
} }

View File

@ -5,6 +5,70 @@
android:viewportHeight="108" android:viewportHeight="108"
android:viewportWidth="108" android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android"> xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#1d1f21" <path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/> android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector> </vector>

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 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

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,25 +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 = "34.0.0" kotlin_version = '1.6.10'
buildToolsVersion = "31.0.0"
minSdkVersion = 21 minSdkVersion = 21
compileSdkVersion = 34 compileSdkVersion = 31
targetSdkVersion = 34 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 = "25.1.8937393" // For M1 Users we need to use the NDK 24 which added support for aarch64
kotlinVersion = "1.8.0" 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") 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
} }
} }
apply plugin: "com.facebook.react.rootproject" 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

@ -24,6 +24,9 @@ android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX # Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true android.enableJetifier=true
# Version of flipper SDK to use with React Native
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
# ./gradlew <task> -PreactNativeArchitectures=x86_64 # ./gradlew <task> -PreactNativeArchitectures=x86_64
@ -35,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.

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