Compare commits

..

5 Commits

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

View File

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

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

8
.prettierrc.js Normal file
View File

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

175
App.tsx
View File

@ -1,22 +1,23 @@
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 {useEffect, useMemo, useState} from 'react'
import {DeviceEventEmitter, useColorScheme} from 'react-native'
import React from 'react'
import { import {
MD3DarkTheme as PaperDarkTheme, DarkTheme as PaperDarkTheme,
MD3LightTheme as PaperDefaultTheme, DefaultTheme as PaperDefaultTheme,
Provider as PaperProvider, Provider as PaperProvider,
} from "react-native-paper"; Snackbar,
import MaterialIcon from "react-native-vector-icons/MaterialCommunityIcons"; } from 'react-native-paper'
import AppSnack from "./AppSnack"; import MaterialIcon from 'react-native-vector-icons/MaterialIcons'
import AppStack from "./AppStack"; import {AppDataSource} from './data-source'
import FatalError from "./FatalError"; import {settingsRepo} from './db'
import { AppDataSource } from "./data-source"; import Routes from './Routes'
import { settingsRepo } from "./db"; import {TOAST} from './toast'
import { ThemeContext } from "./use-theme"; import {ThemeContext} from './use-theme'
export const CombinedDefaultTheme = { export const CombinedDefaultTheme = {
...NavigationDefaultTheme, ...NavigationDefaultTheme,
@ -25,7 +26,7 @@ export const CombinedDefaultTheme = {
...NavigationDefaultTheme.colors, ...NavigationDefaultTheme.colors,
...PaperDefaultTheme.colors, ...PaperDefaultTheme.colors,
}, },
}; }
export const CombinedDarkTheme = { export const CombinedDarkTheme = {
...NavigationDarkTheme, ...NavigationDarkTheme,
@ -34,92 +35,98 @@ export const CombinedDarkTheme = {
...NavigationDarkTheme.colors, ...NavigationDarkTheme.colors,
...PaperDarkTheme.colors, ...PaperDarkTheme.colors,
}, },
}; }
const App = () => { const App = () => {
console.log("Re rendered app"); const isDark = useColorScheme() === 'dark'
const systemTheme = useColorScheme(); const [initialized, setInitialized] = useState(false)
const [snackbar, setSnackbar] = useState('')
const [theme, setTheme] = useState('system')
const [appSettings, setAppSettings] = useState({ const [lightColor, setLightColor] = useState<string>(
startup: undefined, CombinedDefaultTheme.colors.primary,
theme: "system", )
lightColor: CombinedDefaultTheme.colors.primary,
darkColor: CombinedDarkTheme.colors.primary, const [darkColor, setDarkColor] = useState<string>(
}); CombinedDarkTheme.colors.primary,
const [error, setError] = useState(""); )
useEffect(() => { useEffect(() => {
(async () => { const init = async () => {
if (!AppDataSource.isInitialized) if (!AppDataSource.isInitialized) await AppDataSource.initialize()
await AppDataSource.initialize().catch((e) => setError(e.toString())); const settings = await settingsRepo.findOne({where: {}})
setTheme(settings.theme)
const gotSettings = await settingsRepo.findOne({ where: {} }); if (settings.lightColor) setLightColor(settings.lightColor)
console.log(`${App.name}.mount`, { gotSettings }); if (settings.darkColor) setDarkColor(settings.darkColor)
setAppSettings({ setInitialized(true)
startup: gotSettings.startup, }
theme: gotSettings.theme, init()
lightColor: const description = DeviceEventEmitter.addListener(
gotSettings.lightColor || CombinedDefaultTheme.colors.primary, TOAST,
darkColor: gotSettings.darkColor || CombinedDarkTheme.colors.primary, ({value}: {value: string}) => {
}); setSnackbar(value)
})(); },
}, []); )
return description.remove
}, [])
const paperTheme = useMemo(() => { const paperTheme = useMemo(() => {
const darkTheme = { const darkTheme = lightColor
...CombinedDarkTheme, ? {
colors: { ...CombinedDarkTheme,
...CombinedDarkTheme.colors, colors: {...CombinedDarkTheme.colors, primary: darkColor},
primary: appSettings.darkColor, }
}, : CombinedDarkTheme
}; const lightTheme = lightColor
const lightTheme = { ? {
...CombinedDefaultTheme, ...CombinedDefaultTheme,
colors: { colors: {...CombinedDefaultTheme.colors, primary: lightColor},
...CombinedDefaultTheme.colors, }
primary: appSettings.lightColor, : CombinedDefaultTheme
}, let value = isDark ? darkTheme : lightTheme
}; if (theme === 'dark') value = darkTheme
let theme = systemTheme === "dark" ? darkTheme : lightTheme; else if (theme === 'light') value = lightTheme
if (appSettings.theme === "dark") theme = darkTheme; return value
else if (appSettings.theme === "light") theme = lightTheme; }, [isDark, theme, lightColor, darkColor])
return theme;
}, [systemTheme, appSettings]); const action = useMemo(
() => ({
label: 'Close',
onPress: () => setSnackbar(''),
color: paperTheme.colors.background,
}),
[paperTheme.colors.background],
)
return ( return (
<PaperProvider <PaperProvider
theme={paperTheme} theme={paperTheme}
settings={{ icon: (props) => <MaterialIcon {...props} /> }} settings={{icon: props => <MaterialIcon {...props} />}}>
>
<NavigationContainer theme={paperTheme}> <NavigationContainer theme={paperTheme}>
{error && ( {initialized && (
<FatalError
message={error}
setAppSettings={setAppSettings}
setError={setError}
/>
)}
{appSettings.startup !== undefined && (
<ThemeContext.Provider <ThemeContext.Provider
value={{ value={{
theme: appSettings.theme, theme,
setTheme: (theme) => setAppSettings({ ...appSettings, theme }), setTheme,
lightColor: appSettings.lightColor, lightColor,
setLightColor: (color) => setLightColor,
setAppSettings({ ...appSettings, lightColor: color }), darkColor,
darkColor: appSettings.darkColor, setDarkColor,
setDarkColor: (color) => }}>
setAppSettings({ ...appSettings, darkColor: color }), <Routes />
}}
>
<AppStack startup={appSettings.startup} />
</ThemeContext.Provider> </ThemeContext.Provider>
)} )}
</NavigationContainer> </NavigationContainer>
<AppSnack textColor={paperTheme.colors.background} /> <Snackbar
duration={3000}
onDismiss={() => setSnackbar('')}
visible={!!snackbar}
action={action}>
{snackbar}
</Snackbar>
</PaperProvider> </PaperProvider>
); )
}; }
export default App; export default App

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

98
BestList.tsx Normal file
View File

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

22
BestPage.tsx Normal file
View File

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

68
Chart.tsx Normal file
View File

@ -0,0 +1,68 @@
import {useTheme} from '@react-navigation/native'
import * as shape from 'd3-shape'
import {View} from 'react-native'
import {Grid, LineChart, XAxis, YAxis} from 'react-native-svg-charts'
import {CombinedDarkTheme, CombinedDefaultTheme} from './App'
import {MARGIN, PADDING} from './constants'
import GymSet from './gym-set'
import useDark from './use-dark'
export default function Chart({
yData,
xFormat,
xData,
yFormat,
}: {
yData: number[]
xData: GymSet[]
xFormat: (value: any, index: number) => string
yFormat: (value: any) => string
}) {
const {colors} = useTheme()
const dark = useDark()
const axesSvg = {
fontSize: 10,
fill: dark
? CombinedDarkTheme.colors.text
: CombinedDefaultTheme.colors.text,
}
const verticalContentInset = {top: 10, bottom: 10}
const xAxisHeight = 30
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: colors.primary,
}}>
<Grid />
</LineChart>
<XAxis
data={xData}
formatLabel={xFormat}
contentInset={{left: 15, right: 16}}
svg={axesSvg}
/>
</View>
</View>
</>
)
}

View File

@ -1,4 +1,4 @@
import { Button, Dialog, Portal, Text } from "react-native-paper"; import {Button, Dialog, Portal, Text} from 'react-native-paper'
export default function ConfirmDialog({ export default function ConfirmDialog({
title, title,
@ -8,17 +8,17 @@ export default function ConfirmDialog({
setShow, setShow,
onCancel, onCancel,
}: { }: {
title: string; title: string
children: JSX.Element | JSX.Element[] | string; children: JSX.Element | JSX.Element[] | string
onOk: () => void; onOk: () => void
show: boolean; show: boolean
setShow: (show: boolean) => void; setShow: (show: boolean) => void
onCancel?: () => void; onCancel?: () => void
}) { }) {
const cancel = () => { const cancel = () => {
setShow(false); setShow(false)
onCancel && onCancel(); onCancel && onCancel()
}; }
return ( return (
<Portal> <Portal>
@ -33,5 +33,5 @@ export default function ConfirmDialog({
</Dialog.Actions> </Dialog.Actions>
</Dialog> </Dialog>
</Portal> </Portal>
); )
} }

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 +1,28 @@
import { DrawerNavigationProp } from "@react-navigation/drawer"; import {DrawerNavigationProp} from '@react-navigation/drawer'
import { useNavigation } from "@react-navigation/native"; import {useNavigation} from '@react-navigation/native'
import { Appbar, IconButton } from "react-native-paper"; import {Appbar, IconButton} from 'react-native-paper'
import { DrawerParams } from "./drawer-params"; import {DrawerParamList} from './drawer-param-list'
import useDark from './use-dark'
export default function DrawerHeader({ export default function DrawerHeader({
name, name,
children, children,
ids,
unSelect,
}: { }: {
name: string; name: keyof DrawerParamList
children?: JSX.Element | JSX.Element[]; children?: JSX.Element | JSX.Element[]
ids?: unknown[],
unSelect?: () => void,
}) { }) {
const navigation = useNavigation<DrawerNavigationProp<DrawerParams>>(); const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>()
const dark = useDark()
return ( return (
<Appbar.Header> <Appbar.Header>
{ids && ids.length > 0 ? (<IconButton icon="arrow-left" onPress={unSelect} />) : ( <IconButton
<IconButton icon="menu" onPress={navigation.openDrawer} /> color={dark ? 'white' : 'white'}
)} icon="menu"
onPress={navigation.openDrawer}
/>
<Appbar.Content title={name} /> <Appbar.Content title={name} />
{children} {children}
</Appbar.Header> </Appbar.Header>
); )
} }

View File

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

@ -3,228 +3,117 @@ import {
RouteProp, RouteProp,
useNavigation, useNavigation,
useRoute, useRoute,
} from "@react-navigation/native"; } from '@react-navigation/native'
import React, { useCallback, useEffect, useState } from "react"; import {useCallback, useEffect, useState} from 'react'
import { import {ScrollView, StyleSheet, View} from 'react-native'
FlatList, import {Button, Text} from 'react-native-paper'
Pressable, import {MARGIN, PADDING} from './constants'
ScrollView, import {planRepo, setRepo} from './db'
StyleSheet, import {DrawerParamList} from './drawer-param-list'
View, import {PlanPageParams} from './plan-page-params'
} from "react-native"; import StackHeader from './StackHeader'
import { import Switch from './Switch'
Button, import {DAYS} from './time'
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<PlanPageParams, 'EditPlan'>>()
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 } =
useNavigation<NavigationProp<DrawerParams>>();
const { navigate: stackNavigate } =
useNavigation<NavigationProp<StackParams>>();
useEffect(() => { useEffect(() => {
setRepo setRepo
.createQueryBuilder() .createQueryBuilder()
.select("name") .select('name')
.distinct(true) .distinct(true)
.orderBy("name")
.getRawMany() .getRawMany()
.then((values) => { .then(values => {
const newNames = values.map((value) => value.name); console.log(EditPlan.name, {values})
console.log(EditPlan.name, { newNames }); setNames(values.map(value => value.name))
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({ await planRepo.save({days: newDays, workouts: newWorkouts, id: plan.id})
title: title, navigation.goBack()
days: newDays, }, [days, workouts, plan, navigation])
exercises: newExercises,
id: plan.id,
});
if (saved.id === 1) toast("Tap your plan again to begin using it");
}, [title, days, exercises, plan]);
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(
(on: boolean, day: string) => { (on: boolean, day: string) => {
if (on) { if (on) {
setDays([...days, day]); setDays([...days, day])
} else { } else {
setDays(days.filter((d) => d !== day)); setDays(days.filter(d => d !== day))
} }
}, },
[setDays, days] [setDays, days],
); )
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 (
<> <>
<StackHeader <StackHeader title="Edit plan" />
title={typeof plan.id === "number" ? "Edit plan" : "Add plan"} <View style={{padding: PADDING, flex: 1}}>
> <ScrollView style={{flex: 1}}>
{typeof plan.id === "number" && ( <Text style={styles.title}>Days</Text>
<IconButton {DAYS.map(day => (
onPress={async () => { <Switch
await save(); key={day}
const newPlan = await planRepo.findOne({ onChange={value => toggleDay(value, day)}
where: { id: plan.id }, onPress={() => toggleDay(!days.includes(day), day)}
}); value={days.includes(day)}>
let first: Partial<GymSet> = await setRepo.findOne({ {day}
where: { name: exercises[0] }, </Switch>
order: { created: "desc" }, ))}
}); <Text style={[styles.title, {marginTop: MARGIN}]}>Workouts</Text>
if (!first) first = { ...defaultSet, name: exercises[0] }; {names.length === 0 ? (
delete first.id; <View>
stackNavigate("StartPlan", { plan: newPlan, first }); <Text>No workouts found.</Text>
}} </View>
icon="play" ) : (
/> names.map(name => (
)} <Switch
</StackHeader> key={name}
<ScrollView style={{ padding: PADDING, flex: 1 }}> onChange={value => toggleWorkout(value, name)}
<AppInput value={workouts.includes(name)}
label="Title" onPress={() => toggleWorkout(!workouts.includes(name), name)}>
value={title} {name}
placeholder={days.join(", ")} </Switch>
onChangeText={(value) => setTitle(value)} ))
/> )}
</ScrollView>
<Text style={styles.title}>Days</Text> <Button
{DAYS.map((day) => renderDay(day))} disabled={workouts.length === 0 && days.length === 0}
style={styles.button}
<Text style={[styles.title, { marginTop: MARGIN }]}>Exercises</Text> mode="contained"
{exercises.map((exercise, index) => icon="save"
renderExercise(exercise, index, true) onPress={save}>
)} Save
{names?.length === 0 && ( </Button>
<> </View>
<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>
<PrimaryButton
disabled={exercises.length === 0 && days.length === 0}
icon="content-save"
onPress={async () => {
await save();
drawerNavigate("Plans");
}}
style={{ margin: MARGIN }}
>
Save
</PrimaryButton>
</> </>
); )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@ -232,4 +121,5 @@ const styles = StyleSheet.create({
fontSize: 20, fontSize: 20,
marginBottom: MARGIN, marginBottom: MARGIN,
}, },
}); button: {},
})

View File

@ -1,370 +1,206 @@
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import { import {
NavigationProp,
RouteProp, RouteProp,
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
useRoute, useRoute,
} from "@react-navigation/native"; } from '@react-navigation/native'
import { format } from "date-fns"; import {format} from 'date-fns'
import { useCallback, useRef, useState } from "react"; import {useCallback, useRef, useState} from 'react'
import { NativeModules, TextInput, View } from "react-native"; import {NativeModules, TextInput, View} from 'react-native'
import DocumentPicker from "react-native-document-picker"; import DocumentPicker from 'react-native-document-picker'
import { import {Button, Card, TouchableRipple} from 'react-native-paper'
Button, import ConfirmDialog from './ConfirmDialog'
Card, import {MARGIN, PADDING} from './constants'
IconButton, import {getNow, setRepo, settingsRepo} from './db'
Menu, import GymSet from './gym-set'
TouchableRipple, import {HomePageParams} from './home-page-params'
} from "react-native-paper"; import MassiveInput from './MassiveInput'
import { check, PERMISSIONS, request, RESULTS } from "react-native-permissions"; import Settings from './settings'
import AppInput from "./AppInput"; import StackHeader from './StackHeader'
import { StackParams } from "./AppStack"; import {toast} from './toast'
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<HomePageParams, 'EditSet'>>()
const { set } = params; const {set} = params
const { navigate } = useNavigation<NavigationProp<DrawerParams>>(); const navigation = useNavigation()
const [settings, setSettings] = useState<Settings>({} as Settings); const [settings, setSettings] = useState<Settings>({} as Settings)
const [name, setName] = useState(set.name); const [name, setName] = useState(set.name)
const [reps, setReps] = useState(set.reps?.toString()); const [reps, setReps] = useState(set.reps?.toString())
const [weight, setWeight] = useState(set.weight?.toString()); const [weight, setWeight] = useState(set.weight?.toString())
const [newImage, setNewImage] = useState(set.image); const [newImage, setNewImage] = useState(set.image)
const [unit, setUnit] = useState(set.unit); const [unit, setUnit] = useState(set.unit)
const [showDelete, setShowDelete] = useState(false); const [showRemove, setShowRemove] = useState(false)
const [showMenu, setShowMenu] = useState(false); const [removeImage, setRemoveImage] = useState(false)
const [created, setCreated] = useState<Date>( const weightRef = useRef<TextInput>(null)
set.created ? new Date(set.created) : new Date() const repsRef = useRef<TextInput>(null)
); const unitRef = useRef<TextInput>(null)
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({ const [selection, setSelection] = useState({
start: 0, start: 0,
end: set.reps?.toString().length, end: set.reps?.toString().length,
}); })
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
settingsRepo.findOne({ where: {} }).then(gotSettings => { settingsRepo.findOne({where: {}}).then(setSettings)
setSettings(gotSettings); }, []),
console.log(`${EditSet.name}.focus:`, { gotSettings }) )
});
}, [])
);
const startTimer = useCallback( const startTimer = useCallback(
async (value: string) => { async (value: string) => {
if (!settings.alarm) return; if (!settings.alarm) return
const first = await setRepo.findOne({ where: { name: value } }); const first = await setRepo.findOne({where: {name: value}})
const milliseconds = const milliseconds =
(first?.minutes ?? 3) * 60 * 1000 + (first?.seconds ?? 0) * 1000; (first?.minutes ?? 3) * 60 * 1000 + (first?.seconds ?? 0) * 1000
console.log(`${EditSet.name}.timer:`, { milliseconds }); const {vibrate, sound, noSound} = settings
const canNotify = await check(PERMISSIONS.ANDROID.POST_NOTIFICATIONS); const args = [milliseconds, vibrate, sound, noSound]
if (canNotify === RESULTS.DENIED) NativeModules.AlarmModule.timer(...args)
await request(PERMISSIONS.ANDROID.POST_NOTIFICATIONS);
if (milliseconds) NativeModules.AlarmModule.timer(milliseconds, `${first.name}`);
}, },
[settings] [settings],
); )
const notify = (value: Partial<GymSet>) => { const added = useCallback(
if (!settings.notify) return navigate("History"); async (value: GymSet) => {
if ( startTimer(value.name)
value.weight > set.weight || console.log(`${EditSet.name}.add`, {set: value})
(value.reps > set.reps && value.weight === set.weight) if (!settings.notify) return
) { if (
toast("Great work King! That's a new record."); value.weight > set.weight ||
} (value.reps > set.reps && value.weight === set.weight)
}; )
toast("Great work King! That's a new record.")
const added = async (value: GymSet) => { },
console.log(`${EditSet.name}.added:`, value); [startTimer, set, settings],
startTimer(value.name); )
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!name) return; console.log(`${EditSet.name}.handleSubmit:`, {set, uri: newImage, name})
if (!name) return
let image = newImage
if (!newImage && !removeImage)
image = await setRepo.findOne({where: {name}}).then(s => s?.image)
let newWeight = Number(weight || 0); console.log(`${EditSet.name}.handleSubmit:`, {image})
let newUnit = unit; const [{now}] = await getNow()
if (settings.autoConvert && unit !== settings.autoConvert) { const saved = await setRepo.save({
newUnit = settings.autoConvert;
newWeight = convert(newWeight, unit, settings.autoConvert);
}
const newSet: Partial<GymSet> = {
id: set.id, id: set.id,
name, name,
reps: Number(reps || 0), created: set.created || now,
weight: newWeight, reps: Number(reps),
unit: newUnit, weight: Number(weight),
unit,
image,
minutes: Number(set.minutes ?? 3), minutes: Number(set.minutes ?? 3),
seconds: Number(set.seconds ?? 30), seconds: Number(set.seconds ?? 30),
sets: set.sets ?? 3, sets: set.sets ?? 3,
hidden: false, hidden: false,
}; })
if (typeof set.id !== 'number') added(saved)
newSet.image = newImage; navigation.goBack()
if (!newImage && !removeImage) { }
newSet.image = await setRepo
.findOne({ where: { name } })
.then((s) => s?.image);
}
if (createdDirty) newSet.created = created.toISOString();
if (typeof set.id !== "number") newSet.created = await getNow();
const saved = await setRepo.save(newSet);
notify(newSet);
if (typeof set.id !== "number") added(saved);
navigate("History");
};
const changeImage = useCallback(async () => { const changeImage = useCallback(async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({ const {fileCopyUri} = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images, type: DocumentPicker.types.images,
copyTo: "documentDirectory", copyTo: 'documentDirectory',
}); })
if (fileCopyUri) setNewImage(fileCopyUri); if (fileCopyUri) setNewImage(fileCopyUri)
}, []); }, [])
const handleRemove = useCallback(async () => { const handleRemove = useCallback(async () => {
setNewImage(""); setNewImage('')
setRemoveImage(true); setRemoveImage(true)
setShowRemoveImage(false); setShowRemove(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 (
<> <>
<StackHeader title={typeof set.id === "number" ? "Edit set" : "Add set"}> <StackHeader title="Edit set" />
{typeof set.id === "number" ? (
<IconButton onPress={() => setShowDelete(true)} icon="delete" />
) : null}
</StackHeader>
<View style={{ padding: PADDING, flex: 1 }}> <View style={{padding: PADDING, flex: 1}}>
<View> <MassiveInput
<AppInput label="Name"
label="Name" value={name}
value={name} onChangeText={setName}
onChangeText={setName} autoCorrect={false}
autoCorrect={false} autoFocus={!name}
autoFocus={!name} onSubmitEditing={() => repsRef.current?.focus()}
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> <MassiveInput
<AppInput label="Reps"
label="Reps" keyboardType="numeric"
keyboardType="numeric" value={reps}
value={reps} onChangeText={setReps}
onChangeText={(newReps) => { onSubmitEditing={() => weightRef.current?.focus()}
const fixed = fixNumeric(newReps); selection={selection}
setReps(fixed.replace(/-/g, '')) onSelectionChange={e => setSelection(e.nativeEvent.selection)}
if (fixed.length !== newReps.length) autoFocus={!!name}
toast("Reps must be a number"); innerRef={repsRef}
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> <MassiveInput
<AppInput label="Weight"
label="Weight" keyboardType="numeric"
keyboardType="numeric" value={weight}
value={weight} onChangeText={setWeight}
onChangeText={(newWeight) => { onSubmitEditing={handleSubmit}
const fixed = fixNumeric(newWeight); innerRef={weightRef}
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 && ( {settings.showUnit && (
<Select <MassiveInput
value={unit} autoCapitalize="none"
onChange={setUnit}
items={[
{ label: "kg", value: "kg" },
{ label: "lb", value: "lb" },
{ label: "stone", value: "stone" },
]}
label="Unit" label="Unit"
value={unit}
onChangeText={setUnit}
innerRef={unitRef}
/> />
)} )}
{settings.showDate && ( {typeof set.id === 'number' && settings.showDate && (
<AppInput <MassiveInput
label="Created" label="Created"
value={format(created, settings.date || "Pp")} disabled
onPressOut={pickDate} value={format(new Date(set.created), settings.date)}
/> />
)} )}
{settings.images && newImage && ( {settings.images && newImage && (
<TouchableRipple <TouchableRipple
style={{ marginBottom: MARGIN }} style={{marginBottom: MARGIN}}
onPress={changeImage} onPress={changeImage}
onLongPress={() => setShowRemoveImage(true)} onLongPress={() => setShowRemove(true)}>
> <Card.Cover source={{uri: newImage}} />
<Card.Cover source={{ uri: newImage }} />
</TouchableRipple> </TouchableRipple>
)} )}
{settings.images && !newImage && ( {settings.images && !newImage && (
<Button <Button
style={{ marginBottom: MARGIN }} style={{marginBottom: MARGIN}}
onPress={changeImage} onPress={changeImage}
icon="image-plus" icon="add-photo-alternate">
>
Image Image
</Button> </Button>
)} )}
</View> </View>
<PrimaryButton <Button
disabled={!name} disabled={!name}
icon="content-save" mode="contained"
style={{ margin: MARGIN }} icon="save"
onPress={handleSubmit} style={{margin: MARGIN}}
> onPress={handleSubmit}>
Save Save
</PrimaryButton> </Button>
<ConfirmDialog <ConfirmDialog
title="Remove image" title="Remove image"
onOk={handleRemove} onOk={handleRemove}
show={showRemoveImage} show={showRemove}
setShow={setShowRemoveImage} setShow={setShowRemove}>
>
Are you sure you want to remove the image? Are you sure you want to remove the image?
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog
title="Delete set"
show={showDelete}
onOk={remove}
setShow={setShowDelete}
>
<>Are you sure you want to delete {name}</>
</ConfirmDialog>
</> </>
); )
} }

View File

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

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

189
EditWorkout.tsx Normal file
View File

@ -0,0 +1,189 @@
import {
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native'
import {useCallback, useRef, useState} from 'react'
import {ScrollView, TextInput, View} from 'react-native'
import DocumentPicker from 'react-native-document-picker'
import {Button, Card, TouchableRipple} from 'react-native-paper'
import ConfirmDialog from './ConfirmDialog'
import {MARGIN, PADDING} from './constants'
import {getNow, planRepo, setRepo, settingsRepo} from './db'
import {defaultSet} from './gym-set'
import MassiveInput from './MassiveInput'
import Settings from './settings'
import StackHeader from './StackHeader'
import {WorkoutsPageParams} from './WorkoutsPage'
export default function EditWorkout() {
const {params} = useRoute<RouteProp<WorkoutsPageParams, 'EditWorkout'>>()
const [removeImage, setRemoveImage] = useState(false)
const [showRemove, setShowRemove] = useState(false)
const [name, setName] = useState(params.value.name)
const [steps, setSteps] = useState(params.value.steps)
const [uri, setUri] = useState(params.value.image)
const [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 navigation = useNavigation()
const setsRef = useRef<TextInput>(null)
const stepsRef = useRef<TextInput>(null)
const minutesRef = useRef<TextInput>(null)
const secondsRef = useRef<TextInput>(null)
const [settings, setSettings] = useState<Settings>()
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({where: {}}).then(setSettings)
}, []),
)
const update = async () => {
await setRepo.update(
{name: params.value.name},
{
name: name || params.value.name,
sets: Number(sets),
minutes: +minutes,
seconds: +seconds,
steps,
image: removeImage ? '' : uri,
},
)
await planRepo.query(
`UPDATE plans
SET workouts = REPLACE(workouts, $1, $2)
WHERE workouts LIKE $3`,
[params.value.name, name, `%${params.value.name}%`],
)
navigation.goBack()
}
const add = async () => {
const [{now}] = await getNow()
await setRepo.save({
...defaultSet,
name,
hidden: true,
image: uri,
minutes: minutes ? +minutes : 3,
seconds: seconds ? +seconds : 30,
sets: sets ? +sets : 3,
steps,
created: now,
})
navigation.goBack()
}
const save = async () => {
if (params.value.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)
setShowRemove(false)
}, [])
const submitName = () => {
if (settings.steps) stepsRef.current?.focus()
else setsRef.current?.focus()
}
return (
<>
<StackHeader title="Edit workout" />
<View style={{padding: PADDING, flex: 1}}>
<ScrollView style={{flex: 1}}>
<MassiveInput
autoFocus
label="Name"
value={name}
onChangeText={setName}
onSubmitEditing={submitName}
/>
{settings?.steps && (
<MassiveInput
innerRef={stepsRef}
selectTextOnFocus={false}
value={steps}
onChangeText={setSteps}
label="Steps"
multiline
onSubmitEditing={() => setsRef.current?.focus()}
/>
)}
<MassiveInput
innerRef={setsRef}
value={sets}
onChangeText={setSets}
label="Sets per workout"
keyboardType="numeric"
onSubmitEditing={() => minutesRef.current?.focus()}
/>
{settings?.alarm && (
<>
<MassiveInput
innerRef={minutesRef}
onSubmitEditing={() => secondsRef.current?.focus()}
value={minutes}
onChangeText={setMinutes}
label="Rest minutes"
keyboardType="numeric"
/>
<MassiveInput
innerRef={secondsRef}
value={seconds}
onChangeText={setSeconds}
label="Rest seconds"
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="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,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>
</>
);
}

18
HomePage.tsx Normal file
View File

@ -0,0 +1,18 @@
import {createStackNavigator} from '@react-navigation/stack'
import EditSet from './EditSet'
import EditSets from './EditSets'
import {HomePageParams} from './home-page-params'
import SetList from './SetList'
const Stack = createStackNavigator<HomePageParams>()
export default function HomePage() {
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
<Stack.Screen name="Sets" component={SetList} />
<Stack.Screen name="EditSet" component={EditSet} />
<Stack.Screen name="EditSets" component={EditSets} />
</Stack.Navigator>
)
}

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

25
LabelledButton.tsx Normal file
View File

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

View File

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

30
MassiveFab.tsx Normal file
View File

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

25
MassiveInput.tsx Normal file
View File

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

View File

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

View File

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

View File

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

20
PlanPage.tsx Normal file
View File

@ -0,0 +1,20 @@
import {createStackNavigator} from '@react-navigation/stack'
import EditPlan from './EditPlan'
import EditSet from './EditSet'
import {PlanPageParams} from './plan-page-params'
import PlanList from './PlanList'
import StartPlan from './StartPlan'
const Stack = createStackNavigator<PlanPageParams>()
export default function PlanPage() {
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
<Stack.Screen name="PlanList" component={PlanList} />
<Stack.Screen name="EditPlan" component={EditPlan} />
<Stack.Screen name="StartPlan" component={StartPlan} />
<Stack.Screen name="EditSet" component={EditSet} />
</Stack.Navigator>
)
}

View File

@ -1,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,13 @@ 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/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 +40,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

BIN
README.md.pdf Normal file

Binary file not shown.

57
Routes.tsx Normal file
View File

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

View File

@ -1,75 +1,70 @@
import React, { useCallback, useMemo, useState } from "react"; import {useCallback, useMemo, useState} from 'react'
import { Pressable, View } from "react-native"; import {View} from 'react-native'
import { IconButton, Menu, useTheme } from "react-native-paper"; import {Button, Menu, Subheading, useTheme} from 'react-native-paper'
import AppInput from "./AppInput"; import {ITEM_PADDING} from './constants'
export interface Item { export interface Item {
value: string; value: string
label: string; label: string
color?: string; color?: string
icon?: string;
} }
function Select({ export default function Select({
value, value,
onChange, onChange,
items, items,
label, label,
}: { }: {
value: string; value: string
onChange: (value: string) => void; onChange: (value: string) => void
items: Item[]; items: Item[]
label?: string; label?: string
}) { }) {
const [show, setShow] = useState(false); const [show, setShow] = useState(false)
const { colors } = useTheme(); const {colors} = useTheme()
let menuButton: React.Ref<View> = null;
const selected = useMemo( const selected = useMemo(
() => items.find((item) => item.value === value) || items[0], () => items.find(item => item.value === value) || items[0],
[items, value] [items, value],
); )
const press = useCallback( const handlePress = useCallback(
(newValue: string) => { (newValue: string) => {
onChange(newValue); onChange(newValue)
setShow(false); setShow(false)
}, },
[onChange] [onChange],
); )
return ( return (
<Menu <View
visible={show} style={{
onDismiss={() => setShow(false)} flexDirection: 'row',
anchor={ alignItems: 'center',
<View> paddingLeft: ITEM_PADDING,
<Pressable onPress={() => setShow(true)}> }}>
<AppInput label={label} value={selected.label} editable={false} /> {label && <Subheading style={{width: 100}}>{label}</Subheading>}
</Pressable> <Menu
<View visible={show}
style={{ position: "absolute", right: 0, flexDirection: "row" }} onDismiss={() => setShow(false)}
> anchor={
<IconButton <Button
ref={menuButton} onPress={() => setShow(true)}
icon="menu-down" style={{
onPress={() => setShow(true)} alignSelf: 'flex-start',
/> }}>
</View> {selected?.label}
</View> </Button>
} }>
> {items.map(item => (
{items.map((item) => ( <Menu.Item
<Menu.Item key={item.value}
title={item.label} titleStyle={{color: item.color || colors.text}}
key={item.value} title={item.label}
onPress={() => press(item.value)} onPress={() => handlePress(item.value)}
titleStyle={{ color: item.color || colors.onSurface }} />
leadingIcon={item.icon} ))}
/> </Menu>
))} </View>
</Menu> )
);
} }
export default React.memo(Select);

View File

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

View File

@ -2,171 +2,135 @@ import {
NavigationProp, NavigationProp,
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
} from "@react-navigation/native"; } from '@react-navigation/native'
import { useCallback, useState } from "react"; import {useCallback, useState} from 'react'
import { FlatList } from "react-native"; import {FlatList} from 'react-native'
import { List } from "react-native-paper"; import {List} from 'react-native-paper'
import { Like } from "typeorm"; import {Like} from 'typeorm'
import { StackParams } from "./AppStack"; import {getNow, setRepo, settingsRepo} from './db'
import DrawerHeader from "./DrawerHeader"; import DrawerHeader from './DrawerHeader'
import ListMenu from "./ListMenu"; import GymSet, {defaultSet} from './gym-set'
import Page from "./Page"; import {HomePageParams} from './home-page-params'
import SetItem from "./SetItem"; import ListMenu from './ListMenu'
import { LIMIT } from "./constants"; import Page from './Page'
import { getNow, setRepo, settingsRepo } from "./db"; import SetItem from './SetItem'
import GymSet, { defaultSet } from "./gym-set"; import Settings from './settings'
import Settings from "./settings";
const limit = 15
export default function SetList() { export default function SetList() {
const [refreshing, setRefreshing] = useState(false); const [sets, setSets] = useState<GymSet[]>([])
const [sets, setSets] = useState<GymSet[]>(); const [offset, setOffset] = useState(0)
const [offset, setOffset] = useState(0); const [term, setTerm] = useState('')
const [end, setEnd] = useState(false); const [end, setEnd] = useState(false)
const [settings, setSettings] = useState<Settings>(); const [settings, setSettings] = useState<Settings>()
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([])
const navigation = useNavigation<NavigationProp<StackParams>>(); const navigation = useNavigation<NavigationProp<HomePageParams>>()
const [term, setTerm] = useState("");
const reset = useCallback( const refresh = useCallback(async (value: string) => {
async (value: string) => { const newSets = await setRepo.find({
const newSets = await setRepo.find({ where: {name: Like(`%${value}%`), hidden: 0 as any},
where: { name: Like(`%${value.trim()}%`), hidden: 0 as any }, take: limit,
take: LIMIT, skip: 0,
skip: 0, order: {created: 'DESC'},
order: { created: "DESC" }, })
}); console.log(`${SetList.name}.refresh:`, {
setSets(newSets); value,
console.log(`${SetList.name}.reset:`, { value, offset }); limit,
setEnd(false); length: newSets.length,
}, })
[offset] setSets(newSets)
); setOffset(0)
setEnd(false)
}, [])
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
console.log(`${SetList.name}.focus:`, { term }); refresh(term)
settingsRepo.findOne({ where: {} }).then(setSettings); settingsRepo.findOne({where: {}}).then(setSettings)
reset(term); }, [refresh, 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( const renderItem = useCallback(
({ item }: { item: GymSet }) => ( ({item}: {item: GymSet}) => (
<SetItem <SetItem
settings={settings} settings={settings}
item={item} item={item}
key={item.id} key={item.id}
onRemove={() => refresh(term)}
ids={ids} ids={ids}
setIds={setIds} setIds={setIds}
/> />
), ),
[settings, ids] [refresh, term, settings, ids],
); )
const next = async () => { const next = useCallback(async () => {
console.log(`${SetList.name}.next:`, { end, refreshing }); if (end) return
if (end || refreshing) return; const newOffset = offset + limit
const newOffset = offset + LIMIT; console.log(`${SetList.name}.next:`, {offset, newOffset, term})
console.log(`${SetList.name}.next:`, { offset, newOffset, term });
const newSets = await setRepo.find({ const newSets = await setRepo.find({
where: { name: Like(`%${term}%`), hidden: 0 as any }, where: {name: Like(`%${term}%`), hidden: 0 as any},
take: LIMIT, take: limit,
skip: newOffset, skip: newOffset,
order: { created: "DESC" }, order: {created: 'DESC'},
}); })
if (newSets.length === 0) return setEnd(true); if (newSets.length === 0) return setEnd(true)
if (!sets) return; if (!sets) return
const map = new Map<number, GymSet>(); setSets([...sets, ...newSets])
for (const set of sets) map.set(set.id, set); if (newSets.length < limit) return setEnd(true)
for (const set of newSets) map.set(set.id, set); setOffset(newOffset)
const unique = Array.from(map.values()); }, [term, end, offset, sets])
setSets(unique);
if (newSets.length < LIMIT) return setEnd(true);
setOffset(newOffset);
};
const onAdd = useCallback(async () => { const onAdd = useCallback(async () => {
const now = await getNow(); const [{now}] = await getNow()
let set: Partial<GymSet> = { ...sets[0] }; let set = sets[0]
if (!set) set = { ...defaultSet }; if (!set) set = {...defaultSet}
set.created = now; set.created = now
delete set.id; delete set.id
navigation.navigate("EditSet", { set }); navigation.navigate('EditSet', {set})
}, [navigation, sets]); }, [navigation, sets])
const search = useCallback(
(value: string) => {
setTerm(value)
refresh(value)
},
[refresh],
)
const edit = useCallback(() => { const edit = useCallback(() => {
navigation.navigate("EditSets", { ids }); navigation.navigate('EditSets', {ids})
setIds([]); setIds([])
}, [ids, navigation]); }, [ids, navigation])
const copy = useCallback(async () => { const copy = useCallback(async () => {
const set = await setRepo.findOne({ const set = await setRepo.findOne({
where: { id: ids.pop() }, where: {id: ids.pop()},
}); })
delete set.id; delete set.id
delete set.created; delete set.created
navigation.navigate("EditSet", { set }); navigation.navigate('EditSet', {set})
setIds([]); setIds([])
}, [ids, navigation]); }, [ids, navigation])
const clear = useCallback(() => { const clear = useCallback(() => {
setIds([]); setIds([])
}, []); }, [])
const remove = async () => { const remove = useCallback(async () => {
setIds([]); setIds([])
await setRepo.delete(ids.length > 0 ? ids : {}); await setRepo.delete(ids.length > 0 ? ids : {})
return reset(term); await refresh(term)
}; }, [ids, refresh, term])
const select = useCallback(() => { const select = useCallback(() => {
if (!sets) return; setIds(sets.map(set => set.id))
if (ids.length === sets.length) return setIds([]); }, [sets])
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 ( return (
<> <>
<DrawerHeader <DrawerHeader name="Home">
name={ids.length > 0 ? `${ids.length} selected` : "History"}
ids={ids}
unSelect={() => setIds([])}
>
<ListMenu <ListMenu
onClear={clear} onClear={clear}
onCopy={copy} onCopy={copy}
@ -178,8 +142,22 @@ export default function SetList() {
</DrawerHeader> </DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}> <Page onAdd={onAdd} term={term} search={search}>
{getContent()} {sets?.length === 0 ? (
<List.Item
title="No sets yet"
description="A set is a group of repetitions. E.g. 8 reps of Squats."
/>
) : (
settings && (
<FlatList
data={sets}
style={{flex: 1}}
renderItem={renderItem}
onEndReached={next}
/>
)
)}
</Page> </Page>
</> </>
); )
} }

View File

@ -1,605 +1,230 @@
import { NavigationProp, useNavigation } from "@react-navigation/native"; import {
import { format } from "date-fns"; NavigationProp,
import { useCallback, useEffect, useMemo, useState } from "react"; useFocusEffect,
import { useForm } from "react-hook-form"; useNavigation,
import { FlatList, NativeModules } from "react-native"; } from '@react-navigation/native'
import DocumentPicker from "react-native-document-picker"; import {format} from 'date-fns'
import { Dirs, FileSystem } from "react-native-file-access"; import {useCallback, useEffect, useMemo, useState} from 'react'
import { Button } from "react-native-paper"; import {Controller, useForm} from 'react-hook-form'
import AppInput from "./AppInput"; import {NativeModules, Platform, View} from 'react-native'
import ConfirmDialog from "./ConfirmDialog"; import DocumentPicker from 'react-native-document-picker'
import { PADDING } from "./constants"; import {Dirs, FileSystem} from 'react-native-file-access'
import { AppDataSource } from "./data-source"; import {Button, Subheading} from 'react-native-paper'
import { setRepo, settingsRepo } from "./db"; import ConfirmDialog from './ConfirmDialog'
import { DrawerParams } from "./drawer-params"; import {ITEM_PADDING, MARGIN, toSentenceCase} from './constants'
import DrawerHeader from "./DrawerHeader"; import {AppDataSource} from './data-source'
import { darkOptions, lightOptions, themeOptions } from "./options"; import {setRepo, settingsRepo} from './db'
import Page from "./Page"; import {DrawerParamList} from './drawer-param-list'
import Select from "./Select"; import DrawerHeader from './DrawerHeader'
import Settings from "./settings"; import LabelledButton from './LabelledButton'
import Switch from "./Switch"; import {darkOptions, lightOptions, themeOptions} from './options'
import { toast } from "./toast"; import Page from './Page'
import { useAppTheme } from "./use-theme"; import Select from './Select'
import Settings from './settings'
import Switch from './Switch'
import {toast} from './toast'
import {useTheme} from './use-theme'
const twelveHours = [ const defaultFormats = ['P', 'Pp', 'ccc p', 'p']
"dd/LL/yyyy",
"dd/LL/yyyy, p",
"ccc p",
"p",
"yyyy-MM-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 [ignoring, setIgnoring] = useState(false); const {control, watch} = useForm<Settings>({
const [term, setTerm] = useState(""); defaultValues: async () => settingsRepo.findOne({where: {}}),
const [formatOptions, setFormatOptions] = useState<string[]>(twelveHours); })
const [importing, setImporting] = useState(false); const settings = watch()
const [deleting, setDeleting] = useState(false); const [term, setTerm] = useState('')
const [error, setError] = useState(""); const [sound, setSound] = useState('')
const { reset } = useNavigation<NavigationProp<DrawerParams>>(); const {setTheme, setLightColor, setDarkColor} = useTheme()
const [formatOptions, setFormatOptions] = useState<string[]>(defaultFormats)
const { watch, setValue } = useForm<Settings>({ const [importing, setImporting] = useState(false)
defaultValues: () => settingsRepo.findOne({ where: {} }), const [ignoring, setIgnoring] = useState(false)
}); const {reset} = useNavigation<NavigationProp<DrawerParamList>>()
const settings = watch();
const {
theme,
setTheme,
lightColor,
setLightColor,
darkColor,
setDarkColor,
} = useAppTheme();
useEffect(() => { useEffect(() => {
NativeModules.SettingsModule.ignoringBattery().then(setIgnoring); if (Object.keys(settings).length === 0) return
NativeModules.SettingsModule.is24().then((is24: boolean) => { console.log(`${SettingsPage.name}.update`, {settings})
console.log(`${SettingsPage.name}.focus:`, { is24 }); settingsRepo.update({}, settings)
if (is24) setFormatOptions(twentyFours); setLightColor(settings.lightColor)
else setFormatOptions(twelveHours); setDarkColor(settings.darkColor)
}); setTheme(settings.theme)
}, []); if (!settings.alarm || ignoring) return
NativeModules.SettingsModule.ignoreBattery()
setIgnoring(true)
}, [settings, setDarkColor, setLightColor, setTheme, ignoring])
const backupString = useMemo(() => { useFocusEffect(
if (!settings.backupDir) return null; useCallback(() => {
const split = decodeURIComponent(settings.backupDir).split(":"); if (Platform.OS !== 'android') return
return split.pop(); NativeModules.SettingsModule.ignoringBattery(setIgnoring)
}, [settings.backupDir]); NativeModules.SettingsModule.is24().then((is24: boolean) => {
console.log(`${SettingsPage.name}.focus:`, {is24})
if (is24) setFormatOptions(['P', 'P, k:m', 'ccc k:m', 'k:m'])
else setFormatOptions(defaultFormats)
})
}, []),
)
const changeSound = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
type: 'audio/*',
copyTo: 'documentDirectory',
})
if (!fileCopyUri) return
settingsRepo.update({}, {sound: fileCopyUri})
setSound(fileCopyUri)
toast('This song will now play after rest timers complete.')
}, [])
const soundString = useMemo(() => { const soundString = useMemo(() => {
if (!settings.sound) return null; if (!sound) return null
const split = settings.sound.split("/"); const split = sound.split('/')
return split.pop(); return split.pop()
}, [settings.sound]); }, [sound])
const confirmDelete = useCallback(async () => { const renderSwitch = useCallback(
setDeleting(false); (key: keyof Settings) => (
await AppDataSource.dropDatabase(); <Switch control={control} name={key}>
await AppDataSource.destroy(); {toSentenceCase(key)}
await AppDataSource.initialize(); </Switch>
toast("Database deleted."); ),
}, []); [control],
)
const confirmImport = useCallback(async () => { const switches: (keyof Settings)[] = [
setImporting(false); 'alarm',
await FileSystem.cp( 'vibrate',
Dirs.DatabaseDir + "/massive.db", 'noSound',
Dirs.DatabaseDir + "/massive-backup.db" 'notify',
); 'images',
await AppDataSource.destroy(); 'showUnit',
const file = await DocumentPicker.pickSingle(); 'steps',
if (!file.uri.endsWith('.db')) 'showDate',
return toast("File name must end with .db") ]
await FileSystem.cp(file.uri, Dirs.DatabaseDir + "/massive.db");
try { const selects: (keyof Settings)[] = [
await AppDataSource.initialize(); 'theme',
} catch (e) { 'darkColor',
setError(e.toString()); 'lightColor',
await FileSystem.cp( 'date',
Dirs.DatabaseDir + "/massive-backup.db", ]
Dirs.DatabaseDir + "/massive.db"
);
await AppDataSource.initialize();
return;
}
await setRepo.update({}, { image: null }); const getItems = useCallback(
await settingsRepo.update({}, { sound: null, backup: false }); (key: keyof Settings) => {
reset({ index: 0, routes: [{ name: "Settings" }] }); const today = new Date()
toast("Imported database successfully.") switch (key) {
}, [reset]); case 'theme':
return themeOptions
const today = new Date(); case 'darkColor':
return lightOptions
const data: Item[] = [ case 'lightColor':
{ return darkOptions
name: "Start up page", case 'date':
renderItem: (name: string) => ( return formatOptions.map(option => ({
<Select
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}`);
}}
/>
),
},
{
name: "Theme",
renderItem: (name: string) => (
<Select
label={name}
items={themeOptions}
value={theme}
onChange={async (value) => {
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), label: format(today, option),
value: option, value: option,
}))} }))
value={settings.date} default:
onChange={async (value) => { return []
setValue("date", value); }
await settingsRepo.update({}, { date: value }); },
toast("Changed date format."); [formatOptions],
}} )
/>
const renderSelect = useCallback(
(key: keyof Settings) => (
<Controller
key={key}
name={key}
control={control}
render={({field: {onChange, value}}) => (
<Select
value={value as string}
onChange={onChange}
items={getItems(key)}
label={toSentenceCase(key)}
/>
)}
/>
),
[control, getItems],
)
const confirmImport = useCallback(async () => {
setImporting(false)
await AppDataSource.destroy()
const result = await DocumentPicker.pickSingle()
await FileSystem.cp(result.uri, Dirs.DatabaseDir + '/massive.db')
await AppDataSource.initialize()
await setRepo.createQueryBuilder().update().set({image: null}).execute()
await settingsRepo
.createQueryBuilder()
.update()
.set({sound: null})
.execute()
reset({index: 0, routes: [{name: 'Settings'}]})
}, [reset])
const exportDatabase = useCallback(async () => {
const path = Dirs.DatabaseDir + '/massive.db'
await FileSystem.cpExternal(path, 'massive.db', 'downloads')
toast('Database exported. Check downloads.')
}, [])
const buttons = [
{
name: 'Alarm sound',
element: (
<LabelledButton label="Alarm sound" onPress={changeSound}>
{soundString || 'Default'}
</LabelledButton>
), ),
}, },
{ {
name: "Auto convert", name: 'Export database',
renderItem: (name: string) => ( element: (
<Select <Button style={{alignSelf: 'flex-start'}} onPress={exportDatabase}>
label={name} Export database
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> </Button>
), ),
}, },
{ {
name: `Alarm sound: ${soundString || "Default"}`, name: 'Import database',
renderItem: (name: string) => ( element: (
<Button <Button
style={{ alignSelf: "flex-start" }} style={{alignSelf: 'flex-start'}}
onPress={async () => { onPress={() => setImporting(true)}>
const { fileCopyUri } = await DocumentPicker.pickSingle({ Import database
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> </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>
),
},
];
return ( return (
<> <>
<DrawerHeader name="Settings" /> <DrawerHeader name="Settings" />
<Page term={term} search={setTerm}> <Page term={term} search={setTerm} style={{flexGrow: 0}}>
<FlatList <View style={{marginTop: MARGIN}}>
data={data.filter((item) => {switches
item.name.toLowerCase().includes(term.toLowerCase()) .filter(s => s.toLowerCase().includes(term.toLowerCase()))
)} .map(s => renderSwitch(s))}
renderItem={({ item }) => item.renderItem(item.name)} {selects
style={{ flex: 1, paddingTop: PADDING }} .filter(s => s.toLowerCase().includes(term.toLowerCase()))
/> .map(key => renderSelect(key))}
{buttons
.filter(b => b.name.includes(term.toLowerCase()))
.map(b => b.element)}
</View>
</Page> </Page>
<ConfirmDialog
title="Failed to import database"
onOk={() => setError("")}
setShow={() => setError("")}
show={!!error}
>
{error}
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
title="Are you sure?" title="Are you sure?"
onOk={confirmImport} onOk={confirmImport}
setShow={setImporting} setShow={setImporting}
show={importing} show={importing}>
>
Importing a database overwrites your current data. This action cannot be Importing a database overwrites your current data. This action cannot be
reversed! reversed!
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog
title="Are you sure?"
onOk={confirmDelete}
setShow={setDeleting}
show={deleting}
>
Deleting your database wipes your current data. This action cannot be
reversed!
</ConfirmDialog>
</> </>
); )
} }

View File

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

View File

@ -1,228 +1,149 @@
import { import {RouteProp, useFocusEffect, useRoute} from '@react-navigation/native'
NavigationProp, import {useCallback, useMemo, useRef, useState} from 'react'
RouteProp, import {NativeModules, TextInput, View} from 'react-native'
useFocusEffect, import {FlatList} from 'react-native-gesture-handler'
useNavigation, import {Button, ProgressBar} from 'react-native-paper'
useRoute, import {getBestSet} from './best.service'
} from "@react-navigation/native"; import {PADDING} from './constants'
import { useCallback, useMemo, useRef, useState } from "react"; import CountMany from './count-many'
import { FlatList, NativeModules, TextInput, View } from "react-native"; import {AppDataSource} from './data-source'
import { IconButton, ProgressBar } from "react-native-paper"; import {getNow, setRepo, settingsRepo} from './db'
import { PERMISSIONS, RESULTS, check, request } from "react-native-permissions"; import GymSet from './gym-set'
import AppInput from "./AppInput"; import MassiveInput from './MassiveInput'
import { StackParams } from "./AppStack"; import {PlanPageParams} from './plan-page-params'
import PrimaryButton from "./PrimaryButton"; import Settings from './settings'
import Select from "./Select"; import StackHeader from './StackHeader'
import StackHeader from "./StackHeader"; import StartPlanItem from './StartPlanItem'
import StartPlanItem from "./StartPlanItem"; import {toast} from './toast'
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() { export default function StartPlan() {
const { params } = useRoute<RouteProp<StackParams, "StartPlan">>(); const {params} = useRoute<RouteProp<PlanPageParams, 'StartPlan'>>()
const [reps, setReps] = useState(params.first?.reps.toString() || "0"); const [reps, setReps] = useState(params.first?.reps.toString() || '0')
const [weight, setWeight] = useState(params.first?.weight.toString() || "0"); const [weight, setWeight] = useState(params.first?.weight.toString() || '0')
const [unit, setUnit] = useState<string>(params.first?.unit || "kg"); const [unit, setUnit] = useState<string>(params.first?.unit || 'kg')
const [selected, setSelected] = useState<number>(0); const [selected, setSelected] = useState(0)
const [settings, setSettings] = useState<Settings>(); const [settings, setSettings] = useState<Settings>()
const [counts, setCounts] = useState<CountMany[]>(); const [counts, setCounts] = useState<CountMany[]>()
const weightRef = useRef<TextInput>(null); const weightRef = useRef<TextInput>(null)
const repsRef = useRef<TextInput>(null); const repsRef = useRef<TextInput>(null)
const exercises = useMemo(() => params.plan.exercises.split(","), [params]); const unitRef = useRef<TextInput>(null)
const navigation = useNavigation<NavigationProp<StackParams>>(); const workouts = useMemo(() => params.plan.workouts.split(','), [params])
const [selection, setSelection] = useState({ const [selection, setSelection] = useState({
start: 0, start: 0,
end: 0, end: 0,
}); })
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const questions = exercises const questions = workouts
.map((exercise, index) => `('${exercise}',${index})`) .map((workout, index) => `('${workout}',${index})`)
.join(","); .join(',')
console.log({questions, workouts})
const select = ` const select = `
SELECT exercises.name, COUNT(sets.id) as total, sets.sets SELECT workouts.name, COUNT(sets.id) as total, sets.sets
FROM (select 0 as name, 0 as sequence union values ${questions}) as exercises FROM (select 0 as name, 0 as sequence union values ${questions}) as workouts
LEFT JOIN sets ON sets.name = exercises.name LEFT JOIN sets ON sets.name = workouts.name
AND sets.created LIKE STRFTIME('%Y-%m-%d%%', 'now', 'localtime') AND sets.created LIKE STRFTIME('%Y-%m-%d%%', 'now', 'localtime')
AND NOT sets.hidden AND NOT sets.hidden
GROUP BY exercises.name GROUP BY workouts.name
ORDER BY exercises.sequence ORDER BY workouts.sequence
LIMIT -1 LIMIT -1
OFFSET 1 OFFSET 1
`; `
const newCounts = await AppDataSource.manager.query(select); const newCounts = await AppDataSource.manager.query(select)
console.log(`${StartPlan.name}.focus:`, { newCounts }); console.log(`${StartPlan.name}.focus:`, {newCounts})
setCounts(newCounts); setCounts(newCounts)
}, [exercises]); }, [workouts])
const select = useCallback( const select = useCallback(
async (index: number, newCounts?: CountMany[]) => { async (index: number, newCounts?: CountMany[]) => {
setSelected(index); setSelected(index)
if (!counts && !newCounts) return; if (!counts && !newCounts) return
const exercise = counts ? counts[index] : newCounts[index]; const workout = counts ? counts[index] : newCounts[index]
console.log(`${StartPlan.name}.next:`, { exercise }); console.log(`${StartPlan.name}.next:`, {workout})
const last = await setRepo.findOne({ const newBest = await getBestSet(workout.name)
where: { name: exercise.name }, if (!newBest) return
order: { created: "desc" }, delete newBest.id
}); console.log(`${StartPlan.name}.next:`, {newBest})
if (!last) return; setReps(newBest.reps.toString())
delete last.id; setWeight(newBest.weight.toString())
console.log(`${StartPlan.name}.select:`, { last }); setUnit(newBest.unit)
setReps(last.reps.toString());
setWeight(last.weight.toString());
setUnit(last.unit);
}, },
[counts] [counts],
); )
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings); settingsRepo.findOne({where: {}}).then(setSettings)
refresh(); refresh()
// eslint-disable-next-line }, [refresh]),
}, []) )
);
const handleSubmit = async () => { const handleSubmit = async () => {
const now = await getNow(); const [{now}] = await getNow()
const exercise = counts[selected]; const workout = counts[selected]
const best = await getBestSet(exercise.name); const best = await getBestSet(workout.name)
delete best.id; 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 = { const newSet: GymSet = {
...best, ...best,
weight: newWeight, weight: +weight,
reps: Number(reps), reps: +reps,
unit: newUnit, unit,
created: now, created: now,
hidden: false, hidden: false,
}; }
await setRepo.save(newSet); await setRepo.save(newSet)
await refresh(); await refresh()
if ( if (
settings.notify && settings.notify &&
(Number(weight) > best.weight || (+weight > best.weight || (+reps > best.reps && +weight === best.weight))
(Number(reps) > best.reps && Number(weight) === best.weight)) )
) { toast("Great work King! That's a new record.")
toast("Great work King! That's a new record."); if (!settings.alarm) return
}
if (!settings.alarm) return;
const milliseconds = const milliseconds =
Number(best.minutes) * 60 * 1000 + Number(best.seconds) * 1000; Number(best.minutes) * 60 * 1000 + Number(best.seconds) * 1000
const canNotify = await check(PERMISSIONS.ANDROID.POST_NOTIFICATIONS); const {vibrate, sound, noSound} = settings
if (canNotify === RESULTS.DENIED) const args = [milliseconds, vibrate, sound, noSound]
await request(PERMISSIONS.ANDROID.POST_NOTIFICATIONS); NativeModules.AlarmModule.timer(...args)
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 ( return (
<> <>
<StackHeader <StackHeader title={params.plan.days.replace(/,/g, ', ')} />
title={params.plan.title || params.plan.days.replace(/,/g, ", ")} <View style={{padding: PADDING, flex: 1, flexDirection: 'column'}}>
> <View style={{flex: 1}}>
<IconButton <MassiveInput
onPress={() => navigation.navigate("EditPlan", { plan: params.plan })} label="Reps"
icon="pencil" keyboardType="numeric"
/> value={reps}
</StackHeader> onChangeText={setReps}
<View style={{ padding: PADDING, flex: 1, flexDirection: "column" }}> onSubmitEditing={() => weightRef.current?.focus()}
<View style={{ flex: 1 }}> selection={selection}
<View> onSelectionChange={e => setSelection(e.nativeEvent.selection)}
<AppInput innerRef={repsRef}
label="Reps" />
keyboardType="numeric" <MassiveInput
value={reps} label="Weight"
onChangeText={(newReps) => { keyboardType="numeric"
const fixed = fixNumeric(newReps); value={weight}
setReps(fixed.replace(/-/g, '')) onChangeText={setWeight}
if (fixed.length !== newReps.length) onSubmitEditing={handleSubmit}
toast("Reps must be a number"); innerRef={weightRef}
else if (fixed.includes('-')) blurOnSubmit
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 && ( {settings?.showUnit && (
<Select <MassiveInput
value={unit} autoCapitalize="none"
onChange={setUnit}
items={[
{ label: "kg", value: "kg" },
{ label: "lb", value: "lb" },
{ label: "stone", value: "stone" },
]}
label="Unit" label="Unit"
value={unit}
onChangeText={setUnit}
innerRef={unitRef}
/> />
)} )}
{counts !== undefined && ( {counts && (
<FlatList <FlatList
data={counts} data={counts}
keyExtractor={(count) => count.name} renderItem={props => (
renderItem={(props) => (
<View> <View>
<StartPlanItem <StartPlanItem
{...props} {...props}
@ -238,10 +159,10 @@ export default function StartPlan() {
/> />
)} )}
</View> </View>
<PrimaryButton icon="content-save" onPress={handleSubmit}> <Button mode="contained" icon="save" onPress={handleSubmit}>
Save Save
</PrimaryButton> </Button>
</View> </View>
</> </>
); )
} }

View File

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

View File

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

79
TimerPage.tsx Normal file
View File

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

131
ViewBest.tsx Normal file
View File

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

View File

@ -1,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>
</>
);
}

87
WorkoutItem.tsx Normal file
View File

@ -0,0 +1,87 @@
import {NavigationProp, useNavigation} from '@react-navigation/native'
import {useCallback, useMemo, useState} from 'react'
import {GestureResponderEvent, Image} from 'react-native'
import {List, Menu, Text} from 'react-native-paper'
import ConfirmDialog from './ConfirmDialog'
import {setRepo} from './db'
import GymSet from './gym-set'
import {WorkoutsPageParams} from './WorkoutsPage'
export default function WorkoutItem({
item,
onRemove,
images,
}: {
item: GymSet
onRemove: () => void
images: boolean
}) {
const [showMenu, setShowMenu] = useState(false)
const [anchor, setAnchor] = useState({x: 0, y: 0})
const [showRemove, setShowRemove] = useState('')
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>()
const remove = useCallback(async () => {
await setRepo.delete({name: item.name})
setShowMenu(false)
onRemove()
}, [setShowMenu, onRemove, item.name])
const longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY})
setShowMenu(true)
},
[setShowMenu, setAnchor],
)
const description = useMemo(() => {
const seconds = item.seconds?.toString().padStart(2, '0')
return `${item.sets} x ${item.minutes || 0}:${seconds}`
}, [item])
return (
<>
<List.Item
onPress={() => navigation.navigate('EditWorkout', {value: item})}
title={item.name}
description={description}
onLongPress={longPress}
left={() =>
images &&
item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)
}
right={() => (
<Text
style={{
alignSelf: 'center',
}}>
<Menu
anchor={anchor}
visible={showMenu}
onDismiss={() => setShowMenu(false)}>
<Menu.Item
icon="delete"
onPress={() => {
setShowRemove(item.name)
setShowMenu(false)
}}
title="Delete"
/>
</Menu>
</Text>
)}
/>
<ConfirmDialog
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>
</>
)
}

122
WorkoutList.tsx Normal file
View File

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

23
WorkoutsPage.tsx Normal file
View File

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

View File

@ -1,27 +1,27 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
CFPropertyList (3.0.6) CFPropertyList (3.0.5)
rexml rexml
addressable (2.8.6) addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15) artifactory (3.0.15)
atomos (0.1.3) atomos (0.1.3)
aws-eventstream (1.3.0) aws-eventstream (1.2.0)
aws-partitions (1.888.0) aws-partitions (1.657.0)
aws-sdk-core (3.191.1) aws-sdk-core (3.166.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8) aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.77.0) aws-sdk-kms (1.59.0)
aws-sdk-core (~> 3, >= 3.191.0) aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.143.0) aws-sdk-s3 (1.117.1)
aws-sdk-core (~> 3, >= 3.191.0) aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8) aws-sigv4 (~> 1.4)
aws-sigv4 (1.8.0) aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4) babosa (1.0.4)
claide (1.1.0) claide (1.1.0)
@ -30,13 +30,14 @@ GEM
commander (4.6.0) commander (4.6.0)
highline (~> 2.0.0) highline (~> 2.0.0)
declarative (0.0.20) declarative (0.0.20)
digest-crc (0.6.5) digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107) domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1) dotenv (2.8.1)
emoji_regex (3.2.3) emoji_regex (3.2.3)
excon (0.109.0) excon (0.93.1)
faraday (1.10.3) faraday (1.10.2)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0) faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1) faraday-excon (~> 1.1)
@ -64,8 +65,8 @@ GEM
faraday-retry (1.0.3) faraday-retry (1.0.3)
faraday_middleware (1.2.0) faraday_middleware (1.2.0)
faraday (~> 1.0) faraday (~> 1.0)
fastimage (2.3.0) fastimage (2.2.6)
fastlane (2.219.0) fastlane (2.210.1)
CFPropertyList (>= 2.3, < 4.0.0) CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0) addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0) artifactory (~> 3.0)
@ -84,22 +85,20 @@ GEM
gh_inspector (>= 1.1.2, < 2.0.0) gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3) google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1) google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31) google-cloud-storage (~> 1.31)
highline (~> 2.0) highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0) json (< 3.0.0)
jwt (>= 2.1.0, < 3) jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0) mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0) multipart-post (~> 2.0.0)
naturally (~> 2.2) naturally (~> 2.2)
optparse (>= 0.1.1) optparse (~> 0.1.1)
plist (>= 3.1.0, < 4.0.0) plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0) rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3) security (= 0.1.3)
simctl (~> 1.6.3) simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0) terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3) terminal-table (>= 1.4.5, < 2.0.0)
tty-screen (>= 0.6.3, < 1.0.0) tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0) word_wrap (~> 1.0.0)
@ -107,9 +106,9 @@ GEM
xcpretty (~> 0.3.0) xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3) xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3) gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0) google-apis-androidpublisher_v3 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a) google-apis-core (>= 0.9.1, < 2.a)
google-apis-core (0.11.3) google-apis-core (0.9.1)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a) httpclient (>= 2.8.1, < 3.a)
@ -117,29 +116,31 @@ GEM
representable (~> 3.0) representable (~> 3.0)
retriable (>= 2.0, < 4.a) retriable (>= 2.0, < 4.a)
rexml rexml
google-apis-iamcredentials_v1 (0.17.0) webrick
google-apis-core (>= 0.11.0, < 2.a) google-apis-iamcredentials_v1 (0.16.0)
google-apis-playcustomapp_v1 (0.13.0) google-apis-core (>= 0.9.1, < 2.a)
google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.12.0)
google-apis-storage_v1 (0.31.0) google-apis-core (>= 0.9.1, < 2.a)
google-apis-core (>= 0.11.0, < 2.a) google-apis-storage_v1 (0.19.0)
google-cloud-core (1.6.1) google-apis-core (>= 0.9.0, < 2.a)
google-cloud-env (>= 1.0, < 3.a) google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0) google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0) faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1) google-cloud-errors (1.3.0)
google-cloud-storage (1.47.0) google-cloud-storage (1.44.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1) google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0) google-apis-storage_v1 (~> 0.19.0)
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
googleauth (1.8.1) googleauth (1.3.0)
faraday (>= 0.17.3, < 3.a) faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11) multi_json (~> 1.11)
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
@ -147,50 +148,55 @@ GEM
http-cookie (1.0.5) http-cookie (1.0.5)
domain_name (~> 0.5) domain_name (~> 0.5)
httpclient (2.8.3) httpclient (2.8.3)
jmespath (1.6.2) jmespath (1.6.1)
json (2.7.1) json (2.6.2)
jwt (2.7.1) jwt (2.5.0)
mini_magick (4.12.0) memoist (0.16.2)
mini_mime (1.1.5) mini_magick (4.11.0)
mini_mime (1.1.2)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.4.0) multipart-post (2.0.0)
nanaimo (0.3.0) nanaimo (0.3.0)
naturally (2.2.1) naturally (2.2.1)
optparse (0.4.0) optparse (0.1.1)
os (1.1.4) os (1.1.4)
plist (3.7.1) plist (3.6.0)
public_suffix (5.0.4) public_suffix (5.0.0)
rake (13.1.0) rake (13.0.6)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0) trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0) uber (< 0.2.0)
retriable (3.1.2) retriable (3.1.2)
rexml (3.2.6) rexml (3.2.5)
rouge (2.0.7) rouge (2.0.7)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (2.3.2) rubyzip (2.3.2)
security (0.1.3) security (0.1.3)
signet (0.18.0) signet (0.17.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0) jwt (>= 1.5, < 3.0)
multi_json (~> 1.10) multi_json (~> 1.10)
simctl (1.6.10) simctl (1.6.8)
CFPropertyList CFPropertyList
naturally naturally
terminal-notifier (2.0.0) terminal-notifier (2.0.0)
terminal-table (3.0.2) terminal-table (1.8.0)
unicode-display_width (>= 1.1.1, < 3) unicode-display_width (~> 1.1, >= 1.1.1)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
tty-cursor (0.7.1) tty-cursor (0.7.1)
tty-screen (0.8.2) tty-screen (0.8.1)
tty-spinner (0.9.3) tty-spinner (0.9.3)
tty-cursor (~> 0.7) tty-cursor (~> 0.7)
uber (0.1.0) uber (0.1.0)
unicode-display_width (2.5.0) unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (1.8.0)
webrick (1.7.0)
word_wrap (1.0.0) word_wrap (1.0.0)
xcodeproj (1.24.0) xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0) CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3) atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0) claide (>= 1.0.2, < 2.0)
@ -204,7 +210,6 @@ GEM
PLATFORMS PLATFORMS
ruby ruby
x64-mingw-ucrt
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES

View File

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

@ -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,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
package="com.massive">
<uses-permission android:name="android.permission.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 android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<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 <uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE" android:name="android.permission.ACCESS_NETWORK_STATE"
tools:node="remove" /> tools:node="remove" />
<uses-permission
android:name="com.google.android.gms.permission.AD_ID"
tools:node="remove" />
<application <application
android:name=".MainApplication" android:name=".MainApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
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">
<activity
android:name=".TimerDone"
android:exported="false">
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<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"
@ -31,30 +35,17 @@
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".TimerDone"
android:exported="false">
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<activity <activity
android:name=".StopAlarm" android:name=".StopAlarm"
android:exported="true" android:exported="true"
android:process=":remote" /> android:process=":remote" />
<service <service
android:name=".TimerService" android:name=".AlarmService"
android:exported="false" 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,236 @@
package com.massive package com.massive
import android.annotation.SuppressLint
import android.app.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Build import android.os.Build
import android.os.CountDownTimer
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.facebook.react.bridge.* import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule
import kotlin.math.floor
@RequiresApi(Build.VERSION_CODES.O)
class AlarmModule(context: ReactApplicationContext?) : class AlarmModule constructor(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) { ReactContextBaseJavaModule(context) {
var countdownTimer: CountDownTimer? = null
var currentMs: Long = 0
var running = false
override fun getName(): String { override fun getName(): String {
return "AlarmModule" return "AlarmModule"
} }
private val stopReceiver = object : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onReceive(context: Context?, intent: Intent?) {
Log.d("AlarmModule", "Received stop broadcast intent")
stop()
}
}
private val addReceiver = object : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onReceive(context: Context?, intent: Intent?) {
val vibrate = intent?.extras?.getBoolean("vibrate") == true
val sound = intent?.extras?.getString("sound")
val noSound = intent?.extras?.getBoolean("noSound") == true
Log.d("AlarmModule", "vibrate=$vibrate,sound=$sound,noSound=$noSound")
add(vibrate, sound, noSound)
}
}
init {
reactApplicationContext.registerReceiver(stopReceiver, IntentFilter(STOP_BROADCAST))
reactApplicationContext.registerReceiver(addReceiver, IntentFilter(ADD_BROADCAST))
}
override fun onCatalystInstanceDestroy() {
reactApplicationContext.unregisterReceiver(stopReceiver)
reactApplicationContext.unregisterReceiver(addReceiver)
super.onCatalystInstanceDestroy()
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod @ReactMethod
fun timer(milliseconds: Int, description: String) { fun add(vibrate: Boolean, sound: String?, noSound: Boolean = false) {
Log.d("AlarmModule", "Add 1 min to alarm.")
countdownTimer?.cancel()
val newMs = if (running) currentMs.toInt().plus(60000) else 60000
countdownTimer = getTimer(newMs, vibrate, sound, noSound)
countdownTimer?.start()
running = true
val manager = getManager()
manager.cancel(NOTIFICATION_ID_DONE)
val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.stopService(intent)
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
fun stop() {
Log.d("AlarmModule", "Stop alarm.")
countdownTimer?.cancel()
running = false
val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext?.stopService(intent)
val manager = getManager()
manager.cancel(NOTIFICATION_ID_DONE)
manager.cancel(NOTIFICATION_ID_PENDING)
val params = Arguments.createMap().apply {
putString("minutes", "00")
putString("seconds", "00")
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("tick", params)
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
fun timer(milliseconds: Int, vibrate: Boolean, sound: String?, noSound: Boolean = false) {
Log.d("AlarmModule", "Queue alarm for $milliseconds delay") Log.d("AlarmModule", "Queue alarm for $milliseconds delay")
val intent = Intent(reactApplicationContext, TimerService::class.java) val manager = getManager()
intent.putExtra("milliseconds", milliseconds) manager.cancel(NOTIFICATION_ID_DONE)
intent.putExtra("description", description) val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.startForegroundService(intent) reactApplicationContext.stopService(intent)
countdownTimer?.cancel()
countdownTimer = getTimer(milliseconds, vibrate, sound, noSound)
countdownTimer?.start()
running = true
}
@RequiresApi(Build.VERSION_CODES.M)
private fun getTimer(
endMs: Int,
vibrate: Boolean,
sound: String?,
noSound: Boolean
): CountDownTimer {
val builder = getBuilder(vibrate, sound, noSound)
return object : CountDownTimer(endMs.toLong(), 1000) {
@RequiresApi(Build.VERSION_CODES.O)
override fun onTick(current: Long) {
currentMs = current
val seconds =
floor((current / 1000).toDouble() % 60).toInt().toString().padStart(2, '0')
val minutes =
floor((current / 1000).toDouble() / 60).toInt().toString().padStart(2, '0')
builder.setContentText("$minutes:$seconds").setAutoCancel(false).setDefaults(0)
.setProgress(endMs, current.toInt(), false)
.setCategory(NotificationCompat.CATEGORY_PROGRESS).priority =
NotificationCompat.PRIORITY_LOW
val manager = getManager()
manager.notify(NOTIFICATION_ID_PENDING, builder.build())
val params = Arguments.createMap().apply {
putString("minutes", minutes)
putString("seconds", seconds)
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("tick", params)
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onFinish() {
val context = reactApplicationContext
val finishIntent = Intent(context, StopAlarm::class.java)
val finishPending = PendingIntent.getActivity(
context, 0, finishIntent, PendingIntent.FLAG_IMMUTABLE
)
val fullIntent = Intent(context, TimerDone::class.java)
val fullPending = PendingIntent.getActivity(
context, 0, fullIntent, PendingIntent.FLAG_IMMUTABLE
)
builder.setContentText("Timer finished.").setProgress(0, 0, false)
.setAutoCancel(true).setOngoing(true).setFullScreenIntent(fullPending, true)
.setContentIntent(finishPending).setChannelId(CHANNEL_ID_DONE)
.setCategory(NotificationCompat.CATEGORY_ALARM).priority =
NotificationCompat.PRIORITY_HIGH
val manager = getManager()
manager.notify(NOTIFICATION_ID_DONE, builder.build())
manager.cancel(NOTIFICATION_ID_PENDING)
Log.d("AlarmModule", "Finished: vibrate=$vibrate,sound=$sound,noSound=$noSound")
val alarmIntent = Intent(context, AlarmService::class.java).apply {
putExtra("vibrate", vibrate)
putExtra("sound", sound)
putExtra("noSound", noSound)
}
context.startService(alarmIntent)
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("finish", Arguments.createMap().apply {
putString("minutes", "00")
putString("seconds", "00")
})
}
}
}
@SuppressLint("UnspecifiedImmutableFlag")
@RequiresApi(Build.VERSION_CODES.M)
private fun getBuilder(
vibrate: Boolean,
sound: String?,
noSound: Boolean
): NotificationCompat.Builder {
val context = reactApplicationContext
val contentIntent = Intent(context, MainActivity::class.java)
val pendingContent =
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
val addBroadcast = Intent(ADD_BROADCAST).apply {
setPackage(reactApplicationContext.packageName)
putExtra("vibrate", vibrate)
putExtra("sound", sound)
putExtra("noSound", noSound)
}
val pendingAdd =
PendingIntent.getBroadcast(context, 0, addBroadcast, PendingIntent.FLAG_MUTABLE)
val stopBroadcast = Intent(STOP_BROADCAST)
stopBroadcast.setPackage(reactApplicationContext.packageName)
val pendingStop =
PendingIntent.getBroadcast(context, 0, stopBroadcast, PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(context, CHANNEL_ID_PENDING)
.setSmallIcon(R.drawable.ic_baseline_hourglass_bottom_24).setContentTitle("Resting")
.setContentIntent(pendingContent)
.addAction(R.drawable.ic_baseline_stop_24, "Stop", pendingStop)
.addAction(R.drawable.ic_baseline_stop_24, "Add 1 min", pendingAdd)
.setDeleteIntent(pendingStop)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun getManager(): NotificationManager {
val alarmsChannel = NotificationChannel(
CHANNEL_ID_DONE, CHANNEL_ID_DONE, NotificationManager.IMPORTANCE_HIGH
)
alarmsChannel.description = "Alarms for rest timers."
alarmsChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
alarmsChannel.setSound(null, null)
val notificationManager = reactApplicationContext.getSystemService(
NotificationManager::class.java
)
notificationManager.createNotificationChannel(alarmsChannel)
val timersChannel = NotificationChannel(
CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, NotificationManager.IMPORTANCE_LOW
)
timersChannel.setSound(null, null)
timersChannel.description = "Progress on rest timers."
notificationManager.createNotificationChannel(timersChannel)
return notificationManager
}
companion object {
const val STOP_BROADCAST = "stop-timer-event"
const val ADD_BROADCAST = "add-timer-event"
const val CHANNEL_ID_PENDING = "Timer"
const val CHANNEL_ID_DONE = "Alarm"
const val NOTIFICATION_ID_PENDING = 1
const val NOTIFICATION_ID_DONE = 2
} }
} }

View File

@ -0,0 +1,78 @@
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")
val noSound = intent.extras?.getBoolean("noSound") == true
if (sound == null && !noSound) {
mediaPlayer = MediaPlayer.create(applicationContext, R.raw.argon)
mediaPlayer?.start()
mediaPlayer?.setOnCompletionListener { vibrator?.cancel() }
} else if (sound != null && !noSound) {
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

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

View File

@ -21,13 +21,13 @@ class SettingsModule constructor(context: ReactApplicationContext?) :
@RequiresApi(Build.VERSION_CODES.M) @RequiresApi(Build.VERSION_CODES.M)
@ReactMethod @ReactMethod
fun ignoringBattery(promise: Promise) { fun ignoringBattery(callback: Callback) {
val packageName = reactApplicationContext.packageName val packageName = reactApplicationContext.packageName
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
promise.resolve(pm.isIgnoringBatteryOptimizations(packageName)) callback.invoke(pm.isIgnoringBatteryOptimizations(packageName))
} else { } else {
promise.resolve(true) callback.invoke(true)
} }
} }

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

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

View File

@ -1,358 +0,0 @@
package com.massive
import android.Manifest
import android.annotation.SuppressLint
import android.app.*
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.Context
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.os.*
import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
class Settings(val sound: String?, val noSound: Boolean, val vibrate: Boolean, val duration: Long)
@RequiresApi(Build.VERSION_CODES.O)
class TimerService : Service() {
private lateinit var timerHandler: Handler
private var timerRunnable: Runnable? = 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()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null && intent.action == TIMER_EXPIRED) onTimerExpired()
else onTimerStart(intent)
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
timerRunnable?.let { timerHandler.removeCallbacks(it) }
applicationContext.unregisterReceiver(stopReceiver)
applicationContext.unregisterReceiver(addReceiver)
mediaPlayer?.stop()
mediaPlayer?.release()
vibrator?.cancel()
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
@SuppressLint("BatteryLife")
fun battery() {
val powerManager =
applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
val ignoring =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
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")
private fun getSettings(): Settings {
val db = DatabaseHelper(applicationContext).readableDatabase
val cursor = db.rawQuery("SELECT sound, noSound, vibrate, duration FROM settings", null)
cursor.moveToFirst()
val sound = cursor.getString(cursor.getColumnIndex("sound"))
val noSound = cursor.getInt(cursor.getColumnIndex("noSound")) == 1
val vibrate = cursor.getInt(cursor.getColumnIndex("vibrate")) == 1
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 =
PendingIntent.getBroadcast(
applicationContext,
0,
stopBroadcast,
PendingIntent.FLAG_IMMUTABLE
)
val builder = NotificationCompat.Builder(this, channelId)
.setContentTitle("Timer finished")
.setContentText(currentDescription)
.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())
}
private fun updateNotification(seconds: Int) {
val notificationManager = NotificationManagerCompat.from(this)
val notification = getProgress(seconds)
if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
notificationManager.notify(ONGOING_ID, notification.build())
}
private fun formatTime(timeInSeconds: Int): String {
val minutes = timeInSeconds / 60
val seconds = timeInSeconds % 60
return String.format("%02d:%02d", minutes, seconds)
}
companion object {
const val STOP_BROADCAST = "stop-timer-event"
const val ADD_BROADCAST = "add-timer-event"
const val TIMER_EXPIRED = "timer-expired-event"
const val ONGOING_ID = 1
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,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,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

@ -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.

View File

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

31
android/gradlew vendored
View File

@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@ -80,11 +80,13 @@ do
esac esac
done done
# This is normally unused APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# shellcheck disable=SC2034
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@ -131,29 +133,22 @@ location of your Java installation."
fi fi
else else
JAVACMD=java JAVACMD=java
if ! command -v java >/dev/null 2>&1 which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." location of your Java installation."
fi
fi fi
# Increase the maximum file descriptors if we can. # Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #( case $MAX_FD in #(
max*) max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) || MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit" warn "Could not query maximum file descriptor limit"
esac esac
case $MAX_FD in #( case $MAX_FD in #(
'' | soft) :;; #( '' | soft) :;; #(
*) *)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" || ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD" warn "Could not set maximum file descriptor limit to $MAX_FD"
esac esac
@ -198,10 +193,6 @@ if "$cygwin" || "$msys" ; then
done done
fi fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command; # Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in # shell script including quotes and variable substitutions, so put them in
@ -214,12 +205,6 @@ set -- \
org.gradle.wrapper.GradleWrapperMain \ org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args. # Use "xargs" to parse quoted args.
# #
# With -n1 it outputs one arg per line, with the quotes and backslashes removed. # With -n1 it outputs one arg per line, with the quotes and backslashes removed.

15
android/gradlew.bat vendored
View File

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

View File

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

View File

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

View File

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

View File

@ -1,27 +1,40 @@
import { DefaultTheme, MD3DarkTheme } from "react-native-paper"; import {DarkTheme, DefaultTheme} from 'react-native-paper'
export const LIGHT_COLORS = [ export const lightColors = [
{ hex: MD3DarkTheme.colors.primary, name: "Purple" }, {hex: DarkTheme.colors.primary, name: 'Purple'},
{ hex: "#B3E5FC", name: "Blue" }, {hex: '#B3E5FC', name: 'Blue'},
{ hex: "#FA8072", name: "Salmon" }, {hex: '#FA8072', name: 'Salmon'},
{ hex: "#FFC0CB", name: "Pink" }, {hex: '#FFC0CB', name: 'Pink'},
{ hex: "#E9DCC9", name: "Linen" }, {hex: '#E9DCC9', name: 'Linen'},
{ hex: "#9ACD32", name: "Green" }, ]
{ hex: "#FFD700", name: "Gold" },
{ hex: "#00CED1", name: "Turquoise" },
];
export const DARK_COLORS = [ export const darkColors = [
{ hex: DefaultTheme.colors.primary, name: "Purple" }, {hex: DefaultTheme.colors.primary, name: 'Purple'},
{ hex: "#0051a9", name: "Blue" }, {hex: '#0051a9', name: 'Blue'},
{ hex: "#000000", name: "Black" }, {hex: '#000000', name: 'Black'},
{ hex: "#863c3c", name: "Brandy" }, {hex: '#863c3c', name: 'Red'},
{ hex: "#1c6000", name: "Kermit" }, {hex: '#1c6000', name: 'Kermit'},
{ hex: "#990000", name: "Red" }, ]
{ hex: "#660066", name: "Magenta" },
];
export function darkenRgba(rgba: string, amount: number) { export const colorShade = (color: any, amount: number) => {
let [r, g, b, a] = rgba.match(/\d+/g).map(Number); color = color.replace(/^#/, '')
return `rgba(${r}, ${g}, ${b}, ${Math.max(0, a - amount)})`; if (color.length === 3)
color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2]
let [r, g, b] = color.match(/.{2}/g)
;[r, g, b] = [
parseInt(r, 16) + amount,
parseInt(g, 16) + amount,
parseInt(b, 16) + amount,
]
r = Math.max(Math.min(255, r), 0).toString(16)
g = Math.max(Math.min(255, g), 0).toString(16)
b = Math.max(Math.min(255, b), 0).toString(16)
const rr = (r.length < 2 ? '0' : '') + r
const gg = (g.length < 2 ? '0' : '') + g
const bb = (b.length < 2 ? '0' : '') + b
return `#${rr}${gg}${bb}`
} }

View File

@ -1,8 +1,13 @@
export const MARGIN = 10; export const MARGIN = 10
export const PADDING = 10; export const PADDING = 10
export const ITEM_PADDING = 8; export const ITEM_PADDING = 8
export const DARK_RIPPLE = "#444444"; export const DARK_RIPPLE = '#444444'
export const LIGHT_RIPPLE = "#c2c2c2"; export const LIGHT_RIPPLE = '#c2c2c2'
export const DARK_SUBDUED = "#909090ff";
export const LIGHT_SUBDUED = "#717171ff"; export const toSentenceCase = (camelCase: string) => {
export const LIMIT = 15; if (camelCase) {
const result = camelCase.replace(/([A-Z])/g, ' $1')
return result[0].toUpperCase() + result.substring(1).toLowerCase()
}
return ''
}

View File

@ -1,11 +0,0 @@
export function convert(weight: number, fromUnit: string, toUnit: string) {
let result = Number(weight);
if (fromUnit === "lb" && toUnit === "kg") result /= 2.2;
else if (fromUnit === "kg" && toUnit === "lb") result *= 2.2;
else if (fromUnit === "stone" && toUnit === "kg") result *= 6.35;
else if (fromUnit === "kg" && toUnit === "stone") result /= 6.35;
else if (fromUnit === "stone" && toUnit === "lb") result *= 14;
else if (fromUnit === "lb" && toUnit === "stone") result /= 14;
result = Math.round((result + Number.EPSILON) * 100) / 100;
return result;
}

View File

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

View File

@ -1,53 +1,39 @@
import { DataSource } from "typeorm"; import {DataSource} from 'typeorm'
import GymSet from "./gym-set"; import GymSet from './gym-set'
import { Sets1667185586014 as sets1667185586014 } from "./migrations/1667185586014-sets"; import {Sets1667185586014 as sets1667185586014} from './migrations/1667185586014-sets'
import { plans1667186124792 } from "./migrations/1667186124792-plans"; import {plans1667186124792} from './migrations/1667186124792-plans'
import { settings1667186130041 } from "./migrations/1667186130041-settings"; import {settings1667186130041} from './migrations/1667186130041-settings'
import { addSound1667186139844 } from "./migrations/1667186139844-add-sound"; import {addSound1667186139844} from './migrations/1667186139844-add-sound'
import { addHidden1667186159379 } from "./migrations/1667186159379-add-hidden"; import {addHidden1667186159379} from './migrations/1667186159379-add-hidden'
import { addNotify1667186166140 } from "./migrations/1667186166140-add-notify"; import {addNotify1667186166140} from './migrations/1667186166140-add-notify'
import { addImage1667186171548 } from "./migrations/1667186171548-add-image"; import {addImage1667186171548} from './migrations/1667186171548-add-image'
import { addImages1667186179488 } from "./migrations/1667186179488-add-images"; import {addImages1667186179488} from './migrations/1667186179488-add-images'
import { insertSettings1667186203827 } from "./migrations/1667186203827-insert-settings"; import {insertSettings1667186203827} from './migrations/1667186203827-insert-settings'
import { addSteps1667186211251 } from "./migrations/1667186211251-add-steps"; import {addSteps1667186211251} from './migrations/1667186211251-add-steps'
import { addSets1667186250618 } from "./migrations/1667186250618-add-sets"; import {addSets1667186250618} from './migrations/1667186250618-add-sets'
import { addMinutes1667186255650 } from "./migrations/1667186255650-add-minutes"; import {addMinutes1667186255650} from './migrations/1667186255650-add-minutes'
import { addSeconds1667186259174 } from "./migrations/1667186259174-add-seconds"; import {addSeconds1667186259174} from './migrations/1667186259174-add-seconds'
import { addShowUnit1667186265588 } from "./migrations/1667186265588-add-show-unit"; import {addShowUnit1667186265588} from './migrations/1667186265588-add-show-unit'
import { addColor1667186320954 } from "./migrations/1667186320954-add-color"; import {addColor1667186320954} from './migrations/1667186320954-add-color'
import { addSteps1667186348425 } from "./migrations/1667186348425-add-steps"; import {addSteps1667186348425} from './migrations/1667186348425-add-steps'
import { addDate1667186431804 } from "./migrations/1667186431804-add-date"; import {addDate1667186431804} from './migrations/1667186431804-add-date'
import { addShowDate1667186435051 } from "./migrations/1667186435051-add-show-date"; import {addShowDate1667186435051} from './migrations/1667186435051-add-show-date'
import { addTheme1667186439366 } from "./migrations/1667186439366-add-theme"; import {addTheme1667186439366} from './migrations/1667186439366-add-theme'
import { addShowSets1667186443614 } from "./migrations/1667186443614-add-show-sets"; import {addShowSets1667186443614} from './migrations/1667186443614-add-show-sets'
import { addSetsCreated1667186451005 } from "./migrations/1667186451005-add-sets-created"; import {addSetsCreated1667186451005} from './migrations/1667186451005-add-sets-created'
import { addNoSound1667186456118 } from "./migrations/1667186456118-add-no-sound"; import {addNoSound1667186456118} from './migrations/1667186456118-add-no-sound'
import { dropMigrations1667190214743 } from "./migrations/1667190214743-drop-migrations"; import {dropMigrations1667190214743} from './migrations/1667190214743-drop-migrations'
import { splitColor1669420187764 } from "./migrations/1669420187764-split-color"; import {splitColor1669420187764} from './migrations/1669420187764-split-color'
import { addBackup1678334268359 } from "./migrations/1678334268359-add-backup"; import {Plan} from './plan'
import { planTitle1692654882408 } from "./migrations/1692654882408-plan-title"; import Settings from './settings'
import { weight1697766633971 } from "./migrations/1697766633971-weight";
import { exercises1699508495726 } from "./migrations/1699508495726-exercises";
import { exercisesFix1699613077628 } from "./migrations/1699613077628-exercises-fix";
import { settingsDuration1699743753975 } from "./migrations/1699743753975-settings-duration";
import { settingsStartup1699783784680 } from "./migrations/1699783784680-settings-startup";
import { settingsBackupDir1699839054226 } from "./migrations/1699839054226-settings-backup-dir";
import { homeHistoryStartup1699853245534 } from "./migrations/1699853245534-home-history-startup";
import { autoConvert1699948105001 } from "./migrations/1699948105001-auto-convert";
import { Plan } from "./plan";
import Settings from "./settings";
import Weight from "./weight";
import { settingsDefaultSets1700009253976 } from "./migrations/1700009253976-settings-default-sets";
import { settingsDefaults1700009729468 } from "./migrations/1700009729468-settings-defaults";
import { leadingZeros1707094662099 } from "./migrations/1707094662099-leading-zeros";
export const AppDataSource = new DataSource({ export const AppDataSource = new DataSource({
type: "react-native", type: 'react-native',
database: "massive.db", database: 'massive.db',
location: "default", location: 'default',
entities: [GymSet, Plan, Settings, Weight], entities: [GymSet, Plan, Settings],
migrationsRun: true, migrationsRun: true,
migrationsTableName: "typeorm_migrations", migrationsTableName: 'typeorm_migrations',
migrations: [ migrations: [
sets1667185586014, sets1667185586014,
plans1667186124792, plans1667186124792,
@ -73,18 +59,5 @@ export const AppDataSource = new DataSource({
addNoSound1667186456118, addNoSound1667186456118,
dropMigrations1667190214743, dropMigrations1667190214743,
splitColor1669420187764, splitColor1669420187764,
addBackup1678334268359,
planTitle1692654882408,
weight1697766633971,
exercises1699508495726,
exercisesFix1699613077628,
settingsDuration1699743753975,
settingsStartup1699783784680,
settingsBackupDir1699839054226,
homeHistoryStartup1699853245534,
autoConvert1699948105001,
settingsDefaultSets1700009253976,
settingsDefaults1700009729468,
leadingZeros1707094662099,
], ],
}); })

View File

@ -1,9 +0,0 @@
export const DAYS = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];

27
db.ts
View File

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

View File

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

View File

@ -1,47 +0,0 @@
import { execSync } from 'child_process';
import { readFileSync, writeFileSync } from 'fs';
import simpleGit from 'simple-git';
import os from 'os';
execSync('npx tsc', { stdio: 'inherit' });
let build = readFileSync('android/app/build.gradle', 'utf8');
const codeMatch = build.match(/versionCode (\d+)/);
if (!codeMatch) throw new Error('versionCode not found in build.gradle');
const versionCode = parseInt(codeMatch[1], 10) + 1;
build = build.replace(/versionCode \d+/, `versionCode ${versionCode}`);
const nameMatch = build.match(/versionName "(\d+\.\d+)"/);
if (!nameMatch) throw new Error('versionName not found in build.gradle');
const versionParts = nameMatch[1].split('.');
versionParts[1] = (parseInt(versionParts[1], 10) + 1).toString();
const versionName = versionParts.join('.');
build = build.replace(/versionName "\d+\.\d+"/, `versionName "${versionName}"`);
writeFileSync('android/app/build.gradle', build);
let packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
packageJson.version = versionName;
writeFileSync('package.json', JSON.stringify(packageJson, null, 2));
const git = simpleGit();
await git.add(['package.json', 'android/app/build.gradle']);
await git.log(['-1']).then(log => {
const newTitle = `${log.latest.message} - ${versionName} 🚀`;
console.log(newTitle);
const message = [newTitle, log.latest.body].join('\n');
return git.commit(message, [], ['--amend']);
}).then(() => {
return git.addTag(versionCode.toString());
}).then(() => {
return git.push('origin', 'HEAD', ['--tags', '--force']);
}).catch(err => {
console.error('Error amending commit:', err);
});
process.chdir('android')
const isWindows = os.platform() === 'win32';
execSync(isWindows ? '.\\gradlew.bat bundleRelease -q' : './gradlew bundleRelease -q', { stdio: 'inherit' });
execSync('bundle install --quiet', { stdio: 'inherit' });
execSync('bundle exec fastlane supply --aab app/build/outputs/bundle/release/app-release.aab', { stdio: 'inherit' });

42
deploy.sh Executable file
View File

@ -0,0 +1,42 @@
#!/bin/bash
set -ex
yarn tsc
yarn lint
git push origin HEAD
cd android || exit 1
build=app/build.gradle
versionCode=$(
grep '^\s*versionCode [0-9]*$' "$build" | awk '{print $2+1}'
)
major=$(
grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" |
sed 's/"//g' | cut -d '.' -f 1 | awk '{print $2}'
)
minor=$(
grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" |
sed 's/"//g' | cut -d '.' -f 2
)
minor=$((minor + 1))
sed -i "s/\(^\s*\)versionCode [0-9]*$/\1versionCode $versionCode/" \
"$build"
sed -i "s/\(^\s*\)versionName \"[0-9]*.[0-9]*\"$/\1versionName \"$major.$minor\"/" "$build"
sed -i "s/\"version\": \"[0-9]*.[0-9]*\"/\"version\": \"$major.$minor\"/" ../package.json
[ "$1" != "--nobundle" ] && ./gradlew bundleRelease
bundle install
bundle exec fastlane supply --aab app/build/outputs/bundle/release/app-release.aab
git add app/build.gradle ../package.json
git commit --no-verify --message "Set versionCode=$versionCode"
git tag "$versionCode"
git push origin HEAD &
git push --tags
cd ..
./install.sh

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