Compare commits

..

3 Commits

Author SHA1 Message Date
Brandon Presley 35e528957a Pause upgrading react-native
Got to the point where I can build + run it
but I'm getting the following error at runtime:

03-03 18:17:39.092  3602  3602 E AndroidRuntime: java.lang.RuntimeException: Unable to instantiate application com.massive.MainApplication package com.massive: java.lang.ClassNotFoundException: Didn't find class "com.massive.MainApplication" on path: DexPathList[[zip file "/data/app/~~eX7muEXu_SHk8LVSAcbWTg==/com.massive-vH0P5ufFnkS84blAZek3Dw==/base.apk"],nativeLibraryDirectories=[/data/app/~~eX7muEXu_SHk8LVSAcbWTg==/com.massive-vH0P5ufFnkS84blAZek3Dw==/lib/arm64, /data/app/~~eX7muEXu_SHk8LVSAcbWTg==/com.massive-vH0P5ufFnkS84blAZek3Dw==/base.apk!/lib/arm64-v8a, /system/lib64, /system_ext/lib64]]
2023-03-03 18:18:51 +13:00
Brandon Presley a3818de8a1 Merge branch 'upgrade' 2023-03-03 17:09:33 +13:00
Brandon Presley e09a1c23f3 Pause upgrading 2023-03-02 18:39:00 +13:00
198 changed files with 16655 additions and 18614 deletions

View File

@ -1,6 +0,0 @@
[android]
target = Google Inc.:Google APIs:23
[maven_repositories]
central = https://repo1.maven.org/maven2

View File

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

7
.gitignore vendored
View File

@ -37,12 +37,6 @@ node_modules/
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
# BUCK
buck-out/
\.buckd/
*.keystore
!debug.keystore
# fastlane # fastlane
# #
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
@ -74,4 +68,3 @@ massive-build
!.yarn/sdks !.yarn/sdks
!.yarn/versions !.yarn/versions
coverage coverage
profiles

1
.node-version Normal file
View File

@ -0,0 +1 @@
18

8
.prettierrc.js Normal file
View File

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

View File

@ -1 +1 @@
2.7.5 2.7.6

174
App.tsx
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

98
BestList.tsx Normal file
View File

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

22
BestPage.tsx Normal file
View File

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

68
Chart.tsx Normal file
View File

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

View File

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

109
Daily.tsx
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

189
EditWorkout.tsx Normal file
View File

@ -0,0 +1,189 @@
import {
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native'
import {useCallback, useRef, useState} from 'react'
import {ScrollView, TextInput, View} from 'react-native'
import DocumentPicker from 'react-native-document-picker'
import {Button, Card, TouchableRipple} from 'react-native-paper'
import AppInput from './AppInput'
import ConfirmDialog from './ConfirmDialog'
import {MARGIN, PADDING} from './constants'
import {getNow, planRepo, setRepo, settingsRepo} from './db'
import {defaultSet} from './gym-set'
import Settings from './settings'
import StackHeader from './StackHeader'
import {WorkoutsPageParams} from './WorkoutsPage'
export default function EditWorkout() {
const {params} = useRoute<RouteProp<WorkoutsPageParams, 'EditWorkout'>>()
const [removeImage, setRemoveImage] = useState(false)
const [showRemove, setShowRemove] = useState(false)
const [name, setName] = useState(params.value.name)
const [steps, setSteps] = useState(params.value.steps)
const [uri, setUri] = useState(params.value.image)
const [minutes, setMinutes] = useState(
params.value.minutes?.toString() ?? '3',
)
const [seconds, setSeconds] = useState(
params.value.seconds?.toString() ?? '30',
)
const [sets, setSets] = useState(params.value.sets?.toString() ?? '3')
const navigation = useNavigation()
const setsRef = useRef<TextInput>(null)
const stepsRef = useRef<TextInput>(null)
const minutesRef = useRef<TextInput>(null)
const secondsRef = useRef<TextInput>(null)
const [settings, setSettings] = useState<Settings>()
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({where: {}}).then(setSettings)
}, []),
)
const update = async () => {
await setRepo.update(
{name: params.value.name},
{
name: name || params.value.name,
sets: Number(sets),
minutes: +minutes,
seconds: +seconds,
steps,
image: removeImage ? '' : uri,
},
)
await planRepo.query(
`UPDATE plans
SET workouts = REPLACE(workouts, $1, $2)
WHERE workouts LIKE $3`,
[params.value.name, name, `%${params.value.name}%`],
)
navigation.goBack()
}
const add = async () => {
const now = await getNow()
await setRepo.save({
...defaultSet,
name,
hidden: true,
image: uri,
minutes: minutes ? +minutes : 3,
seconds: seconds ? +seconds : 30,
sets: sets ? +sets : 3,
steps,
created: now,
})
navigation.goBack()
}
const save = async () => {
if (params.value.name) return update()
return add()
}
const changeImage = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: 'documentDirectory',
})
if (fileCopyUri) setUri(fileCopyUri)
}, [])
const handleRemove = useCallback(async () => {
setUri('')
setRemoveImage(true)
setShowRemove(false)
}, [])
const submitName = () => {
if (settings.steps) stepsRef.current?.focus()
else setsRef.current?.focus()
}
return (
<>
<StackHeader title={params.value.name ? 'Edit workout' : 'Add workout'} />
<View style={{padding: PADDING, flex: 1}}>
<ScrollView style={{flex: 1}}>
<AppInput
autoFocus
label="Name"
value={name}
onChangeText={setName}
onSubmitEditing={submitName}
/>
{settings?.steps && (
<AppInput
innerRef={stepsRef}
selectTextOnFocus={false}
value={steps}
onChangeText={setSteps}
label="Steps"
multiline
onSubmitEditing={() => setsRef.current?.focus()}
/>
)}
<AppInput
innerRef={setsRef}
value={sets}
onChangeText={setSets}
label="Sets per workout"
keyboardType="numeric"
onSubmitEditing={() => minutesRef.current?.focus()}
/>
{settings?.alarm && (
<>
<AppInput
innerRef={minutesRef}
onSubmitEditing={() => secondsRef.current?.focus()}
value={minutes}
onChangeText={setMinutes}
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={() => setShowRemove(true)}>
<Card.Cover source={{uri}} />
</TouchableRipple>
)}
{settings?.images && !uri && (
<Button
style={{marginBottom: MARGIN}}
onPress={changeImage}
icon="add-photo-alternate">
Image
</Button>
)}
</ScrollView>
<Button disabled={!name} mode="contained" icon="save" onPress={save}>
Save
</Button>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemove}
setShow={setShowRemove}>
Are you sure you want to remove the image?
</ConfirmDialog>
</View>
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

18
HomePage.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

20
PlanPage.tsx Normal file
View File

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

View File

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

View File

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

56
Routes.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -1,605 +1,323 @@
import { NavigationProp, useNavigation } from "@react-navigation/native"; import {NavigationProp, useNavigation} from '@react-navigation/native'
import { format } from "date-fns"; import {format} from 'date-fns'
import { useCallback, useEffect, useMemo, useState } from "react"; import {useCallback, useEffect, useMemo, useState} from 'react'
import { useForm } from "react-hook-form"; import {useForm} from 'react-hook-form'
import { FlatList, NativeModules } from "react-native"; import {NativeModules, ScrollView, View} from 'react-native'
import DocumentPicker from "react-native-document-picker"; import DocumentPicker from 'react-native-document-picker'
import { Dirs, FileSystem } from "react-native-file-access"; import {Dirs, FileSystem} from 'react-native-file-access'
import { Button } from "react-native-paper"; import {Button, Subheading} from 'react-native-paper'
import AppInput from "./AppInput"; import ConfirmDialog from './ConfirmDialog'
import ConfirmDialog from "./ConfirmDialog"; import {ITEM_PADDING, MARGIN} from './constants'
import { PADDING } from "./constants"; import {AppDataSource} from './data-source'
import { AppDataSource } from "./data-source"; import {setRepo, settingsRepo} from './db'
import { setRepo, settingsRepo } from "./db"; import {DrawerParamList} from './drawer-param-list'
import { DrawerParams } from "./drawer-params"; import DrawerHeader from './DrawerHeader'
import DrawerHeader from "./DrawerHeader"; import Input from './input'
import { darkOptions, lightOptions, themeOptions } from "./options"; import {darkOptions, lightOptions, themeOptions} from './options'
import Page from "./Page"; import Page from './Page'
import Select from "./Select"; import Select from './Select'
import Settings from "./settings"; import Settings from './settings'
import Switch from "./Switch"; import Switch from './Switch'
import { toast } from "./toast"; import {toast} from './toast'
import { useAppTheme } from "./use-theme"; import {useTheme} from './use-theme'
const twelveHours = [ const twelveHours = ['P', 'Pp', 'ccc p', 'p', 'yyyy-MM-d', 'yyyy.MM.d']
"dd/LL/yyyy", const twentyFours = ['P', 'P, k:m', 'ccc k:m', 'k:m', 'yyyy-MM-d', 'yyyy.MM.d']
"dd/LL/yyyy, p",
"ccc p",
"p",
"yyyy-MM-dd",
"yyyy-MM-dd, p",
"yyyy.MM.dd",
];
const twentyFours = [
"dd/LL/yyyy",
"dd/LL/yyyy, k:mm",
"ccc k:mm",
"k:mm",
"yyyy-MM-dd",
"yyyy-MM-dd, k:mm",
"yyyy.MM.dd",
];
interface Item {
name: string;
renderItem: (name: string) => React.JSX.Element;
}
export default function SettingsPage() { export default function SettingsPage() {
const [ignoring, setIgnoring] = useState(false); const [ignoring, setIgnoring] = useState(false)
const [term, setTerm] = useState(""); const [term, setTerm] = useState('')
const [formatOptions, setFormatOptions] = useState<string[]>(twelveHours); const [formatOptions, setFormatOptions] = useState<string[]>(twelveHours)
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false)
const [deleting, setDeleting] = useState(false); const {reset} = useNavigation<NavigationProp<DrawerParamList>>()
const [error, setError] = useState("");
const { reset } = useNavigation<NavigationProp<DrawerParams>>();
const { watch, setValue } = useForm<Settings>({ const {watch, setValue} = useForm<Settings>({
defaultValues: () => settingsRepo.findOne({ where: {} }), defaultValues: () => settingsRepo.findOne({where: {}}),
}); })
const settings = watch(); const settings = watch()
const { const {theme, setTheme, lightColor, setLightColor, darkColor, setDarkColor} =
theme, useTheme()
setTheme,
lightColor,
setLightColor,
darkColor,
setDarkColor,
} = useAppTheme();
useEffect(() => { useEffect(() => {
NativeModules.SettingsModule.ignoringBattery().then(setIgnoring); NativeModules.SettingsModule.ignoringBattery(setIgnoring)
NativeModules.SettingsModule.is24().then((is24: boolean) => { NativeModules.SettingsModule.is24().then((is24: boolean) => {
console.log(`${SettingsPage.name}.focus:`, { is24 }); console.log(`${SettingsPage.name}.focus:`, {is24})
if (is24) setFormatOptions(twentyFours); if (is24) setFormatOptions(twentyFours)
else setFormatOptions(twelveHours); else setFormatOptions(twelveHours)
}); })
}, []); }, [])
const backupString = useMemo(() => { const update = useCallback((key: keyof Settings, value: unknown) => {
if (!settings.backupDir) return null; return settingsRepo
const split = decodeURIComponent(settings.backupDir).split(":"); .createQueryBuilder()
return split.pop(); .update()
}, [settings.backupDir]); .set({[key]: value})
.printSql()
.execute()
}, [])
const soundString = useMemo(() => { const soundString = useMemo(() => {
if (!settings.sound) return null; if (!settings.sound) return null
const split = settings.sound.split("/"); const split = settings.sound.split('/')
return split.pop(); return split.pop()
}, [settings.sound]); }, [settings.sound])
const confirmDelete = useCallback(async () => { const changeSound = useCallback(async () => {
setDeleting(false); const {fileCopyUri} = await DocumentPicker.pickSingle({
await AppDataSource.dropDatabase(); type: 'audio/*',
await AppDataSource.destroy(); copyTo: 'documentDirectory',
await AppDataSource.initialize(); })
toast("Database deleted."); if (!fileCopyUri) return
}, []); setValue('sound', fileCopyUri)
await update('sound', fileCopyUri)
toast('Sound will play after rest timers.')
}, [setValue, update])
const switches: Input<boolean>[] = useMemo(
() => [
{name: 'Rest timers', value: settings.alarm, key: 'alarm'},
{name: 'Vibrate', value: settings.vibrate, key: 'vibrate'},
{name: 'Disable sound', value: settings.noSound, key: 'noSound'},
{name: 'Notifications', value: settings.notify, key: 'notify'},
{name: 'Show images', value: settings.images, key: 'images'},
{name: 'Show unit', value: settings.showUnit, key: 'showUnit'},
{name: 'Show steps', value: settings.steps, key: 'steps'},
{name: 'Show date', value: settings.showDate, key: 'showDate'},
],
[settings],
)
const filter = useCallback(
({name}) => name.toLowerCase().includes(term.toLowerCase()),
[term],
)
const changeBoolean = useCallback(
async (key: keyof Settings, value: boolean) => {
setValue(key, value)
await update(key, value)
switch (key) {
case 'alarm':
if (value) toast('Timers will now run after each set.')
else toast('Stopped timers running after each set.')
if (value && !ignoring) NativeModules.SettingsModule.ignoreBattery()
return
case 'vibrate':
if (value) toast('Alarms will now vibrate.')
else toast('Alarms will no longer vibrate.')
return
case 'notify':
if (value) toast('Show notifications for new records.')
else toast('Stopped notifications for new records.')
return
case 'images':
if (value) toast('Show images for sets.')
else toast('Hid images for sets.')
return
case 'showUnit':
if (value) toast('Show option to select unit for sets.')
else toast('Hid unit option for sets.')
return
case 'steps':
if (value) toast('Show steps for a workout.')
else toast('Hid steps for workouts.')
return
case 'showDate':
if (value) toast('Show date for sets.')
else toast('Hid date on sets.')
return
case 'noSound':
if (value) toast('Disable sound on rest timer alarms.')
else toast('Enabled sound for rest timer alarms.')
return
}
},
[ignoring, setValue, update],
)
const renderSwitch = useCallback(
(item: Input<boolean>) => (
<Switch
key={item.name}
value={item.value}
onChange={value => changeBoolean(item.key, value)}
title={item.name}
/>
),
[changeBoolean],
)
const switchesMarkup = useMemo(
() => switches.filter(filter).map(s => renderSwitch(s)),
[filter, switches, renderSwitch],
)
const changeString = useCallback(
async (key: keyof Settings, value: string) => {
setValue(key, value)
await update(key, value)
switch (key) {
case 'date':
return toast('Changed date format')
case 'darkColor':
setDarkColor(value)
return toast('Set primary color for dark mode.')
case 'lightColor':
setLightColor(value)
return toast('Set primary color for light mode.')
case 'vibrate':
return toast('Set primary color for light mode.')
case 'sound':
return toast('Sound will play after rest timers.')
case 'theme':
setTheme(value as string)
if (value === 'dark') toast('Theme will always be dark.')
else if (value === 'light') toast('Theme will always be light.')
else if (value === 'system') toast('Theme will follow system.')
return
}
},
[update, setTheme, setDarkColor, setLightColor, setValue],
)
const selects: Input<string>[] = useMemo(() => {
const today = new Date()
return [
{name: 'Theme', value: theme, items: themeOptions, key: 'theme'},
{
name: 'Dark color',
value: darkColor,
items: lightOptions,
key: 'darkColor',
},
{
name: 'Light color',
value: lightColor,
items: darkOptions,
key: 'lightColor',
},
{
name: 'Date format',
value: settings.date,
items: formatOptions.map(option => ({
label: format(today, option),
value: option,
})),
key: 'date',
},
]
}, [settings.date, darkColor, formatOptions, theme, lightColor])
const renderSelect = useCallback(
(item: Input<string>) => (
<Select
key={item.name}
value={item.value}
onChange={value => changeString(item.key, value)}
label={item.name}
items={item.items}
/>
),
[changeString],
)
const selectsMarkup = useMemo(
() => selects.filter(filter).map(renderSelect),
[filter, selects, renderSelect],
)
const confirmImport = useCallback(async () => { const confirmImport = useCallback(async () => {
setImporting(false); setImporting(false)
await FileSystem.cp( await AppDataSource.destroy()
Dirs.DatabaseDir + "/massive.db", const result = await DocumentPicker.pickSingle()
Dirs.DatabaseDir + "/massive-backup.db" await FileSystem.cp(result.uri, Dirs.DatabaseDir + '/massive.db')
); await AppDataSource.initialize()
await AppDataSource.destroy(); await setRepo.createQueryBuilder().update().set({image: null}).execute()
const file = await DocumentPicker.pickSingle(); await update('sound', null)
if (!file.uri.endsWith('.db')) NativeModules.SettingsModule.ignoringBattery(
return toast("File name must end with .db") async (isIgnoring: boolean) => {
await FileSystem.cp(file.uri, Dirs.DatabaseDir + "/massive.db"); const {alarm} = await settingsRepo.findOne({where: {}})
if (alarm && !isIgnoring) NativeModules.SettingsModule.ignoreBattery()
reset({index: 0, routes: [{name: 'Settings'}]})
},
)
}, [reset, update])
try { const exportDatabase = useCallback(async () => {
await AppDataSource.initialize(); const path = Dirs.DatabaseDir + '/massive.db'
} catch (e) { await FileSystem.cpExternal(path, 'massive.db', 'downloads')
setError(e.toString()); toast('Database exported. Check downloads.')
await FileSystem.cp( }, [])
Dirs.DatabaseDir + "/massive-backup.db",
Dirs.DatabaseDir + "/massive.db"
);
await AppDataSource.initialize();
return;
}
await setRepo.update({}, { image: null }); const buttons = useMemo(
await settingsRepo.update({}, { sound: null, backup: false }); () => [
reset({ index: 0, routes: [{ name: "Settings" }] }); {
toast("Imported database successfully.") name: 'Alarm sound',
}, [reset]); element: (
<View
key="alarm-sound"
style={{
flexDirection: 'row',
alignItems: 'center',
paddingLeft: ITEM_PADDING,
}}>
<Subheading style={{width: 100}}>Alarm sound</Subheading>
<Button onPress={changeSound}>{soundString || 'Default'}</Button>
</View>
),
},
{
name: 'Export database',
element: (
<Button
key="export-db"
style={{alignSelf: 'flex-start'}}
onPress={exportDatabase}>
Export database
</Button>
),
},
{
name: 'Import database',
element: (
<Button
key="import-db"
style={{alignSelf: 'flex-start'}}
onPress={() => setImporting(true)}>
Import database
</Button>
),
},
],
[changeSound, exportDatabase, soundString],
)
const today = new Date(); const buttonsMarkup = useMemo(
() => buttons.filter(filter).map(b => b.element),
const data: Item[] = [ [buttons, filter],
{ )
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 ( return (
<> <>
<DrawerHeader name="Settings" /> <DrawerHeader name="Settings" />
<Page term={term} search={setTerm}> <Page term={term} search={setTerm} style={{flexGrow: 1}}>
<FlatList <ScrollView style={{marginTop: MARGIN, flex: 1}}>
data={data.filter((item) => {switchesMarkup}
item.name.toLowerCase().includes(term.toLowerCase()) {selectsMarkup}
)} {buttonsMarkup}
renderItem={({ item }) => item.renderItem(item.name)} </ScrollView>
style={{ flex: 1, paddingTop: PADDING }}
/>
</Page> </Page>
<ConfirmDialog
title="Failed to import database"
onOk={() => setError("")}
setShow={() => setError("")}
show={!!error}
>
{error}
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
title="Are you sure?" title="Are you sure?"
onOk={confirmImport} onOk={confirmImport}
setShow={setImporting} setShow={setImporting}
show={importing} show={importing}>
>
Importing a database overwrites your current data. This action cannot be Importing a database overwrites your current data. This action cannot be
reversed! reversed!
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog
title="Are you sure?"
onOk={confirmDelete}
setShow={setDeleting}
show={deleting}
>
Deleting your database wipes your current data. This action cannot be
reversed!
</ConfirmDialog>
</> </>
); )
} }

View File

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

View File

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

View File

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

View File

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

74
TimerPage.tsx Normal file
View File

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

137
ViewBest.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

87
WorkoutItem.tsx Normal file
View File

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

122
WorkoutList.tsx Normal file
View File

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

23
WorkoutsPage.tsx Normal file
View File

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

View File

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

View File

@ -1,7 +1,7 @@
apply plugin: "com.android.application" apply plugin: "com.android.application"
apply plugin: "com.facebook.react" apply plugin: "com.facebook.react"
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. * This is the configuration block to customize your React Native Android app.
@ -13,8 +13,8 @@ react {
// root = file("../") // root = file("../")
// The folder where the react-native NPM package is. Default is ../node_modules/react-native // The folder where the react-native NPM package is. Default is ../node_modules/react-native
// reactNativeDir = file("../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 // The folder where the react-native Codegen package is. Default is ../node_modules/react-native-codegen
// codegenDir = file("../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 // 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") // cliFile = file("../node_modules/react-native/cli.js")
@ -52,10 +52,18 @@ react {
// hermesFlags = ["-O", "-output-source-map"] // hermesFlags = ["-O", "-output-source-map"]
} }
/**
* Set this to true to create four separate APKs instead of one,
* one for each native architecture. This is useful if you don't
* use App Bundles (https://developer.android.com/guide/app-bundle/)
* and want to have separate APKs to upload to the Play Store.
*/
def enableSeparateBuildPerCPUArchitecture = false
/** /**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode. * Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/ */
def enableProguardInReleaseBuilds = true def enableProguardInReleaseBuilds = false
/** /**
* The preferred build flavor of JavaScriptCore (JSC) * The preferred build flavor of JavaScriptCore (JSC)
@ -70,25 +78,37 @@ def enableProguardInReleaseBuilds = true
*/ */
def jscFlavor = 'org.webkit:android-jsc:+' def jscFlavor = 'org.webkit:android-jsc:+'
/**
* Private function to get the list of Native Architectures you want to build.
* This reads the value from reactNativeArchitectures in your gradle.properties
* file and works together with the --active-arch-only flag of react-native run-android.
*/
def reactNativeArchitectures() {
def value = project.getProperties().get("reactNativeArchitectures")
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
}
android { android {
ndkVersion rootProject.ext.ndkVersion ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion compileSdkVersion rootProject.ext.compileSdkVersion
compileSdk rootProject.ext.compileSdkVersion
namespace "com.massive" namespace "com.massive"
lintOptions {
checkReleaseBuilds false
abortOnError false
}
defaultConfig { defaultConfig {
applicationId "com.massive" applicationId "com.massive"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 36250 versionCode 36154
versionName "2.35" versionName "1.128"
}
splits {
abi {
reset()
enable enableSeparateBuildPerCPUArchitecture
universalApk false // If true, also generate a universal APK
include(*reactNativeArchitectures())
}
} }
signingConfigs { signingConfigs {
release { release {
@ -106,6 +126,10 @@ android {
keyAlias MYAPP_UPLOAD_KEY_ALIAS keyAlias MYAPP_UPLOAD_KEY_ALIAS
keyPassword MYAPP_UPLOAD_KEY_PASSWORD keyPassword MYAPP_UPLOAD_KEY_PASSWORD
} }
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
} }
} }
buildTypes { buildTypes {
@ -120,18 +144,40 @@ android {
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
} }
} }
// applicationVariants are e.g. debug, release
applicationVariants.all { variant ->
variant.outputs.each { output ->
// For each separate APK per architecture, set a unique version code as described here:
// https://developer.android.com/studio/build/configure-apk-splits.html
// Example: versionCode 1 will generate 1001 for armeabi-v7a, 1002 for x86, etc.
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 // The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android") 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")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
implementation 'androidx.constraintlayout:constraintlayout:2.1.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "androidx.core:core-ktx:1.6.0"
implementation "androidx.appcompat:appcompat:1.3.1"
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
exclude group: 'com.squareup.okhttp3', module: 'okhttp'
}
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}")
if (hermesEnabled.toBoolean()) { if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android") implementation("com.facebook.react:hermes-android")
} else { } else {
@ -140,7 +186,3 @@ dependencies {
} }
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']
]
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"

Binary file not shown.

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

@ -1,28 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
package="com.massive">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission <uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE" android:name="android.permission.ACCESS_NETWORK_STATE"
tools:node="remove" /> tools:node="remove" />
<uses-permission
android:name="com.google.android.gms.permission.AD_ID"
tools:node="remove" />
<application <application
android:name=".MainApplication" android:name=".MainApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme">
<activity
android:name=".TimerDone"
android:exported="false">
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
@ -31,30 +36,17 @@
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".TimerDone"
android:exported="false">
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<activity <activity
android:name=".StopAlarm" android:name=".StopAlarm"
android:exported="true" android:exported="true"
android:process=":remote" /> android:process=":remote" />
<service <service
android:name=".TimerService" android:name=".AlarmService"
android:exported="false" android:exported="false" />
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="App does not require SCHEDULE_EXACT_ALARM or USE_EXACT_ALARM, but needs foreground service for foreground timer."/>
</service>
</application> </application>
</manifest> </manifest>

View File

@ -1,25 +1,191 @@
package com.massive package com.massive
import android.annotation.SuppressLint
import android.app.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Build import android.os.Build
import android.os.CountDownTimer
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.facebook.react.bridge.* import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule
import kotlin.math.floor
@RequiresApi(Build.VERSION_CODES.O)
class AlarmModule(context: ReactApplicationContext?) : class AlarmModule constructor(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) { ReactContextBaseJavaModule(context) {
private var countdownTimer: CountDownTimer? = null
var currentMs: Long = 0
var running = false
override fun getName(): String { override fun getName(): String {
return "AlarmModule" return "AlarmModule"
} }
private val stopReceiver = object : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onReceive(context: Context?, intent: Intent?) {
Log.d("AlarmModule", "Received stop broadcast intent")
stop()
}
}
private val addReceiver = object : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onReceive(context: Context?, intent: Intent?) {
add()
}
}
init {
reactApplicationContext.registerReceiver(stopReceiver, IntentFilter(STOP_BROADCAST))
reactApplicationContext.registerReceiver(addReceiver, IntentFilter(ADD_BROADCAST))
}
override fun onCatalystInstanceDestroy() {
reactApplicationContext.unregisterReceiver(stopReceiver)
reactApplicationContext.unregisterReceiver(addReceiver)
super.onCatalystInstanceDestroy()
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod @ReactMethod
fun timer(milliseconds: Int, description: String) { fun add() {
Log.d("AlarmModule", "Add 1 min to alarm.")
countdownTimer?.cancel()
val newMs = if (running) currentMs.toInt().plus(60000) else 60000
countdownTimer = getTimer(newMs)
countdownTimer?.start()
running = true
val manager = getManager()
manager.cancel(AlarmService.NOTIFICATION_ID_DONE)
val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.stopService(intent)
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
fun stop() {
Log.d("AlarmModule", "Stop alarm.")
countdownTimer?.cancel()
running = false
val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext?.stopService(intent)
val manager = getManager()
manager.cancel(AlarmService.NOTIFICATION_ID_DONE)
manager.cancel(NOTIFICATION_ID_PENDING)
val params = Arguments.createMap().apply {
putString("minutes", "00")
putString("seconds", "00")
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("tick", params)
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
fun timer(milliseconds: Int) {
Log.d("AlarmModule", "Queue alarm for $milliseconds delay") Log.d("AlarmModule", "Queue alarm for $milliseconds delay")
val intent = Intent(reactApplicationContext, TimerService::class.java) val manager = getManager()
intent.putExtra("milliseconds", milliseconds) manager.cancel(AlarmService.NOTIFICATION_ID_DONE)
intent.putExtra("description", description) val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.startForegroundService(intent) reactApplicationContext.stopService(intent)
countdownTimer?.cancel()
countdownTimer = getTimer(milliseconds)
countdownTimer?.start()
running = true
}
@RequiresApi(Build.VERSION_CODES.M)
private fun getTimer(
endMs: Int,
): CountDownTimer {
val builder = getBuilder()
return object : CountDownTimer(endMs.toLong(), 1000) {
@RequiresApi(Build.VERSION_CODES.O)
override fun onTick(current: Long) {
currentMs = current
val seconds =
floor((current / 1000).toDouble() % 60).toInt().toString().padStart(2, '0')
val minutes =
floor((current / 1000).toDouble() / 60).toInt().toString().padStart(2, '0')
builder.setContentText("$minutes:$seconds").setAutoCancel(false).setDefaults(0)
.setProgress(endMs, current.toInt(), false)
.setCategory(NotificationCompat.CATEGORY_PROGRESS).priority =
NotificationCompat.PRIORITY_LOW
val manager = getManager()
manager.notify(NOTIFICATION_ID_PENDING, builder.build())
val params = Arguments.createMap().apply {
putString("minutes", minutes)
putString("seconds", seconds)
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("tick", params)
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onFinish() {
val context = reactApplicationContext
context.startForegroundService(Intent(context, AlarmService::class.java))
context
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("finish", Arguments.createMap().apply {
putString("minutes", "00")
putString("seconds", "00")
})
}
}
}
@SuppressLint("UnspecifiedImmutableFlag")
@RequiresApi(Build.VERSION_CODES.M)
private fun getBuilder(): NotificationCompat.Builder {
val context = reactApplicationContext
val contentIntent = Intent(context, MainActivity::class.java)
val pendingContent =
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
val addBroadcast = Intent(ADD_BROADCAST).apply {
setPackage(context.packageName)
}
val pendingAdd =
PendingIntent.getBroadcast(context, 0, addBroadcast, PendingIntent.FLAG_MUTABLE)
val stopBroadcast = Intent(STOP_BROADCAST)
stopBroadcast.setPackage(context.packageName)
val pendingStop =
PendingIntent.getBroadcast(context, 0, stopBroadcast, PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(context, CHANNEL_ID_PENDING)
.setSmallIcon(R.drawable.ic_baseline_hourglass_bottom_24).setContentTitle("Resting")
.setContentIntent(pendingContent)
.addAction(R.drawable.ic_baseline_stop_24, "Stop", pendingStop)
.addAction(R.drawable.ic_baseline_stop_24, "Add 1 min", pendingAdd)
.setDeleteIntent(pendingStop)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun getManager(): NotificationManager {
val notificationManager = reactApplicationContext.getSystemService(
NotificationManager::class.java
)
val timersChannel = NotificationChannel(
CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, NotificationManager.IMPORTANCE_LOW
)
timersChannel.setSound(null, null)
timersChannel.description = "Progress on rest timers."
notificationManager.createNotificationChannel(timersChannel)
return notificationManager
}
companion object {
const val STOP_BROADCAST = "stop-timer-event"
const val ADD_BROADCAST = "add-timer-event"
const val CHANNEL_ID_PENDING = "Timer"
const val NOTIFICATION_ID_PENDING = 1
} }
} }

View File

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

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

@ -3,10 +3,6 @@ package com.massive
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper 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) : class DatabaseHelper(context: Context) :
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
@ -15,74 +11,6 @@ class DatabaseHelper(context: Context) :
private const val DATABASE_VERSION = 1 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 onCreate(db: SQLiteDatabase) {
} }

View File

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

View File

@ -1,9 +1,8 @@
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.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactActivityDelegate import com.facebook.react.defaults.DefaultReactActivityDelegate
class MainActivity : ReactActivity() { class MainActivity : ReactActivity() {
@ -11,7 +10,9 @@ class MainActivity : ReactActivity() {
* Returns the name of the main component registered from JavaScript. This is used to schedule * Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component. * rendering of the component.
*/ */
override fun getMainComponentName(): String = "massive" override fun getMainComponentName(): kotlin.String {
return "RnDiffApp"
}
/** /**
* Returns the instance of the [ReactActivityDelegate]. Here we use a util class [ ] which allows you to easily enable Fabric and Concurrent React * Returns the instance of the [ReactActivityDelegate]. Here we use a util class [ ] which allows you to easily enable Fabric and Concurrent React
@ -21,11 +22,9 @@ class MainActivity : ReactActivity() {
return DefaultReactActivityDelegate( return DefaultReactActivityDelegate(
this, this,
mainComponentName, // If you opted-in for the New Architecture, we enable the Fabric Renderer. mainComponentName, // If you opted-in for the New Architecture, we enable the Fabric Renderer.
fabricEnabled DefaultNewArchitectureEntryPoint.fabricEnabled, // fabricEnabled
// If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18).
DefaultNewArchitectureEntryPoint.concurrentReactEnabled // concurrentRootEnabled
) )
} }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(null)
}
} }

View File

@ -3,43 +3,45 @@ package com.massive
import android.app.Application import android.app.Application
import com.facebook.react.PackageList import com.facebook.react.PackageList
import com.facebook.react.ReactApplication import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeHost import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.flipper.ReactNativeFlipper
import com.facebook.soloader.SoLoader import com.facebook.soloader.SoLoader
class MainApplication : Application(), ReactApplication { class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = private val mReactNativeHost: ReactNativeHost = object : DefaultReactNativeHost(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) // Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
return PackageList(this).packages
}
override fun getJSMainModuleName(): String {
return "index"
}
override val isNewArchEnabled: Boolean
protected get() = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean
protected get() = BuildConfig.IS_HERMES_ENABLED
}
override fun getReactNativeHost(): ReactNativeHost {
return mReactNativeHost
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
SoLoader.init(this, false) SoLoader.init(this, /* native exopackage */false)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app. // If you opted-in for the New Architecture, we load the native entry point for this app.
load() load()
} }
ReactNativeFlipper.initializeFlipper(this, reactNativeHost.reactInstanceManager) ReactNativeFlipper.initializeFlipper(this, reactNativeHost.reactInstanceManager)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,116 +0,0 @@
package com.massive.newarchitecture;
import android.app.Application;
import androidx.annotation.NonNull;
import com.facebook.react.PackageList;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.react.ReactPackageTurboModuleManagerDelegate;
import com.facebook.react.bridge.JSIModulePackage;
import com.facebook.react.bridge.JSIModuleProvider;
import com.facebook.react.bridge.JSIModuleSpec;
import com.facebook.react.bridge.JSIModuleType;
import com.facebook.react.bridge.JavaScriptContextHolder;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.UIManager;
import com.facebook.react.fabric.ComponentFactory;
import com.facebook.react.fabric.CoreComponentsRegistry;
import com.facebook.react.fabric.FabricJSIModuleProvider;
import com.facebook.react.fabric.ReactNativeConfig;
import com.facebook.react.uimanager.ViewManagerRegistry;
import com.massive.BuildConfig;
import com.massive.newarchitecture.components.MainComponentsRegistry;
import com.massive.newarchitecture.modules.MainApplicationTurboModuleManagerDelegate;
import java.util.ArrayList;
import java.util.List;
/**
* A {@link ReactNativeHost} that helps you load everything needed for the New Architecture, both
* TurboModule delegates and the Fabric Renderer.
*
* <p>Please note that this class is used ONLY if you opt-in for the New Architecture (see the
* `newArchEnabled` property). Is ignored otherwise.
*/
public class MainApplicationReactNativeHost extends ReactNativeHost {
public MainApplicationReactNativeHost(Application application) {
super(application);
}
@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
}
@Override
protected List<ReactPackage> getPackages() {
List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
// TurboModules must also be loaded here providing a valid TurboReactPackage implementation:
// packages.add(new TurboReactPackage() { ... });
// If you have custom Fabric Components, their ViewManagers should also be loaded here
// inside a ReactPackage.
return packages;
}
@Override
protected String getJSMainModuleName() {
return "index";
}
@NonNull
@Override
protected ReactPackageTurboModuleManagerDelegate.Builder
getReactPackageTurboModuleManagerDelegateBuilder() {
// Here we provide the ReactPackageTurboModuleManagerDelegate Builder. This is necessary
// for the new architecture and to use TurboModules correctly.
return new MainApplicationTurboModuleManagerDelegate.Builder();
}
@Override
protected JSIModulePackage getJSIModulePackage() {
return new JSIModulePackage() {
@Override
public List<JSIModuleSpec> getJSIModules(
final ReactApplicationContext reactApplicationContext,
final JavaScriptContextHolder jsContext) {
final List<JSIModuleSpec> specs = new ArrayList<>();
// Here we provide a new JSIModuleSpec that will be responsible of providing the
// custom Fabric Components.
specs.add(
new JSIModuleSpec() {
@Override
public JSIModuleType getJSIModuleType() {
return JSIModuleType.UIManager;
}
@Override
public JSIModuleProvider<UIManager> getJSIModuleProvider() {
final ComponentFactory componentFactory = new ComponentFactory();
CoreComponentsRegistry.register(componentFactory);
// Here we register a Components Registry.
// The one that is generated with the template contains no components
// and just provides you the one from React Native core.
MainComponentsRegistry.register(componentFactory);
final ReactInstanceManager reactInstanceManager = getReactInstanceManager();
ViewManagerRegistry viewManagerRegistry =
new ViewManagerRegistry(
reactInstanceManager.getOrCreateViewManagers(reactApplicationContext));
return new FabricJSIModuleProvider(
reactApplicationContext,
componentFactory,
ReactNativeConfig.DEFAULT_CONFIG,
viewManagerRegistry);
}
});
return specs;
}
};
}
}

View File

@ -1,36 +0,0 @@
package com.massive.newarchitecture.components;
import com.facebook.jni.HybridData;
import com.facebook.proguard.annotations.DoNotStrip;
import com.facebook.react.fabric.ComponentFactory;
import com.facebook.soloader.SoLoader;
/**
* Class responsible to load the custom Fabric Components. This class has native methods and needs a
* corresponding C++ implementation/header file to work correctly (already placed inside the jni/
* folder for you).
*
* <p>Please note that this class is used ONLY if you opt-in for the New Architecture (see the
* `newArchEnabled` property). Is ignored otherwise.
*/
@DoNotStrip
public class MainComponentsRegistry {
static {
SoLoader.loadLibrary("fabricjni");
}
@DoNotStrip private final HybridData mHybridData;
@DoNotStrip
private native HybridData initHybrid(ComponentFactory componentFactory);
@DoNotStrip
private MainComponentsRegistry(ComponentFactory componentFactory) {
mHybridData = initHybrid(componentFactory);
}
@DoNotStrip
public static MainComponentsRegistry register(ComponentFactory componentFactory) {
return new MainComponentsRegistry(componentFactory);
}
}

View File

@ -1,48 +0,0 @@
package com.massive.newarchitecture.modules;
import com.facebook.jni.HybridData;
import com.facebook.react.ReactPackage;
import com.facebook.react.ReactPackageTurboModuleManagerDelegate;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.soloader.SoLoader;
import java.util.List;
/**
* Class responsible to load the TurboModules. This class has native methods and needs a
* corresponding C++ implementation/header file to work correctly (already placed inside the jni/
* folder for you).
*
* <p>Please note that this class is used ONLY if you opt-in for the New Architecture (see the
* `newArchEnabled` property). Is ignored otherwise.
*/
public class MainApplicationTurboModuleManagerDelegate
extends ReactPackageTurboModuleManagerDelegate {
private static volatile boolean sIsSoLibraryLoaded;
protected MainApplicationTurboModuleManagerDelegate(
ReactApplicationContext reactApplicationContext, List<ReactPackage> packages) {
super(reactApplicationContext, packages);
}
protected native HybridData initHybrid();
native boolean canCreateTurboModule(String moduleName);
public static class Builder extends ReactPackageTurboModuleManagerDelegate.Builder {
protected MainApplicationTurboModuleManagerDelegate build(
ReactApplicationContext context, List<ReactPackage> packages) {
return new MainApplicationTurboModuleManagerDelegate(context, packages);
}
}
@Override
protected synchronized void maybeLoadOtherSoLibraries() {
if (!sIsSoLibraryLoaded) {
// If you change the name of your application .so file in the Android.mk file,
// make sure you update the name here as well.
SoLoader.loadLibrary("massive_appmodules");
sIsSoLibraryLoaded = true;
}
}
}

View File

@ -1,24 +0,0 @@
#include "MainApplicationModuleProvider.h"
#include <rncore.h>
namespace facebook {
namespace react {
std::shared_ptr<TurboModule> MainApplicationModuleProvider(
const std::string moduleName,
const JavaTurboModule::InitParams &params) {
// Here you can provide your own module provider for TurboModules coming from
// either your application or from external libraries. The approach to follow
// is similar to the following (for a library called `samplelibrary`:
//
// auto module = samplelibrary_ModuleProvider(moduleName, params);
// if (module != nullptr) {
// return module;
// }
// return rncore_ModuleProvider(moduleName, params);
return rncore_ModuleProvider(moduleName, params);
}
} // namespace react
} // namespace facebook

View File

@ -1,16 +0,0 @@
#pragma once
#include <memory>
#include <string>
#include <ReactCommon/JavaTurboModule.h>
namespace facebook {
namespace react {
std::shared_ptr<TurboModule> MainApplicationModuleProvider(
const std::string moduleName,
const JavaTurboModule::InitParams &params);
} // namespace react
} // namespace facebook

View File

@ -1,45 +0,0 @@
#include "MainApplicationTurboModuleManagerDelegate.h"
#include "MainApplicationModuleProvider.h"
namespace facebook {
namespace react {
jni::local_ref<MainApplicationTurboModuleManagerDelegate::jhybriddata>
MainApplicationTurboModuleManagerDelegate::initHybrid(
jni::alias_ref<jhybridobject>) {
return makeCxxInstance();
}
void MainApplicationTurboModuleManagerDelegate::registerNatives() {
registerHybrid({
makeNativeMethod(
"initHybrid", MainApplicationTurboModuleManagerDelegate::initHybrid),
makeNativeMethod(
"canCreateTurboModule",
MainApplicationTurboModuleManagerDelegate::canCreateTurboModule),
});
}
std::shared_ptr<TurboModule>
MainApplicationTurboModuleManagerDelegate::getTurboModule(
const std::string name,
const std::shared_ptr<CallInvoker> jsInvoker) {
// Not implemented yet: provide pure-C++ NativeModules here.
return nullptr;
}
std::shared_ptr<TurboModule>
MainApplicationTurboModuleManagerDelegate::getTurboModule(
const std::string name,
const JavaTurboModule::InitParams &params) {
return MainApplicationModuleProvider(name, params);
}
bool MainApplicationTurboModuleManagerDelegate::canCreateTurboModule(
std::string name) {
return getTurboModule(name, nullptr) != nullptr ||
getTurboModule(name, {.moduleName = name}) != nullptr;
}
} // namespace react
} // namespace facebook

View File

@ -1,38 +0,0 @@
#include <memory>
#include <string>
#include <ReactCommon/TurboModuleManagerDelegate.h>
#include <fbjni/fbjni.h>
namespace facebook {
namespace react {
class MainApplicationTurboModuleManagerDelegate
: public jni::HybridClass<
MainApplicationTurboModuleManagerDelegate,
TurboModuleManagerDelegate> {
public:
// Adapt it to the package you used for your Java class.
static constexpr auto kJavaDescriptor =
"Lcom/massive/newarchitecture/modules/MainApplicationTurboModuleManagerDelegate;";
static jni::local_ref<jhybriddata> initHybrid(jni::alias_ref<jhybridobject>);
static void registerNatives();
std::shared_ptr<TurboModule> getTurboModule(
const std::string name,
const std::shared_ptr<CallInvoker> jsInvoker) override;
std::shared_ptr<TurboModule> getTurboModule(
const std::string name,
const JavaTurboModule::InitParams &params) override;
/**
* Test-only method. Allows user to verify whether a TurboModule can be
* created by instances of this class.
*/
bool canCreateTurboModule(std::string name);
};
} // namespace react
} // namespace facebook

View File

@ -1,61 +0,0 @@
#include "MainComponentsRegistry.h"
#include <CoreComponentsRegistry.h>
#include <fbjni/fbjni.h>
#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
#include <react/renderer/components/rncore/ComponentDescriptors.h>
namespace facebook {
namespace react {
MainComponentsRegistry::MainComponentsRegistry(ComponentFactory *delegate) {}
std::shared_ptr<ComponentDescriptorProviderRegistry const>
MainComponentsRegistry::sharedProviderRegistry() {
auto providerRegistry = CoreComponentsRegistry::sharedProviderRegistry();
// Custom Fabric Components go here. You can register custom
// components coming from your App or from 3rd party libraries here.
//
// providerRegistry->add(concreteComponentDescriptorProvider<
// AocViewerComponentDescriptor>());
return providerRegistry;
}
jni::local_ref<MainComponentsRegistry::jhybriddata>
MainComponentsRegistry::initHybrid(
jni::alias_ref<jclass>,
ComponentFactory *delegate) {
auto instance = makeCxxInstance(delegate);
auto buildRegistryFunction =
[](EventDispatcher::Weak const &eventDispatcher,
ContextContainer::Shared const &contextContainer)
-> ComponentDescriptorRegistry::Shared {
auto registry = MainComponentsRegistry::sharedProviderRegistry()
->createComponentDescriptorRegistry(
{eventDispatcher, contextContainer});
auto mutableRegistry =
std::const_pointer_cast<ComponentDescriptorRegistry>(registry);
mutableRegistry->setFallbackComponentDescriptor(
std::make_shared<UnimplementedNativeViewComponentDescriptor>(
ComponentDescriptorParameters{
eventDispatcher, contextContainer, nullptr}));
return registry;
};
delegate->buildRegistryFunction = buildRegistryFunction;
return instance;
}
void MainComponentsRegistry::registerNatives() {
registerHybrid({
makeNativeMethod("initHybrid", MainComponentsRegistry::initHybrid),
});
}
} // namespace react
} // namespace facebook

View File

@ -1,32 +0,0 @@
#pragma once
#include <ComponentFactory.h>
#include <fbjni/fbjni.h>
#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
#include <react/renderer/componentregistry/ComponentDescriptorRegistry.h>
namespace facebook {
namespace react {
class MainComponentsRegistry
: public facebook::jni::HybridClass<MainComponentsRegistry> {
public:
// Adapt it to the package you used for your Java class.
constexpr static auto kJavaDescriptor =
"Lcom/massive/newarchitecture/components/MainComponentsRegistry;";
static void registerNatives();
MainComponentsRegistry(ComponentFactory *delegate);
private:
static std::shared_ptr<ComponentDescriptorProviderRegistry const>
sharedProviderRegistry();
static jni::local_ref<jhybriddata> initHybrid(
jni::alias_ref<jclass>,
ComponentFactory *delegate);
};
} // namespace react
} // namespace facebook

View File

@ -1,11 +0,0 @@
#include <fbjni/fbjni.h>
#include "MainApplicationTurboModuleManagerDelegate.h"
#include "MainComponentsRegistry.h"
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
return facebook::jni::initialize(vm, [] {
facebook::react::MainApplicationTurboModuleManagerDelegate::
registerNatives();
facebook::react::MainComponentsRegistry::registerNatives();
});
}

View File

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

View File

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

View File

@ -2,24 +2,20 @@
buildscript { buildscript {
ext { ext {
buildToolsVersion = "34.0.0" kotlin_version = '1.6.10'
buildToolsVersion = "33.0.0"
minSdkVersion = 21 minSdkVersion = 21
compileSdkVersion = 34 compileSdkVersion = 33
targetSdkVersion = 34 targetSdkVersion = 33
ndkVersion = "23.1.7779620"
// We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP.
ndkVersion = "25.1.8937393"
kotlinVersion = "1.8.0"
} }
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath("com.android.tools.build:gradle") classpath('com.android.tools.build:gradle:7.3.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 "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }
} }
apply plugin: "com.facebook.react.rootproject"

View File

@ -1,41 +1,33 @@
# Project-wide Gradle settings. # Project-wide Gradle settings.
# IDE (e.g. Android Studio) users: # IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override* # Gradle settings configured through the IDE *will override*
# any settings specified in this file. # any settings specified in this file.
# For more details on how to configure your build environment visit # For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html # http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true # org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the # AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK # Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn # https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true 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
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture. # Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in # This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want # your application. You should enable this flag either if you want
# 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 hermesEnabled=true

Binary file not shown.

View File

@ -1,7 +1,6 @@
#Fri Mar 03 17:27:19 NZDT 2023
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip
validateDistributionUrl=true
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

31
android/gradlew vendored
View File

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

15
android/gradlew.bat vendored
View File

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

View File

@ -1,6 +1,6 @@
rootProject.name = 'massive' rootProject.name = 'massive'
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
include ':app' include ':app'
includeBuild('../node_modules/@react-native/gradle-plugin') includeBuild('../node_modules/react-native-gradle-plugin')
include ':react-native-sqlite-storage' include ':react-native-sqlite-storage'
project(':react-native-sqlite-storage').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-sqlite-storage/platforms/android') project(':react-native-sqlite-storage').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-sqlite-storage/platforms/android')

View File

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

View File

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

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