Compare commits

..

1 Commits
master ... rnfs

Author SHA1 Message Date
Brandon Presley c6396b65b6 Quit trying to convert to rnfs 2022-08-22 23:19:42 +12:00
241 changed files with 14905 additions and 21331 deletions

View File

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

View File

@ -1,22 +1,16 @@
module.exports = { module.exports = {
root: true, root: true,
extends: '@react-native', extends: '@react-native-community',
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
overrides: [ overrides: [
{ {
files: ['*.ts', '*.tsx', '*.js'], files: ['*.ts', '*.tsx'],
rules: { rules: {
'jsx-quotes': 0,
'prettier/prettier': 0,
'@typescript-eslint/no-shadow': ['error'], '@typescript-eslint/no-shadow': ['error'],
'no-shadow': 'off', 'no-shadow': 'off',
'no-undef': 'off', 'no-undef': 'off',
semi: 'off',
curly: 'off',
'react/react-in-jsx-scope': 'off',
'react-native/no-inline-styles': 'off',
'no-spaced-func': 'off',
}, },
}, },
], ],
ignorePatterns: ['coverage/', 'mock-providers.tsx'], };
}

View File

@ -1 +0,0 @@
*.png

15
.gitignore vendored
View File

@ -62,16 +62,7 @@ buck-out/
/ios/Pods/ /ios/Pods/
/vendor/bundle/ /vendor/bundle/
deploy.sh
README.pdf README.pdf
massive-build .yarn
build
# Yarn
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
coverage
profiles

7
.prettierrc.js Normal file
View File

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

View File

@ -1 +1 @@
2.7.5 2.7.4

12
.vscode/launch.json vendored
View File

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

View File

@ -1 +1 @@
{} {}

File diff suppressed because one or more lines are too long

166
App.tsx
View File

@ -1,124 +1,86 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import {createDrawerNavigator} from '@react-navigation/drawer';
import { import {
NavigationContainer,
DarkTheme as NavigationDarkTheme, DarkTheme as NavigationDarkTheme,
DefaultTheme as NavigationDefaultTheme, DefaultTheme as NavigationDefaultTheme,
} from "@react-navigation/native"; NavigationContainer,
import React, { useEffect, useMemo, useState } from "react"; } from '@react-navigation/native';
import { useColorScheme } from "react-native"; import React, {useEffect, useState} from 'react';
import {StatusBar, useColorScheme} from 'react-native';
import { import {
MD3DarkTheme as PaperDarkTheme, DarkTheme as PaperDarkTheme,
MD3LightTheme as PaperDefaultTheme, DefaultTheme as PaperDefaultTheme,
Provider as PaperProvider, Provider,
} from "react-native-paper"; } from 'react-native-paper';
import MaterialIcon from "react-native-vector-icons/MaterialCommunityIcons"; import {SQLiteDatabase} from 'react-native-sqlite-storage';
import AppSnack from "./AppSnack"; import Ionicon from 'react-native-vector-icons/Ionicons';
import AppStack from "./AppStack"; import {createPlans, createSets, getDb} from './db';
import FatalError from "./FatalError"; import Routes from './Routes';
import { AppDataSource } from "./data-source";
import { settingsRepo } from "./db";
import { ThemeContext } from "./use-theme";
export const CombinedDefaultTheme = { export const Drawer = createDrawerNavigator<DrawerParamList>();
...NavigationDefaultTheme, export type DrawerParamList = {
...PaperDefaultTheme, Home: {};
colors: { Settings: {};
...NavigationDefaultTheme.colors, Best: {};
...PaperDefaultTheme.colors, Plans: {};
},
}; };
export const CombinedDarkTheme = { export const DatabaseContext = React.createContext<SQLiteDatabase>({} as any);
...NavigationDarkTheme,
...PaperDarkTheme, const {getItem, setItem} = AsyncStorage;
const CombinedDefaultTheme = {
...PaperDefaultTheme,
...NavigationDefaultTheme,
colors: {
...PaperDefaultTheme.colors,
...NavigationDefaultTheme.colors,
},
};
const CombinedDarkTheme = {
...PaperDarkTheme,
...NavigationDarkTheme,
colors: { colors: {
...NavigationDarkTheme.colors,
...PaperDarkTheme.colors, ...PaperDarkTheme.colors,
...NavigationDarkTheme.colors,
}, },
}; };
const App = () => { const App = () => {
console.log("Re rendered app"); const [db, setDb] = useState<SQLiteDatabase | null>(null);
const systemTheme = useColorScheme(); const dark = useColorScheme() === 'dark';
const [appSettings, setAppSettings] = useState({
startup: undefined,
theme: "system",
lightColor: CombinedDefaultTheme.colors.primary,
darkColor: CombinedDarkTheme.colors.primary,
});
const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
(async () => { const init = async () => {
if (!AppDataSource.isInitialized) const gotDb = await getDb();
await AppDataSource.initialize().catch((e) => setError(e.toString())); await gotDb.executeSql(createPlans);
await gotDb.executeSql(createSets);
const gotSettings = await settingsRepo.findOne({ where: {} }); setDb(gotDb);
console.log(`${App.name}.mount`, { gotSettings }); const minutes = await getItem('minutes');
setAppSettings({ if (minutes === null) await setItem('minutes', '3');
startup: gotSettings.startup, const seconds = await getItem('seconds');
theme: gotSettings.theme, if (seconds === null) await setItem('seconds', '30');
lightColor: const alarmEnabled = await getItem('alarmEnabled');
gotSettings.lightColor || CombinedDefaultTheme.colors.primary, if (alarmEnabled === null) await setItem('alarmEnabled', 'false');
darkColor: gotSettings.darkColor || CombinedDarkTheme.colors.primary, const vibrate = await getItem('vibrate');
}); if (vibrate === null) await setItem('vibrate', 'true');
})(); if (!(await getItem('predictiveSets')))
await setItem('predictiveSets', 'true');
if (!(await getItem('maxSets'))) await setItem('maxSets', '3');
};
init();
}, []); }, []);
const paperTheme = useMemo(() => {
const darkTheme = {
...CombinedDarkTheme,
colors: {
...CombinedDarkTheme.colors,
primary: appSettings.darkColor,
},
};
const lightTheme = {
...CombinedDefaultTheme,
colors: {
...CombinedDefaultTheme.colors,
primary: appSettings.lightColor,
},
};
let theme = systemTheme === "dark" ? darkTheme : lightTheme;
if (appSettings.theme === "dark") theme = darkTheme;
else if (appSettings.theme === "light") theme = lightTheme;
return theme;
}, [systemTheme, appSettings]);
return ( return (
<PaperProvider <Provider
theme={paperTheme} theme={dark ? CombinedDarkTheme : CombinedDefaultTheme}
settings={{ icon: (props) => <MaterialIcon {...props} /> }} settings={{icon: props => <Ionicon {...props} />}}>
> <NavigationContainer
<NavigationContainer theme={paperTheme}> theme={dark ? CombinedDarkTheme : CombinedDefaultTheme}>
{error && ( <StatusBar barStyle={dark ? 'light-content' : 'dark-content'} />
<FatalError <Routes db={db} />
message={error}
setAppSettings={setAppSettings}
setError={setError}
/>
)}
{appSettings.startup !== undefined && (
<ThemeContext.Provider
value={{
theme: appSettings.theme,
setTheme: (theme) => setAppSettings({ ...appSettings, theme }),
lightColor: appSettings.lightColor,
setLightColor: (color) =>
setAppSettings({ ...appSettings, lightColor: color }),
darkColor: appSettings.darkColor,
setDarkColor: (color) =>
setAppSettings({ ...appSettings, darkColor: color }),
}}
>
<AppStack startup={appSettings.startup} />
</ThemeContext.Provider>
)}
</NavigationContainer> </NavigationContainer>
</Provider>
<AppSnack textColor={paperTheme.colors.background} />
</PaperProvider>
); );
}; };

View File

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

View File

@ -1,21 +0,0 @@
import { ComponentProps } from "react";
import { FAB, useTheme } from "react-native-paper";
export default function AppFab(props: Partial<ComponentProps<typeof FAB>>) {
const { colors } = useTheme();
return (
<FAB
icon="plus"
testID="add"
color={colors.background}
style={{
position: "absolute",
right: 20,
bottom: 20,
backgroundColor: colors.primary,
}}
{...props}
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

95
BestList.tsx Normal file
View File

@ -0,0 +1,95 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import React, {useCallback, useContext, useEffect, useState} from 'react';
import {FlatList, StyleSheet, View} from 'react-native';
import {List, Searchbar} from 'react-native-paper';
import {DatabaseContext} from './App';
import Best from './best';
import {BestPageParams} from './BestPage';
export default function BestList() {
const [bests, setBests] = useState<Best[]>([]);
const [search, setSearch] = useState('');
const [refreshing, setRefresing] = useState(false);
const db = useContext(DatabaseContext);
const navigation = useNavigation<NavigationProp<BestPageParams>>();
const refresh = useCallback(async () => {
const bestWeight = `
SELECT name, reps, unit, MAX(weight) AS weight
FROM sets
WHERE name LIKE ?
GROUP BY name;
`;
const bestReps = `
SELECT name, MAX(reps) as reps, unit, weight
FROM sets
WHERE name = ?
AND weight = ?
GROUP BY name;
`;
const [weight] = await db.executeSql(bestWeight, [`%${search}%`]);
if (!weight) return setBests([]);
let newBest: Best[] = [];
for (let i = 0; i < weight.rows.length; i++) {
const [reps] = await db.executeSql(bestReps, [
weight.rows.item(i).name,
weight.rows.item(i).weight,
]);
newBest = newBest.concat(reps.rows.raw());
}
setBests(newBest);
}, [search, db]);
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh]),
);
useEffect(() => {
refresh();
}, [search, refresh]);
const renderItem = ({item}: {item: Best}) => (
<List.Item
key={item.name}
title={item.name}
description={`${item.reps} x ${item.weight}${item.unit}`}
onPress={() => navigation.navigate('ViewBest', {best: item})}
/>
);
return (
<View style={styles.container}>
<Searchbar placeholder="Search" value={search} onChangeText={setSearch} />
<FlatList
ListEmptyComponent={
<List.Item
title="No exercises yet"
description="Once sets have been added, this will highlight your personal bests."
/>
}
refreshing={refreshing}
onRefresh={async () => {
setRefresing(true);
await refresh();
setRefresing(false);
}}
renderItem={renderItem}
data={bests}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 10,
flexGrow: 1,
paddingBottom: '10%',
},
});

42
BestPage.tsx Normal file
View File

@ -0,0 +1,42 @@
import {DrawerNavigationProp} from '@react-navigation/drawer';
import {useNavigation} from '@react-navigation/native';
import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import {IconButton} from 'react-native-paper';
import {DrawerParamList} from './App';
import Best from './best';
import BestList from './BestList';
import ViewBest from './ViewBest';
const Stack = createStackNavigator<BestPageParams>();
export type BestPageParams = {
BestList: {};
ViewBest: {
best: Best;
};
};
export default function BestPage() {
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
<Stack.Screen name="BestList" component={BestList} />
<Stack.Screen
name="ViewBest"
component={ViewBest}
listeners={{
beforeRemove: () => {
navigation.setOptions({
headerLeft: () => (
<IconButton icon="menu" onPress={navigation.openDrawer} />
),
title: 'Best',
});
},
}}
/>
</Stack.Navigator>
);
}

View File

@ -1,4 +1,6 @@
import { Button, Dialog, Portal, Text } from "react-native-paper"; import React from 'react';
import {Text} from 'react-native';
import {Button, Dialog, Portal} from 'react-native-paper';
export default function ConfirmDialog({ export default function ConfirmDialog({
title, title,
@ -6,20 +8,13 @@ export default function ConfirmDialog({
onOk, onOk,
show, show,
setShow, setShow,
onCancel,
}: { }: {
title: string; title: string;
children: JSX.Element | JSX.Element[] | string; children: string;
onOk: () => void; onOk: () => void;
show: boolean; show: boolean;
setShow: (show: boolean) => void; setShow: (show: boolean) => void;
onCancel?: () => void;
}) { }) {
const cancel = () => {
setShow(false);
onCancel && onCancel();
};
return ( return (
<Portal> <Portal>
<Dialog visible={show} onDismiss={() => setShow(false)}> <Dialog visible={show} onDismiss={() => setShow(false)}>
@ -29,7 +24,7 @@ export default function ConfirmDialog({
</Dialog.Content> </Dialog.Content>
<Dialog.Actions> <Dialog.Actions>
<Button onPress={onOk}>OK</Button> <Button onPress={onOk}>OK</Button>
<Button onPress={cancel}>Cancel</Button> <Button onPress={() => setShow(false)}>Cancel</Button>
</Dialog.Actions> </Dialog.Actions>
</Dialog> </Dialog>
</Portal> </Portal>

109
Daily.tsx
View File

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

54
DayMenu.tsx Normal file
View File

@ -0,0 +1,54 @@
import React, {useState} from 'react';
import {Button, Menu} from 'react-native-paper';
const days = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
];
export default function DayMenu({
onSelect,
onDelete,
selected,
index,
}: {
onSelect: (option: string) => void;
onDelete: () => void;
selected: string;
index: number;
}) {
const [show, setShow] = useState(false);
const select = (day: string) => {
onSelect(day);
setShow(false);
};
return (
<Menu
visible={show}
onDismiss={() => setShow(false)}
anchor={
<Button icon="today" onPress={() => setShow(true)}>
{selected || 'Pick a day'}
</Button>
}>
{days.map(day => (
<Menu.Item
key={day}
icon={selected === day ? 'checkmark-circle' : 'ellipse'}
onPress={() => select(day)}
title={day}
/>
))}
{index > 0 && (
<Menu.Item icon="trash" title="Delete" onPress={onDelete} />
)}
</Menu>
);
}

View File

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

143
DrawerMenu.tsx Normal file
View File

@ -0,0 +1,143 @@
import {NavigationProp, useNavigation} from '@react-navigation/native';
import React, {useCallback, useContext, useState} from 'react';
import {ToastAndroid} from 'react-native';
import DocumentPicker from 'react-native-document-picker';
import RNFS from 'react-native-fs';
import {Divider, IconButton, Menu} from 'react-native-paper';
import {DatabaseContext, DrawerParamList} from './App';
import ConfirmDialog from './ConfirmDialog';
import {write} from './file';
import {Plan} from './plan';
import Set from './set';
const setFields = 'id,name,reps,weight,created,unit';
const planFields = 'id,days,workouts';
export default function DrawerMenu({name}: {name: keyof DrawerParamList}) {
const [showMenu, setShowMenu] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const db = useContext(DatabaseContext);
const {reset} = useNavigation<NavigationProp<DrawerParamList>>();
const exportSets = useCallback(async () => {
const [result] = await db.executeSql('SELECT * FROM sets');
if (result.rows.length === 0) return;
const sets: Set[] = result.rows.raw();
const data = [setFields]
.concat(
sets.map(
set =>
`${set.id},${set.name},${set.reps},${set.weight},${set.created},${set.unit}`,
),
)
.join('\n');
console.log(`${DrawerMenu.name}.exportSets`, {length: sets.length});
await write('sets.csv', data);
}, [db]);
const exportPlans = useCallback(async () => {
const [result] = await db.executeSql('SELECT * FROM plans');
if (result.rows.length === 0) return;
const sets: Plan[] = result.rows.raw();
const data = [planFields]
.concat(sets.map(set => `"${set.id}","${set.days}","${set.workouts}"`))
.join('\n');
console.log(`${DrawerMenu.name}.exportPlans`, {length: sets.length});
await write('plans.csv', data);
}, [db]);
const download = useCallback(async () => {
setShowMenu(false);
if (name === 'Home') exportSets();
else if (name === 'Plans') exportPlans();
}, [name, exportSets, exportPlans]);
const uploadSets = useCallback(async () => {
const result = await DocumentPicker.pickSingle();
const file = await RNFS.readFile(result.uri);
console.log(`${DrawerMenu.name}.${uploadSets.name}:`, file.length);
const lines = file.split('\n');
if (lines[0] != setFields)
return ToastAndroid.show('Invalid csv.', ToastAndroid.SHORT);
const values = lines
.slice(1)
.filter(line => line)
.map(set => {
const cells = set.split(',');
return `('${cells[1]}',${cells[2]},${cells[3]},'${cells[4]}','${cells[5]}')`;
})
.join(',');
await db.executeSql(
`INSERT INTO sets(name,reps,weight,created,unit) VALUES ${values}`,
);
ToastAndroid.show('Data imported.', ToastAndroid.SHORT);
reset({index: 0, routes: [{name}]});
}, [db, reset, name]);
const uploadPlans = useCallback(async () => {
const result = await DocumentPicker.pickSingle();
const file = await RNFS.readFile(result.uri);
console.log(`${DrawerMenu.name}.uploadPlans:`, file.length);
const lines = file.split('\n');
if (lines[0] != planFields)
return ToastAndroid.show('Invalid csv.', ToastAndroid.SHORT);
const values = file
.split('\n')
.slice(1)
.filter(line => line)
.map(set => {
const cells = set.split('","').map(cell => cell.replace(/"/g, ''));
return `('${cells[1]}','${cells[2]}')`;
})
.join(',');
await db.executeSql(`INSERT INTO plans(days,workouts) VALUES ${values}`);
ToastAndroid.show('Data imported.', ToastAndroid.SHORT);
}, [db]);
const upload = useCallback(async () => {
setShowMenu(false);
if (name === 'Home') await uploadSets();
else if (name === 'Plans') await uploadPlans();
reset({index: 0, routes: [{name}]});
}, [name, uploadPlans, uploadSets, reset]);
const remove = useCallback(async () => {
setShowMenu(false);
setShowRemove(false);
if (name === 'Home') await db.executeSql(`DELETE FROM sets`);
else if (name === 'Plans') await db.executeSql(`DELETE FROM plans`);
ToastAndroid.show('All data has been deleted.', ToastAndroid.SHORT);
reset({index: 0, routes: [{name}]});
}, [db, reset, name]);
if (name === 'Home' || name === 'Plans')
return (
<Menu
visible={showMenu}
onDismiss={() => setShowMenu(false)}
anchor={
<IconButton
onPress={() => setShowMenu(true)}
icon="ellipsis-vertical"
/>
}>
<Menu.Item icon="arrow-down" onPress={download} title="Download" />
<Menu.Item icon="arrow-up" onPress={upload} title="Upload" />
<Divider />
<Menu.Item
icon="trash"
onPress={() => setShowRemove(true)}
title="Delete"
/>
<ConfirmDialog
title="Delete all data"
show={showRemove}
setShow={setShowRemove}
onOk={remove}>
This irreversibly deletes all data from the app. Are you sure?
</ConfirmDialog>
</Menu>
);
return null;
}

View File

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

View File

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

View File

@ -1,91 +1,79 @@
import { import {
NavigationProp, DarkTheme,
DefaultTheme,
RouteProp, RouteProp,
useFocusEffect,
useNavigation, useNavigation,
useRoute, useRoute,
} from "@react-navigation/native"; } from '@react-navigation/native';
import React, { useCallback, useEffect, useState } from "react"; import React, {useCallback, useContext, useEffect, useState} from 'react';
import { import {ScrollView, StyleSheet, Text, useColorScheme, View} from 'react-native';
FlatList, import {Button, IconButton, Switch} from 'react-native-paper';
Pressable, import {DatabaseContext} from './App';
ScrollView, import MassiveSwitch from './MassiveSwitch';
StyleSheet, import {PlanPageParams} from './PlanPage';
View, import {DAYS} from './time';
} from "react-native";
import {
Button,
IconButton,
Switch as PaperSwitch,
Text,
} from "react-native-paper";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import PrimaryButton from "./PrimaryButton";
import StackHeader from "./StackHeader";
import Switch from "./Switch";
import { MARGIN, PADDING } from "./constants";
import { DAYS } from "./days";
import { planRepo, setRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import GymSet, { defaultSet } from "./gym-set";
import { toast } from "./toast";
export default function EditPlan() { export default function EditPlan() {
const { params } = useRoute<RouteProp<StackParams, "EditPlan">>(); const {params} = useRoute<RouteProp<PlanPageParams, 'EditPlan'>>();
const { plan } = params; const [days, setDays] = useState<string[]>(params.plan.days.split(','));
const [title, setTitle] = useState<string>(plan?.title); const [workouts, setWorkouts] = useState<string[]>(
const [names, setNames] = useState<string[]>(); params.plan.workouts.split(','),
const [days, setDays] = useState<string[]>(
plan.days ? plan.days.split(",") : []
); );
const [names, setNames] = useState<string[]>([]);
const db = useContext(DatabaseContext);
const navigation = useNavigation();
const dark = useColorScheme() === 'dark';
const [exercises, setExercises] = useState<string[]>( useFocusEffect(
plan.exercises ? plan.exercises.split(",") : [] useCallback(() => {
navigation.getParent()?.setOptions({
headerLeft: () => (
<IconButton icon="arrow-back" onPress={() => navigation.goBack()} />
),
title: 'Plan',
});
}, [navigation]),
); );
const { navigate: drawerNavigate } =
useNavigation<NavigationProp<DrawerParams>>();
const { navigate: stackNavigate } =
useNavigation<NavigationProp<StackParams>>();
useEffect(() => { useEffect(() => {
setRepo const refresh = async () => {
.createQueryBuilder() const [namesResult] = await db.executeSql(
.select("name") 'SELECT DISTINCT name FROM sets',
.distinct(true) );
.orderBy("name") if (!namesResult.rows.length) return setNames([]);
.getRawMany() setNames(namesResult.rows.raw().map(({name}) => name));
.then((values) => { };
const newNames = values.map((value) => value.name); refresh();
console.log(EditPlan.name, { newNames }); }, [db]);
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, params});
if (!days || !exercises) return; if (!days || !workouts) return;
const newExercises = exercises.filter((exercise) => exercise).join(","); const newWorkouts = workouts.filter(workout => workout).join(',');
const newDays = days.filter((day) => day).join(","); const newDays = days.filter(day => day).join(',');
const saved = await planRepo.save({ if (!params.plan.id)
title: title, await db.executeSql(`INSERT INTO plans(days, workouts) VALUES (?, ?)`, [
days: newDays, newDays,
exercises: newExercises, newWorkouts,
id: plan.id, ]);
}); else
if (saved.id === 1) toast("Tap your plan again to begin using it"); await db.executeSql(
}, [title, days, exercises, plan]); `UPDATE plans SET days = ?, workouts = ? WHERE id = ?`,
[newDays, newWorkouts, params.plan.id],
);
navigation.goBack();
}, [days, workouts, db, params, navigation]);
const toggleExercise = useCallback( const toggleWorkout = useCallback(
(on: boolean, name: string) => { (on: boolean, name: string) => {
if (on) { if (on) {
setExercises([...exercises, name]); setWorkouts([...workouts, name]);
} else { } else {
setExercises(exercises.filter((exercise) => exercise !== name)); setWorkouts(workouts.filter(workout => workout !== name));
} }
}, },
[setExercises, exercises] [setWorkouts, workouts],
); );
const toggleDay = useCallback( const toggleDay = useCallback(
@ -93,143 +81,64 @@ export default function EditPlan() {
if (on) { if (on) {
setDays([...days, day]); setDays([...days, day]);
} else { } else {
setDays(days.filter((d) => d !== day)); setDays(days.filter(d => d !== day));
} }
}, },
[setDays, days] [setDays, days],
); );
const renderDay = (day: string) => (
<Switch
key={day}
onChange={(value) => toggleDay(value, day)}
value={days.includes(day)}
title={day}
/>
);
const renderExercise = (name: string, index: number, movable: boolean) => (
<Pressable
onPress={() => toggleExercise(!exercises.includes(name), name)}
style={{ flexDirection: "row", alignItems: "center" }}
key={name}
>
<PaperSwitch
value={exercises.includes(name)}
style={{ marginRight: MARGIN }}
onValueChange={(value) => toggleExercise(value, name)}
/>
<Text>{name}</Text>
{movable && (
<>
<IconButton
icon="arrow-up"
style={{ marginLeft: "auto" }}
onPressIn={() => moveUp(index)}
/>
<IconButton icon="arrow-down" onPressIn={() => moveDown(index)} />
</>
)}
</Pressable>
);
const moveDown = (from: number) => {
if (from === exercises.length - 1) return;
const to = from + 1;
const newExercises = [...exercises];
const copy = newExercises[from];
newExercises[from] = newExercises[to];
newExercises[to] = copy;
setExercises(newExercises);
};
const moveUp = (from: number) => {
if (from === 0) return;
const to = from - 1;
const newExercises = [...exercises];
const copy = newExercises[from];
newExercises[from] = newExercises[to];
newExercises[to] = copy;
setExercises(newExercises);
};
return ( return (
<> <View style={{padding: 10}}>
<StackHeader <ScrollView style={{height: '90%'}}>
title={typeof plan.id === "number" ? "Edit plan" : "Add plan"}
>
{typeof plan.id === "number" && (
<IconButton
onPress={async () => {
await save();
const newPlan = await planRepo.findOne({
where: { id: plan.id },
});
let first: Partial<GymSet> = await setRepo.findOne({
where: { name: exercises[0] },
order: { created: "desc" },
});
if (!first) first = { ...defaultSet, name: exercises[0] };
delete first.id;
stackNavigate("StartPlan", { plan: newPlan, first });
}}
icon="play"
/>
)}
</StackHeader>
<ScrollView style={{ padding: PADDING, flex: 1 }}>
<AppInput
label="Title"
value={title}
placeholder={days.join(", ")}
onChangeText={(value) => setTitle(value)}
/>
<Text style={styles.title}>Days</Text> <Text style={styles.title}>Days</Text>
{DAYS.map((day) => renderDay(day))} {DAYS.map(day => (
<View key={day} style={[styles.row, {alignItems: 'center'}]}>
<Text style={[styles.title, { marginTop: MARGIN }]}>Exercises</Text> <MassiveSwitch
{exercises.map((exercise, index) => value={days.includes(day)}
renderExercise(exercise, index, true) onValueChange={value => toggleDay(value, day)}
/>
<Text onPress={() => toggleDay(!days.includes(day), day)}>
{day}
</Text>
</View>
))}
<Text style={[styles.title, {marginTop: 10}]}>Workouts</Text>
{names.length === 0 && (
<Text style={{maxWidth: '80%'}}>
No sets found. Try going to the home page and adding some workouts
first.
</Text>
)} )}
{names?.length === 0 && ( {names.map(name => (
<> <View key={name} style={[styles.row, {alignItems: 'center'}]}>
<Text>No exercises yet.</Text> <MassiveSwitch
<Button value={workouts.includes(name)}
onPress={() => onValueChange={value => toggleWorkout(value, name)}
stackNavigate("EditExercise", { gymSet: defaultSet }) />
} <Text onPress={() => toggleWorkout(!workouts.includes(name), name)}>
style={{ alignSelf: "flex-start" }} {name}
> </Text>
Add some? </View>
</Button> ))}
</>
)}
{names !== undefined &&
names
.filter((name) => !exercises.includes(name))
.map((name, index) => renderExercise(name, index, false))}
<View style={{ marginBottom: MARGIN }}></View>
</ScrollView> </ScrollView>
<Button
<PrimaryButton style={{marginTop: 10}}
disabled={exercises.length === 0 && days.length === 0} mode="contained"
icon="content-save" icon="save"
onPress={async () => { onPress={save}>
await save();
drawerNavigate("Plans");
}}
style={{ margin: MARGIN }}
>
Save Save
</PrimaryButton> </Button>
</> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
title: { title: {
fontSize: 20, fontSize: 20,
marginBottom: MARGIN, marginBottom: 10,
},
row: {
flexDirection: 'row',
flexWrap: 'wrap',
}, },
}); });

View File

@ -1,370 +1,81 @@
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker"; import AsyncStorage from '@react-native-async-storage/async-storage';
import { import {
NavigationProp,
RouteProp, RouteProp,
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
useRoute, useRoute,
} from "@react-navigation/native"; } from '@react-navigation/native';
import { format } from "date-fns"; import React, {useCallback, useContext} from 'react';
import { useCallback, useRef, useState } from "react"; import {NativeModules, View} from 'react-native';
import { NativeModules, TextInput, View } from "react-native"; import {IconButton} from 'react-native-paper';
import DocumentPicker from "react-native-document-picker"; import {DatabaseContext} from './App';
import { import {HomePageParams} from './HomePage';
Button, import Set from './set';
Card, import SetForm from './SetForm';
IconButton,
Menu,
TouchableRipple,
} from "react-native-paper";
import { check, PERMISSIONS, request, RESULTS } from "react-native-permissions";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import ConfirmDialog from "./ConfirmDialog";
import { MARGIN, PADDING } from "./constants";
import { convert } from "./conversions";
import { getNow, setRepo, settingsRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import { fixNumeric } from "./fix-numeric";
import GymSet from "./gym-set";
import Select from "./Select";
import Settings from "./settings";
import StackHeader from "./StackHeader";
import { toast } from "./toast";
import PrimaryButton from "./PrimaryButton";
export default function EditSet() { export default function EditSet() {
const { params } = useRoute<RouteProp<StackParams, "EditSet">>(); const {params} = useRoute<RouteProp<HomePageParams, 'EditSet'>>();
const { set } = params; const db = useContext(DatabaseContext);
const { navigate } = useNavigation<NavigationProp<DrawerParams>>(); const navigation = useNavigation();
const [settings, setSettings] = useState<Settings>({} as Settings);
const [name, setName] = useState(set.name);
const [reps, setReps] = useState(set.reps?.toString());
const [weight, setWeight] = useState(set.weight?.toString());
const [newImage, setNewImage] = useState(set.image);
const [unit, setUnit] = useState(set.unit);
const [showDelete, setShowDelete] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [created, setCreated] = useState<Date>(
set.created ? new Date(set.created) : new Date()
);
const [createdDirty, setCreatedDirty] = useState(false);
const [showRemoveImage, setShowRemoveImage] = useState(false);
const [removeImage, setRemoveImage] = useState(false);
const [setOptions, setSets] = useState<GymSet[]>([]);
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const [selection, setSelection] = useState({
start: 0,
end: set.reps?.toString().length,
});
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
settingsRepo.findOne({ where: {} }).then(gotSettings => { navigation.getParent()?.setOptions({
setSettings(gotSettings); headerLeft: () => (
console.log(`${EditSet.name}.focus:`, { gotSettings }) <IconButton icon="arrow-back" onPress={() => navigation.goBack()} />
),
title: 'Set',
}); });
}, []) }, [navigation]),
); );
const startTimer = useCallback( const startTimer = useCallback(async () => {
async (value: string) => { const enabled = await AsyncStorage.getItem('alarmEnabled');
if (!settings.alarm) return; if (enabled !== 'true') return;
const first = await setRepo.findOne({ where: { name: value } }); const minutes = await AsyncStorage.getItem('minutes');
const milliseconds = const seconds = await AsyncStorage.getItem('seconds');
(first?.minutes ?? 3) * 60 * 1000 + (first?.seconds ?? 0) * 1000; const milliseconds = Number(minutes) * 60 * 1000 + Number(seconds) * 1000;
console.log(`${EditSet.name}.timer:`, { milliseconds }); const vibrate = (await AsyncStorage.getItem('vibrate')) === 'true';
const canNotify = await check(PERMISSIONS.ANDROID.POST_NOTIFICATIONS); NativeModules.AlarmModule.timer(milliseconds, vibrate);
if (canNotify === RESULTS.DENIED) }, []);
await request(PERMISSIONS.ANDROID.POST_NOTIFICATIONS);
if (milliseconds) NativeModules.AlarmModule.timer(milliseconds, `${first.name}`); const update = useCallback(
async (set: Set) => {
console.log(`${EditSet.name}.update`, set);
await db.executeSql(
`UPDATE sets SET name = ?, reps = ?, weight = ?, created = ?, unit = ? WHERE id = ?`,
[set.name, set.reps, set.weight, set.created, set.unit, set.id],
);
navigation.goBack();
}, },
[settings] [db, navigation],
); );
const notify = (value: Partial<GymSet>) => { const add = useCallback(
if (!settings.notify) return navigate("History"); async (set: Set) => {
if ( const {name, reps, weight, unit} = set;
value.weight > set.weight || const insert = `
(value.reps > set.reps && value.weight === set.weight) INSERT INTO sets(name, reps, weight, created, unit)
) { VALUES (?,?,?,strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'),?)
toast("Great work King! That's a new record."); `;
} startTimer();
}; await db.executeSql(insert, [name, reps, weight, unit]);
navigation.goBack();
},
[db, navigation, startTimer],
);
const added = async (value: GymSet) => { const save = useCallback(
console.log(`${EditSet.name}.added:`, value); async (set: Set) => {
startTimer(value.name); if (params.set.id) return update(set);
}; return add(set);
},
const handleSubmit = async () => { [update, add, params.set.id],
if (!name) return; );
let newWeight = Number(weight || 0);
let newUnit = unit;
if (settings.autoConvert && unit !== settings.autoConvert) {
newUnit = settings.autoConvert;
newWeight = convert(newWeight, unit, settings.autoConvert);
}
const newSet: Partial<GymSet> = {
id: set.id,
name,
reps: Number(reps || 0),
weight: newWeight,
unit: newUnit,
minutes: Number(set.minutes ?? 3),
seconds: Number(set.seconds ?? 30),
sets: set.sets ?? 3,
hidden: false,
};
newSet.image = newImage;
if (!newImage && !removeImage) {
newSet.image = await setRepo
.findOne({ where: { name } })
.then((s) => s?.image);
}
if (createdDirty) newSet.created = created.toISOString();
if (typeof set.id !== "number") newSet.created = await getNow();
const saved = await setRepo.save(newSet);
notify(newSet);
if (typeof set.id !== "number") added(saved);
navigate("History");
};
const changeImage = useCallback(async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: "documentDirectory",
});
if (fileCopyUri) setNewImage(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setNewImage("");
setRemoveImage(true);
setShowRemoveImage(false);
}, []);
const pickDate = useCallback(() => {
DateTimePickerAndroid.open({
value: created,
onChange: (event, date) => {
if (event.type === 'dismissed') return;
if (date === created) return;
setCreated(date);
setCreatedDirty(true);
DateTimePickerAndroid.open({
value: date,
onChange: (__, time) => setCreated(time),
mode: "time",
});
},
mode: "date",
});
}, [created]);
const remove = async () => {
await setRepo.delete(set.id);
navigate("History");
};
const openMenu = async () => {
if (setOptions.length > 0) return setShowMenu(true);
const latestSets = await setRepo
.createQueryBuilder()
.select()
.addSelect("MAX(created) as created")
.groupBy("name")
.getMany();
setSets(latestSets);
setShowMenu(true);
};
const select = (setOption: GymSet) => {
setName(setOption.name);
setReps(setOption.reps.toString());
setWeight(setOption.weight.toString());
setNewImage(setOption.image);
setUnit(setOption.unit);
setSelection({
start: 0,
end: setOption.reps.toString().length,
});
setShowMenu(false);
};
return ( return (
<> <View style={{padding: 10}}>
<StackHeader title={typeof set.id === "number" ? "Edit set" : "Add set"}> <SetForm save={save} set={params.set} />
{typeof set.id === "number" ? ( </View>
<IconButton onPress={() => setShowDelete(true)} icon="delete" />
) : null}
</StackHeader>
<View style={{ padding: PADDING, flex: 1 }}>
<View>
<AppInput
label="Name"
value={name}
onChangeText={setName}
autoCorrect={false}
autoFocus={!name}
onSubmitEditing={() => repsRef.current?.focus()}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<Menu
visible={showMenu}
onDismiss={() => setShowMenu(false)}
anchor={<IconButton icon="menu-down" onPress={openMenu} />}
>
{setOptions.map((setOption) => (
<Menu.Item
title={setOption.name}
key={setOption.id}
onPress={() => select(setOption)}
/>
))}
</Menu>
</View>
</View>
<View>
<AppInput
label="Reps"
keyboardType="numeric"
value={reps}
onChangeText={(newReps) => {
const fixed = fixNumeric(newReps);
setReps(fixed.replace(/-/g, ''))
if (fixed.length !== newReps.length)
toast("Reps must be a number");
else if (fixed.includes('-'))
toast("Reps must be a positive value")
}}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
innerRef={repsRef}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setReps((Number(reps) + 1).toString())}
/>
<IconButton
icon="minus"
onPress={() => setReps((Number(reps) - 1).toString())}
/>
</View>
</View>
<View>
<AppInput
label="Weight"
keyboardType="numeric"
value={weight}
onChangeText={(newWeight) => {
const fixed = fixNumeric(newWeight);
setWeight(fixed);
if (fixed.length !== newWeight.length)
toast("Weight must be a number");
}}
onSubmitEditing={handleSubmit}
innerRef={weightRef}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setWeight((Number(weight) + 2.5).toString())}
/>
<IconButton
icon="minus"
onPress={() => setWeight((Number(weight) - 2.5).toString())}
/>
</View>
</View>
{settings.showUnit && (
<Select
value={unit}
onChange={setUnit}
items={[
{ label: "kg", value: "kg" },
{ label: "lb", value: "lb" },
{ label: "stone", value: "stone" },
]}
label="Unit"
/>
)}
{settings.showDate && (
<AppInput
label="Created"
value={format(created, settings.date || "Pp")}
onPressOut={pickDate}
/>
)}
{settings.images && newImage && (
<TouchableRipple
style={{ marginBottom: MARGIN }}
onPress={changeImage}
onLongPress={() => setShowRemoveImage(true)}
>
<Card.Cover source={{ uri: newImage }} />
</TouchableRipple>
)}
{settings.images && !newImage && (
<Button
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="image-plus"
>
Image
</Button>
)}
</View>
<PrimaryButton
disabled={!name}
icon="content-save"
style={{ margin: MARGIN }}
onPress={handleSubmit}
>
Save
</PrimaryButton>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemoveImage}
setShow={setShowRemoveImage}
>
Are you sure you want to remove the image?
</ConfirmDialog>
<ConfirmDialog
title="Delete set"
show={showDelete}
onOk={remove}
setShow={setShowDelete}
>
<>Are you sure you want to delete {name}</>
</ConfirmDialog>
</>
); );
} }

View File

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

View File

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

View File

@ -1,69 +0,0 @@
import { NavigationProp, useNavigation } from "@react-navigation/native";
import { useCallback, useMemo } from "react";
import { Image } from "react-native";
import { List, useTheme } from "react-native-paper";
import { StackParams } from "./AppStack";
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants";
import GymSet from "./gym-set";
export default function ExerciseItem({
item,
setNames,
names,
images,
alarm,
}: {
item: GymSet;
images: boolean;
setNames: (value: string[]) => void;
names: string[];
alarm: boolean;
}) {
const navigation = useNavigation<NavigationProp<StackParams>>();
const { dark } = useTheme();
const description = useMemo(() => {
const seconds = item.seconds?.toString().padStart(2, "0");
const time = ` x ${item.minutes || 0}:${seconds}`;
if (alarm) return item.sets.toString() + time;
return item.sets.toString();
}, [item.sets, item.minutes, item.seconds, alarm]);
const left = useCallback(() => {
if (!images || !item.image) return null;
return (
<Image source={{ uri: item.image }} style={{ height: 75, width: 75 }} />
);
}, [item.image, images]);
const long = () => {
if (names.length > 0) return;
setNames([item.name]);
};
const backgroundColor = useMemo(() => {
if (!names.includes(item.name)) return;
if (dark) return DARK_RIPPLE;
return LIGHT_RIPPLE;
}, [dark, names, item.name]);
const press = () => {
console.log(`${ExerciseItem.name}.press:`, { names });
if (names.length === 0)
return navigation.navigate("EditExercise", { gymSet: item });
const removing = names.find((name) => name === item.name);
if (removing) setNames(names.filter((name) => name !== item.name));
else setNames([...names, item.name]);
};
return (
<List.Item
onPress={press}
title={item.name}
description={description}
onLongPress={long}
left={left}
style={{ backgroundColor }}
/>
);
}

View File

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

View File

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

View File

@ -1,9 +1,6 @@
source 'https://rubygems.org' source 'https://rubygems.org'
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version # You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby ">= 2.6.10" ruby '2.7.5'
# Cocoapods 1.15 introduced a bug which break the build. We will remove the upper gem 'cocoapods', '~> 1.11', '>= 1.11.2'
# bound in the template on Cocoapods with next React Native release.
gem 'cocoapods', '>= 1.13', '< 1.15'
gem 'activesupport', '>= 6.1.7.5', '< 7.1.0'

View File

@ -1,100 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.5)
rexml
activesupport (6.1.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
atomos (0.1.3)
claide (1.1.0)
cocoapods (1.11.3)
addressable (~> 2.8)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.11.3)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 1.4.0, < 2.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.4.0, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
fourflusher (>= 2.3.0, < 3.0)
gh_inspector (~> 1.0)
molinillo (~> 0.8.0)
nap (~> 1.0)
ruby-macho (>= 1.0, < 3.0)
xcodeproj (>= 1.21.0, < 2.0)
cocoapods-core (1.11.3)
activesupport (>= 5.0, < 7)
addressable (~> 2.8)
algoliasearch (~> 1.0)
concurrent-ruby (~> 1.1)
fuzzy_match (~> 2.0.4)
nap (~> 1.0)
netrc (~> 0.11)
public_suffix (~> 4.0)
typhoeus (~> 1.0)
cocoapods-deintegrate (1.0.5)
cocoapods-downloader (1.6.3)
cocoapods-plugins (1.0.0)
nap
cocoapods-search (1.0.1)
cocoapods-trunk (1.6.0)
nap (>= 0.8, < 2.0)
netrc (~> 0.11)
cocoapods-try (1.2.0)
colored2 (3.1.2)
concurrent-ruby (1.1.10)
escape (0.0.4)
ethon (0.16.0)
ffi (>= 1.15.0)
ffi (1.15.5)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
httpclient (2.8.3)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
json (2.6.2)
minitest (5.16.3)
molinillo (0.8.0)
nanaimo (0.3.0)
nap (1.1.0)
netrc (0.11.0)
public_suffix (4.0.7)
rexml (3.2.5)
ruby-macho (2.5.1)
typhoeus (1.4.0)
ethon (>= 0.9.0)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
zeitwerk (2.6.6)
PLATFORMS
ruby
DEPENDENCIES
cocoapods (~> 1.11, >= 1.11.2)
RUBY VERSION
ruby 2.7.5p203
BUNDLED WITH
2.1.4

View File

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

43
HomePage.tsx Normal file
View File

@ -0,0 +1,43 @@
import {DrawerNavigationProp} from '@react-navigation/drawer';
import {useNavigation} from '@react-navigation/native';
import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import {IconButton} from 'react-native-paper';
import {DrawerParamList} from './App';
import EditSet from './EditSet';
import Set from './set';
import SetList from './SetList';
const Stack = createStackNavigator<HomePageParams>();
export type HomePageParams = {
Sets: {};
EditSet: {
set: Set;
};
};
export default function HomePage() {
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
<Stack.Screen name="Sets" component={SetList} />
<Stack.Screen
name="EditSet"
options={{headerRight: () => <IconButton icon="clock" />}}
component={EditSet}
listeners={{
beforeRemove: () => {
navigation.setOptions({
headerLeft: () => (
<IconButton icon="menu" onPress={navigation.openDrawer} />
),
title: 'Home',
});
},
}}
/>
</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>
</>
);
}

View File

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

25
MassiveFab.tsx Normal file
View File

@ -0,0 +1,25 @@
import {DarkTheme, DefaultTheme} from '@react-navigation/native';
import React from 'react';
import {useColorScheme} from 'react-native';
import {FAB} from 'react-native-paper';
export default function MassiveFab(
props: Partial<React.ComponentProps<typeof FAB>>,
) {
const dark = useColorScheme() === 'dark';
return (
<FAB
{...props}
icon="add"
style={{
position: 'absolute',
right: 10,
bottom: 60,
backgroundColor: dark
? DarkTheme.colors.primary
: DefaultTheme.colors.primary,
}}
/>
);
}

18
MassiveSwitch.tsx Normal file
View File

@ -0,0 +1,18 @@
import React from 'react';
import {DarkTheme, DefaultTheme} from '@react-navigation/native';
import {useColorScheme} from 'react-native';
import {Switch} from 'react-native-paper';
export default function MassiveSwitch(
props: Partial<React.ComponentProps<typeof Switch>>,
) {
const dark = useColorScheme() === 'dark';
return (
<Switch
color={dark ? DarkTheme.colors.primary : DefaultTheme.colors.primary}
style={{marginRight: 5}}
{...props}
/>
);
}

View File

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

View File

@ -1,106 +1,50 @@
import { import {NavigationProp, useNavigation} from '@react-navigation/native';
NavigationProp, import React, {useCallback, useContext, useState} from 'react';
useFocusEffect, import {GestureResponderEvent} from 'react-native';
useNavigation, import {List, Menu} from 'react-native-paper';
} from "@react-navigation/native"; import {DatabaseContext} from './App';
import { useCallback, useMemo, useState } from "react"; import {Plan} from './plan';
import { Text } from "react-native"; import {PlanPageParams} from './PlanPage';
import { List, useTheme } from "react-native-paper";
import { StackParams } from "./AppStack";
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants";
import { DAYS } from "./days";
import { setRepo } from "./db";
import GymSet, { defaultSet } from "./gym-set";
import { Plan } from "./plan";
export default function PlanItem({ export default function PlanItem({
item, item,
setIds, onRemove,
ids,
}: { }: {
item: Plan; item: Plan;
ids: number[]; onRemove: () => void;
setIds: (value: number[]) => void;
}) { }) {
const [today, setToday] = useState<string>(); const [show, setShow] = useState(false);
const { dark } = useTheme(); const [anchor, setAnchor] = useState({x: 0, y: 0});
const days = useMemo(() => item.days.split(","), [item.days]); const db = useContext(DatabaseContext);
const navigation = useNavigation<NavigationProp<StackParams>>(); const navigation = useNavigation<NavigationProp<PlanPageParams>>();
useFocusEffect( const remove = useCallback(async () => {
useCallback(() => { await db.executeSql(`DELETE FROM plans WHERE id = ?`, [item.id]);
const newToday = DAYS[new Date().getDay()]; setShow(false);
setToday(newToday); onRemove();
}, []) }, [db, setShow, item.id, onRemove]);
const longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
setShow(true);
},
[setAnchor, setShow],
); );
const start = useCallback(async () => {
const exercise = item.exercises.split(",")[0];
let first: Partial<GymSet> = await setRepo.findOne({
where: { name: exercise },
order: { created: "desc" },
});
if (!first) first = { ...defaultSet, name: exercise };
delete first.id;
if (ids.length === 0) {
return navigation.navigate("StartPlan", { plan: item, first });
}
const removing = ids.find((id) => id === item.id);
if (removing) setIds(ids.filter((id) => id !== item.id));
else setIds([...ids, item.id]);
}, [ids, setIds, item, navigation]);
const longPress = useCallback(() => {
if (ids.length > 0) return;
setIds([item.id]);
}, [ids.length, item.id, setIds]);
const currentDays = days.map((day, index) => (
<Text key={day}>
{day === today ? (
<Text
style={{
fontWeight: "bold",
textDecorationLine: "underline",
}}
>
{day}
</Text>
) : (
day
)}
{index === days.length - 1 ? "" : ", "}
</Text>
));
const title = useMemo(
() =>
item.title ? (
<Text style={{ fontWeight: "bold" }}>{item.title}</Text>
) : (
currentDays
),
[item.title, currentDays]
);
const description = useMemo(
() => (item.title ? currentDays : item.exercises.replace(/,/g, ", ")),
[item.title, currentDays, item.exercises]
);
const backgroundColor = useMemo(() => {
if (!ids.includes(item.id)) return;
if (dark) return DARK_RIPPLE;
return LIGHT_RIPPLE;
}, [dark, ids, item.id]);
return ( return (
<List.Item <>
onPress={start} <List.Item
title={title} onPress={() => navigation.navigate('EditPlan', {plan: item})}
description={description} title={item.days.replace(/,/g, ', ')}
onLongPress={longPress} description={item.workouts.replace(/,/g, ', ')}
style={{ backgroundColor }} onLongPress={longPress}
/> right={() => (
<Menu anchor={anchor} visible={show} onDismiss={() => setShow(false)}>
<Menu.Item icon="trash" onPress={remove} title="Delete" />
</Menu>
)}
/>
</>
); );
} }

View File

@ -2,126 +2,90 @@ import {
NavigationProp, NavigationProp,
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
} from "@react-navigation/native"; } from '@react-navigation/native';
import { useCallback, useState } from "react"; import React, {useCallback, useContext, useEffect, useState} from 'react';
import { FlatList } from "react-native"; import {FlatList, StyleSheet, View} from 'react-native';
import { List } from "react-native-paper"; import {List, Searchbar} from 'react-native-paper';
import { Like } from "typeorm"; import {DatabaseContext} from './App';
import { StackParams } from "./AppStack"; import MassiveFab from './MassiveFab';
import { planRepo } from "./db"; import {Plan} from './plan';
import DrawerHeader from "./DrawerHeader"; import PlanItem from './PlanItem';
import ListMenu from "./ListMenu"; import {PlanPageParams} from './PlanPage';
import Page from "./Page";
import { defaultPlan, Plan } from "./plan";
import PlanItem from "./PlanItem";
export default function PlanList() { export default function PlanList() {
const [term, setTerm] = useState(""); const [search, setSearch] = useState('');
const [plans, setPlans] = useState<Plan[]>(); const [plans, setPlans] = useState<Plan[]>([]);
const [ids, setIds] = useState<number[]>([]); const [refreshing, setRefresing] = useState(false);
const navigation = useNavigation<NavigationProp<StackParams>>(); const db = useContext(DatabaseContext);
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
const refresh = useCallback(async (value: string) => { const refresh = useCallback(async () => {
console.log(`${PlanList.name}.refresh:`, value); const selectPlans = `
planRepo SELECT * from plans
.find({ WHERE days LIKE ? OR workouts LIKE ?
where: [ `;
{ title: Like(`%${value.trim()}%`) }, const getPlans = ({s}: {s: string}) =>
{ days: Like(`%${value.trim()}%`) }, db.executeSql(selectPlans, [`%${s}%`, `%${s}%`]);
{ exercises: Like(`%${value.trim()}%`) }, const [plansResult] = await getPlans({s: search});
], setPlans(plansResult.rows.raw());
}) }, [search, db]);
.then(setPlans);
}, []);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
refresh(term); refresh();
// eslint-disable-next-line }, [refresh]),
}, [term])
); );
const search = useCallback( useEffect(() => {
(value: string) => { refresh();
setTerm(value); }, [search, refresh]);
refresh(value);
},
[refresh]
);
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: Plan }) => ( ({item}: {item: Plan}) => (
<PlanItem ids={ids} setIds={setIds} item={item} key={item.id} /> <PlanItem item={item} key={item.id} onRemove={refresh} />
), ),
[ids] [refresh],
); );
const onAdd = () =>
navigation.navigate("EditPlan", {
plan: defaultPlan,
});
const edit = useCallback(async () => {
const plan = await planRepo.findOne({ where: { id: ids.pop() } });
navigation.navigate("EditPlan", { plan });
setIds([]);
}, [ids, navigation]);
const copy = useCallback(async () => {
const plan = await planRepo.findOne({
where: { id: ids.pop() },
});
delete plan.id;
navigation.navigate("EditPlan", { plan });
setIds([]);
}, [ids, navigation]);
const clear = useCallback(() => {
setIds([]);
}, []);
const remove = useCallback(async () => {
await planRepo.delete(ids.length > 0 ? ids : {});
await refresh(term);
setIds([]);
}, [ids, refresh, term]);
const select = useCallback(() => {
if (!plans) return;
if (ids.length === plans.length) return setIds([]);
setIds(plans.map((plan) => plan.id));
}, [plans, ids.length]);
return ( return (
<> <View style={styles.container}>
<DrawerHeader name={ids.length > 0 ? `${ids.length} selected` : "Plans"} <Searchbar value={search} onChangeText={setSearch} placeholder="Search" />
ids={ids} <FlatList
unSelect={() => setIds([])} style={{height: '100%'}}
> data={plans}
<ListMenu renderItem={renderItem}
onClear={clear} keyExtractor={set => set.id.toString()}
onCopy={copy} refreshing={refreshing}
onDelete={remove} onRefresh={() => {
onEdit={edit} setRefresing(true);
ids={ids} refresh().finally(() => setRefresing(false));
onSelect={select} }}
/> ListEmptyComponent={
</DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}>
{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 />
style={{ flex: 1 }}
data={plans} <MassiveFab
renderItem={renderItem} onPress={() =>
keyExtractor={(set) => set.id?.toString() || ""} navigation.navigate('EditPlan', {
/> plan: {days: '', workouts: '', id: 0},
)} })
</Page> }
</> />
</View>
); );
} }
const styles = StyleSheet.create({
container: {
flexGrow: 1,
padding: 10,
paddingBottom: '10%',
},
progress: {
marginTop: 10,
},
});

42
PlanPage.tsx Normal file
View File

@ -0,0 +1,42 @@
import {DrawerNavigationProp} from '@react-navigation/drawer';
import {useNavigation} from '@react-navigation/native';
import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import {IconButton} from 'react-native-paper';
import {DrawerParamList} from './App';
import EditPlan from './EditPlan';
import {Plan} from './plan';
import PlanList from './PlanList';
const Stack = createStackNavigator<PlanPageParams>();
export type PlanPageParams = {
PlanList: {};
EditPlan: {
plan: Plan;
};
};
export default function PlanPage() {
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
<Stack.Screen name="PlanList" component={PlanList} />
<Stack.Screen
name="EditPlan"
component={EditPlan}
listeners={{
beforeRemove: () => {
navigation.setOptions({
headerLeft: () => (
<IconButton icon="menu" onPress={navigation.openDrawer} />
),
title: 'Plans',
});
},
}}
/>
</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

@ -1,9 +1,6 @@
# Massive # Massive
Massive tracks your reps and sets at the gym. No internet connectivity or high spec device is required. Massive tracks your reps and sets at the gym. No internet connectivity or high spec device is required.
<br />
<br />
<img src="https://img.shields.io/f-droid/v/com.massive.svg?logo=F-Droid" />
## Features ## Features
@ -12,25 +9,21 @@ Massive tracks your reps and sets at the gym. No internet connectivity or high s
- Progress graphs - Progress graphs
- Day planner - Day planner
<a href="https://play.google.com/store/apps/details?id=com.massive"> <a href='https://play.google.com/store/apps/details?id=com.massive&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'>
<img height="75" alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"/> <img height="70" alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png'/>
</a>
<a href="https://f-droid.org/en/packages/com.massive">
<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="75">
</a> </a>
# Screenshots # Screenshots
<img src="metadata/en-US/images/phoneScreenshots/home.png" width="318"/> <img src="images/home.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/edit.png" width="318"/> <img src="images/edit.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/plans.png" width="318"/> <img src="images/timer.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/plan-edit.png" width="318"/> <img src="images/plans.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/plan-start.png" width="318"/> <img src="images/plan-edit.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/best-view.png" width="318"/> <img src="images/best.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/settings.png" width="318"/> <img src="images/best-view.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/drawer.png" width="318"/> <img src="images/settings.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/exercises.png" width="318"/> <img src="images/drawer.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/exercise-edit.png" width="318"/>
# Building from Source # Building from Source
@ -41,29 +34,24 @@ 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 `./app/build/outputs/apk/release/app-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 run the `android` script:
``` ```
npm run android yarn android
``` ```
# Fdroid Metadata
You can find the metadata yaml file in the fdroiddata repository:
https://gitlab.com/fdroid/fdroiddata/-/blob/master/metadata/com.massive.yml

56
Routes.tsx Normal file
View File

@ -0,0 +1,56 @@
import React from 'react';
import {useColorScheme} from 'react-native';
import {IconButton} from 'react-native-paper';
import {SQLiteDatabase} from 'react-native-sqlite-storage';
import {DatabaseContext, Drawer, DrawerParamList} from './App';
import BestPage from './BestPage';
import DrawerMenu from './DrawerMenu';
import HomePage from './HomePage';
import PlanPage from './PlanPage';
import SettingsPage from './SettingsPage';
interface Route {
name: keyof DrawerParamList;
component: React.ComponentType<any>;
icon: string;
}
export default function Routes({db}: {db: SQLiteDatabase | null}) {
const dark = useColorScheme() === 'dark';
if (!db) return null;
const routes: Route[] = [
{name: 'Home', component: HomePage, icon: 'home'},
{name: 'Plans', component: PlanPage, icon: 'calendar'},
{name: 'Best', component: BestPage, icon: 'stats-chart'},
{name: 'Settings', component: SettingsPage, icon: 'settings'},
];
return (
<DatabaseContext.Provider value={db}>
<Drawer.Navigator
screenOptions={{
headerTintColor: dark ? 'white' : 'black',
drawerType: 'slide',
swipeEdgeWidth: 1000,
}}>
{routes.map(route => (
<Drawer.Screen
key={route.name}
name={route.name}
component={route.component}
options={{
headerRight: () => <DrawerMenu name={route.name} />,
drawerIcon: ({focused}) => (
<IconButton
icon={focused ? route.icon : `${route.icon}-outline`}
/>
),
}}
/>
))}
</Drawer.Navigator>
</DatabaseContext.Provider>
);
}

View File

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

88
SetForm.tsx Normal file
View File

@ -0,0 +1,88 @@
import React, {useRef, useState} from 'react';
import {ScrollView, StyleSheet, Text} from 'react-native';
import {Button, TextInput} from 'react-native-paper';
import Set from './set';
export default function SetForm({
save,
set,
}: {
set: Set;
save: (set: Set) => void;
}) {
const [name, setName] = useState(set.name);
const [reps, setReps] = useState(set.reps.toString());
const [weight, setWeight] = useState(set.weight.toString());
const [unit, setUnit] = useState(set.unit);
const [selection, setSelection] = useState({
start: 0,
end: set.reps.toString().length,
});
const weightRef = useRef<any>(null);
const handleSubmit = () => {
save({
name,
reps: Number(reps),
created: set.created,
weight: Number(weight),
id: set.id,
unit,
});
};
return (
<>
<ScrollView style={{height: '90%'}}>
<TextInput
style={styles.marginBottom}
label="Name"
value={name}
onChangeText={setName}
autoCorrect={false}
selectTextOnFocus
/>
<TextInput
style={styles.marginBottom}
label="Reps"
keyboardType="numeric"
value={reps}
onChangeText={setReps}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
autoFocus
selectTextOnFocus
blurOnSubmit={false}
/>
<TextInput
style={styles.marginBottom}
label="Weight"
keyboardType="numeric"
value={weight}
onChangeText={setWeight}
onSubmitEditing={handleSubmit}
ref={weightRef}
selectTextOnFocus
/>
<TextInput
style={styles.marginBottom}
label="Unit (kg)"
value={unit}
onChangeText={setUnit}
onSubmitEditing={handleSubmit}
selectTextOnFocus
/>
<Text>{set.created?.replace('T', ' ')}</Text>
</ScrollView>
<Button mode="contained" icon="save" onPress={handleSubmit}>
Save
</Button>
</>
);
}
const styles = StyleSheet.create({
marginBottom: {
marginBottom: 10,
},
});

View File

@ -1,91 +1,82 @@
import { NavigationProp, useNavigation } from "@react-navigation/native"; import {NavigationProp, useNavigation} from '@react-navigation/native';
import { format } from "date-fns"; import React, {useCallback, useContext, useState} from 'react';
import React, { useCallback, useMemo } from "react"; import {GestureResponderEvent, Text} from 'react-native';
import { Image } from "react-native"; import {Divider, List, Menu} from 'react-native-paper';
import { List, Text, useTheme } from "react-native-paper"; import {DatabaseContext} from './App';
import { StackParams } from "./AppStack"; import {HomePageParams} from './HomePage';
import { import Set from './set';
DARK_RIPPLE,
DARK_SUBDUED,
LIGHT_RIPPLE,
LIGHT_SUBDUED,
} from "./constants";
import GymSet from "./gym-set";
import Settings from "./settings";
const SetItem = React.memo( export default function SetItem({
({ item,
item, onRemove,
settings, dates,
ids, setDates,
setIds, }: {
disablePress, item: Set;
customBg, onRemove: () => void;
}: { dates: boolean;
item: GymSet; setDates: (value: boolean) => void;
settings: Settings; }) {
ids: number[]; const [showMenu, setShowMenu] = useState(false);
setIds: (value: number[]) => void; const [anchor, setAnchor] = useState({x: 0, y: 0});
disablePress?: boolean; const db = useContext(DatabaseContext);
customBg?: string; const navigation = useNavigation<NavigationProp<HomePageParams>>();
}) => {
const { dark } = useTheme();
const navigation = useNavigation<NavigationProp<StackParams>>();
const longPress = useCallback(() => { const remove = useCallback(async () => {
if (ids.length > 0) return; await db.executeSql(`DELETE FROM sets WHERE id = ?`, [item.id]);
setIds([item.id]); setShowMenu(false);
}, [ids.length, item.id, setIds]); onRemove();
}, [setShowMenu, db, onRemove, item.id]);
const press = useCallback(() => { const copy = useCallback(() => {
if (disablePress) return; const set: Set = {...item};
if (ids.length === 0) set.id = 0;
return navigation.navigate("EditSet", { set: item }); setShowMenu(false);
const removing = ids.find((id) => id === item.id); navigation.navigate('EditSet', {set});
if (removing) setIds(ids.filter((id) => id !== item.id)); }, [navigation, item]);
else setIds([...ids, item.id]);
}, [ids, item, navigation, setIds, disablePress]);
const backgroundColor = useMemo(() => { const longPress = useCallback(
if (!ids.includes(item.id)) return; (e: GestureResponderEvent) => {
if (dark) return DARK_RIPPLE; setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
return LIGHT_RIPPLE; setShowMenu(true);
}, [dark, ids, item.id]); },
[setShowMenu, setAnchor],
);
const image = useCallback(() => { const toggleDates = useCallback(() => {
if (!settings.images || !item.image) return null; setDates(!dates);
return ( setShowMenu(false);
<Image source={{ uri: item.image }} style={{ height: 75, width: 75 }} /> }, [dates, setDates]);
);
}, [item.image, settings.images]);
return ( return (
<>
<List.Item <List.Item
onPress={press} onPress={() => navigation.navigate('EditSet', {set: item})}
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 }}
left={image}
right={() => ( right={() => (
<Text <Text
style={{ style={{
alignSelf: "center", alignSelf: 'center',
color: dark ? DARK_SUBDUED : LIGHT_SUBDUED, }}>
}} {dates ? item.created?.replace('T', ' ') : null}
> <Menu
{`${item.reps} x ${item.weight}${item.unit || "kg"}`} anchor={anchor}
visible={showMenu}
onDismiss={() => setShowMenu(false)}>
<Menu.Item icon="copy" onPress={copy} title="Copy" />
<Menu.Item
icon="calendar-outline"
onPress={toggleDates}
title="Dates"
/>
<Divider />
<Menu.Item icon="trash" onPress={remove} title="Delete" />
</Menu>
</Text> </Text>
)} )}
/> />
); </>
} );
); }
export default SetItem;

View File

@ -2,184 +2,206 @@ import {
NavigationProp, NavigationProp,
useFocusEffect, useFocusEffect,
useNavigation, useNavigation,
} from "@react-navigation/native"; } from '@react-navigation/native';
import { useCallback, useState } from "react"; import React, {useCallback, useContext, useEffect, useState} from 'react';
import { FlatList } from "react-native"; import {FlatList, StyleSheet, View} from 'react-native';
import { List } from "react-native-paper"; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Like } from "typeorm"; import {List, Searchbar} from 'react-native-paper';
import { StackParams } from "./AppStack"; import {DatabaseContext} from './App';
import DrawerHeader from "./DrawerHeader"; import {HomePageParams} from './HomePage';
import ListMenu from "./ListMenu"; import MassiveFab from './MassiveFab';
import Page from "./Page"; import {Plan} from './plan';
import SetItem from "./SetItem"; import Set from './set';
import { LIMIT } from "./constants"; import SetItem from './SetItem';
import { getNow, setRepo, settingsRepo } from "./db"; import {DAYS} from './time';
import GymSet, { defaultSet } from "./gym-set";
import Settings from "./settings"; const limit = 15;
export default function SetList() { export default function SetList() {
const [refreshing, setRefreshing] = useState(false); const [sets, setSets] = useState<Set[]>();
const [sets, setSets] = useState<GymSet[]>(); const [nextSet, setNextSet] = useState<Set>();
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [refreshing, setRefreshing] = useState(false);
const [end, setEnd] = useState(false); const [end, setEnd] = useState(false);
const [settings, setSettings] = useState<Settings>(); const [dates, setDates] = useState(false);
const [ids, setIds] = useState<number[]>([]); const db = useContext(DatabaseContext);
const navigation = useNavigation<NavigationProp<StackParams>>(); const navigation = useNavigation<NavigationProp<HomePageParams>>();
const [term, setTerm] = useState("");
const reset = useCallback( const selectSets = `
async (value: string) => { SELECT * from sets
const newSets = await setRepo.find({ WHERE name LIKE ?
where: { name: Like(`%${value.trim()}%`), hidden: 0 as any }, ORDER BY created DESC
take: LIMIT, LIMIT ? OFFSET ?
skip: 0, `;
order: { created: "DESC" },
}); const refresh = useCallback(async () => {
setSets(newSets); const [result] = await db.executeSql(selectSets, [`%${search}%`, limit, 0]);
console.log(`${SetList.name}.reset:`, { value, offset }); if (!result) return setSets([]);
setEnd(false); console.log(`${SetList.name}.refresh:`, {search, limit});
setSets(result.rows.raw());
setOffset(0);
setEnd(false);
}, [search, db, selectSets]);
const refreshLoader = useCallback(async () => {
setRefreshing(true);
refresh().finally(() => setRefreshing(false));
}, [setRefreshing, refresh]);
useEffect(() => {
refresh();
}, [search, refresh]);
const getTodaysPlan = useCallback(async (): Promise<Plan[]> => {
const today = DAYS[new Date().getDay()];
const [result] = await db.executeSql(
`SELECT * FROM plans WHERE days LIKE ? LIMIT 1`,
[`%${today}%`],
);
return result.rows.raw();
}, [db]);
const getTodaysSets = useCallback(async (): Promise<Set[]> => {
const today = new Date().toISOString().split('T')[0];
const [result] = await db.executeSql(
`SELECT * FROM sets WHERE created LIKE ? ORDER BY created DESC`,
[`${today}%`],
);
return result.rows.raw();
}, [db]);
const getBest = useCallback(
async (query: string): Promise<Set> => {
const bestWeight = `
SELECT name, reps, unit, MAX(weight) AS weight
FROM sets
WHERE name = ?
GROUP BY name;
`;
const bestReps = `
SELECT name, MAX(reps) as reps, unit, weight
FROM sets
WHERE name = ?
AND weight = ?
GROUP BY name;
`;
const [weightResult] = await db.executeSql(bestWeight, [query]);
if (!weightResult.rows.length)
return {
weight: 0,
name: '',
reps: 0,
id: 0,
};
const [repsResult] = await db.executeSql(bestReps, [
query,
weightResult.rows.item(0).weight,
]);
return repsResult.rows.item(0);
}, },
[offset] [db],
); );
const predict = useCallback(async () => {
if ((await AsyncStorage.getItem('predictiveSets')) === 'false') return;
const todaysPlan = await getTodaysPlan();
if (todaysPlan.length === 0) return;
const todaysSets = await getTodaysSets();
const todaysWorkouts = todaysPlan[0].workouts.split(',');
let nextWorkout = todaysWorkouts[0];
if (todaysSets.length > 0) {
const count = todaysSets.filter(
s => s.name === todaysSets[0].name,
).length;
const maxSets = await AsyncStorage.getItem('maxSets');
nextWorkout = todaysSets[0].name;
if (count >= Number(maxSets))
nextWorkout =
todaysWorkouts[todaysWorkouts.indexOf(todaysSets[0].name!) + 1];
}
const best = await getBest(nextWorkout);
setNextSet({...best});
}, [getTodaysSets, getTodaysPlan, getBest]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
console.log(`${SetList.name}.focus:`, { term }); refresh();
settingsRepo.findOne({ where: {} }).then(setSettings); predict();
reset(term); }, [refresh, predict]),
// 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: Set}) => (
<SetItem <SetItem
settings={settings} dates={dates}
setDates={setDates}
item={item} item={item}
key={item.id} key={item.id}
ids={ids} onRemove={refresh}
setIds={setIds}
/> />
), ),
[settings, ids] [refresh, dates, setDates],
); );
const next = async () => { const next = useCallback(async () => {
console.log(`${SetList.name}.next:`, { end, refreshing }); if (end) return;
if (end || refreshing) return; setRefreshing(true);
const newOffset = offset + LIMIT; const newOffset = offset + limit;
console.log(`${SetList.name}.next:`, { offset, newOffset, term }); console.log(`${SetList.name}.next:`, {
const newSets = await setRepo.find({ offset,
where: { name: Like(`%${term}%`), hidden: 0 as any }, limit,
take: LIMIT, newOffset,
skip: newOffset, search,
order: { created: "DESC" },
}); });
if (newSets.length === 0) return setEnd(true); const [result] = await db
.executeSql(selectSets, [`%${search}%`, limit, newOffset])
.finally(() => setRefreshing(false));
if (result.rows.length === 0) return setEnd(true);
if (!sets) return; if (!sets) return;
const map = new Map<number, GymSet>(); setSets([...sets, ...result.rows.raw()]);
for (const set of sets) map.set(set.id, set); if (result.rows.length < limit) return setEnd(true);
for (const set of newSets) map.set(set.id, set);
const unique = Array.from(map.values());
setSets(unique);
if (newSets.length < LIMIT) return setEnd(true);
setOffset(newOffset); setOffset(newOffset);
}; }, [search, end, offset, sets, db, selectSets]);
const onAdd = useCallback(async () => { const onAdd = useCallback(async () => {
const now = await getNow(); const set: Set = {
let set: Partial<GymSet> = { ...sets[0] }; name: '',
if (!set) set = { ...defaultSet }; id: 0,
set.created = now; reps: 0,
delete set.id; weight: 0,
navigation.navigate("EditSet", { set }); unit: 'kg',
}, [navigation, sets]); };
navigation.navigate('EditSet', {set: nextSet || set});
const edit = useCallback(() => { }, [navigation, nextSet]);
navigation.navigate("EditSets", { ids });
setIds([]);
}, [ids, navigation]);
const copy = useCallback(async () => {
const set = await setRepo.findOne({
where: { id: ids.pop() },
});
delete set.id;
delete set.created;
navigation.navigate("EditSet", { set });
setIds([]);
}, [ids, navigation]);
const clear = useCallback(() => {
setIds([]);
}, []);
const remove = async () => {
setIds([]);
await setRepo.delete(ids.length > 0 ? ids : {});
return reset(term);
};
const select = useCallback(() => {
if (!sets) return;
if (ids.length === sets.length) return setIds([]);
setIds(sets.map((set) => set.id));
}, [sets, ids]);
const getContent = () => {
if (!settings || sets === undefined) return null;
if (sets.length === 0)
return (
<List.Item
title="No sets yet"
description="A set is a group of repetitions. E.g. 8 reps of Squats."
/>
);
return (
<FlatList
data={sets ?? []}
style={{ flex: 1 }}
renderItem={renderItem}
onEndReached={next}
onEndReachedThreshold={0.5}
refreshing={refreshing}
keyExtractor={(set) => set.id.toString()}
onRefresh={() => {
setOffset(0);
setRefreshing(true);
reset(term).finally(() => setRefreshing(false));
}}
/>
);
};
return ( return (
<> <View style={styles.container}>
<DrawerHeader <Searchbar placeholder="Search" value={search} onChangeText={setSearch} />
name={ids.length > 0 ? `${ids.length} selected` : "History"} <FlatList
ids={ids} data={sets}
unSelect={() => setIds([])} style={{height: '100%'}}
> ListEmptyComponent={
<ListMenu <List.Item
onClear={clear} title="No sets yet"
onCopy={copy} description="A set is a group of repetitions. E.g. 8 reps of Squats."
onDelete={remove} />
onEdit={edit} }
ids={ids} renderItem={renderItem}
onSelect={select} keyExtractor={set => set.id!.toString()}
/> onEndReached={next}
</DrawerHeader> refreshing={refreshing}
onRefresh={refreshLoader}
<Page onAdd={onAdd} term={term} search={search}> />
{getContent()} <MassiveFab onPress={onAdd} />
</Page> </View>
</>
); );
} }
const styles = StyleSheet.create({
container: {
flexGrow: 1,
padding: 10,
paddingBottom: '10%',
},
});

View File

@ -1,605 +1,151 @@
import { NavigationProp, useNavigation } from "@react-navigation/native"; import AsyncStorage from '@react-native-async-storage/async-storage';
import { format } from "date-fns"; import React, {useCallback, useEffect, useState} from 'react';
import { useCallback, useEffect, useMemo, useState } from "react"; import {
import { useForm } from "react-hook-form"; NativeModules,
import { FlatList, NativeModules } from "react-native"; StyleSheet,
import DocumentPicker from "react-native-document-picker"; Text,
import { Dirs, FileSystem } from "react-native-file-access"; ToastAndroid,
import { Button } from "react-native-paper"; View,
import AppInput from "./AppInput"; } from 'react-native';
import ConfirmDialog from "./ConfirmDialog"; import {TextInput} from 'react-native-paper';
import { PADDING } from "./constants"; import ConfirmDialog from './ConfirmDialog';
import { AppDataSource } from "./data-source"; import MassiveSwitch from './MassiveSwitch';
import { setRepo, settingsRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import DrawerHeader from "./DrawerHeader";
import { darkOptions, lightOptions, themeOptions } from "./options";
import Page from "./Page";
import Select from "./Select";
import Settings from "./settings";
import Switch from "./Switch";
import { toast } from "./toast";
import { useAppTheme } from "./use-theme";
const twelveHours = [ const {getItem, setItem} = AsyncStorage;
"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 [vibrate, setVibrate] = useState(true);
const [minutes, setMinutes] = useState<string>('');
const [maxSets, setMaxSets] = useState<string>('3');
const [seconds, setSeconds] = useState<string>('');
const [alarm, setAlarm] = useState<boolean>(false);
const [predictive, setPredictive] = useState<boolean>(false);
const [battery, setBattery] = useState(false);
const [ignoring, setIgnoring] = useState(false); const [ignoring, setIgnoring] = useState(false);
const [term, setTerm] = useState("");
const [formatOptions, setFormatOptions] = useState<string[]>(twelveHours);
const [importing, setImporting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState("");
const { reset } = useNavigation<NavigationProp<DrawerParams>>();
const { watch, setValue } = useForm<Settings>({ const refresh = useCallback(async () => {
defaultValues: () => settingsRepo.findOne({ where: {} }), setMinutes((await getItem('minutes')) || '');
}); setSeconds((await getItem('seconds')) || '');
const settings = watch(); setAlarm((await getItem('alarmEnabled')) === 'true');
setPredictive((await getItem('predictiveSets')) === 'true');
const { setMaxSets((await getItem('maxSets')) || '');
theme, NativeModules.AlarmModule.ignoringBattery(setIgnoring);
setTheme, }, []);
lightColor,
setLightColor,
darkColor,
setDarkColor,
} = useAppTheme();
useEffect(() => { useEffect(() => {
NativeModules.SettingsModule.ignoringBattery().then(setIgnoring); refresh();
NativeModules.SettingsModule.is24().then((is24: boolean) => { }, [refresh]);
console.log(`${SettingsPage.name}.focus:`, { is24 });
if (is24) setFormatOptions(twentyFours);
else setFormatOptions(twelveHours);
});
}, []);
const backupString = useMemo(() => { const changeAlarmEnabled = useCallback(
if (!settings.backupDir) return null; (enabled: boolean) => {
const split = decodeURIComponent(settings.backupDir).split(":"); setAlarm(enabled);
return split.pop(); if (enabled && !ignoring) setBattery(true);
}, [settings.backupDir]); setItem('alarmEnabled', enabled ? 'true' : 'false');
},
[setBattery, ignoring],
);
const soundString = useMemo(() => { const changePredictive = useCallback(
if (!settings.sound) return null; (enabled: boolean) => {
const split = settings.sound.split("/"); setPredictive(enabled);
return split.pop(); setItem('predictiveSets', enabled ? 'true' : 'false');
}, [settings.sound]); ToastAndroid.show(
'Predictive sets guess whats next based on todays plan.',
const confirmDelete = useCallback(async () => { ToastAndroid.LONG,
setDeleting(false);
await AppDataSource.dropDatabase();
await AppDataSource.destroy();
await AppDataSource.initialize();
toast("Database deleted.");
}, []);
const confirmImport = useCallback(async () => {
setImporting(false);
await FileSystem.cp(
Dirs.DatabaseDir + "/massive.db",
Dirs.DatabaseDir + "/massive-backup.db"
);
await AppDataSource.destroy();
const file = await DocumentPicker.pickSingle();
if (!file.uri.endsWith('.db'))
return toast("File name must end with .db")
await FileSystem.cp(file.uri, Dirs.DatabaseDir + "/massive.db");
try {
await AppDataSource.initialize();
} catch (e) {
setError(e.toString());
await FileSystem.cp(
Dirs.DatabaseDir + "/massive-backup.db",
Dirs.DatabaseDir + "/massive.db"
); );
await AppDataSource.initialize(); },
return; [setPredictive],
} );
await setRepo.update({}, { image: null }); const textInputs = (
await settingsRepo.update({}, { sound: null, backup: false });
reset({ index: 0, routes: [{ name: "Settings" }] });
toast("Imported database successfully.")
}, [reset]);
const today = new Date();
const data: Item[] = [
{
name: "Start up page",
renderItem: (name: string) => (
<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),
value: option,
}))}
value={settings.date}
onChange={async (value) => {
setValue("date", value);
await settingsRepo.update({}, { date: value });
toast("Changed date format.");
}}
/>
),
},
{
name: "Auto convert",
renderItem: (name: string) => (
<Select
label={name}
items={[
{ label: "Off", value: "", icon: 'scale-off' },
{ label: "Kilograms", value: "kg", icon: 'weight-kilogram' },
{ label: "Pounds", value: "lb", icon: 'weight-pound' },
{ label: "Stone", value: "stone", icon: 'weight' },
]}
value={settings.autoConvert}
onChange={async (value) => {
setValue("autoConvert", value);
await settingsRepo.update({}, { autoConvert: value });
if (value) toast(`Sets now automatically convert to ${value}`);
else toast("Stopped automatically converting sets.");
}}
/>
),
},
{
name: "Vibration duration (ms)",
renderItem: (name: string) => (
<AppInput
value={settings.duration?.toString() ?? "300"}
label={name}
onChangeText={(value) => setValue("duration", Number(value))}
onSubmitEditing={async (e) => {
const value = Number(e.nativeEvent.text);
setValue("duration", value);
await settingsRepo.update({}, { duration: value });
toast("Changed duration of alarm vibrations.");
}}
keyboardType="numeric"
blurOnSubmit
/>
),
},
{
name: "Default sets",
renderItem: (name: string) => (
<AppInput
value={settings.defaultSets?.toString() ?? "3"}
label={name}
onChangeText={(value) => setValue("defaultSets", Number(value))}
onSubmitEditing={async (e) => {
const value = Number(e.nativeEvent.text);
setValue("defaultSets", value);
await settingsRepo.update({}, { defaultSets: value });
toast(`New exercises now have ${value} sets by default.`);
}}
keyboardType="numeric"
blurOnSubmit
/>
),
},
{
name: "Default minutes",
renderItem: (name: string) => (
<AppInput
value={settings.defaultMinutes?.toString() ?? "3"}
label={name}
onChangeText={(value) => setValue("defaultMinutes", Number(value))}
onSubmitEditing={async (e) => {
const value = Number(e.nativeEvent.text);
setValue("defaultMinutes", value);
await settingsRepo.update({}, { defaultMinutes: value });
toast(`New exercises now wait ${value} minutes by default.`);
}}
keyboardType="numeric"
blurOnSubmit
/>
),
},
{
name: "Default seconds",
renderItem: (name: string) => (
<AppInput
value={settings.defaultSeconds?.toString() ?? "30"}
label={name}
onChangeText={(value) => setValue("defaultSeconds", Number(value))}
onSubmitEditing={async (e) => {
const value = Number(e.nativeEvent.text);
setValue("defaultSeconds", value);
await settingsRepo.update({}, { defaultSeconds: value });
toast(`New exercises now wait ${value} seconds by default.`);
}}
keyboardType="numeric"
blurOnSubmit
/>
),
},
{
name: "Dark color",
renderItem: (name: string) => (
<Select
label={name}
items={lightOptions}
value={darkColor}
onChange={async (value) => {
setValue("darkColor", value);
setDarkColor(value);
await settingsRepo.update({}, { darkColor: value });
toast("Set primary color for dark mode.");
}}
/>
),
},
{
name: "Light color",
renderItem: (name: string) => (
<Select
label={name}
items={darkOptions}
value={lightColor}
onChange={async (value) => {
setValue("lightColor", value);
setLightColor(value);
await settingsRepo.update({}, { lightColor: value });
toast("Set primary color for light mode.");
}}
/>
),
},
{
name: "Rest timers",
renderItem: (name: string) => (
<Switch
value={settings.alarm}
onChange={async (value) => {
setValue("alarm", value);
if (value && !ignoring) {
NativeModules.SettingsModule.ignoreBattery();
}
await settingsRepo.update({}, { alarm: value });
if (value) toast("Timers will now run after each set.");
else toast("Stopped timers running after each set.");
}}
title={name}
/>
),
},
{
name: "Vibrate",
renderItem: (name: string) => (
<Switch
value={settings.vibrate}
onChange={async (value) => {
setValue("vibrate", value);
await settingsRepo.update({}, { vibrate: value });
if (value) toast("Alarms will vibrate.");
else toast("Stopped alarms from vibrating.");
}}
title={name}
/>
),
},
{
name: "Sound",
renderItem: (name: string) => (
<Switch
value={!settings.noSound}
onChange={async (value) => {
setValue("noSound", !value);
await settingsRepo.update({}, { noSound: !value });
if (!value) toast("Alarms will no longer make a sound.");
else toast("Enabled sound for alarms.");
}}
title={name}
/>
),
},
{
name: "Notifications",
renderItem: (name: string) => (
<Switch
value={settings.notify}
onChange={async (value) => {
setValue("notify", value);
await settingsRepo.update({}, { notify: value });
if (value) toast("Show notifications for new records.");
else toast("Stopped notifications for new records.");
}}
title={name}
/>
),
},
{
name: "Show images",
renderItem: (name: string) => (
<Switch
value={settings.images}
onChange={async (value) => {
setValue("images", value);
await settingsRepo.update({}, { images: value });
if (value) toast("Show images for sets.");
else toast("Hid images for sets.");
}}
title={name}
/>
),
},
{
name: "Show unit",
renderItem: (name: string) => (
<Switch
value={settings.showUnit}
onChange={async (value) => {
setValue("showUnit", value);
await settingsRepo.update({}, { showUnit: value });
if (value) toast("Show option to select unit for sets.");
else toast("Hid unit option for sets.");
}}
title={name}
/>
),
},
{
name: "Show date",
renderItem: (name: string) => (
<Switch
value={settings.showDate}
onChange={async (value) => {
setValue("showDate", value);
await settingsRepo.update({}, { showDate: value });
if (value) toast("Show date for sets.");
else toast("Hid date on sets.");
}}
title={name}
/>
),
},
{
name: "Automatic backup",
renderItem: (name: string) => (
<Switch
value={settings.backup}
onChange={async (value) => {
setValue("backup", value);
await settingsRepo.update({}, { backup: value });
if (value) {
const result = await DocumentPicker.pickDirectory();
setValue("backupDir", result.uri);
await settingsRepo.update({}, { backupDir: result.uri });
console.log(`${SettingsPage.name}.backup:`, { result });
toast("Backup database daily.");
NativeModules.BackupModule.start(result.uri);
} else {
toast("Stopped backing up daily");
NativeModules.BackupModule.stop();
}
}}
title={name}
/>
),
},
{
name: `Backup directory: ${backupString || "Not set yet!"}`,
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const result = await DocumentPicker.pickDirectory();
setValue("backupDir", result.uri);
await settingsRepo.update({}, { backupDir: result.uri });
toast("Changed backup directory.");
if (!settings.backup) return;
NativeModules.BackupModule.stop();
NativeModules.BackupModule.start(result.uri);
}}
>
{name}
</Button>
),
},
{
name: `Alarm sound: ${soundString || "Default"}`,
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.audio,
copyTo: "documentDirectory",
});
if (!fileCopyUri) return;
setValue("sound", fileCopyUri);
await settingsRepo.update({}, { sound: fileCopyUri });
toast("Sound will play after rest timers.");
}}
>
{name}
</Button>
),
},
{
name: "Export database",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const result = await DocumentPicker.pickDirectory();
const error = await NativeModules.BackupModule.once(result.uri);
if (error) toast(error);
else toast("Database exported.");
}}
>
{name}
</Button>
),
},
{
name: "Export sets as CSV",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const result = await DocumentPicker.pickDirectory();
await NativeModules.BackupModule.exportSets(result.uri);
toast("Exported sets as CSV.");
}}
>
{name}
</Button>
),
},
{
name: "Export plans as CSV",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const result = await DocumentPicker.pickDirectory();
await NativeModules.BackupModule.exportPlans(result.uri);
toast("Exported plans as CSV.");
}}
>
{name}
</Button>
),
},
{
name: "Import database",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={() => setImporting(true)}
>
{name}
</Button>
),
},
{
name: "Delete database",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={() => setDeleting(true)}
>
{name}
</Button>
),
},
];
return (
<> <>
<DrawerHeader name="Settings" /> <TextInput
label="Rest minutes"
<Page term={term} search={setTerm}> value={minutes}
<FlatList keyboardType="numeric"
data={data.filter((item) => placeholder="3"
item.name.toLowerCase().includes(term.toLowerCase()) onChangeText={text => {
)} setMinutes(text);
renderItem={({ item }) => item.renderItem(item.name)} setItem('minutes', text);
style={{ flex: 1, paddingTop: PADDING }} }}
/> style={styles.text}
</Page> selectTextOnFocus
/>
<ConfirmDialog <TextInput
title="Failed to import database" label="Rest seconds"
onOk={() => setError("")} value={seconds}
setShow={() => setError("")} keyboardType="numeric"
show={!!error} placeholder="30"
> onChangeText={s => {
{error} setSeconds(s);
</ConfirmDialog> setItem('seconds', s);
}}
<ConfirmDialog style={styles.text}
title="Are you sure?" selectTextOnFocus
onOk={confirmImport} />
setShow={setImporting} <TextInput
show={importing} label="Sets per workout"
> value={maxSets}
Importing a database overwrites your current data. This action cannot be keyboardType="numeric"
reversed! onChangeText={value => {
</ConfirmDialog> setMaxSets(value);
setItem('maxSets', value);
<ConfirmDialog }}
title="Are you sure?" style={styles.text}
onOk={confirmDelete} selectTextOnFocus
setShow={setDeleting} />
show={deleting}
>
Deleting your database wipes your current data. This action cannot be
reversed!
</ConfirmDialog>
</> </>
); );
const changeVibrate = useCallback(
(value: boolean) => {
setVibrate(value);
setItem('vibrate', value ? 'true' : 'false');
},
[setVibrate],
);
return (
<View style={styles.container}>
{textInputs}
<Text style={styles.text}>Rest timers</Text>
<MassiveSwitch
style={[styles.text, {alignSelf: 'flex-start'}]}
value={alarm}
onValueChange={changeAlarmEnabled}
/>
<Text style={styles.text}>Vibrate</Text>
<MassiveSwitch
style={[styles.text, {alignSelf: 'flex-start'}]}
value={vibrate}
onValueChange={changeVibrate}
/>
<ConfirmDialog
title="Battery optimizations"
show={battery}
setShow={setBattery}
onOk={() => {
NativeModules.AlarmModule.openSettings();
setBattery(false);
}}>
Disable battery optimizations for Massive to use rest timers.
</ConfirmDialog>
<Text style={styles.text}>Predictive sets</Text>
<MassiveSwitch
style={[styles.text, {alignSelf: 'flex-start'}]}
value={predictive}
onValueChange={changePredictive}
/>
</View>
);
} }
const styles = StyleSheet.create({
container: {
padding: 10,
flex: 1,
},
text: {
marginBottom: 10,
},
});

View File

@ -1,20 +0,0 @@
import { useNavigation } from "@react-navigation/native";
import { Appbar, IconButton } from "react-native-paper";
export default function StackHeader({
title,
children,
}: {
title: string;
children?: JSX.Element | JSX.Element[];
}) {
const navigation = useNavigation();
return (
<Appbar.Header>
<IconButton icon="arrow-left" onPress={navigation.goBack} />
<Appbar.Content title={title} />
{children}
</Appbar.Header>
);
}

View File

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

View File

@ -1,134 +0,0 @@
import { NavigationProp, useNavigation } from "@react-navigation/native";
import React, { useCallback, useState } from "react";
import {
GestureResponderEvent,
ListRenderItemInfo,
NativeModules,
View,
} from "react-native";
import { List, Menu, RadioButton, useTheme } from "react-native-paper";
import { Like } from "typeorm";
import { StackParams } from "./AppStack";
import CountMany from "./count-many";
import { getNow, setRepo } from "./db";
import { toast } from "./toast";
interface Props extends ListRenderItemInfo<CountMany> {
onSelect: (index: number) => void;
selected: number;
onUndo: () => void;
}
export default function StartPlanItem(props: Props) {
const { index, item, onSelect, selected, onUndo } = props;
const { colors } = useTheme();
const [anchor, setAnchor] = useState({ x: 0, y: 0 });
const [showMenu, setShowMenu] = useState(false);
const { navigate: stackNavigate } =
useNavigation<NavigationProp<StackParams>>();
const undo = useCallback(async () => {
const now = await getNow();
const created = now.split("T")[0];
const first = await setRepo.findOne({
where: {
name: item.name,
hidden: 0 as any,
created: Like(`${created}%`),
},
order: { created: "desc" },
});
setShowMenu(false);
if (!first) return toast("Nothing to undo.");
await setRepo.delete(first.id);
onUndo();
}, [setShowMenu, onUndo, item.name]);
const longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
setShowMenu(true);
},
[setShowMenu, setAnchor]
);
const edit = useCallback(async () => {
const now = await getNow();
const created = now.split("T")[0];
const first = await setRepo.findOne({
where: {
name: item.name,
hidden: 0 as any,
created: Like(`${created}%`),
},
order: { created: "desc" },
});
setShowMenu(false);
if (!first) return toast("Nothing to edit.");
stackNavigate("EditSet", { set: first });
}, [item.name, stackNavigate]);
const view = useCallback(() => {
setShowMenu(false);
stackNavigate("ViewSetList", { name: item.name });
}, [item.name, stackNavigate]);
const graph = useCallback(() => {
setShowMenu(false);
stackNavigate("ViewGraph", { name: item.name });
}, [item.name, stackNavigate]);
const left = useCallback(
() => (
<View style={{ alignItems: "center", justifyContent: "center" }}>
<RadioButton
onPress={() => onSelect(index)}
value={index.toString()}
status={selected === index ? "checked" : "unchecked"}
color={colors.primary}
/>
</View>
),
[index, selected, colors.primary, onSelect]
);
const right = useCallback(
() => (
<View
style={{
width: "25%",
justifyContent: "center",
}}
>
<Menu
anchor={anchor}
visible={showMenu}
onDismiss={() => setShowMenu(false)}
>
<Menu.Item leadingIcon="eye-outline" onPress={view} title="Peek" />
<Menu.Item
leadingIcon="chart-bell-curve-cumulative"
onPress={graph}
title="Graph"
/>
<Menu.Item leadingIcon="pencil" onPress={edit} title="Edit" />
<Menu.Item leadingIcon="undo" onPress={undo} title="Undo" />
</Menu>
</View>
),
[anchor, showMenu, edit, undo, view, graph]
);
return (
<List.Item
onLongPress={longPress}
title={item.name}
description={
item.sets ? `${item.total} / ${item.sets}` : item.total.toString()
}
onPress={() => onSelect(index)}
left={left}
right={right}
/>
);
}

View File

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

94
ViewBest.tsx Normal file
View File

@ -0,0 +1,94 @@
import {
DarkTheme,
DefaultTheme,
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native';
import * as shape from 'd3-shape';
import React, {useCallback, useContext, useEffect, useState} from 'react';
import {Text, useColorScheme, View} from 'react-native';
import {IconButton} from 'react-native-paper';
import {Grid, LineChart, XAxis, YAxis} from 'react-native-svg-charts';
import {DatabaseContext} from './App';
import {BestPageParams} from './BestPage';
import Set from './set';
import {formatMonth} from './time';
export default function ViewBest() {
const {params} = useRoute<RouteProp<BestPageParams, 'ViewBest'>>();
const [sets, setSets] = useState<Set[]>([]);
const db = useContext(DatabaseContext);
const navigation = useNavigation();
const dark = useColorScheme() === 'dark';
useFocusEffect(
useCallback(() => {
console.log(`${ViewBest.name}.useFocusEffect`);
navigation.getParent()?.setOptions({
headerLeft: () => (
<IconButton icon="arrow-back" onPress={() => navigation.goBack()} />
),
title: params.best.name,
});
}, [navigation, params.best.name]),
);
useEffect(() => {
console.log(`${ViewBest.name}.useEffect`);
const selectBest = `
SELECT max(weight) AS weight, STRFTIME('%Y-%m-%d', created) as created, unit
FROM sets
WHERE name = ?
GROUP BY name, STRFTIME('%Y-%m-%d', created)
`;
const refresh = async () => {
const [result] = await db.executeSql(selectBest, [params.best.name]);
if (result.rows.length === 0) return;
console.log(`${ViewBest.name}.${refresh.name}:`, result.rows.raw());
setSets(result.rows.raw());
};
refresh();
}, [params.best.name, db]);
const axesSvg = {fontSize: 10, fill: 'grey'};
const verticalContentInset = {top: 10, bottom: 10};
const xAxisHeight = 30;
return (
<View style={{padding: 10}}>
<Text>Best weight per day</Text>
<View style={{height: 300, padding: 20, flexDirection: 'row'}}>
<YAxis
data={sets.map(set => set.weight)}
style={{marginBottom: xAxisHeight}}
contentInset={verticalContentInset}
svg={axesSvg}
formatLabel={value => `${value}${sets[0].unit}`}
/>
<View style={{flex: 1, marginLeft: 10}}>
<LineChart
style={{flex: 1}}
data={sets.map(set => set.weight)}
contentInset={verticalContentInset}
curve={shape.curveBasis}
svg={{
stroke: dark
? DarkTheme.colors.primary
: DefaultTheme.colors.primary,
}}>
<Grid />
</LineChart>
<XAxis
style={{marginHorizontal: -10, height: xAxisHeight}}
data={sets}
formatLabel={(_value, index) => formatMonth(sets[index].created!)}
contentInset={{left: 10, right: 10}}
svg={axesSvg}
/>
</View>
</View>
</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>
</>
);
}

46
WorkoutMenu.tsx Normal file
View File

@ -0,0 +1,46 @@
import React, {useState} from 'react';
import {Button, Menu} from 'react-native-paper';
export default function DayMenu({
onSelect,
onDelete,
selected,
index,
names,
}: {
onSelect: (option: string) => void;
onDelete: () => void;
selected: string;
index: number;
names: string[];
}) {
const [show, setShow] = useState(false);
const select = (day: string) => {
onSelect(day);
setShow(false);
};
return (
<Menu
visible={show}
onDismiss={() => setShow(false)}
anchor={
<Button icon="barbell" onPress={() => setShow(true)}>
{selected || 'Pick a workout'}
</Button>
}>
{names.map(name => (
<Menu.Item
key={name}
icon={selected === name ? 'checkmark-circle' : 'ellipse'}
onPress={() => select(name)}
title={name}
/>
))}
{index > 0 && (
<Menu.Item icon="trash" title="Delete" onPress={onDelete} />
)}
</Menu>
);
}

14
__tests__/App-test.tsx Normal file
View File

@ -0,0 +1,14 @@
/**
* @format
*/
import 'react-native';
import React from 'react';
import App from '../App';
// Note: test renderer must be required after react-native.
import renderer from 'react-test-renderer';
it('renders correctly', () => {
renderer.create(<App />);
});

View File

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

View File

@ -1,214 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
rexml
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.888.0)
aws-sdk-core (3.191.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.77.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.143.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.109.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.3.0)
fastlane (2.219.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (>= 0.1.1)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.5)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.7.1)
jwt (2.7.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.4.0)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.4.0)
os (1.1.4)
plist (3.7.1)
public_suffix (5.0.4)
rake (13.1.0)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.6)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
signet (0.18.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.5.0)
word_wrap (1.0.0)
xcodeproj (1.24.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
x64-mingw-ucrt
x86_64-linux
DEPENDENCIES
fastlane
BUNDLED WITH
2.3.25

View File

@ -1,94 +1,113 @@
apply plugin: "com.android.application" apply plugin: "com.android.application"
apply plugin: "com.facebook.react"
apply plugin: "kotlin-android" apply plugin: "kotlin-android"
apply plugin: "org.jetbrains.kotlin.android"
/** import com.android.build.OutputFile
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '..'
// root = file("../")
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
// reactNativeDir = file("../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen
// codegenDir = file("../node_modules/@react-native/codegen")
// The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js
// cliFile = file("../node_modules/react-native/cli.js")
/* Variants */ project.ext.react = [
// The list of variants to that are debuggable. For those we're going to enableHermes: false, // clean and rebuild if changing
// skip the bundling of the JS bundle and the assets. By default is just 'debug'. ]
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */ 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", false);
// 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 17
versionName "2.35" versionName "1.1"
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
if (isNewArchitectureEnabled()) {
externalNativeBuild {
ndkBuild {
arguments "APP_PLATFORM=android-21",
"APP_STL=c++_shared",
"NDK_TOOLCHAIN_VERSION=clang",
"GENERATED_SRC_DIR=$buildDir/generated/source",
"PROJECT_BUILD_DIR=$buildDir",
"REACT_ANDROID_DIR=$rootDir/../node_modules/react-native/ReactAndroid",
"REACT_ANDROID_BUILD_DIR=$rootDir/../node_modules/react-native/ReactAndroid/build",
"NODE_MODULES_DIR=$rootDir/../node_modules"
cFlags "-Wall", "-Werror", "-fexceptions", "-frtti", "-DWITH_INSPECTOR=1"
cppFlags "-std=c++17"
targets "massive_appmodules"
}
}
if (!enableSeparateBuildPerCPUArchitecture) {
ndk {
abiFilters (*reactNativeArchitectures())
}
}
}
}
if (isNewArchitectureEnabled()) {
externalNativeBuild {
ndkBuild {
path "$projectDir/src/main/jni/Android.mk"
}
}
def reactAndroidProjectDir = project(':ReactAndroid').projectDir
def packageReactNdkDebugLibs = tasks.register("packageReactNdkDebugLibs", Copy) {
dependsOn(":ReactAndroid:packageReactNdkDebugLibsForBuck")
from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib")
into("$buildDir/react-ndk/exported")
}
def packageReactNdkReleaseLibs = tasks.register("packageReactNdkReleaseLibs", Copy) {
dependsOn(":ReactAndroid:packageReactNdkReleaseLibsForBuck")
from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib")
into("$buildDir/react-ndk/exported")
}
afterEvaluate {
preDebugBuild.dependsOn(packageReactNdkDebugLibs)
preReleaseBuild.dependsOn(packageReactNdkReleaseLibs)
configureNdkBuildRelease.dependsOn(preReleaseBuild)
configureNdkBuildDebug.dependsOn(preDebugBuild)
reactNativeArchitectures().each { architecture ->
tasks.findByName("configureNdkBuildDebug[${architecture}]")?.configure {
dependsOn("preDebugBuild")
}
tasks.findByName("configureNdkBuildRelease[${architecture}]")?.configure {
dependsOn("preReleaseBuild")
}
}
}
}
splits {
abi {
reset()
enable enableSeparateBuildPerCPUArchitecture
universalApk false
include (*reactNativeArchitectures())
}
} }
signingConfigs { signingConfigs {
release { release {
@ -113,34 +132,83 @@ android {
signingConfig signingConfigs.debug signingConfig signingConfigs.debug
} }
release { release {
// Caution! In production, you need to generate your own keystore file. signingConfig signingConfigs.release
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
minifyEnabled enableProguardInReleaseBuilds minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
} }
} }
applicationVariants.all { variant ->
variant.outputs.each { output ->
def versionCodes = ["armeabi-v7a": 1, "x86": 2, "arm64-v8a": 3, "x86_64": 4]
def abi = output.getFilter(OutputFile.ABI)
if (abi != null) { // null for the universal-debug, universal-release variants
output.versionCodeOverride =
defaultConfig.versionCode * 1000 + versionCodes.get(abi)
}
}
}
} }
dependencies { dependencies {
// The version of react-native is set by the React Native Gradle Plugin def work_version = "2.7.1"
implementation("com.facebook.react:react-android")
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.opencsv:opencsv:5.5.2'
implementation project(':react-native-sqlite-storage')
implementation project(':react-native-vector-icons')
implementation("com.facebook.react:flipper-integration")
if (hermesEnabled.toBoolean()) { implementation "androidx.work:work-runtime:$work_version"
implementation("com.facebook.react:hermes-android") implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.work:work-rxjava2:$work_version"
implementation "androidx.work:work-gcm:$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"
implementation project(':react-native-fs')
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

@ -9,41 +9,3 @@
# Add any project specific keep options here: # Add any project specific keep options here:
-keep public class com.horcrux.svg.** {*;} -keep public class com.horcrux.svg.** {*;}
-dontobfuscate
-keep,allowobfuscation @interface com.facebook.proguard.annotations.DoNotStrip
-keep,allowobfuscation @interface com.facebook.proguard.annotations.KeepGettersAndSetters
-keep @com.facebook.proguard.annotations.DoNotStrip class *
-keepclassmembers class * {
@com.facebook.proguard.annotations.DoNotStrip *;
}
-keep @com.facebook.proguard.annotations.DoNotStripAny class * {
*;
}
-keepclassmembers @com.facebook.proguard.annotations.KeepGettersAndSetters class * {
void set*(***);
*** get*();
}
-keep class * implements com.facebook.react.bridge.JavaScriptModule { *; }
-keep class * implements com.facebook.react.bridge.NativeModule { *; }
-keepclassmembers,includedescriptorclasses class * { native <methods>; }
-keepclassmembers class * { @com.facebook.react.uimanager.annotations.ReactProp <methods>; }
-keepclassmembers class * { @com.facebook.react.uimanager.annotations.ReactPropGroup <methods>; }
-dontwarn com.facebook.react.**
-keep,includedescriptorclasses class com.facebook.react.bridge.** { *; }
-keep,includedescriptorclasses class com.facebook.react.turbomodule.core.** { *; }
# hermes
-keep class com.facebook.jni.** { *; }
# okio
-keep class sun.misc.Unsafe { *; }
-dontwarn java.nio.file.*
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn okio.**
-keep class com.facebook.hermes.unicode.** { *; }
-keep class com.facebook.jni.** { *; }

View File

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

View File

@ -0,0 +1,73 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* <p>This source code is licensed under the MIT license found in the LICENSE file in the root
* directory of this source tree.
*/
package com.massive;
import android.content.Context;
import com.facebook.flipper.android.AndroidFlipperClient;
import com.facebook.flipper.android.utils.FlipperUtils;
import com.facebook.flipper.core.FlipperClient;
import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin;
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin;
import com.facebook.flipper.plugins.inspector.DescriptorMapping;
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
import com.facebook.flipper.plugins.react.ReactFlipperPlugin;
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
import com.facebook.react.ReactInstanceEventListener;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.network.NetworkingModule;
import okhttp3.OkHttpClient;
public class ReactNativeFlipper {
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
if (FlipperUtils.shouldEnableFlipper(context)) {
final FlipperClient client = AndroidFlipperClient.getInstance(context);
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
client.addPlugin(new ReactFlipperPlugin());
client.addPlugin(new DatabasesFlipperPlugin(context));
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
client.addPlugin(CrashReporterPlugin.getInstance());
NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin();
NetworkingModule.setCustomClientBuilder(
new NetworkingModule.CustomClientBuilder() {
@Override
public void apply(OkHttpClient.Builder builder) {
builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin));
}
});
client.addPlugin(networkFlipperPlugin);
client.start();
// Fresco Plugin needs to ensure that ImagePipelineFactory is initialized
// Hence we run if after all native modules have been initialized
ReactContext reactContext = reactInstanceManager.getCurrentReactContext();
if (reactContext == null) {
reactInstanceManager.addReactInstanceEventListener(
new ReactInstanceEventListener() {
@Override
public void onReactContextInitialized(ReactContext reactContext) {
reactInstanceManager.removeReactInstanceEventListener(this);
reactContext.runOnNativeModulesQueueThread(
new Runnable() {
@Override
public void run() {
client.addPlugin(new FrescoFlipperPlugin());
}
});
}
});
} else {
client.addPlugin(new FrescoFlipperPlugin());
}
}
}
}

View File

@ -1,28 +1,26 @@
<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"> 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.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
android:name="android.permission.ACCESS_NETWORK_STATE" <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
tools:node="remove" />
<uses-permission
android:name="com.google.android.gms.permission.AD_ID"
tools:node="remove" />
<application <application
android:name=".MainApplication" android:name=".MainApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme"
android:requestLegacyExternalStorage="true"
android:dataExtractionRules="@xml/data_extraction_rules">
<meta-data
android:name="com.dieam.reactnativepushnotification.notification_foreground"
android:value="false" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
@ -34,27 +32,9 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:exported="true" android:process=":remote" android:name=".StopAlarm" />
<activity <service android:name=".StopTimer" android:exported="true" android:process=":remote" />
android:name=".TimerDone" <service android:name=".AlarmService" android:exported="true" />
android:exported="false"> <service android:name=".TimerService" android:exported="true" />
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<activity
android:name=".StopAlarm"
android:exported="true"
android:process=":remote" />
<service
android:name=".TimerService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="App does not require SCHEDULE_EXACT_ALARM or USE_EXACT_ALARM, but needs foreground service for foreground timer."/>
</service>
</application> </application>
</manifest> </manifest>

View File

@ -1,25 +1,55 @@
package com.massive package com.massive
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.facebook.react.bridge.* import com.facebook.react.bridge.Callback
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
@RequiresApi(Build.VERSION_CODES.O)
class AlarmModule(context: ReactApplicationContext?) : class AlarmModule internal constructor(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) { ReactContextBaseJavaModule(context) {
override fun getName(): String { override fun getName(): String {
return "AlarmModule" return "AlarmModule"
} }
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod @ReactMethod
fun timer(milliseconds: Int, description: String) { fun timer(milliseconds: Int, vibrate: Boolean) {
Log.d("AlarmModule", "Queue alarm for $milliseconds delay") Log.d("AlarmModule", "Queue alarm for $milliseconds delay")
val intent = Intent(reactApplicationContext, TimerService::class.java) val intent = Intent(reactApplicationContext, TimerService::class.java)
intent.putExtra("milliseconds", milliseconds) intent.putExtra("milliseconds", milliseconds)
intent.putExtra("description", description) intent.putExtra("vibrate", vibrate)
reactApplicationContext.startForegroundService(intent) reactApplicationContext.startService(intent)
}
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun ignoringBattery(callback: Callback) {
val packageName = reactApplicationContext.packageName
val pm =
reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
callback.invoke(pm.isIgnoringBatteryOptimizations(packageName))
} else {
callback.invoke(true)
}
}
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun openSettings() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.parse("package:" + reactApplicationContext.packageName)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
reactApplicationContext.startActivity(intent)
} }
} }

View File

@ -0,0 +1,53 @@
package com.massive
import android.app.Service
import android.media.MediaPlayer.OnPreparedListener
import android.media.MediaPlayer
import android.os.Vibrator
import androidx.annotation.RequiresApi
import android.content.Intent
import android.media.AudioAttributes
import android.os.Build
import android.os.VibrationEffect
import android.os.IBinder
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
}
mediaPlayer = MediaPlayer.create(applicationContext, R.raw.argon)
mediaPlayer?.start()
mediaPlayer?.setOnCompletionListener { vibrator?.cancel() }
val pattern = longArrayOf(0, 300, 1300, 300, 1300, 300)
vibrator = applicationContext.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

@ -1,31 +1,28 @@
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
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 {
return DefaultReactActivityDelegate(
this,
mainComponentName, // If you opted-in for the New Architecture, we enable the Fabric Renderer.
fabricEnabled
)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun createReactActivityDelegate(): ReactActivityDelegate {
super.onCreate(null) return MainActivityDelegate(this, mainComponentName)
}
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,72 @@
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 org.pgsqlite.SQLitePluginPackage
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(SQLitePluginPackage())
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()
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()
}
}
} }
ReactNativeFlipper.initializeFlipper(this, reactNativeHost.reactInstanceManager)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,358 +1,150 @@
package com.massive package com.massive
import android.Manifest
import android.annotation.SuppressLint
import android.app.* import android.app.*
import android.content.ActivityNotFoundException import android.app.NotificationManager.IMPORTANCE_HIGH
import android.content.BroadcastReceiver import android.app.NotificationManager.IMPORTANCE_LOW
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.os.Build
import android.content.pm.PackageManager import android.os.CountDownTimer
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE import android.os.IBinder
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.*
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import kotlin.math.floor
class Settings(val sound: String?, val noSound: Boolean, val vibrate: Boolean, val duration: Long)
@RequiresApi(Build.VERSION_CODES.O)
class TimerService : Service() { class TimerService : Service() {
private var manager: NotificationManager? = null
private var endMs: Int? = null
private var currentMs: Long? = null
private var countdownTimer: CountDownTimer? = null
private var vibrate: Boolean = true
private lateinit var timerHandler: Handler @RequiresApi(Build.VERSION_CODES.O)
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 { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null && intent.action == TIMER_EXPIRED) onTimerExpired() Log.d("TimerService", "Started timer service.")
else onTimerStart(intent) Log.d("TimerService", "endMs=$endMs,currentMs=$currentMs")
return START_STICKY vibrate = intent!!.extras!!.getBoolean("vibrate")
if (intent.action == "add") {
manager?.cancel(NOTIFICATION_ID_DONE)
endMs = currentMs!!.toInt().plus(60000)
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
} else {
endMs = intent.extras!!.getInt("milliseconds")
}
Log.d("TimerService", "endMs=$endMs,currentMs=$currentMs")
manager = getManager(applicationContext)
val builder = getBuilder(applicationContext)
countdownTimer?.cancel()
countdownTimer = getTimer(builder)
countdownTimer!!.start()
return super.onStartCommand(intent, flags, startId)
} }
override fun onDestroy() { private fun getTimer(builder: NotificationCompat.Builder): CountDownTimer {
super.onDestroy() return object : CountDownTimer(endMs!!.toLong(), 1000) {
timerRunnable?.let { timerHandler.removeCallbacks(it) } override fun onTick(current: Long) {
applicationContext.unregisterReceiver(stopReceiver) currentMs = current
applicationContext.unregisterReceiver(addReceiver) val seconds = floor((current / 1000).toDouble() % 60)
mediaPlayer?.stop() .toInt().toString().padStart(2, '0')
mediaPlayer?.release() val minutes = floor((current / 1000).toDouble() / 60)
vibrator?.cancel() .toInt().toString().padStart(2, '0')
builder.setContentText("$minutes:$seconds")
.setAutoCancel(false)
.setDefaults(0)
.setProgress(endMs!!, current.toInt(), false)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.priority = NotificationCompat.PRIORITY_LOW
manager?.notify(NOTIFICATION_ID_PENDING, builder.build())
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onFinish() {
val finishIntent = Intent(applicationContext, StopAlarm::class.java)
val finishPending =
PendingIntent.getActivity(
applicationContext,
0,
finishIntent,
PendingIntent.FLAG_IMMUTABLE
)
builder.setContentText("Timer finished.")
.setAutoCancel(true)
.setProgress(0, 0, false)
.setOngoing(false)
.setContentIntent(finishPending)
.setChannelId(CHANNEL_ID_DONE)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.priority = NotificationCompat.PRIORITY_HIGH
manager?.notify(NOTIFICATION_ID_DONE, builder.build())
manager?.cancel(NOTIFICATION_ID_PENDING)
val alarmIntent = Intent(applicationContext, AlarmService::class.java)
alarmIntent.putExtra("vibrate", vibrate)
applicationContext.startService(alarmIntent)
}
}
} }
override fun onBind(intent: Intent?): IBinder? { override fun onBind(p0: Intent?): IBinder? {
return null return null
} }
@SuppressLint("BatteryLife") override fun onDestroy() {
fun battery() { Log.d("TimerService", "Destroying...")
val powerManager = countdownTimer?.cancel()
applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager manager?.cancel(NOTIFICATION_ID_PENDING)
val ignoring = manager?.cancel(NOTIFICATION_ID_DONE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) super.onDestroy()
powerManager.isIgnoringBatteryOptimizations(
applicationContext.packageName
)
else true
if (ignoring) return
val intent = Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.parse("package:" + applicationContext.packageName)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
try {
applicationContext.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(
applicationContext,
"Requests to ignore battery optimizations are disabled on your device.",
Toast.LENGTH_LONG
).show()
}
} }
@SuppressLint("Range") @RequiresApi(Build.VERSION_CODES.M)
private fun getSettings(): Settings { private fun getBuilder(context: Context): NotificationCompat.Builder {
val db = DatabaseHelper(applicationContext).readableDatabase val contentIntent = Intent(context, MainActivity::class.java)
val cursor = db.rawQuery("SELECT sound, noSound, vibrate, duration FROM settings", null) val pendingContent =
cursor.moveToFirst() PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
val sound = cursor.getString(cursor.getColumnIndex("sound")) val stopIntent = Intent(context, StopTimer::class.java)
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 = val pendingStop =
PendingIntent.getBroadcast( PendingIntent.getService(context, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
applicationContext, val addIntent = Intent(context, TimerService::class.java)
0, addIntent.action = "add"
stopBroadcast, addIntent.putExtra("vibrate", vibrate)
PendingIntent.FLAG_IMMUTABLE val pendingAdd = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
) PendingIntent.getService(context, 0, addIntent, PendingIntent.FLAG_MUTABLE)
} else {
val builder = NotificationCompat.Builder(this, channelId) PendingIntent.getService(context, 0, addIntent, PendingIntent.FLAG_UPDATE_CURRENT)
.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()) return NotificationCompat.Builder(context, CHANNEL_ID_PENDING)
.setSmallIcon(R.drawable.ic_baseline_hourglass_bottom_24)
.setContentTitle("Resting")
.setContentIntent(pendingContent)
.addAction(R.drawable.ic_baseline_stop_24, "Stop", pendingStop)
.addAction(R.drawable.ic_baseline_stop_24, "Add 1 min", pendingAdd)
} }
private fun updateNotification(seconds: Int) { @RequiresApi(Build.VERSION_CODES.O)
val notificationManager = NotificationManagerCompat.from(this) private fun getManager(context: Context): NotificationManager {
val notification = getProgress(seconds) val alarmsChannel = NotificationChannel(
if (ActivityCompat.checkSelfPermission( CHANNEL_ID_DONE,
this, CHANNEL_ID_DONE,
Manifest.permission.POST_NOTIFICATIONS IMPORTANCE_HIGH
) != PackageManager.PERMISSION_GRANTED )
) { alarmsChannel.description = "Alarms for rest timers."
return alarmsChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
} val notificationManager = context.getSystemService(
notificationManager.notify(ONGOING_ID, notification.build()) NotificationManager::class.java
} )
notificationManager.createNotificationChannel(alarmsChannel)
private fun formatTime(timeInSeconds: Int): String { val timersChannel =
val minutes = timeInSeconds / 60 NotificationChannel(CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, IMPORTANCE_LOW)
val seconds = timeInSeconds % 60 timersChannel.description = "Progress on rest timers."
return String.format("%02d:%02d", minutes, seconds) notificationManager.createNotificationChannel(timersChannel)
return notificationManager
} }
companion object { companion object {
const val STOP_BROADCAST = "stop-timer-event" private const val CHANNEL_ID_PENDING = "MassiveTimer"
const val ADD_BROADCAST = "add-timer-event" private const val CHANNEL_ID_DONE = "MassiveDone"
const val TIMER_EXPIRED = "timer-expired-event" private const val NOTIFICATION_ID_PENDING = 1
const val ONGOING_ID = 1 private const val NOTIFICATION_ID_DONE = 2
const val FINISHED_ID = 1
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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