Compare commits
12 Commits
a7d84c4dbd
...
d513b147bc
Author | SHA1 | Date |
---|---|---|
Brandon Presley | d513b147bc | |
Brandon Presley | 9e790e7e1d | |
Brandon Presley | 839d872c1c | |
Brandon Presley | 65f6eaff57 | |
Brandon Presley | 0159956ee8 | |
Brandon Presley | c0b6ba8606 | |
Brandon Presley | 42c2502e12 | |
Brandon Presley | f2e5192002 | |
Brandon Presley | 0cb8d7b962 | |
Brandon Presley | c965064e57 | |
Brandon Presley | d591d2e453 | |
Brandon Presley | c387ee7e61 |
|
@ -64,3 +64,4 @@ buck-out/
|
|||
|
||||
deploy.sh
|
||||
README.pdf
|
||||
.yarn
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.2.1.cjs
|
|
@ -0,0 +1,143 @@
|
|||
import {NavigationProp, useNavigation} from '@react-navigation/native';
|
||||
import React, {useCallback, useContext, useState} from 'react';
|
||||
import {ToastAndroid} from 'react-native';
|
||||
import DocumentPicker from 'react-native-document-picker';
|
||||
import {FileSystem} from 'react-native-file-access';
|
||||
import {Divider, IconButton, Menu} from 'react-native-paper';
|
||||
import {DatabaseContext, DrawerParamList} from './App';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import {write} from './file';
|
||||
import {Plan} from './plan';
|
||||
import Set from './set';
|
||||
|
||||
const setFields = 'id,name,reps,weight,created,unit';
|
||||
const planFields = 'id,days,workouts';
|
||||
|
||||
export default function DrawerMenu({name}: {name: keyof DrawerParamList}) {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [showRemove, setShowRemove] = useState(false);
|
||||
const db = useContext(DatabaseContext);
|
||||
const {reset} = useNavigation<NavigationProp<DrawerParamList>>();
|
||||
|
||||
const exportSets = useCallback(async () => {
|
||||
const [result] = await db.executeSql('SELECT * FROM sets');
|
||||
if (result.rows.length === 0) return;
|
||||
const sets: Set[] = result.rows.raw();
|
||||
const data = [setFields]
|
||||
.concat(
|
||||
sets.map(
|
||||
set =>
|
||||
`${set.id},${set.name},${set.reps},${set.weight},${set.created},${set.unit}`,
|
||||
),
|
||||
)
|
||||
.join('\n');
|
||||
console.log(`${DrawerMenu.name}.exportSets`, {length: sets.length});
|
||||
await write('sets.csv', data);
|
||||
}, [db]);
|
||||
|
||||
const exportPlans = useCallback(async () => {
|
||||
const [result] = await db.executeSql('SELECT * FROM plans');
|
||||
if (result.rows.length === 0) return;
|
||||
const sets: Plan[] = result.rows.raw();
|
||||
const data = [planFields]
|
||||
.concat(sets.map(set => `"${set.id}","${set.days}","${set.workouts}"`))
|
||||
.join('\n');
|
||||
console.log(`${DrawerMenu.name}.exportPlans`, {length: sets.length});
|
||||
await write('plans.csv', data);
|
||||
}, [db]);
|
||||
|
||||
const download = useCallback(async () => {
|
||||
setShowMenu(false);
|
||||
if (name === 'Home') exportSets();
|
||||
else if (name === 'Plans') exportPlans();
|
||||
}, [name, exportSets, exportPlans]);
|
||||
|
||||
const uploadSets = useCallback(async () => {
|
||||
const result = await DocumentPicker.pickSingle();
|
||||
const file = await FileSystem.readFile(result.uri);
|
||||
console.log(`${DrawerMenu.name}.${uploadSets.name}:`, file.length);
|
||||
const lines = file.split('\n');
|
||||
if (lines[0] != setFields)
|
||||
return ToastAndroid.show('Invalid csv.', ToastAndroid.SHORT);
|
||||
const values = lines
|
||||
.slice(1)
|
||||
.filter(line => line)
|
||||
.map(set => {
|
||||
const cells = set.split(',');
|
||||
return `('${cells[1]}',${cells[2]},${cells[3]},'${cells[4]}','${cells[5]}')`;
|
||||
})
|
||||
.join(',');
|
||||
await db.executeSql(
|
||||
`INSERT INTO sets(name,reps,weight,created,unit) VALUES ${values}`,
|
||||
);
|
||||
ToastAndroid.show('Data imported.', ToastAndroid.SHORT);
|
||||
reset({index: 0, routes: [{name}]});
|
||||
}, [db, reset, name]);
|
||||
|
||||
const uploadPlans = useCallback(async () => {
|
||||
const result = await DocumentPicker.pickSingle();
|
||||
const file = await FileSystem.readFile(result.uri);
|
||||
console.log(`${DrawerMenu.name}.uploadPlans:`, file.length);
|
||||
const lines = file.split('\n');
|
||||
if (lines[0] != planFields)
|
||||
return ToastAndroid.show('Invalid csv.', ToastAndroid.SHORT);
|
||||
const values = file
|
||||
.split('\n')
|
||||
.slice(1)
|
||||
.filter(line => line)
|
||||
.map(set => {
|
||||
const cells = set.split('","').map(cell => cell.replace(/"/g, ''));
|
||||
return `('${cells[1]}','${cells[2]}')`;
|
||||
})
|
||||
.join(',');
|
||||
await db.executeSql(`INSERT INTO plans(days,workouts) VALUES ${values}`);
|
||||
ToastAndroid.show('Data imported.', ToastAndroid.SHORT);
|
||||
}, [db]);
|
||||
|
||||
const upload = useCallback(async () => {
|
||||
setShowMenu(false);
|
||||
if (name === 'Home') await uploadSets();
|
||||
else if (name === 'Plans') await uploadPlans();
|
||||
reset({index: 0, routes: [{name}]});
|
||||
}, [name, uploadPlans, uploadSets, reset]);
|
||||
|
||||
const remove = useCallback(async () => {
|
||||
setShowMenu(false);
|
||||
setShowRemove(false);
|
||||
if (name === 'Home') await db.executeSql(`DELETE FROM sets`);
|
||||
else if (name === 'Plans') await db.executeSql(`DELETE FROM plans`);
|
||||
ToastAndroid.show('All data has been deleted.', ToastAndroid.SHORT);
|
||||
reset({index: 0, routes: [{name}]});
|
||||
}, [db, reset, name]);
|
||||
|
||||
if (name === 'Home' || name === 'Plans')
|
||||
return (
|
||||
<Menu
|
||||
visible={showMenu}
|
||||
onDismiss={() => setShowMenu(false)}
|
||||
anchor={
|
||||
<IconButton
|
||||
onPress={() => setShowMenu(true)}
|
||||
icon="ellipsis-vertical"
|
||||
/>
|
||||
}>
|
||||
<Menu.Item icon="arrow-down" onPress={download} title="Download" />
|
||||
<Menu.Item icon="arrow-up" onPress={upload} title="Upload" />
|
||||
<Divider />
|
||||
<Menu.Item
|
||||
icon="trash"
|
||||
onPress={() => setShowRemove(true)}
|
||||
title="Delete"
|
||||
/>
|
||||
<ConfirmDialog
|
||||
title="Delete all data"
|
||||
show={showRemove}
|
||||
setShow={setShowRemove}
|
||||
onOk={remove}>
|
||||
This irreversibly deletes all data from the app. Are you sure?
|
||||
</ConfirmDialog>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
64
EditSet.tsx
64
EditSet.tsx
|
@ -55,28 +55,62 @@ export default function EditSet() {
|
|||
return result.rows.raw();
|
||||
}, [db]);
|
||||
|
||||
const getBest = useCallback(
|
||||
async (query: string): Promise<Set> => {
|
||||
const bestWeight = `
|
||||
SELECT name, reps, unit, MAX(weight) AS weight
|
||||
FROM sets
|
||||
WHERE name = ?
|
||||
GROUP BY name;
|
||||
`;
|
||||
const bestReps = `
|
||||
SELECT name, MAX(reps) as reps, unit, weight
|
||||
FROM sets
|
||||
WHERE name = ?
|
||||
AND weight = ?
|
||||
GROUP BY name;
|
||||
`;
|
||||
const [weightResult] = await db.executeSql(bestWeight, [query]);
|
||||
if (!weightResult.rows.length)
|
||||
return {
|
||||
weight: 0,
|
||||
name: '',
|
||||
reps: 0,
|
||||
created: new Date().toISOString(),
|
||||
id: 0,
|
||||
};
|
||||
const [repsResult] = await db.executeSql(bestReps, [
|
||||
query,
|
||||
weightResult.rows.item(0).weight,
|
||||
]);
|
||||
return repsResult.rows.item(0);
|
||||
},
|
||||
[db],
|
||||
);
|
||||
|
||||
const predict = useCallback(async () => {
|
||||
if ((await AsyncStorage.getItem('predictiveSets')) === 'false') return;
|
||||
const todaysPlan = await getTodaysPlan();
|
||||
if (todaysPlan.length === 0) return;
|
||||
const todaysSets = await getTodaysSets();
|
||||
const todaysWorkouts = todaysPlan[0].workouts.split(',');
|
||||
if (todaysSets.length === 0) return setName(todaysWorkouts[0]);
|
||||
const count = todaysSets.filter(
|
||||
set => set.name === todaysSets[0].name,
|
||||
).length;
|
||||
const maxSets = await AsyncStorage.getItem('maxSets');
|
||||
if (count < Number(maxSets)) {
|
||||
setName(todaysSets[0].name);
|
||||
setReps(todaysSets[0].reps.toString());
|
||||
setWeight(todaysSets[0].weight.toString());
|
||||
return setUnit(todaysSets[0].unit);
|
||||
let nextWorkout = todaysWorkouts[0];
|
||||
if (todaysSets.length > 0) {
|
||||
const count = todaysSets.filter(
|
||||
set => set.name === todaysSets[0].name,
|
||||
).length;
|
||||
const maxSets = await AsyncStorage.getItem('maxSets');
|
||||
nextWorkout = todaysSets[0].name;
|
||||
if (count >= Number(maxSets))
|
||||
nextWorkout =
|
||||
todaysWorkouts[todaysWorkouts.indexOf(todaysSets[0].name!) + 1];
|
||||
}
|
||||
const nextWorkout =
|
||||
todaysWorkouts[todaysWorkouts.indexOf(todaysSets[0].name!) + 1];
|
||||
if (!nextWorkout) return;
|
||||
setName(nextWorkout);
|
||||
}, [getTodaysSets, getTodaysPlan]);
|
||||
const best = await getBest(nextWorkout);
|
||||
setName(best.name);
|
||||
setReps(best.reps.toString());
|
||||
setWeight(best.weight.toString());
|
||||
setUnit(best.unit);
|
||||
}, [getTodaysSets, getTodaysPlan, getBest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (params.set.id) return;
|
||||
|
|
|
@ -25,6 +25,7 @@ export default function HomePage() {
|
|||
<Stack.Screen name="Sets" component={SetList} />
|
||||
<Stack.Screen
|
||||
name="EditSet"
|
||||
options={{headerRight: () => <IconButton icon="clock" />}}
|
||||
component={EditSet}
|
||||
listeners={{
|
||||
beforeRemove: () => {
|
||||
|
|
|
@ -4,6 +4,7 @@ import {IconButton} from 'react-native-paper';
|
|||
import {SQLiteDatabase} from 'react-native-sqlite-storage';
|
||||
import {DatabaseContext, Drawer, DrawerParamList} from './App';
|
||||
import BestPage from './BestPage';
|
||||
import DrawerMenu from './DrawerMenu';
|
||||
import HomePage from './HomePage';
|
||||
import PlanPage from './PlanPage';
|
||||
import SettingsPage from './SettingsPage';
|
||||
|
@ -36,6 +37,7 @@ export default function Routes({db}: {db: SQLiteDatabase | null}) {
|
|||
name={route.name}
|
||||
component={route.component}
|
||||
options={{
|
||||
headerRight: () => <DrawerMenu name={route.name} />,
|
||||
drawerIcon: ({focused}) => (
|
||||
<IconButton
|
||||
icon={focused ? route.icon : `${route.icon}-outline`}
|
||||
|
|
125
SettingsPage.tsx
125
SettingsPage.tsx
|
@ -1,17 +1,13 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import React, {useCallback, useContext, useEffect, useState} from 'react';
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {
|
||||
NativeModules,
|
||||
PermissionsAndroid,
|
||||
StyleSheet,
|
||||
Text,
|
||||
ToastAndroid,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import {Dirs, FileSystem} from 'react-native-file-access';
|
||||
import {Button, Snackbar, Switch, TextInput} from 'react-native-paper';
|
||||
import {DatabaseContext} from './App';
|
||||
import Set from './set';
|
||||
import DocumentPicker from 'react-native-document-picker';
|
||||
import {TextInput} from 'react-native-paper';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import MassiveSwitch from './MassiveSwitch';
|
||||
|
||||
|
@ -23,12 +19,8 @@ export default function SettingsPage() {
|
|||
const [seconds, setSeconds] = useState<string>('');
|
||||
const [alarmEnabled, setAlarmEnabled] = useState<boolean>(false);
|
||||
const [predictiveSets, setPredictiveSets] = useState<boolean>(false);
|
||||
const [snackbar, setSnackbar] = useState('');
|
||||
const [showBattery, setShowBattery] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [ignoring, setIgnoring] = useState(false);
|
||||
const [timeoutId, setTimeoutId] = useState(0);
|
||||
const db = useContext(DatabaseContext);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setMinutes((await getItem('minutes')) || '');
|
||||
|
@ -36,77 +28,13 @@ export default function SettingsPage() {
|
|||
setAlarmEnabled((await getItem('alarmEnabled')) === 'true');
|
||||
setPredictiveSets((await getItem('predictiveSets')) === 'true');
|
||||
setMaxSets((await getItem('maxSets')) || '');
|
||||
NativeModules.AlarmModule.ignoringBatteryOptimizations(setIgnoring);
|
||||
NativeModules.AlarmModule.ignoringBattery(setIgnoring);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const toast = useCallback(
|
||||
(message: string, timeout = 3000) => {
|
||||
setSnackbar(message);
|
||||
clearTimeout(timeoutId);
|
||||
setTimeoutId(setTimeout(() => setSnackbar(''), timeout));
|
||||
},
|
||||
[setSnackbar, timeoutId, setTimeoutId],
|
||||
);
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
setShowDelete(false);
|
||||
await db.executeSql(`DELETE FROM sets`);
|
||||
toast('All data has been deleted!');
|
||||
}, [db, toast]);
|
||||
|
||||
const exportSets = useCallback(async () => {
|
||||
const fileName = 'sets.csv';
|
||||
const filePath = `${Dirs.DocumentDir}/${fileName}`;
|
||||
const [result] = await db.executeSql('SELECT * FROM sets');
|
||||
if (result.rows.length === 0) return;
|
||||
const sets: Set[] = result.rows.raw();
|
||||
const data = ['id,name,reps,weight,created,unit']
|
||||
.concat(
|
||||
sets.map(
|
||||
set =>
|
||||
`${set.id},${set.name},${set.reps},${set.weight},${set.created},${set.unit}`,
|
||||
),
|
||||
)
|
||||
.join('\n');
|
||||
console.log('SettingsPage.exportSets', {length: sets.length});
|
||||
const permission = async () => {
|
||||
const granted = await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE,
|
||||
);
|
||||
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||
};
|
||||
const granted = await permission();
|
||||
if (granted) {
|
||||
await FileSystem.writeFile(filePath, data);
|
||||
if (!FileSystem.exists(filePath)) return;
|
||||
await FileSystem.cpExternal(filePath, fileName, 'downloads');
|
||||
}
|
||||
toast('Exported data. Check your downloads folder.');
|
||||
}, [db, toast]);
|
||||
|
||||
const importSets = useCallback(async () => {
|
||||
const result = await DocumentPicker.pickSingle();
|
||||
const file = await FileSystem.readFile(result.uri);
|
||||
console.log(`${SettingsPage.name}.${importSets.name}:`, file.length);
|
||||
const values = file
|
||||
.split('\n')
|
||||
.slice(1)
|
||||
.filter(line => line)
|
||||
.map(set => {
|
||||
const cells = set.split(',');
|
||||
return `('${cells[1]}',${cells[2]},${cells[3]},'${cells[4]}','${cells[5]}')`;
|
||||
})
|
||||
.join(',');
|
||||
await db.executeSql(
|
||||
`INSERT INTO sets(name,reps,weight,created,unit) VALUES ${values}`,
|
||||
);
|
||||
toast('Data imported.');
|
||||
}, [db, toast]);
|
||||
|
||||
const changeAlarmEnabled = useCallback(
|
||||
(enabled: boolean) => {
|
||||
setAlarmEnabled(enabled);
|
||||
|
@ -120,9 +48,12 @@ export default function SettingsPage() {
|
|||
(enabled: boolean) => {
|
||||
setPredictiveSets(enabled);
|
||||
setItem('predictiveSets', enabled ? 'true' : 'false');
|
||||
toast('Predictive sets guess whats next based on todays plan.', 10000);
|
||||
ToastAndroid.show(
|
||||
'Predictive sets guess whats next based on todays plan.',
|
||||
ToastAndroid.LONG,
|
||||
);
|
||||
},
|
||||
[setPredictiveSets, toast],
|
||||
[setPredictiveSets],
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -174,7 +105,7 @@ export default function SettingsPage() {
|
|||
show={showBattery}
|
||||
setShow={setShowBattery}
|
||||
onOk={() => {
|
||||
NativeModules.AlarmModule.openBatteryOptimizations();
|
||||
NativeModules.AlarmModule.openSettings();
|
||||
setShowBattery(false);
|
||||
}}>
|
||||
Disable battery optimizations for Massive to use rest timers.
|
||||
|
@ -186,42 +117,6 @@ export default function SettingsPage() {
|
|||
value={predictiveSets}
|
||||
onValueChange={changePredictive}
|
||||
/>
|
||||
|
||||
<Button
|
||||
style={{alignSelf: 'flex-start'}}
|
||||
icon="arrow-down"
|
||||
onPress={exportSets}>
|
||||
Export
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
style={{alignSelf: 'flex-start'}}
|
||||
icon="arrow-up"
|
||||
onPress={importSets}>
|
||||
Import
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
style={{alignSelf: 'flex-start'}}
|
||||
icon="trash"
|
||||
onPress={() => setShowDelete(true)}>
|
||||
Delete all data
|
||||
</Button>
|
||||
|
||||
<ConfirmDialog
|
||||
title="Delete all data"
|
||||
show={showDelete}
|
||||
setShow={setShowDelete}
|
||||
onOk={clear}>
|
||||
This irreversibly deletes all data from the app. Are you sure?
|
||||
</ConfirmDialog>
|
||||
|
||||
<Snackbar
|
||||
visible={!!snackbar}
|
||||
onDismiss={() => setSnackbar('')}
|
||||
action={{label: 'Close', onPress: () => setSnackbar('')}}>
|
||||
{snackbar}
|
||||
</Snackbar>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -35,6 +35,5 @@
|
|||
<service android:name=".StopTimer" android:exported="true" android:process=":remote" />
|
||||
<service android:name=".AlarmService" android:exported="true" />
|
||||
<service android:name=".TimerService" android:exported="true" />
|
||||
<receiver android:name=".TimerBroadcast" android:exported="true" android:process=":remote"/>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
@ -32,7 +32,7 @@ class AlarmModule internal constructor(context: ReactApplicationContext?) :
|
|||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@ReactMethod
|
||||
fun ignoringBatteryOptimizations(callback: Callback) {
|
||||
fun ignoringBattery(callback: Callback) {
|
||||
val packageName = reactApplicationContext.packageName
|
||||
val pm =
|
||||
reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
|
@ -45,7 +45,7 @@ class AlarmModule internal constructor(context: ReactApplicationContext?) :
|
|||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@ReactMethod
|
||||
fun openBatteryOptimizations() {
|
||||
fun openSettings() {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intent.data = Uri.parse("package:" + reactApplicationContext.packageName)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
|
|
|
@ -8,6 +8,7 @@ import android.util.Log
|
|||
class StopTimer : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
applicationContext.stopService(Intent(applicationContext, TimerService::class.java))
|
||||
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,89 +0,0 @@
|
|||
package com.massive
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import kotlin.math.floor
|
||||
|
||||
class TimerBroadcast : BroadcastReceiver() {
|
||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val notificationManager = getManager(context)
|
||||
val builder = getBuilder(context)
|
||||
if (intent.action == "tick") {
|
||||
val endMs = intent.extras!!.getInt("endMs")
|
||||
val currentMs = intent.extras!!.getLong("currentMs")
|
||||
val seconds = floor((currentMs / 1000).toDouble() % 60)
|
||||
.toInt().toString().padStart(2, '0')
|
||||
val minutes = floor((currentMs / 1000).toDouble() / 60)
|
||||
.toInt().toString().padStart(2, '0')
|
||||
builder.setContentText("$minutes:$seconds")
|
||||
.setAutoCancel(false)
|
||||
.setDefaults(0)
|
||||
.setProgress(endMs, currentMs.toInt(), false)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.priority = NotificationCompat.PRIORITY_LOW
|
||||
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
else if (intent.action === "finish") {
|
||||
Log.d("TimerBroadcast", "Finishing...")
|
||||
val finishIntent = Intent(context, StopAlarm::class.java)
|
||||
val finishPending =
|
||||
PendingIntent.getActivity(context, 0, finishIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
builder.setContentText("Timer finished.")
|
||||
.setAutoCancel(true)
|
||||
.setOngoing(false)
|
||||
.setContentIntent(finishPending)
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||
.priority = NotificationCompat.PRIORITY_HIGH
|
||||
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
||||
context.startService(Intent(context, AlarmService::class.java))
|
||||
}
|
||||
else if (intent.action === "stop") {
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun getBuilder(context: Context): NotificationCompat.Builder {
|
||||
val contentIntent = Intent(context, MainActivity::class.java)
|
||||
val pendingContent =
|
||||
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
val actionIntent = Intent(context, StopTimer::class.java)
|
||||
val pendingAction =
|
||||
PendingIntent.getService(context, 0, actionIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_baseline_hourglass_bottom_24)
|
||||
.setContentTitle("Resting")
|
||||
.setContentIntent(pendingContent)
|
||||
.addAction(R.drawable.ic_baseline_stop_24, "STOP", pendingAction)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun getManager(context: Context): NotificationManager {
|
||||
val importance = NotificationManager.IMPORTANCE_LOW
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_ID, importance
|
||||
)
|
||||
channel.description = "Alarms for rest timings."
|
||||
val notificationManager = context.getSystemService(
|
||||
NotificationManager::class.java
|
||||
)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
return notificationManager
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "MassiveTimer"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +1,73 @@
|
|||
package com.massive
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.CountDownTimer
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import kotlin.math.floor
|
||||
|
||||
class TimerService : Service() {
|
||||
private var notificationManager: NotificationManager? = null
|
||||
private var endMs: Int? = null
|
||||
private var countdownTimer: CountDownTimer? = null
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d("TimerService", "Started timer service.")
|
||||
val endMs = intent!!.extras!!.getInt("milliseconds")
|
||||
countdownTimer?.cancel()
|
||||
countdownTimer = object : CountDownTimer(endMs.toLong(), 1000) {
|
||||
override fun onTick(currentMs: Long) {
|
||||
val broadcastIntent = Intent(applicationContext, TimerBroadcast::class.java)
|
||||
broadcastIntent.putExtra("endMs", endMs)
|
||||
broadcastIntent.putExtra("currentMs", currentMs)
|
||||
broadcastIntent.action = "tick"
|
||||
sendBroadcast(broadcastIntent)
|
||||
}
|
||||
override fun onFinish() {
|
||||
val broadcastIntent = Intent(applicationContext, TimerBroadcast::class.java)
|
||||
broadcastIntent.action = "finish"
|
||||
sendBroadcast(broadcastIntent)
|
||||
}
|
||||
if (intent?.action == "add") {
|
||||
endMs = endMs?.plus(60000)
|
||||
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
|
||||
}
|
||||
else {
|
||||
endMs = intent!!.extras!!.getInt("milliseconds")
|
||||
}
|
||||
notificationManager = getManager(applicationContext)
|
||||
val builder = getBuilder(applicationContext)
|
||||
countdownTimer?.cancel()
|
||||
countdownTimer = getTimer(builder, notificationManager!!)
|
||||
countdownTimer!!.start()
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
private fun getTimer(builder: NotificationCompat.Builder, notificationManager: NotificationManager): CountDownTimer {
|
||||
return object : CountDownTimer(endMs!!.toLong(), 1000) {
|
||||
override fun onTick(currentMs: Long) {
|
||||
val seconds = floor((currentMs / 1000).toDouble() % 60)
|
||||
.toInt().toString().padStart(2, '0')
|
||||
val minutes = floor((currentMs / 1000).toDouble() / 60)
|
||||
.toInt().toString().padStart(2, '0')
|
||||
builder.setContentText("$minutes:$seconds")
|
||||
.setAutoCancel(false)
|
||||
.setDefaults(0)
|
||||
.setProgress(endMs!!, currentMs.toInt(), false)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.priority = NotificationCompat.PRIORITY_LOW
|
||||
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
override fun onFinish() {
|
||||
val finishIntent = Intent(applicationContext, StopAlarm::class.java)
|
||||
val finishPending =
|
||||
PendingIntent.getActivity(applicationContext, 0, finishIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
builder.setContentText("Timer finished.")
|
||||
.setAutoCancel(true)
|
||||
.setOngoing(false)
|
||||
.setContentIntent(finishPending)
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||
.priority = NotificationCompat.PRIORITY_HIGH
|
||||
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
||||
applicationContext.startService(Intent(applicationContext, AlarmService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
@ -41,9 +75,46 @@ class TimerService : Service() {
|
|||
override fun onDestroy() {
|
||||
Log.d("TimerService", "Destroying...")
|
||||
countdownTimer?.cancel()
|
||||
val broadcastIntent = Intent(applicationContext, TimerBroadcast::class.java)
|
||||
broadcastIntent.action = "stop"
|
||||
sendBroadcast(broadcastIntent)
|
||||
notificationManager?.cancel(NOTIFICATION_ID)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun getBuilder(context: Context): NotificationCompat.Builder {
|
||||
val contentIntent = Intent(context, MainActivity::class.java)
|
||||
val pendingContent =
|
||||
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
val stopIntent = Intent(context, StopTimer::class.java)
|
||||
val pendingStop =
|
||||
PendingIntent.getService(context, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
val addIntent = Intent(context, TimerService::class.java)
|
||||
addIntent.action = "add"
|
||||
val pendingAdd = PendingIntent.getService(context, 0, addIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.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)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun getManager(context: Context): NotificationManager {
|
||||
val importance = NotificationManager.IMPORTANCE_LOW
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_ID, importance
|
||||
)
|
||||
channel.description = "Alarms for rest timings."
|
||||
val notificationManager = context.getSystemService(
|
||||
NotificationManager::class.java
|
||||
)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
return notificationManager
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "MassiveTimer"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
}
|
||||
}
|
8
db.ts
8
db.ts
|
@ -21,11 +21,3 @@ export const createPlans = `
|
|||
workouts TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
const selectProgress = `
|
||||
SELECT count(*) as count from sets
|
||||
WHERE created LIKE ?
|
||||
AND name = ?
|
||||
`;
|
||||
export const getProgress = ({created, name}: {created: string; name: string}) =>
|
||||
getDb().then(db => db.executeSql(selectProgress, [`%${created}%`, name]));
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import {PermissionsAndroid, ToastAndroid} from 'react-native';
|
||||
import {Dirs, FileSystem} from 'react-native-file-access';
|
||||
|
||||
export const write = async (name: string, data: string) => {
|
||||
const filePath = `${Dirs.DocumentDir}/${name}`;
|
||||
const permission = async () => {
|
||||
const granted = await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE,
|
||||
);
|
||||
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||
};
|
||||
const granted = await permission();
|
||||
if (!granted) return;
|
||||
await FileSystem.writeFile(filePath, data);
|
||||
if (!FileSystem.exists(filePath)) return;
|
||||
await FileSystem.cpExternal(filePath, name, 'downloads');
|
||||
ToastAndroid.show(`Saved "${name}". Check downloads`, ToastAndroid.LONG);
|
||||
};
|
|
@ -78,5 +78,6 @@
|
|||
"json",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue