Add basic CRUD for sets

This commit is contained in:
Brandon Presley 2022-07-03 13:50:01 +12:00
parent 73ce91f111
commit bb9a6c5f37
17 changed files with 2073 additions and 139 deletions

86
Alarm.tsx Normal file
View File

@ -0,0 +1,86 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import React, {useEffect, useState} from 'react';
import {Button, Modal, StyleSheet, Text, View} from 'react-native';
import BackgroundTimer from 'react-native-background-timer';
export default function Alarm({onClose}: {onClose: () => void}) {
const [seconds, setSeconds] = useState(0);
const [minutes, setMinutes] = useState(0);
let intervalId: number;
useEffect(() => {
AsyncStorage.getItem('nextAlarm').then(async next => {
if (!next) return;
const ms = new Date(next).getTime() - new Date().getTime();
if (ms <= 0) return;
let secondsLeft = ms / 1000;
console.log({secondsLeft});
setSeconds(secondsLeft % 60);
setMinutes(Math.floor(secondsLeft / 60));
intervalId = setInterval(() => {
console.log({seconds, secondsLeft});
secondsLeft--;
if (secondsLeft <= 0) return clearInterval(intervalId);
setSeconds(Math.ceil(secondsLeft % 60));
setMinutes(Math.floor(secondsLeft / 60));
}, 1000);
});
return () => clearInterval(intervalId);
}, []);
const stop = () => {
BackgroundTimer.clearInterval(intervalId);
onClose();
};
return (
<Modal
animationType="none"
transparent={true}
visible
onRequestClose={onClose}>
<View style={styles.modal}>
<Text style={styles.title}>Rest</Text>
<Text style={styles.timer}>
{minutes}:{seconds}
</Text>
<View style={{flexDirection: 'row'}}>
<View style={styles.button}>
<Button title="Close" color="#014B44" onPress={onClose} />
</View>
<View style={styles.button}>
<Button title="Stop" onPress={stop} />
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
timer: {
marginBottom: 20,
},
title: {
fontSize: 20,
marginBottom: 10,
},
modal: {
margin: 20,
backgroundColor: '#20232a',
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
alignItems: 'center',
},
button: {
marginRight: 10,
},
});

104
App.tsx
View File

@ -1,65 +1,61 @@
import React, {useState} from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
import {
Button,
SafeAreaView,
StatusBar,
TextInput,
useColorScheme,
Vibration,
View,
} from 'react-native';
import BackgroundTimer from 'react-native-background-timer';
import {Notifications} from 'react-native-notifications';
import Sound from 'react-native-sound';
DarkTheme,
DefaultTheme,
NavigationContainer,
} from '@react-navigation/native';
import React, {useEffect} from 'react';
import {StatusBar, useColorScheme} from 'react-native';
import {setupSchema} from './db';
import Exercises from './Exercises';
import Home from './Home';
import Settings from './Settings';
import Ionicons from 'react-native-vector-icons/Ionicons';
const Tab = createBottomTabNavigator<RootStackParamList>();
export type RootStackParamList = {
Home: {};
Exercises: {};
Settings: {};
Alarm: {};
};
setupSchema();
const App = () => {
const dark = useColorScheme() === 'dark';
const alarm = new Sound('argon.mp3', Sound.MAIN_BUNDLE, error => {
if (error) throw new Error(error);
});
const [timer, setTimer] = useState('0');
Notifications.registerRemoteNotifications();
Notifications.events().registerNotificationOpened(
(notification, completion) => {
console.log('Notification opened:', notification);
alarm.stop();
Vibration.cancel();
completion();
},
);
const press = () => {
BackgroundTimer.setTimeout(() => {
alarm.play(_onEnd => Vibration.cancel());
Vibration.vibrate([0, 400, 600], /*repeat=*/ true);
Notifications.postLocalNotification({
title: 'title',
body: 'body',
badge: 1,
identifier: 'identifier',
payload: {},
sound: 'sound',
thread: 'thread',
type: 'type',
});
}, Number(timer));
};
useEffect(() => {
AsyncStorage.getItem('minutes').then(async minutes => {
if (!minutes) await AsyncStorage.setItem('minutes', '3');
});
}, []);
return (
<SafeAreaView style={{flex: 1}}>
<NavigationContainer theme={dark ? DarkTheme : DefaultTheme}>
<StatusBar barStyle={dark ? 'light-content' : 'dark-content'} />
<View
style={{
margin: 10,
alignItems: 'center',
}}>
<TextInput placeholder="Timer" value={timer} onChangeText={setTimer} />
</View>
<View style={{margin: 30, marginTop: 'auto'}}>
<Button title="Run timer" onPress={press} />
</View>
</SafeAreaView>
<Tab.Navigator
screenOptions={({route}) => ({
tabBarIcon: ({focused, color, size}) => {
let icon = '';
if (route.name === 'Home') icon = focused ? 'home' : 'home-outline';
else if (route.name === 'Settings')
icon = focused ? 'settings' : 'settings-outline';
else if (route.name === 'Exercises')
icon = focused ? 'barbell' : 'barbell-outline';
// You can return any component that you like here!
return <Ionicons name={icon} size={size} color={color} />;
},
tabBarActiveTintColor: 'tomato',
tabBarInactiveTintColor: 'gray',
})}>
<Tab.Screen name="Home" component={Home} />
<Tab.Screen name="Exercises" component={Exercises} />
<Tab.Screen name="Settings" component={Settings} />
</Tab.Navigator>
</NavigationContainer>
);
};

152
EditSet.tsx Normal file
View File

@ -0,0 +1,152 @@
import React, {useEffect, useRef, useState} from 'react';
import {Modal, StyleSheet, Text, TextInput, View} from 'react-native';
import {Button} from 'react-native-paper';
import {getDb} from './db';
import Set from './Set';
export default function EditSet({
id,
onSave,
show,
setShow,
setId,
}: {
id?: number;
setId: (id?: number) => void;
onSave: () => void;
show: boolean;
setShow: (visible: boolean) => void;
}) {
const [name, setName] = useState('');
const [reps, setReps] = useState('');
const [weight, setWeight] = useState('');
const [unit, setUnit] = useState('');
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const unitRef = useRef<TextInput>(null);
useEffect(() => {
if (!id) return;
getDb().then(async db => {
const [result] = await db.executeSql(`SELECT * FROM sets WHERE id = ?`, [
id,
]);
if (!result.rows.item(0)) throw new Error("Can't find specified Set.");
const set: Set = result.rows.item(0);
setName(set.name);
setReps(set.reps.toString());
setWeight(set.weight.toString());
setUnit(set.unit);
});
}, [id]);
const save = async () => {
if (!name || !reps || !weight) return;
const db = await getDb();
if (!id)
await db.executeSql(
`INSERT INTO sets(name, reps, weight, created, unit) VALUES (?,?,?,?,?)`,
[name, reps, weight, new Date().toISOString(), unit || 'kg'],
);
else
await db.executeSql(
`UPDATE sets SET name = ?, reps = ?, weight = ?, unit = ? WHERE id = ?`,
[name, reps, weight, unit, id],
);
setShow(false);
onSave();
};
const remove = async () => {
if (!id) return;
const db = await getDb();
await db.executeSql(`DELETE FROM sets WHERE id = ?`, [id]);
setShow(false);
onSave();
};
return (
<View>
<Modal
animationType="none"
transparent={true}
visible={show}
onRequestClose={() => setShow(false)}>
<View style={styles.modal}>
<Text style={styles.title}>Add a set</Text>
<TextInput
autoFocus
placeholder="Name *"
value={name}
onChangeText={setName}
onSubmitEditing={() => weightRef.current?.focus()}
/>
<TextInput
placeholder="Weight *"
keyboardType="numeric"
value={weight}
onChangeText={setWeight}
onSubmitEditing={() => repsRef.current?.focus()}
ref={weightRef}
/>
<TextInput
placeholder="Reps *"
keyboardType="numeric"
value={reps}
onChangeText={setReps}
ref={repsRef}
onSubmitEditing={() => unitRef.current?.focus()}
/>
<TextInput
placeholder="Unit (kg)"
value={unit}
onChangeText={setUnit}
ref={unitRef}
onSubmitEditing={save}
/>
<View style={styles.bottom}>
<Button mode="contained" icon="save" onPress={save}>
Save
</Button>
<Button icon="close" onPress={() => setShow(false)}>
Cancel
</Button>
<Button icon="trash" onPress={remove} disabled={!id}>
Delete
</Button>
</View>
</View>
</Modal>
<Button
mode="contained"
onPress={() => {
setId(undefined);
setShow(true);
}}>
Add
</Button>
</View>
);
}
const styles = StyleSheet.create({
bottom: {
flexDirection: 'row',
},
title: {
fontSize: 20,
},
modal: {
margin: 20,
backgroundColor: '#20232a',
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
});

15
Exercises.tsx Normal file
View File

@ -0,0 +1,15 @@
import {NativeStackScreenProps} from '@react-navigation/native-stack';
import {Button, Text, View} from 'react-native';
import {RootStackParamList} from './App';
import React from 'react';
export default function Exercises({
navigation,
}: NativeStackScreenProps<RootStackParamList, 'Exercises'>) {
return (
<View>
<Text>Pull ups - 1 rep</Text>
<Button title="Go home" onPress={() => navigation.navigate('Home', {})} />
</View>
);
}

188
Home.tsx Normal file
View File

@ -0,0 +1,188 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import {NativeStackScreenProps} from '@react-navigation/native-stack';
import React, {useEffect, useState} from 'react';
import {
FlatList,
SafeAreaView,
StyleSheet,
TextInput,
Vibration,
View,
} from 'react-native';
import BackgroundTimer from 'react-native-background-timer';
import {Button, List} from 'react-native-paper';
import PushNotification from 'react-native-push-notification';
import Sound from 'react-native-sound';
import Alarm from './Alarm';
import {RootStackParamList} from './App';
import {ALARM} from './channels';
import {getDb} from './db';
import EditSet from './EditSet';
import Set from './Set';
const limit = 20;
export default function Home({
navigation,
}: NativeStackScreenProps<RootStackParamList, 'Home'>) {
const [sets, setSets] = useState<Set[]>();
const [id, setId] = useState<number>();
const [offset, setOffset] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const [showTimer, setShowTimer] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [search, setSearch] = useState('');
const refresh = async () => {
setRefreshing(true);
const db = await getDb();
const [result] = await db.executeSql(
`SELECT * from sets WHERE name LIKE ? LIMIT ? OFFSET ?`,
[`%${search}%`, limit, 0],
);
setRefreshing(false);
if (!result) return setSets([]);
setSets(result.rows.raw());
setOffset(0);
};
const alarm = new Sound('argon.mp3', Sound.MAIN_BUNDLE, error => {
if (error) throw new Error(error);
});
const focus = async () => {
alarm.stop();
Vibration.cancel();
};
useEffect(() => {
refresh();
}, [search]);
useEffect(() => navigation.addListener('focus', focus), [navigation]);
const renderItem = ({item}: {item: Set}) => (
<List.Item
onPress={() => {
setId(item.id);
setShowEdit(true);
}}
key={item.id}
title={item.name}
description={`${item.reps} x ${item.weight}${item.unit}`}
/>
);
const save = async () => {
refresh();
const enabled = await AsyncStorage.getItem('alarmEnabled');
console.log({enabled});
if (enabled !== 'true') return;
const minutes = await AsyncStorage.getItem('minutes');
const seconds = await AsyncStorage.getItem('seconds');
const milliseconds = Number(minutes) * 60 * 1000 + Number(seconds) * 1000;
const when = new Date();
when.setTime(when.getTime() + milliseconds);
await AsyncStorage.setItem('nextAlarm', when.toISOString());
const timeoutId = BackgroundTimer.setTimeout(() => {
alarm.play(_onEnd => Vibration.cancel());
Vibration.vibrate([0, 400, 600], /*repeat=*/ true);
PushNotification.localNotification({
message: 'Timer up',
channelId: ALARM,
vibrate: true,
});
}, Number(milliseconds));
BackgroundTimer.clearTimeout(
Number(await AsyncStorage.getItem('timeoutId')),
);
await AsyncStorage.setItem('timeoutId', timeoutId.toString());
};
const close = () => {
alarm.stop();
Vibration.cancel();
setShowTimer(false);
};
const next = async () => {
const newOffset = offset + limit;
setRefreshing(true);
const db = await getDb();
const [result] = await db.executeSql(
`SELECT * from sets WHERE name LIKE ? LIMIT ? OFFSET ?`,
[`%${search}%`, limit, newOffset],
);
setRefreshing(false);
if (!result) return;
if (!sets) return;
setSets([...sets, ...result.rows.raw()]);
setOffset(newOffset);
};
return (
<SafeAreaView style={styles.container}>
<TextInput placeholder="Search" value={search} onChangeText={setSearch} />
<FlatList
style={{height: '100%'}}
data={sets}
renderItem={renderItem}
keyExtractor={set => set.id.toString()}
onRefresh={refresh}
refreshing={refreshing}
onScrollEndDrag={next}
/>
<View style={styles.bottom}>
<View style={styles.button}></View>
<View style={styles.button}>
<Button icon="time" onPress={() => setShowTimer(true)}>
Time left
</Button>
</View>
<View style={styles.button}>
<Button icon="stop" onPress={close}>
Stop
</Button>
</View>
<View style={styles.button}>
<EditSet
id={id}
setId={setId}
show={showEdit}
setShow={setShowEdit}
onSave={save}
/>
</View>
</View>
{showTimer && <Alarm onClose={close} />}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
name: {
fontSize: 18,
},
button: {
marginRight: 10,
},
container: {
flex: 1,
paddingLeft: 20,
paddingRight: 20,
},
bottom: {
alignSelf: 'center',
marginBottom: 10,
flexDirection: 'row',
},
set: {
marginBottom: 10,
fontSize: 18,
shadowColor: 'red',
shadowRadius: 10,
shadowOffset: {width: 2, height: 40},
shadowOpacity: 8,
},
});

8
Set.ts Normal file
View File

@ -0,0 +1,8 @@
export default interface Set {
id: number;
name: string;
reps: number;
weight: number;
created: string;
unit: string;
}

69
Settings.tsx Normal file
View File

@ -0,0 +1,69 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import {NativeStackScreenProps} from '@react-navigation/native-stack';
import React, {useEffect, useState} from 'react';
import {StyleSheet, Switch, Text, TextInput, View} from 'react-native';
import {Button} from 'react-native-paper';
import {RootStackParamList} from './App';
import {getDb} from './db';
export default function Settings({
navigation,
}: NativeStackScreenProps<RootStackParamList, 'Settings'>) {
const [minutes, setMinutes] = useState<string>('');
const [seconds, setSeconds] = useState<string>('');
const [alarmEnabled, setAlarmEnabled] = useState<boolean>(true);
useEffect(() => {
(async () => {
setMinutes((await AsyncStorage.getItem('minutes')) || '3');
setSeconds((await AsyncStorage.getItem('seconds')) || '');
setAlarmEnabled((await AsyncStorage.getItem('alarmEnabled')) === 'true');
})();
}, [navigation]);
useEffect(() => {
if (minutes) AsyncStorage.setItem('minutes', minutes);
if (seconds) AsyncStorage.setItem('seconds', seconds);
AsyncStorage.setItem('alarmEnabled', alarmEnabled ? 'true' : 'false');
}, [minutes, seconds, alarmEnabled]);
const clear = async () => {
const db = await getDb();
await db.executeSql(`DELETE FROM sets`);
};
return (
<View style={styles.container}>
<Text>Rest minutes</Text>
<TextInput
value={minutes}
keyboardType="numeric"
placeholder="3"
onChangeText={setMinutes}
/>
<Text>Rest seconds</Text>
<TextInput
value={seconds}
keyboardType="numeric"
placeholder="30"
onChangeText={setSeconds}
/>
<Text>Alarm enabled?</Text>
<Switch
style={{alignSelf: 'flex-start'}}
value={alarmEnabled}
onValueChange={setAlarmEnabled}
/>
<Button icon="trash" onPress={clear}>
Clear sets
</Button>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 10,
},
});

View File

@ -82,6 +82,7 @@ project.ext.react = [
]
apply from: "../../node_modules/react-native/react.gradle"
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
/**
* Set this to true to create two separate APKs instead of one:
@ -129,6 +130,12 @@ def reactNativeArchitectures() {
}
android {
packagingOptions {
pickFirst '**/armeabi-v7a/libfolly_runtime.so'
pickFirst '**/x86/libfolly_runtime.so'
pickFirst '**/arm64-v8a/libfolly_runtime.so'
pickFirst '**/x86_64/libfolly_runtime.so'
}
ndkVersion rootProject.ext.ndkVersion
compileSdkVersion rootProject.ext.compileSdkVersion
@ -262,7 +269,7 @@ dependencies {
//noinspection GradleDynamicVersion
implementation "com.facebook.react:react-native:+" // From node_modules
implementation project(':react-native-notifications');
implementation project(':react-native-sqlite-storage')
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {

View File

@ -2,6 +2,7 @@
package="com.massive">
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application
android:name=".MainApplication"
@ -10,6 +11,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme">
<meta-data android:name="com.dieam.reactnativepushnotification.notification_foreground" android:value="false"/>
<activity
android:name=".MainActivity"
android:label="@string/app_name"

View File

@ -12,7 +12,7 @@ import com.facebook.soloader.SoLoader;
import com.massive.newarchitecture.MainApplicationReactNativeHost;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
import com.wix.reactnativenotifications.RNNotificationsPackage;
import org.pgsqlite.SQLitePluginPackage;
public class MainApplication extends Application implements ReactApplication {
@ -27,9 +27,7 @@ public class MainApplication extends Application implements ReactApplication {
protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
packages.add(new RNNotificationsPackage(MainApplication.this));
packages.add(new SQLitePluginPackage());
return packages;
}

View File

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

View File

@ -1,3 +1,4 @@
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: ['react-native-reanimated/plugin', 'react-native-paper/babel'],
};

13
channels.ts Normal file
View File

@ -0,0 +1,13 @@
import PushNotification, {Importance} from 'react-native-push-notification';
export const ALARM = 'alarm';
PushNotification.createChannel(
{
channelId: ALARM,
channelName: 'Alarms',
channelDescription: 'Notifications of when alarms are triggered.',
importance: Importance.HIGH,
vibrate: false,
},
created => console.log(`Created channel ${created}`),
);

17
db.ts Normal file
View File

@ -0,0 +1,17 @@
import {enablePromise, openDatabase} from 'react-native-sqlite-storage';
enablePromise(true);
export const getDb = () => openDatabase({name: 'massive.db'});
const schema = `
CREATE TABLE IF NOT EXISTS sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
reps INTEGER NOT NULL,
weight INTEGER NOT NULL,
created TEXT NOT NULL,
unit TEXT DEFAULT 'kg'
);
`;
export const setupSchema = () => getDb().then(db => db.executeSql(schema));

View File

@ -1,9 +1,43 @@
/**
* @format
*/
import 'react-native-gesture-handler';
import 'react-native-sqlite-storage';
import React from 'react';
import {AppRegistry} from 'react-native';
import App from './App';
import {name as appName} from './app.json';
import PushNotification from 'react-native-push-notification';
import {Provider as PaperProvider, DarkTheme} from 'react-native-paper';
import Ionicon from 'react-native-vector-icons/Ionicons';
AppRegistry.registerComponent(appName, () => App);
export default function Main() {
return (
<PaperProvider
theme={DarkTheme}
settings={{icon: props => <Ionicon {...props} />}}>
<App />
</PaperProvider>
);
}
AppRegistry.registerComponent(appName, () => Main);
PushNotification.configure({
onRegister: function (token) {
console.log('TOKEN:', token);
},
onNotification: function (notification) {
console.log('NOTIFICATION:', notification);
},
onAction: function (notification) {
console.log('ACTION:', notification.action);
console.log('NOTIFICATION:', notification);
},
onRegistrationError: function (err) {
console.error(err.message, err);
},
popInitialNotification: true,
requestPermissions: false,
});

View File

@ -10,12 +10,29 @@
"lint": "eslint . --ext .js,.jsx,.ts,.tsx"
},
"dependencies": {
"@babel/preset-env": "^7.1.6",
"@react-native-async-storage/async-storage": "^1.17.7",
"@react-native-community/push-notification-ios": "^1.10.1",
"@react-navigation/bottom-tabs": "^6.3.1",
"@react-navigation/native": "^6.0.10",
"@react-navigation/native-stack": "^6.6.2",
"@types/react-native-background-timer": "^2.0.0",
"@types/react-native-push-notification": "^8.1.1",
"@types/react-native-sqlite-storage": "^5.0.2",
"@types/react-native-vector-icons": "^6.4.11",
"react": "18.0.0",
"react-devtools": "^4.24.7",
"react-native": "0.69.1",
"react-native-background-timer": "^2.4.1",
"react-native-notifications": "^4.3.1",
"react-native-sound": "^0.11.2"
"react-native-gesture-handler": "^2.5.0",
"react-native-paper": "^4.12.2",
"react-native-push-notification": "^8.1.1",
"react-native-reanimated": "^2.9.0",
"react-native-safe-area-context": "^4.3.1",
"react-native-screens": "^3.14.0",
"react-native-sound": "^0.11.2",
"react-native-sqlite-storage": "^6.0.1",
"react-native-vector-icons": "^9.2.0"
},
"devDependencies": {
"@babel/core": "^7.12.9",

1473
yarn.lock

File diff suppressed because it is too large Load Diff