Compare commits
576 Commits
Author | SHA1 | Date | |
---|---|---|---|
314b09017b | |||
331597e3ee | |||
79cde3a219 | |||
63e1db7349 | |||
da17f8899c | |||
8648cf166e | |||
af96ec8507 | |||
f51284e4ea | |||
f778426aba | |||
44283fc990 | |||
c97ba1151e | |||
95681c0b3d | |||
158dd61668 | |||
e628d345ca | |||
85915b9aa0 | |||
9833752bab | |||
556347e632 | |||
9dc188e6ec | |||
82da62f699 | |||
36d3de401b | |||
040d588b5a | |||
47d4532169 | |||
3e41c3bbd8 | |||
b776d88327 | |||
adc2d05b2c | |||
c3a3e33e25 | |||
89606b9d21 | |||
6dabb7049f | |||
4b42ab5f21 | |||
a7da93583d | |||
1b2cbab370 | |||
09354829a8 | |||
514efc6467 | |||
1603496424 | |||
0beb1397a6 | |||
a5b6673e9a | |||
6a7bd632e5 | |||
4303fe2cc4 | |||
23ed95dcdb | |||
8f1f9f6e7d | |||
bdd5e23f32 | |||
9c9a5fdd63 | |||
90db607190 | |||
457134df6b | |||
db5cc566ea | |||
76e5aeacfd | |||
2fb46e1dcc | |||
d1342c0efa | |||
288ae1ae0c | |||
0e7920bde9 | |||
d2a1c432bb | |||
5dd569ef72 | |||
dfc4f73ca4 | |||
79a48b1e47 | |||
13b340f5be | |||
4db820f10a | |||
7b401388b5 | |||
a1643c349d | |||
640a25a0f4 | |||
c9b1ab1f9d | |||
00d4edcfc3 | |||
8dd8f786ef | |||
a84cab6bbf | |||
f4db61aeec | |||
3af3e1faf2 | |||
7bc9c00a63 | |||
a03731c6ff | |||
f1e0911488 | |||
1a75d8897d | |||
9f7cbba80a | |||
de2aa67e6e | |||
28ec021258 | |||
04ef72e48b | |||
467df629b0 | |||
e7f85a9954 | |||
5e6896eaba | |||
6438a9c48a | |||
8e8961419c | |||
b0696d1d58 | |||
73d9b1c617 | |||
a6130b3a10 | |||
7bee8ae732 | |||
6c8731c17a | |||
6fa2bbb506 | |||
069f770c96 | |||
b41c30d886 | |||
495b89fba3 | |||
42912040ff | |||
c7952738b5 | |||
cffc458338 | |||
05237fc293 | |||
5fd7e75908 | |||
705052f1b4 | |||
efc97bdf47 | |||
d0702b7675 | |||
24e230e8b9 | |||
67689f4af8 | |||
a2721e9f12 | |||
bafdecd3e3 | |||
e432c1b711 | |||
08f91bf531 | |||
80f2dfdff5 | |||
3c9b93f0bc | |||
f221ebb8df | |||
a68d4d6a69 | |||
5d9df37778 | |||
8246155c13 | |||
58f1c905b2 | |||
805b7bdc34 | |||
a772e36160 | |||
ad95438120 | |||
3c953530a4 | |||
13e1d4cc21 | |||
21773e3b4f | |||
27ff4861d9 | |||
e43188ccdf | |||
9287c31e70 | |||
5612df5d8c | |||
a78e07dac8 | |||
86f01eb002 | |||
5335f4afbc | |||
d71ad8c170 | |||
53799fdcc4 | |||
651b130caa | |||
73c7486eb3 | |||
ea2ff913db | |||
0be8f03133 | |||
7863b9caa0 | |||
3fdc5900e3 | |||
e51aad21f3 | |||
f48124123c | |||
3603c67133 | |||
a9000898f3 | |||
dd7cb0406b | |||
a5ddf5c94d | |||
9be10610d2 | |||
5e37490c2d | |||
7f1513f0a5 | |||
14edb66e28 | |||
3ed2d4f0cd | |||
46dd50adfb | |||
e430873771 | |||
a9266ba77b | |||
051df31925 | |||
a3138c48b5 | |||
2b302bab73 | |||
7bf802ea45 | |||
5115055280 | |||
41ed9464c9 | |||
596b695c5b | |||
c664a9603c | |||
186b7e0fe9 | |||
d6e7d6158c | |||
2c6a773548 | |||
b33a829816 | |||
85ea20640d | |||
|
2176acd924 | ||
|
7e81424f60 | ||
|
736cee5ccd | ||
a9b86fb555 | |||
60cc619e39 | |||
48432188c3 | |||
27b7e91e91 | |||
fc6f5e3b53 | |||
8625ca2189 | |||
250335800f | |||
d89721c718 | |||
930ebdc9ca | |||
777eddf943 | |||
e5d9f5fa92 | |||
fb19685bb5 | |||
f9e357ff80 | |||
75f2a8269a | |||
f714941c88 | |||
faeb5ee1e0 | |||
fa19434e77 | |||
888ae576b0 | |||
f02249e254 | |||
a7099a205c | |||
1273b6a6d8 | |||
86566fb54d | |||
c1b63815a2 | |||
a1440b680f | |||
fcd1a4146e | |||
7483a504ee | |||
8122694c10 | |||
71d4ad805c | |||
9c21ee022d | |||
cf68b51fef | |||
af5a7f5abe | |||
2e347deb53 | |||
c3b14e901d | |||
1818e39f41 | |||
afee8f0c50 | |||
9217712a31 | |||
6568d224ea | |||
42589fe9ab | |||
3600003660 | |||
9c184c5924 | |||
6df9bba2ae | |||
f6eb7959e1 | |||
216fc43a81 | |||
533b21a907 | |||
0b2d4d52e1 | |||
0b6471a766 | |||
55e0a9f75e | |||
f85074a41f | |||
228fc212bf | |||
e46e23c9e1 | |||
e9b02d5eb1 | |||
d8eeac66ab | |||
2aa8073856 | |||
b14d20f1f4 | |||
6071957a40 | |||
46262fe6b4 | |||
67d90d4e02 | |||
c2994da041 | |||
284983c1cf | |||
96674cd51f | |||
567e885e76 | |||
c1b6659058 | |||
a284f045d2 | |||
1016997269 | |||
825981460e | |||
53c5a08a14 | |||
76017be226 | |||
26e0391022 | |||
|
4a32f5c85e | ||
|
a3e0ba84cb | ||
521fa0e9d3 | |||
9db11460fe | |||
c4aad7beb5 | |||
51b2f9396f | |||
de4c8081a6 | |||
d3c3a09a0f | |||
8e31dc2186 | |||
4375a9c24e | |||
6676efe69f | |||
2d1bed0671 | |||
1b1bb41ed7 | |||
4e9cd59b0b | |||
849bee6e87 | |||
dc27ae9868 | |||
0c5a221e0f | |||
ea6137ac52 | |||
5a5253ce82 | |||
30124485c7 | |||
b504de45a2 | |||
62ca3ef1c4 | |||
434f29652f | |||
c50dc4aacf | |||
fa180db0fb | |||
93b4861da9 | |||
5a07aee7f4 | |||
3930a99cf7 | |||
e03101f673 | |||
ef637d3e56 | |||
ecece9bbcd | |||
71a1e69c7b | |||
be4098962e | |||
1b9d35d71e | |||
29cbc43534 | |||
a001760ab0 | |||
38332c193c | |||
6fb2022e4d | |||
87233f34a8 | |||
c9adaf59ff | |||
008667c3a2 | |||
157a26b843 | |||
6012747643 | |||
a1b240caae | |||
e6488c38c5 | |||
3528ba593f | |||
e7e2f299da | |||
19ec8ac5e9 | |||
58ab135b09 | |||
1d8d7b070e | |||
261f1c8bf0 | |||
ae842e0ad7 | |||
16668a80a5 | |||
162d67c351 | |||
f04125efc5 | |||
9433aed5a2 | |||
a4eca33e27 | |||
2aa8c690f1 | |||
6b7849b414 | |||
f506aa5af7 | |||
89edc661a4 | |||
401ce5d2b8 | |||
8639b53e7f | |||
3dea1e952c | |||
b74f77e506 | |||
6b74b5114c | |||
2be1fa8e9f | |||
f66c180768 | |||
3a718142e5 | |||
c70e9f5c69 | |||
79296b6518 | |||
c31cebb6d3 | |||
9bfe9737ea | |||
4cc4679dfd | |||
730a736585 | |||
c51bfbd852 | |||
970cf36c94 | |||
bfa7518e40 | |||
dc73035607 | |||
60fe324e06 | |||
e6d5e928a9 | |||
60fd0130b3 | |||
0f73fb9d8f | |||
9db4990202 | |||
427b80cc52 | |||
77f77b0ec4 | |||
1e213b32f8 | |||
bc4bc44b7d | |||
04eb738c73 | |||
13c1f64398 | |||
bac2e3498f | |||
eb23fc2210 | |||
b68f903a1c | |||
7b403050f3 | |||
91b8b2af13 | |||
442f1a1d67 | |||
3c17a12f6e | |||
f87373479a | |||
bb85935e1d | |||
f57de5265d | |||
ae84228913 | |||
075d038ccc | |||
97442bc292 | |||
24e7ee58d9 | |||
1e4e66363b | |||
806480532f | |||
86ad6b93d6 | |||
9c808ce84b | |||
aaca9240a2 | |||
568819e85f | |||
1e0daeec90 | |||
7b4fddfebf | |||
7c9b4bf5f4 | |||
584a505308 | |||
5fcd0e39af | |||
97ade15700 | |||
f4d70db377 | |||
03358c203b | |||
57c71a39e9 | |||
2dfff2c851 | |||
1e88a98353 | |||
a2d8f4d8ac | |||
f9449a9860 | |||
ba61e79808 | |||
7760c94626 | |||
8019df7418 | |||
da4484cf4f | |||
29d14d74ff | |||
0e5de0e519 | |||
facbfe4da5 | |||
b6616a551a | |||
f7c895f608 | |||
1110ccb741 | |||
84b369d54b | |||
fcce1ad9ef | |||
44b2b26b6d | |||
e8dfd5d427 | |||
98c7fac75d | |||
4a95ed050c | |||
cafcb996e3 | |||
90fa309c09 | |||
09e178c5ce | |||
f52b1437f2 | |||
6f57b235d6 | |||
aa2d146527 | |||
a8fac1db69 | |||
e48e125499 | |||
1d0d7c2fff | |||
2e5edb741e | |||
4873fcb653 | |||
8835a3efd3 | |||
187a0fbc68 | |||
2aaaac1929 | |||
7b568d3b04 | |||
156f1fc33f | |||
1f513f2a03 | |||
f91d529f39 | |||
ffbefe7a4f | |||
0f6102f433 | |||
3bf2193d46 | |||
2a868cc9ee | |||
2dfbb7224f | |||
ffc0662171 | |||
0ed3b9817c | |||
2c029b5f6a | |||
18eaa9fc14 | |||
202d34d785 | |||
07a3d240ea | |||
7a97b11e79 | |||
306f13214a | |||
58b2990ab2 | |||
0a2e0086b3 | |||
83852b3216 | |||
6a4d167e08 | |||
e9c2ee743e | |||
949b435853 | |||
6ac84d1d32 | |||
6d49cbcc80 | |||
af9dcd0b13 | |||
31f1528c35 | |||
8d7fe149f5 | |||
139d75493e | |||
fadab1f30b | |||
49b5eb48c6 | |||
ace327ecad | |||
f56f0063c4 | |||
3c4bba3f85 | |||
1a53fa324b | |||
13ca9cef3e | |||
bdb27894f7 | |||
b782d66bf2 | |||
09ee09f509 | |||
bd6b20fb4e | |||
eafad1f47e | |||
bc7aca03e8 | |||
1bc145f60c | |||
e7321b6d8e | |||
b7f1c2192e | |||
111ee4201f | |||
294c6ee639 | |||
8ad6189dfc | |||
9752aa9dd1 | |||
e0da621198 | |||
e33ff1172a | |||
992b3d0ba6 | |||
2ae9d2a4c1 | |||
eba33c2599 | |||
a804d9ef05 | |||
5fafc6a63a | |||
a85bc04c35 | |||
129b00dc5d | |||
ba2a2259f3 | |||
e1a90a98fb | |||
8a240b78cd | |||
6e75614d10 | |||
cc97c760bb | |||
4aa62dace8 | |||
3784285695 | |||
5a22c73834 | |||
463852e6a6 | |||
3d591f4618 | |||
e6dcd4a47e | |||
5441aa164b | |||
1c58dc2db1 | |||
8504f8b811 | |||
46dcfb96bf | |||
afbdd2fed5 | |||
82888ce530 | |||
ac0af26f77 | |||
6d6a6f7a20 | |||
859fa2a89f | |||
ef7342b788 | |||
4735b1589b | |||
4e6de66f90 | |||
cd602cee33 | |||
97827c68b2 | |||
3be82e0b36 | |||
b19033b814 | |||
21d9149498 | |||
c9125575cc | |||
80b1a1ef56 | |||
36e6637ba2 | |||
48bb4a34cf | |||
e84dd7bdea | |||
2d9e561908 | |||
a664b65ce2 | |||
149872ea7e | |||
b95024abe0 | |||
4ba86be8af | |||
3cb6e8757b | |||
88d751f13b | |||
c73937396e | |||
d21e7986e3 | |||
dc84fa5f6c | |||
d723bd9745 | |||
db4f6fb482 | |||
ae947d5405 | |||
c05a76ed1a | |||
5d5f586f7f | |||
d23d489ec0 | |||
6238b47e6a | |||
77db34b310 | |||
3714db438e | |||
f078ede58a | |||
7bd2254719 | |||
2fd9635e40 | |||
4d35d617e8 | |||
b8c98babe6 | |||
b89209b852 | |||
3012b69e00 | |||
0b041f9643 | |||
81deba0dbc | |||
6d29ac09be | |||
d97cdbfe99 | |||
e3758e44e7 | |||
cd424eba6e | |||
bca65c90e6 | |||
1d0dd70a49 | |||
9bd8fa9353 | |||
46f0875497 | |||
8461f86e88 | |||
d80135d4ed | |||
2782d34a05 | |||
c4e26e2560 | |||
861b5df9f4 | |||
b6665ed4b5 | |||
90f09e3a31 | |||
a65274c2d6 | |||
228383ed23 | |||
780500ac75 | |||
52f8241054 | |||
e6228b3990 | |||
f41e7b3ffe | |||
9dd929b177 | |||
6316e99e6e | |||
1a43f41170 | |||
2e1e84ac6f | |||
ca09613d9b | |||
3b0391310b | |||
05a7747f05 | |||
da3dd99bc0 | |||
5901722f22 | |||
636bfa35a4 | |||
e7438138a6 | |||
e0b84af9e7 | |||
5481e8a20d | |||
17b88f39e4 | |||
3cbabb723a | |||
2be2893dc1 | |||
e501276463 | |||
e2c790870b | |||
73fb90961e | |||
eb53d58991 | |||
3164347158 | |||
c1f5f62145 | |||
c65bbf948f | |||
4a924df8fb | |||
93878f14e4 | |||
ba3ed2a272 | |||
a5c70050a7 | |||
6f41f87dc1 | |||
76a3584dbb | |||
300651f4e9 | |||
b0b804eae1 | |||
794504dee0 | |||
c866fac9d2 | |||
df45938bc3 | |||
edf823ca8b | |||
0b489cac2a | |||
a02247542e | |||
9d42760dff | |||
a530b563b9 | |||
6af167268c | |||
53a5b09f7e | |||
c8cedef8fb | |||
4d219581d0 | |||
71c358a532 | |||
4ba5b204f2 | |||
c6fe5b576a | |||
c4930d12ca | |||
4199bf0058 | |||
6076e0014f | |||
4eaf2c2134 | |||
c41b8438d3 | |||
90a3d473b6 | |||
b0b3f7a880 | |||
ee76864a16 | |||
374cbdf45d | |||
66c24a96bd |
16
.eslintrc.js
16
.eslintrc.js
|
@ -1,16 +1,22 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
extends: '@react-native-community',
|
extends: '@react-native',
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
plugins: ['@typescript-eslint'],
|
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: ['*.ts', '*.tsx'],
|
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',
|
||||||
|
semi: 'off',
|
||||||
|
curly: 'off',
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'react-native/no-inline-styles': 'off',
|
||||||
|
'no-spaced-func': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
ignorePatterns: ['coverage/', 'mock-providers.tsx'],
|
||||||
|
}
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -73,3 +73,4 @@ massive-build
|
||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/sdks
|
!.yarn/sdks
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
coverage
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
arrowParens: 'avoid',
|
|
||||||
bracketSameLine: true,
|
|
||||||
bracketSpacing: false,
|
|
||||||
singleQuote: true,
|
|
||||||
trailingComma: 'all',
|
|
||||||
};
|
|
|
@ -1 +1 @@
|
||||||
2.7.4
|
2.7.5
|
||||||
|
|
12
.vscode/launch.json
vendored
Normal file
12
.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug Android Hermes - Experimental",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "reactnativedirect",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"platform": "android"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
136
App.tsx
136
App.tsx
|
@ -2,18 +2,21 @@ import {
|
||||||
DarkTheme as NavigationDarkTheme,
|
DarkTheme as NavigationDarkTheme,
|
||||||
DefaultTheme as NavigationDefaultTheme,
|
DefaultTheme as NavigationDefaultTheme,
|
||||||
NavigationContainer,
|
NavigationContainer,
|
||||||
} from '@react-navigation/native';
|
} from "@react-navigation/native";
|
||||||
import React, {useState} from 'react';
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import {useColorScheme} from 'react-native';
|
import { DeviceEventEmitter, useColorScheme } from "react-native";
|
||||||
import {
|
import {
|
||||||
DarkTheme as PaperDarkTheme,
|
MD3DarkTheme as PaperDarkTheme,
|
||||||
DefaultTheme as PaperDefaultTheme,
|
MD3LightTheme as PaperDefaultTheme,
|
||||||
Provider,
|
Provider as PaperProvider,
|
||||||
} from 'react-native-paper';
|
Snackbar,
|
||||||
import Ionicon from 'react-native-vector-icons/MaterialIcons';
|
} from "react-native-paper";
|
||||||
import {lightColors} from './colors';
|
import MaterialIcon from "react-native-vector-icons/MaterialIcons";
|
||||||
import MassiveSnack from './MassiveSnack';
|
import { AppDataSource } from "./data-source";
|
||||||
import Routes from './Routes';
|
import { settingsRepo } from "./db";
|
||||||
|
import Routes from "./Routes";
|
||||||
|
import { TOAST } from "./toast";
|
||||||
|
import { ThemeContext } from "./use-theme";
|
||||||
|
|
||||||
export const CombinedDefaultTheme = {
|
export const CombinedDefaultTheme = {
|
||||||
...NavigationDefaultTheme,
|
...NavigationDefaultTheme,
|
||||||
|
@ -23,51 +26,102 @@ export const CombinedDefaultTheme = {
|
||||||
...PaperDefaultTheme.colors,
|
...PaperDefaultTheme.colors,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CombinedDarkTheme = {
|
export const CombinedDarkTheme = {
|
||||||
...NavigationDarkTheme,
|
...NavigationDarkTheme,
|
||||||
...PaperDarkTheme,
|
...PaperDarkTheme,
|
||||||
colors: {
|
colors: {
|
||||||
...NavigationDarkTheme.colors,
|
...NavigationDarkTheme.colors,
|
||||||
...PaperDarkTheme.colors,
|
...PaperDarkTheme.colors,
|
||||||
primary: lightColors[0].hex,
|
|
||||||
background: '#0E0E0E',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CustomTheme = React.createContext({
|
|
||||||
color: '',
|
|
||||||
setColor: (_value: string) => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const dark = useColorScheme() === 'dark';
|
const phoneTheme = useColorScheme();
|
||||||
const [color, setColor] = useState(
|
const [initialized, setInitialized] = useState(false);
|
||||||
dark
|
const [snackbar, setSnackbar] = useState("");
|
||||||
? CombinedDarkTheme.colors.primary.toUpperCase()
|
const [appTheme, setAppTheme] = useState("system");
|
||||||
: CombinedDefaultTheme.colors.primary.toUpperCase(),
|
|
||||||
|
const [lightColor, setLightColor] = useState<string>(
|
||||||
|
CombinedDefaultTheme.colors.primary
|
||||||
);
|
);
|
||||||
const theme = dark
|
|
||||||
? {
|
const [darkColor, setDarkColor] = useState<string>(
|
||||||
...CombinedDarkTheme,
|
CombinedDarkTheme.colors.primary
|
||||||
colors: {...CombinedDarkTheme.colors, primary: color},
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
|
||||||
|
const settings = await settingsRepo.findOne({ where: {} });
|
||||||
|
setAppTheme(settings.theme);
|
||||||
|
if (settings.lightColor) setLightColor(settings.lightColor);
|
||||||
|
if (settings.darkColor) setDarkColor(settings.darkColor);
|
||||||
|
setInitialized(true);
|
||||||
|
})();
|
||||||
|
const description = DeviceEventEmitter.addListener(
|
||||||
|
TOAST,
|
||||||
|
({ value }: { value: string }) => {
|
||||||
|
setSnackbar(value);
|
||||||
}
|
}
|
||||||
: {
|
);
|
||||||
...CombinedDefaultTheme,
|
return description.remove;
|
||||||
colors: {...CombinedDefaultTheme.colors, primary: color},
|
}, []);
|
||||||
};
|
|
||||||
|
const paperTheme = useMemo(() => {
|
||||||
|
const darkTheme = lightColor
|
||||||
|
? {
|
||||||
|
...CombinedDarkTheme,
|
||||||
|
colors: { ...CombinedDarkTheme.colors, primary: darkColor },
|
||||||
|
}
|
||||||
|
: CombinedDarkTheme;
|
||||||
|
const lightTheme = lightColor
|
||||||
|
? {
|
||||||
|
...CombinedDefaultTheme,
|
||||||
|
colors: { ...CombinedDefaultTheme.colors, primary: lightColor },
|
||||||
|
}
|
||||||
|
: CombinedDefaultTheme;
|
||||||
|
let value = phoneTheme === "dark" ? darkTheme : lightTheme;
|
||||||
|
if (appTheme === "dark") value = darkTheme;
|
||||||
|
else if (appTheme === "light") value = lightTheme;
|
||||||
|
return value;
|
||||||
|
}, [phoneTheme, appTheme, lightColor, darkColor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomTheme.Provider value={{color, setColor}}>
|
<PaperProvider
|
||||||
<Provider
|
theme={paperTheme}
|
||||||
theme={theme}
|
settings={{ icon: (props) => <MaterialIcon {...props} /> }}
|
||||||
settings={{icon: props => <Ionicon {...props} />}}>
|
>
|
||||||
<NavigationContainer theme={theme}>
|
<NavigationContainer theme={paperTheme}>
|
||||||
<MassiveSnack>
|
{initialized && (
|
||||||
|
<ThemeContext.Provider
|
||||||
|
value={{
|
||||||
|
theme: appTheme,
|
||||||
|
setTheme: setAppTheme,
|
||||||
|
lightColor,
|
||||||
|
setLightColor,
|
||||||
|
darkColor,
|
||||||
|
setDarkColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Routes />
|
<Routes />
|
||||||
</MassiveSnack>
|
</ThemeContext.Provider>
|
||||||
</NavigationContainer>
|
)}
|
||||||
</Provider>
|
</NavigationContainer>
|
||||||
</CustomTheme.Provider>
|
|
||||||
|
<Snackbar
|
||||||
|
duration={3000}
|
||||||
|
onDismiss={() => setSnackbar("")}
|
||||||
|
visible={!!snackbar}
|
||||||
|
action={{
|
||||||
|
label: "Close",
|
||||||
|
onPress: () => setSnackbar(""),
|
||||||
|
textColor: paperTheme.colors.background,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{snackbar}
|
||||||
|
</Snackbar>
|
||||||
|
</PaperProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
31
AppFab.tsx
Normal file
31
AppFab.tsx
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { ComponentProps, useMemo } from "react";
|
||||||
|
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>>) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
const fabColor = useMemo(
|
||||||
|
() =>
|
||||||
|
lightColors.map((color) => color.hex).includes(colors.primary)
|
||||||
|
? CombinedDarkTheme.colors.background
|
||||||
|
: CombinedDefaultTheme.colors.background,
|
||||||
|
[colors.primary]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FAB
|
||||||
|
icon="add"
|
||||||
|
testID="add"
|
||||||
|
color={fabColor}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 20,
|
||||||
|
bottom: 20,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
26
AppInput.tsx
Normal file
26
AppInput.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import React, { ComponentProps, Ref } from "react";
|
||||||
|
import { TextInput } from "react-native-paper";
|
||||||
|
import { CombinedDefaultTheme } from "./App";
|
||||||
|
import { MARGIN } from "./constants";
|
||||||
|
import useDark from "./use-dark";
|
||||||
|
|
||||||
|
function AppInput(
|
||||||
|
props: Partial<ComponentProps<typeof TextInput>> & {
|
||||||
|
innerRef?: Ref<any>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const dark = useDark();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
selectionColor={dark ? "#2A2A2A" : CombinedDefaultTheme.colors.border}
|
||||||
|
style={{ marginBottom: MARGIN, minWidth: 100 }}
|
||||||
|
selectTextOnFocus
|
||||||
|
ref={props.innerRef}
|
||||||
|
blurOnSubmit={false}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(AppInput);
|
74
BestList.tsx
74
BestList.tsx
|
@ -1,74 +0,0 @@
|
||||||
import {
|
|
||||||
NavigationProp,
|
|
||||||
useFocusEffect,
|
|
||||||
useNavigation,
|
|
||||||
} from '@react-navigation/native';
|
|
||||||
import React, {useCallback, useEffect, useState} from 'react';
|
|
||||||
import {FlatList, Image} from 'react-native';
|
|
||||||
import {List} from 'react-native-paper';
|
|
||||||
import {getBestReps, getBestWeights} from './best.service';
|
|
||||||
import {BestPageParams} from './BestPage';
|
|
||||||
import Page from './Page';
|
|
||||||
import Set from './set';
|
|
||||||
import {settings} from './settings.service';
|
|
||||||
|
|
||||||
export default function BestList() {
|
|
||||||
const [bests, setBests] = useState<Set[]>([]);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const navigation = useNavigation<NavigationProp<BestPageParams>>();
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
const weights = await getBestWeights(search);
|
|
||||||
console.log(`${BestList.name}.refresh:`, {length: weights.length});
|
|
||||||
let newBest: Set[] = [];
|
|
||||||
for (const set of weights) {
|
|
||||||
const reps = await getBestReps(set.name, set.weight);
|
|
||||||
newBest.push(...reps);
|
|
||||||
}
|
|
||||||
setBests(newBest);
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
refresh();
|
|
||||||
navigation.getParent()?.setOptions({
|
|
||||||
headerRight: () => null,
|
|
||||||
});
|
|
||||||
}, [refresh, navigation]),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, [search, refresh]);
|
|
||||||
|
|
||||||
const renderItem = ({item}: {item: Set}) => (
|
|
||||||
<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 (
|
|
||||||
<Page search={search} setSearch={setSearch}>
|
|
||||||
<FlatList
|
|
||||||
style={{height: '99%'}}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<List.Item
|
|
||||||
title="No exercises yet"
|
|
||||||
description="Once sets have been added, this will highlight your personal bests."
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
renderItem={renderItem}
|
|
||||||
data={bests}
|
|
||||||
/>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
42
BestPage.tsx
42
BestPage.tsx
|
@ -1,42 +0,0 @@
|
||||||
import {DrawerNavigationProp} from '@react-navigation/drawer';
|
|
||||||
import {useNavigation} from '@react-navigation/native';
|
|
||||||
import {createStackNavigator} from '@react-navigation/stack';
|
|
||||||
import React from 'react';
|
|
||||||
import {IconButton} from 'react-native-paper';
|
|
||||||
import BestList from './BestList';
|
|
||||||
import {DrawerParamList} from './drawer-param-list';
|
|
||||||
import Set from './set';
|
|
||||||
import ViewBest from './ViewBest';
|
|
||||||
|
|
||||||
const Stack = createStackNavigator<BestPageParams>();
|
|
||||||
export type BestPageParams = {
|
|
||||||
BestList: {};
|
|
||||||
ViewBest: {
|
|
||||||
best: Set;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function BestPage() {
|
|
||||||
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack.Navigator
|
|
||||||
screenOptions={{headerShown: false, animationEnabled: false}}>
|
|
||||||
<Stack.Screen name="BestList" component={BestList} />
|
|
||||||
<Stack.Screen
|
|
||||||
name="ViewBest"
|
|
||||||
component={ViewBest}
|
|
||||||
listeners={{
|
|
||||||
beforeRemove: () => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<IconButton icon="menu" onPress={navigation.openDrawer} />
|
|
||||||
),
|
|
||||||
title: 'Best',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
51
Chart.tsx
51
Chart.tsx
|
@ -1,10 +1,11 @@
|
||||||
import * as shape from 'd3-shape';
|
import { useTheme } from "@react-navigation/native";
|
||||||
import React, {useContext} from 'react';
|
import * as shape from "d3-shape";
|
||||||
import {View} from 'react-native';
|
import { View } from "react-native";
|
||||||
import {Grid, LineChart, XAxis, YAxis} from 'react-native-svg-charts';
|
import { Grid, LineChart, XAxis, YAxis } from "react-native-svg-charts";
|
||||||
import {CustomTheme} from './App';
|
import { CombinedDarkTheme, CombinedDefaultTheme } from "./App";
|
||||||
import {MARGIN, PADDING} from './constants';
|
import { MARGIN, PADDING } from "./constants";
|
||||||
import Set from './set';
|
import GymSet from "./gym-set";
|
||||||
|
import useDark from "./use-dark";
|
||||||
|
|
||||||
export default function Chart({
|
export default function Chart({
|
||||||
yData,
|
yData,
|
||||||
|
@ -13,41 +14,53 @@ export default function Chart({
|
||||||
yFormat,
|
yFormat,
|
||||||
}: {
|
}: {
|
||||||
yData: number[];
|
yData: number[];
|
||||||
xData: Set[];
|
xData: GymSet[];
|
||||||
xFormat: (value: any, index: number) => string;
|
xFormat: (value: any, index: number) => string;
|
||||||
yFormat: (value: any) => string;
|
yFormat: (value: any) => string;
|
||||||
}) {
|
}) {
|
||||||
const {color} = useContext(CustomTheme);
|
const { colors } = useTheme();
|
||||||
const axesSvg = {fontSize: 10, fill: 'grey'};
|
const dark = useDark();
|
||||||
const verticalContentInset = {top: 10, bottom: 10};
|
const axesSvg = {
|
||||||
|
fontSize: 10,
|
||||||
|
fill: dark
|
||||||
|
? CombinedDarkTheme.colors.text
|
||||||
|
: CombinedDefaultTheme.colors.text,
|
||||||
|
};
|
||||||
|
const verticalContentInset = { top: 10, bottom: 10 };
|
||||||
const xAxisHeight = 30;
|
const xAxisHeight = 30;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={{height: 300, padding: PADDING, flexDirection: 'row'}}>
|
<View
|
||||||
|
style={{
|
||||||
|
height: 300,
|
||||||
|
padding: PADDING,
|
||||||
|
flexDirection: "row",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<YAxis
|
<YAxis
|
||||||
data={yData}
|
data={yData}
|
||||||
style={{marginBottom: xAxisHeight}}
|
style={{ marginBottom: xAxisHeight }}
|
||||||
contentInset={verticalContentInset}
|
contentInset={verticalContentInset}
|
||||||
svg={axesSvg}
|
svg={axesSvg}
|
||||||
formatLabel={yFormat}
|
formatLabel={yFormat}
|
||||||
/>
|
/>
|
||||||
<View style={{flex: 1, marginLeft: MARGIN}}>
|
<View style={{ flex: 1, marginLeft: MARGIN }}>
|
||||||
<LineChart
|
<LineChart
|
||||||
style={{flex: 1}}
|
style={{ flex: 1 }}
|
||||||
data={yData}
|
data={yData}
|
||||||
contentInset={verticalContentInset}
|
contentInset={verticalContentInset}
|
||||||
curve={shape.curveBasis}
|
curve={shape.curveBasis}
|
||||||
svg={{
|
svg={{
|
||||||
stroke: color,
|
stroke: colors.primary,
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<Grid />
|
<Grid />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
<XAxis
|
<XAxis
|
||||||
style={{marginHorizontal: -10, height: xAxisHeight}}
|
|
||||||
data={xData}
|
data={xData}
|
||||||
formatLabel={xFormat}
|
formatLabel={xFormat}
|
||||||
contentInset={{left: 10, right: 10}}
|
contentInset={{ left: 15, right: 16 }}
|
||||||
svg={axesSvg}
|
svg={axesSvg}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import React from 'react';
|
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,
|
||||||
|
@ -7,13 +6,20 @@ export default function ConfirmDialog({
|
||||||
onOk,
|
onOk,
|
||||||
show,
|
show,
|
||||||
setShow,
|
setShow,
|
||||||
|
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;
|
||||||
}) {
|
}) {
|
||||||
|
const cancel = () => {
|
||||||
|
setShow(false);
|
||||||
|
onCancel && onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<Dialog visible={show} onDismiss={() => setShow(false)}>
|
<Dialog visible={show} onDismiss={() => setShow(false)}>
|
||||||
|
@ -23,7 +29,7 @@ export default function ConfirmDialog({
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
<Dialog.Actions>
|
<Dialog.Actions>
|
||||||
<Button onPress={onOk}>OK</Button>
|
<Button onPress={onOk}>OK</Button>
|
||||||
<Button onPress={() => setShow(false)}>Cancel</Button>
|
<Button onPress={cancel}>Cancel</Button>
|
||||||
</Dialog.Actions>
|
</Dialog.Actions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|
22
DrawerHeader.tsx
Normal file
22
DrawerHeader.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { DrawerNavigationProp } from "@react-navigation/drawer";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { Appbar, IconButton } from "react-native-paper";
|
||||||
|
import { DrawerParamList } from "./drawer-param-list";
|
||||||
|
|
||||||
|
export default function DrawerHeader({
|
||||||
|
name,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
children?: JSX.Element | JSX.Element[];
|
||||||
|
}) {
|
||||||
|
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Appbar.Header>
|
||||||
|
<IconButton icon="menu" onPress={navigation.openDrawer} />
|
||||||
|
<Appbar.Content title={name} />
|
||||||
|
{children}
|
||||||
|
</Appbar.Header>
|
||||||
|
);
|
||||||
|
}
|
150
DrawerMenu.tsx
150
DrawerMenu.tsx
|
@ -1,150 +0,0 @@
|
||||||
import {NavigationProp, useNavigation} from '@react-navigation/native';
|
|
||||||
import React, {useCallback, useContext, useState} from 'react';
|
|
||||||
import DocumentPicker from 'react-native-document-picker';
|
|
||||||
import {FileSystem} from 'react-native-file-access';
|
|
||||||
import {Divider, IconButton, Menu} from 'react-native-paper';
|
|
||||||
import ConfirmDialog from './ConfirmDialog';
|
|
||||||
import {DrawerParamList} from './drawer-param-list';
|
|
||||||
import {SnackbarContext} from './MassiveSnack';
|
|
||||||
import {Plan} from './plan';
|
|
||||||
import {addPlans, deletePlans, getAllPlans} from './plan.service';
|
|
||||||
import {addSets, deleteSets, getAllSets} from './set.service';
|
|
||||||
import {write} from './write';
|
|
||||||
|
|
||||||
const setFields =
|
|
||||||
'id,name,reps,weight,created,unit,hidden,sets,minutes,seconds';
|
|
||||||
const planFields = 'id,days,workouts';
|
|
||||||
|
|
||||||
export default function DrawerMenu({name}: {name: keyof DrawerParamList}) {
|
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
|
||||||
const [showRemove, setShowRemove] = useState(false);
|
|
||||||
const {toast} = useContext(SnackbarContext);
|
|
||||||
const {reset} = useNavigation<NavigationProp<DrawerParamList>>();
|
|
||||||
|
|
||||||
const exportSets = useCallback(async () => {
|
|
||||||
const sets = await getAllSets();
|
|
||||||
const data = [setFields]
|
|
||||||
.concat(
|
|
||||||
sets.map(
|
|
||||||
set =>
|
|
||||||
`${set.id},${set.name},${set.reps},${set.weight},${set.created},${set.unit},${set.hidden},${set.sets},${set.minutes},${set.seconds}`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.join('\n');
|
|
||||||
console.log(`${DrawerMenu.name}.exportSets`, {length: sets.length});
|
|
||||||
await write('sets.csv', data);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const exportPlans = useCallback(async () => {
|
|
||||||
const plans: Plan[] = await getAllPlans();
|
|
||||||
const data = [planFields]
|
|
||||||
.concat(plans.map(set => `"${set.id}","${set.days}","${set.workouts}"`))
|
|
||||||
.join('\n');
|
|
||||||
console.log(`${DrawerMenu.name}.exportPlans`, {length: plans.length});
|
|
||||||
await write('plans.csv', data);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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');
|
|
||||||
console.log(lines[0]);
|
|
||||||
if (!setFields.includes(lines[0])) return toast('Invalid csv.', 3000);
|
|
||||||
const values = lines
|
|
||||||
.slice(1)
|
|
||||||
.filter(line => line)
|
|
||||||
.map(set => {
|
|
||||||
const [
|
|
||||||
,
|
|
||||||
setName,
|
|
||||||
reps,
|
|
||||||
weight,
|
|
||||||
created,
|
|
||||||
unit,
|
|
||||||
hidden,
|
|
||||||
sets,
|
|
||||||
minutes,
|
|
||||||
seconds,
|
|
||||||
] = set.split(',');
|
|
||||||
return `('${setName}',${reps},${weight},'${created}','${unit}',${hidden},${
|
|
||||||
sets ?? 3
|
|
||||||
},${minutes ?? 3},${seconds ?? 30})`;
|
|
||||||
})
|
|
||||||
.join(',');
|
|
||||||
await addSets(setFields.split(',').slice(1).join(','), values);
|
|
||||||
toast('Data imported.', 3000);
|
|
||||||
reset({index: 0, routes: [{name}]});
|
|
||||||
}, [reset, name, toast]);
|
|
||||||
|
|
||||||
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 toast('Invalid csv.', 3000);
|
|
||||||
const values = file
|
|
||||||
.split('\n')
|
|
||||||
.slice(1)
|
|
||||||
.filter(line => line)
|
|
||||||
.map(set => {
|
|
||||||
const [, days, workouts] = set
|
|
||||||
.split('","')
|
|
||||||
.map(cell => cell.replace(/"/g, ''));
|
|
||||||
return `('${days}','${workouts}')`;
|
|
||||||
})
|
|
||||||
.join(',');
|
|
||||||
await addPlans(values);
|
|
||||||
toast('Data imported.', 3000);
|
|
||||||
}, [toast]);
|
|
||||||
|
|
||||||
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 deleteSets();
|
|
||||||
else if (name === 'Plans') await deletePlans();
|
|
||||||
toast('All data has been deleted.', 4000);
|
|
||||||
reset({index: 0, routes: [{name}]});
|
|
||||||
}, [reset, name, toast]);
|
|
||||||
|
|
||||||
if (name === 'Home' || name === 'Plans')
|
|
||||||
return (
|
|
||||||
<Menu
|
|
||||||
visible={showMenu}
|
|
||||||
onDismiss={() => setShowMenu(false)}
|
|
||||||
anchor={
|
|
||||||
<IconButton onPress={() => setShowMenu(true)} icon="more-vert" />
|
|
||||||
}>
|
|
||||||
<Menu.Item icon="arrow-downward" onPress={download} title="Download" />
|
|
||||||
<Menu.Item icon="arrow-upward" onPress={upload} title="Upload" />
|
|
||||||
<Divider />
|
|
||||||
<Menu.Item
|
|
||||||
icon="delete"
|
|
||||||
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;
|
|
||||||
}
|
|
205
EditPlan.tsx
205
EditPlan.tsx
|
@ -1,78 +1,69 @@
|
||||||
import {
|
import {
|
||||||
NavigationProp,
|
NavigationProp,
|
||||||
RouteProp,
|
RouteProp,
|
||||||
useFocusEffect,
|
|
||||||
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 {ScrollView, StyleSheet, View} from 'react-native';
|
import { ScrollView, StyleSheet, View } from "react-native";
|
||||||
import {Button, IconButton, Text} from 'react-native-paper';
|
import { Button, IconButton, Text } from "react-native-paper";
|
||||||
import {MARGIN, PADDING} from './constants';
|
import { MARGIN, PADDING } from "./constants";
|
||||||
import {DrawerParamList} from './drawer-param-list';
|
import { planRepo, setRepo } from "./db";
|
||||||
import {PlanPageParams} from './plan-page-params';
|
import { defaultSet } from "./gym-set";
|
||||||
import {addPlan, updatePlan} from './plan.service';
|
import { PlanPageParams } from "./plan-page-params";
|
||||||
import {getNames} from './set.service';
|
import StackHeader from "./StackHeader";
|
||||||
import Switch from './Switch';
|
import Switch from "./Switch";
|
||||||
import {DAYS} from './time';
|
import { DAYS } from "./time";
|
||||||
|
import AppInput from "./AppInput";
|
||||||
|
|
||||||
export default function EditPlan() {
|
export default function EditPlan() {
|
||||||
const {params} = useRoute<RouteProp<PlanPageParams, 'EditPlan'>>();
|
const { params } = useRoute<RouteProp<PlanPageParams, "EditPlan">>();
|
||||||
const {plan} = params;
|
const { plan } = params;
|
||||||
|
const [title, setTitle] = useState<string>(plan?.title);
|
||||||
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 [workouts, setWorkouts] = useState<string[]>(
|
||||||
plan.workouts ? plan.workouts.split(',') : [],
|
plan.workouts ? plan.workouts.split(",") : []
|
||||||
);
|
);
|
||||||
const [names, setNames] = useState<string[]>([]);
|
const [names, setNames] = useState<string[]>([]);
|
||||||
const navigation = useNavigation<NavigationProp<DrawerParamList>>();
|
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
console.log(`${EditPlan.name}.focus:`, {plan});
|
|
||||||
navigation.getParent()?.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<IconButton icon="arrow-back" onPress={() => navigation.goBack()} />
|
|
||||||
),
|
|
||||||
headerRight: () => null,
|
|
||||||
title: plan.id ? 'Edit plan' : 'Create plan',
|
|
||||||
});
|
|
||||||
}, [navigation, plan]),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getNames().then(n => {
|
setRepo
|
||||||
console.log(EditPlan.name, {n});
|
.createQueryBuilder()
|
||||||
setNames(n);
|
.select("name")
|
||||||
});
|
.distinct(true)
|
||||||
|
.orderBy("name")
|
||||||
|
.getRawMany()
|
||||||
|
.then((values) => {
|
||||||
|
console.log(EditPlan.name, { values });
|
||||||
|
setNames(values.map((value) => value.name));
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
console.log(`${EditPlan.name}.save`, {days, workouts, plan});
|
console.log(`${EditPlan.name}.save`, { days, workouts, plan });
|
||||||
if (!days || !workouts) return;
|
if (!days || !workouts) return;
|
||||||
const newWorkouts = workouts.filter(workout => workout).join(',');
|
const newWorkouts = workouts.filter((workout) => workout).join(",");
|
||||||
const newDays = days.filter(day => day).join(',');
|
const newDays = days.filter((day) => day).join(",");
|
||||||
if (typeof plan.id === 'undefined')
|
await planRepo.save({
|
||||||
await addPlan({days: newDays, workouts: newWorkouts});
|
title: title,
|
||||||
else
|
days: newDays,
|
||||||
await updatePlan({
|
workouts: newWorkouts,
|
||||||
days: newDays,
|
id: plan.id,
|
||||||
workouts: newWorkouts,
|
});
|
||||||
id: plan.id,
|
}, [title, days, workouts, plan]);
|
||||||
});
|
|
||||||
navigation.goBack();
|
|
||||||
}, [days, workouts, plan, navigation]);
|
|
||||||
|
|
||||||
const toggleWorkout = useCallback(
|
const toggleWorkout = useCallback(
|
||||||
(on: boolean, name: string) => {
|
(on: boolean, name: string) => {
|
||||||
if (on) {
|
if (on) {
|
||||||
setWorkouts([...workouts, name]);
|
setWorkouts([...workouts, name]);
|
||||||
} else {
|
} else {
|
||||||
setWorkouts(workouts.filter(workout => workout !== name));
|
setWorkouts(workouts.filter((workout) => workout !== name));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setWorkouts, workouts],
|
[setWorkouts, workouts]
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleDay = useCallback(
|
const toggleDay = useCallback(
|
||||||
|
@ -80,66 +71,83 @@ export default function EditPlan() {
|
||||||
if (on) {
|
if (on) {
|
||||||
setDays([...days, day]);
|
setDays([...days, day]);
|
||||||
} else {
|
} else {
|
||||||
setDays(days.filter(d => d !== day));
|
setDays(days.filter((d) => d !== day));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setDays, days],
|
[setDays, days]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{padding: PADDING}}>
|
<>
|
||||||
<ScrollView style={{height: '90%'}}>
|
<StackHeader
|
||||||
<Text style={styles.title}>Days</Text>
|
title={typeof plan.id === "number" ? "Edit plan" : "Add plan"}
|
||||||
{DAYS.map(day => (
|
>
|
||||||
<Switch
|
{typeof plan.id === "number" && (
|
||||||
key={day}
|
<IconButton
|
||||||
onValueChange={value => toggleDay(value, day)}
|
onPress={async () => {
|
||||||
onPress={() => toggleDay(!days.includes(day), day)}
|
await save();
|
||||||
value={days.includes(day)}>
|
const newPlan = await planRepo.findOne({
|
||||||
{day}
|
where: { id: plan.id },
|
||||||
</Switch>
|
});
|
||||||
))}
|
let first = await setRepo.findOne({
|
||||||
<Text style={[styles.title, {marginTop: MARGIN}]}>Workouts</Text>
|
where: { name: workouts[0] },
|
||||||
{names.length === 0 ? (
|
order: { created: "desc" },
|
||||||
<View>
|
});
|
||||||
<Text>No workouts found.</Text>
|
if (!first) first = { ...defaultSet, name: workouts[0] };
|
||||||
</View>
|
delete first.id;
|
||||||
) : (
|
navigation.navigate("StartPlan", { plan: newPlan, first });
|
||||||
names.map(name => (
|
}}
|
||||||
<Switch
|
icon="play-arrow"
|
||||||
key={name}
|
/>
|
||||||
onValueChange={value => toggleWorkout(value, name)}
|
|
||||||
value={workouts.includes(name)}
|
|
||||||
onPress={() => toggleWorkout(!workouts.includes(name), name)}>
|
|
||||||
{name}
|
|
||||||
</Switch>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</StackHeader>
|
||||||
{names.length === 0 ? (
|
<View style={{ padding: PADDING, flex: 1 }}>
|
||||||
|
<ScrollView style={{ flex: 1 }}>
|
||||||
|
<AppInput
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChangeText={(value) => setTitle(value)}
|
||||||
|
/>
|
||||||
|
<Text style={styles.title}>Days</Text>
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<Switch
|
||||||
|
key={day}
|
||||||
|
onChange={(value) => toggleDay(value, day)}
|
||||||
|
value={days.includes(day)}
|
||||||
|
title={day}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Text style={[styles.title, { marginTop: MARGIN }]}>Workouts</Text>
|
||||||
|
{names.length === 0 ? (
|
||||||
|
<View>
|
||||||
|
<Text>No workouts found.</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
names.map((name) => (
|
||||||
|
<Switch
|
||||||
|
key={name}
|
||||||
|
onChange={(value) => toggleWorkout(value, name)}
|
||||||
|
value={workouts.includes(name)}
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
disabled={workouts.length === 0 && days.length === 0}
|
disabled={workouts.length === 0 && days.length === 0}
|
||||||
mode="contained"
|
style={styles.button}
|
||||||
onPress={() => {
|
mode="outlined"
|
||||||
navigation.goBack();
|
|
||||||
navigation.navigate('Workouts', {
|
|
||||||
screen: 'EditWorkout',
|
|
||||||
params: {value: {name: ''}},
|
|
||||||
});
|
|
||||||
}}>
|
|
||||||
Add workout
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
disabled={workouts.length === 0 && days.length === 0}
|
|
||||||
style={{marginTop: MARGIN}}
|
|
||||||
mode="contained"
|
|
||||||
icon="save"
|
icon="save"
|
||||||
onPress={save}>
|
onPress={async () => {
|
||||||
|
await save();
|
||||||
|
navigation.navigate("PlanList");
|
||||||
|
}}
|
||||||
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</View>
|
||||||
</View>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -148,4 +156,5 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
marginBottom: MARGIN,
|
marginBottom: MARGIN,
|
||||||
},
|
},
|
||||||
|
button: {},
|
||||||
});
|
});
|
||||||
|
|
331
EditSet.tsx
331
EditSet.tsx
|
@ -1,94 +1,277 @@
|
||||||
|
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
|
||||||
import {
|
import {
|
||||||
RouteProp,
|
RouteProp,
|
||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
useRoute,
|
useRoute,
|
||||||
} from '@react-navigation/native';
|
} from "@react-navigation/native";
|
||||||
import React, {useCallback, useContext} from 'react';
|
import { format } from "date-fns";
|
||||||
import {NativeModules, View} from 'react-native';
|
import { useCallback, useRef, useState } from "react";
|
||||||
import {IconButton} from 'react-native-paper';
|
import { NativeModules, TextInput, View } from "react-native";
|
||||||
import {PADDING} from './constants';
|
import DocumentPicker from "react-native-document-picker";
|
||||||
import {HomePageParams} from './home-page-params';
|
import { Button, Card, IconButton, TouchableRipple } from "react-native-paper";
|
||||||
import {SnackbarContext} from './MassiveSnack';
|
import AppInput from "./AppInput";
|
||||||
import Set from './set';
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import {addSet, updateSet} from './set.service';
|
import { MARGIN, PADDING } from "./constants";
|
||||||
import SetForm from './SetForm';
|
import { getNow, setRepo, settingsRepo } from "./db";
|
||||||
import {getSettings, settings, updateSettings} from './settings.service';
|
import GymSet from "./gym-set";
|
||||||
|
import { HomePageParams } from "./home-page-params";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import StackHeader from "./StackHeader";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
import { fixNumeric } from "./fix-numeric";
|
||||||
|
|
||||||
export default function EditSet() {
|
export default function EditSet() {
|
||||||
const {params} = useRoute<RouteProp<HomePageParams, 'EditSet'>>();
|
const { params } = useRoute<RouteProp<HomePageParams, "EditSet">>();
|
||||||
const {set, count, workouts} = params;
|
const { set } = params;
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const {toast} = useContext(SnackbarContext);
|
const [settings, setSettings] = useState<Settings>({} as Settings);
|
||||||
|
const [name, setName] = useState(set.name);
|
||||||
|
const [reps, setReps] = useState(set.reps?.toString());
|
||||||
|
const [weight, setWeight] = useState(set.weight?.toString());
|
||||||
|
const [newImage, setNewImage] = useState(set.image);
|
||||||
|
const [unit, setUnit] = useState(set.unit);
|
||||||
|
const [created, setCreated] = useState<Date>(
|
||||||
|
set.created ? new Date(set.created) : new Date()
|
||||||
|
);
|
||||||
|
const [createdDirty, setCreatedDirty] = useState(false);
|
||||||
|
const [showRemove, setShowRemove] = useState(false);
|
||||||
|
const [removeImage, setRemoveImage] = useState(false);
|
||||||
|
const weightRef = useRef<TextInput>(null);
|
||||||
|
const repsRef = useRef<TextInput>(null);
|
||||||
|
const unitRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
const [selection, setSelection] = useState({
|
||||||
|
start: 0,
|
||||||
|
end: set.reps?.toString().length,
|
||||||
|
});
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
console.log(`${EditSet.name}.focus:`, set);
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
let title = 'Create set';
|
}, [])
|
||||||
if (typeof set.id === 'number') title = 'Edit set';
|
|
||||||
else if (Number(set.sets) > 0)
|
|
||||||
title = `${set.name} (${count + 1} / ${set.sets})`;
|
|
||||||
navigation.getParent()?.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<IconButton icon="arrow-back" onPress={() => navigation.goBack()} />
|
|
||||||
),
|
|
||||||
headerRight: null,
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
}, [navigation, set, count]),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const startTimer = useCallback(async (_set: Set) => {
|
const startTimer = useCallback(
|
||||||
if (!settings.alarm) return;
|
async (value: string) => {
|
||||||
const milliseconds =
|
if (!settings.alarm) return;
|
||||||
Number(_set.minutes) * 60 * 1000 + Number(_set.seconds) * 1000;
|
const first = await setRepo.findOne({ where: { name: value } });
|
||||||
NativeModules.AlarmModule.timer(
|
const milliseconds =
|
||||||
milliseconds,
|
(first?.minutes ?? 3) * 60 * 1000 + (first?.seconds ?? 0) * 1000;
|
||||||
!!settings.vibrate,
|
if (milliseconds) NativeModules.AlarmModule.timer(milliseconds);
|
||||||
settings.sound,
|
},
|
||||||
);
|
[settings]
|
||||||
const next = new Date();
|
);
|
||||||
next.setTime(next.getTime() + milliseconds);
|
|
||||||
await updateSettings({...settings, nextAlarm: next.toISOString()});
|
const added = useCallback(
|
||||||
await getSettings();
|
async (value: GymSet) => {
|
||||||
|
startTimer(value.name);
|
||||||
|
console.log(`${EditSet.name}.add`, { set: value });
|
||||||
|
if (!settings.notify) return;
|
||||||
|
if (
|
||||||
|
value.weight > set.weight ||
|
||||||
|
(value.reps > set.reps && value.weight === set.weight)
|
||||||
|
) {
|
||||||
|
toast("Great work King! That's a new record.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[startTimer, set, settings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const newSet: Partial<GymSet> = {
|
||||||
|
id: set.id,
|
||||||
|
name,
|
||||||
|
reps: Number(reps),
|
||||||
|
weight: Number(weight),
|
||||||
|
unit,
|
||||||
|
minutes: Number(set.minutes ?? 3),
|
||||||
|
seconds: Number(set.seconds ?? 30),
|
||||||
|
sets: set.sets ?? 3,
|
||||||
|
hidden: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
newSet.image = newImage;
|
||||||
|
if (!newImage && !removeImage) {
|
||||||
|
newSet.image = await setRepo
|
||||||
|
.findOne({ where: { name } })
|
||||||
|
.then((s) => s?.image);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdDirty) newSet.created = created.toISOString();
|
||||||
|
if (typeof set.id !== "number") newSet.created = await getNow();
|
||||||
|
|
||||||
|
const saved = await setRepo.save(newSet);
|
||||||
|
if (typeof set.id !== "number") added(saved);
|
||||||
|
navigation.goBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeImage = useCallback(async () => {
|
||||||
|
const { fileCopyUri } = await DocumentPicker.pickSingle({
|
||||||
|
type: DocumentPicker.types.images,
|
||||||
|
copyTo: "documentDirectory",
|
||||||
|
});
|
||||||
|
if (fileCopyUri) setNewImage(fileCopyUri);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const update = useCallback(
|
const handleRemove = useCallback(async () => {
|
||||||
async (_set: Set) => {
|
setNewImage("");
|
||||||
console.log(`${EditSet.name}.update`, _set);
|
setRemoveImage(true);
|
||||||
await updateSet(_set);
|
setShowRemove(false);
|
||||||
navigation.goBack();
|
}, []);
|
||||||
},
|
|
||||||
[navigation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const add = useCallback(
|
const pickDate = useCallback(() => {
|
||||||
async (_set: Set) => {
|
DateTimePickerAndroid.open({
|
||||||
console.log(`${EditSet.name}.add`, {set: _set});
|
value: created,
|
||||||
startTimer(_set);
|
onChange: (_, date) => {
|
||||||
await addSet(_set);
|
if (date === created) return;
|
||||||
if (!settings.notify) return navigation.goBack();
|
setCreated(date);
|
||||||
if (
|
setCreatedDirty(true);
|
||||||
_set.weight > set.weight ||
|
DateTimePickerAndroid.open({
|
||||||
(_set.reps > set.reps && _set.weight === set.weight)
|
value: date,
|
||||||
)
|
onChange: (__, time) => setCreated(time),
|
||||||
toast("Great work King, that's a new record!", 3000);
|
mode: "time",
|
||||||
navigation.goBack();
|
});
|
||||||
},
|
},
|
||||||
[navigation, startTimer, set, toast],
|
mode: "date",
|
||||||
);
|
});
|
||||||
|
}, [created]);
|
||||||
const save = useCallback(
|
|
||||||
async (_set: Set) => {
|
|
||||||
if (typeof set.id === 'number') return update(_set);
|
|
||||||
return add(_set);
|
|
||||||
},
|
|
||||||
[update, add, set.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{padding: PADDING}}>
|
<>
|
||||||
<SetForm save={save} set={set} workouts={workouts} />
|
<StackHeader
|
||||||
</View>
|
title={typeof set.id === "number" ? "Edit set" : "Add set"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ padding: PADDING, flex: 1 }}>
|
||||||
|
<AppInput
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoFocus={!name}
|
||||||
|
onSubmitEditing={() => repsRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: "row" }}>
|
||||||
|
<AppInput
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
marginBottom: MARGIN,
|
||||||
|
}}
|
||||||
|
label="Reps"
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={reps}
|
||||||
|
onChangeText={(newReps) => {
|
||||||
|
const fixed = fixNumeric(newReps);
|
||||||
|
setReps(fixed);
|
||||||
|
if (fixed.length !== newReps.length)
|
||||||
|
toast("Reps must be a number");
|
||||||
|
}}
|
||||||
|
onSubmitEditing={() => weightRef.current?.focus()}
|
||||||
|
selection={selection}
|
||||||
|
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
|
||||||
|
innerRef={repsRef}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="add"
|
||||||
|
onPress={() => setReps((Number(reps) + 1).toString())}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="remove"
|
||||||
|
onPress={() => setReps((Number(reps) - 1).toString())}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: MARGIN,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppInput
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="add"
|
||||||
|
onPress={() => setWeight((Number(weight) + 2.5).toString())}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="remove"
|
||||||
|
onPress={() => setWeight((Number(weight) - 2.5).toString())}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{settings.showUnit && (
|
||||||
|
<AppInput
|
||||||
|
autoCapitalize="none"
|
||||||
|
label="Unit"
|
||||||
|
value={unit}
|
||||||
|
onChangeText={setUnit}
|
||||||
|
innerRef={unitRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings.showDate && (
|
||||||
|
<AppInput
|
||||||
|
label="Created"
|
||||||
|
value={format(created, settings.date || "P")}
|
||||||
|
onPressOut={pickDate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings.images && newImage && (
|
||||||
|
<TouchableRipple
|
||||||
|
style={{ marginBottom: MARGIN }}
|
||||||
|
onPress={changeImage}
|
||||||
|
onLongPress={() => setShowRemove(true)}
|
||||||
|
>
|
||||||
|
<Card.Cover source={{ uri: newImage }} />
|
||||||
|
</TouchableRipple>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings.images && !newImage && (
|
||||||
|
<Button
|
||||||
|
style={{ marginBottom: MARGIN }}
|
||||||
|
onPress={changeImage}
|
||||||
|
icon="add-photo-alternate"
|
||||||
|
>
|
||||||
|
Image
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={!name}
|
||||||
|
mode="outlined"
|
||||||
|
icon="save"
|
||||||
|
style={{ margin: MARGIN }}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Remove image"
|
||||||
|
onOk={handleRemove}
|
||||||
|
show={showRemove}
|
||||||
|
setShow={setShowRemove}
|
||||||
|
>
|
||||||
|
Are you sure you want to remove the image?
|
||||||
|
</ConfirmDialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
192
EditSets.tsx
Normal file
192
EditSets.tsx
Normal file
|
@ -0,0 +1,192 @@
|
||||||
|
import {
|
||||||
|
RouteProp,
|
||||||
|
useFocusEffect,
|
||||||
|
useNavigation,
|
||||||
|
useRoute,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import DocumentPicker from "react-native-document-picker";
|
||||||
|
import { Button, Card, IconButton, TouchableRipple } from "react-native-paper";
|
||||||
|
import { In } from "typeorm";
|
||||||
|
import AppInput from "./AppInput";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
import { MARGIN, PADDING } from "./constants";
|
||||||
|
import { setRepo, settingsRepo } from "./db";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
import { HomePageParams } from "./home-page-params";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import StackHeader from "./StackHeader";
|
||||||
|
|
||||||
|
export default function EditSets() {
|
||||||
|
const { params } = useRoute<RouteProp<HomePageParams, "EditSets">>();
|
||||||
|
const { ids } = params;
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const [settings, setSettings] = useState<Settings>({} as Settings);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [reps, setReps] = useState("");
|
||||||
|
const [weight, setWeight] = useState("");
|
||||||
|
const [newImage, setNewImage] = useState("");
|
||||||
|
const [unit, setUnit] = useState("");
|
||||||
|
const [showRemove, setShowRemove] = useState(false);
|
||||||
|
const [names, setNames] = useState("");
|
||||||
|
const [oldReps, setOldReps] = useState("");
|
||||||
|
const [weights, setWeights] = useState("");
|
||||||
|
const [units, setUnits] = useState("");
|
||||||
|
|
||||||
|
const [selection, setSelection] = useState({
|
||||||
|
start: 0,
|
||||||
|
end: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
|
setRepo.find({ where: { id: In(ids) } }).then((sets) => {
|
||||||
|
setNames(sets.map((set) => set.name).join(", "));
|
||||||
|
setOldReps(sets.map((set) => set.reps).join(", "));
|
||||||
|
setWeights(sets.map((set) => set.weight).join(", "));
|
||||||
|
setUnits(sets.map((set) => set.unit).join(", "));
|
||||||
|
});
|
||||||
|
}, [ids])
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
console.log(`${EditSets.name}.handleSubmit:`, { uri: newImage, name });
|
||||||
|
const update: Partial<GymSet> = {};
|
||||||
|
if (name) update.name = name;
|
||||||
|
if (reps) update.reps = Number(reps);
|
||||||
|
if (weight) update.weight = Number(weight);
|
||||||
|
if (unit) update.unit = unit;
|
||||||
|
if (newImage) update.image = newImage;
|
||||||
|
if (Object.keys(update).length > 0) await setRepo.update(ids, update);
|
||||||
|
navigation.goBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeImage = useCallback(async () => {
|
||||||
|
const { fileCopyUri } = await DocumentPicker.pickSingle({
|
||||||
|
type: DocumentPicker.types.images,
|
||||||
|
copyTo: "documentDirectory",
|
||||||
|
});
|
||||||
|
if (fileCopyUri) setNewImage(fileCopyUri);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemove = useCallback(async () => {
|
||||||
|
setNewImage("");
|
||||||
|
setShowRemove(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StackHeader title={`Edit ${ids.length} sets`} />
|
||||||
|
|
||||||
|
<View style={{ padding: PADDING, flex: 1 }}>
|
||||||
|
<AppInput
|
||||||
|
label={`Names: ${names}`}
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoFocus={!name}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: MARGIN,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppInput
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
label={`Reps: ${oldReps}`}
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={reps}
|
||||||
|
onChangeText={setReps}
|
||||||
|
selection={selection}
|
||||||
|
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
|
||||||
|
autoFocus={!!name}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="add"
|
||||||
|
onPress={() => setReps((Number(reps) + 1).toString())}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="remove"
|
||||||
|
onPress={() => setReps((Number(reps) - 1).toString())}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: MARGIN,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppInput
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
label={`Weights: ${weights}`}
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={weight}
|
||||||
|
onChangeText={setWeight}
|
||||||
|
onSubmitEditing={handleSubmit}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="add"
|
||||||
|
onPress={() => setWeight((Number(weight) + 2.5).toString())}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="remove"
|
||||||
|
onPress={() => setWeight((Number(weight) - 2.5).toString())}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{settings.showUnit && (
|
||||||
|
<AppInput
|
||||||
|
autoCapitalize="none"
|
||||||
|
label={`Units: ${units}`}
|
||||||
|
value={unit}
|
||||||
|
onChangeText={setUnit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings.images && newImage && (
|
||||||
|
<TouchableRipple
|
||||||
|
style={{ marginBottom: MARGIN }}
|
||||||
|
onPress={changeImage}
|
||||||
|
onLongPress={() => setShowRemove(true)}
|
||||||
|
>
|
||||||
|
<Card.Cover source={{ uri: newImage }} />
|
||||||
|
</TouchableRipple>
|
||||||
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Remove image"
|
||||||
|
onOk={handleRemove}
|
||||||
|
show={showRemove}
|
||||||
|
setShow={setShowRemove}
|
||||||
|
>
|
||||||
|
Are you sure you want to remove the image?
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
{settings.images && !newImage && (
|
||||||
|
<Button
|
||||||
|
style={{ marginBottom: MARGIN }}
|
||||||
|
onPress={changeImage}
|
||||||
|
icon="add-photo-alternate"
|
||||||
|
>
|
||||||
|
Image
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
mode="outlined"
|
||||||
|
icon="save"
|
||||||
|
style={{ margin: MARGIN }}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
260
EditWorkout.tsx
260
EditWorkout.tsx
|
@ -3,75 +3,82 @@ import {
|
||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
useRoute,
|
useRoute,
|
||||||
} from '@react-navigation/native';
|
} from "@react-navigation/native";
|
||||||
import React, {useCallback, useContext, useState} from 'react';
|
import { useCallback, useRef, useState } from "react";
|
||||||
import {ScrollView, View} from 'react-native';
|
import { ScrollView, TextInput, 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 ConfirmDialog from './ConfirmDialog';
|
import AppInput from "./AppInput";
|
||||||
import {MARGIN, PADDING} from './constants';
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import MassiveInput from './MassiveInput';
|
import { MARGIN, PADDING } from "./constants";
|
||||||
import {SnackbarContext} from './MassiveSnack';
|
import { getNow, planRepo, setRepo, settingsRepo } from "./db";
|
||||||
import {updatePlanWorkouts} from './plan.service';
|
import { fixNumeric } from "./fix-numeric";
|
||||||
import {addSet, updateManySet, updateSetImage} from './set.service';
|
import { defaultSet } from "./gym-set";
|
||||||
import {settings} from './settings.service';
|
import Settings from "./settings";
|
||||||
import {WorkoutsPageParams} from './WorkoutsPage';
|
import StackHeader from "./StackHeader";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
import { WorkoutsPageParams } from "./WorkoutsPage";
|
||||||
|
|
||||||
export default function EditWorkout() {
|
export default function EditWorkout() {
|
||||||
const {params} = useRoute<RouteProp<WorkoutsPageParams, 'EditWorkout'>>();
|
const { params } = useRoute<RouteProp<WorkoutsPageParams, "EditWorkout">>();
|
||||||
const [removeImage, setRemoveImage] = useState(false);
|
const [removeImage, setRemoveImage] = useState(false);
|
||||||
const [showRemove, setShowRemove] = useState(false);
|
const [showRemove, setShowRemove] = useState(false);
|
||||||
const [name, setName] = useState(params.value.name);
|
const [name, setName] = useState(params.value.name);
|
||||||
const [steps, setSteps] = useState(params.value.steps);
|
const [steps, setSteps] = useState(params.value.steps);
|
||||||
const [uri, setUri] = useState(params.value.image);
|
const [uri, setUri] = useState(params.value.image);
|
||||||
const [minutes, setMinutes] = useState(
|
const [minutes, setMinutes] = useState(
|
||||||
params.value.minutes?.toString() ?? '3',
|
params.value.minutes?.toString() ?? "3"
|
||||||
);
|
);
|
||||||
const [seconds, setSeconds] = useState(
|
const [seconds, setSeconds] = useState(
|
||||||
params.value.seconds?.toString() ?? '30',
|
params.value.seconds?.toString() ?? "30"
|
||||||
);
|
);
|
||||||
const [sets, setSets] = useState(params.value.sets?.toString() ?? '3');
|
const [sets, setSets] = useState(params.value.sets?.toString() ?? "3");
|
||||||
const {toast} = useContext(SnackbarContext);
|
|
||||||
const navigation = useNavigation();
|
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(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
navigation.getParent()?.setOptions({
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
headerLeft: () => (
|
}, [])
|
||||||
<IconButton icon="arrow-back" onPress={() => navigation.goBack()} />
|
|
||||||
),
|
|
||||||
headerRight: null,
|
|
||||||
title: params.value.name || 'New workout',
|
|
||||||
});
|
|
||||||
if (!name) return;
|
|
||||||
}, [navigation, name, params.value.name]),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const update = async () => {
|
const update = async () => {
|
||||||
await updateManySet({
|
await setRepo.update(
|
||||||
oldName: params.value.name,
|
{ name: params.value.name },
|
||||||
newName: name || params.value.name,
|
{
|
||||||
sets: sets ?? '3',
|
name: name || params.value.name,
|
||||||
seconds: seconds?.toString() ?? '30',
|
sets: Number(sets),
|
||||||
minutes: minutes?.toString() ?? '3',
|
minutes: +minutes,
|
||||||
steps,
|
seconds: +seconds,
|
||||||
});
|
steps,
|
||||||
await updatePlanWorkouts(params.value.name, name || params.value.name);
|
image: removeImage ? "" : uri,
|
||||||
if (uri || removeImage) await updateSetImage(params.value.name, 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();
|
navigation.goBack();
|
||||||
};
|
};
|
||||||
|
|
||||||
const add = async () => {
|
const add = async () => {
|
||||||
await addSet({
|
const now = await getNow();
|
||||||
|
await setRepo.save({
|
||||||
|
...defaultSet,
|
||||||
name,
|
name,
|
||||||
reps: 0,
|
|
||||||
weight: 0,
|
|
||||||
hidden: true,
|
hidden: true,
|
||||||
image: uri,
|
image: uri,
|
||||||
minutes: minutes ? +minutes : 3,
|
minutes: minutes ? +minutes : 3,
|
||||||
seconds: seconds ? +seconds : 30,
|
seconds: seconds ? +seconds : 30,
|
||||||
sets: sets ? +sets : 3,
|
sets: sets ? +sets : 3,
|
||||||
steps,
|
steps,
|
||||||
|
created: now,
|
||||||
});
|
});
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
};
|
};
|
||||||
|
@ -82,93 +89,116 @@ export default function EditWorkout() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeImage = useCallback(async () => {
|
const changeImage = useCallback(async () => {
|
||||||
const {fileCopyUri} = await DocumentPicker.pickSingle({
|
const { fileCopyUri } = await DocumentPicker.pickSingle({
|
||||||
type: 'image/*',
|
type: DocumentPicker.types.images,
|
||||||
copyTo: 'documentDirectory',
|
copyTo: "documentDirectory",
|
||||||
});
|
});
|
||||||
if (fileCopyUri) setUri(fileCopyUri);
|
if (fileCopyUri) setUri(fileCopyUri);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRemove = useCallback(async () => {
|
const handleRemove = useCallback(async () => {
|
||||||
setUri('');
|
setUri("");
|
||||||
setRemoveImage(true);
|
setRemoveImage(true);
|
||||||
setShowRemove(false);
|
setShowRemove(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleName = (value: string) => {
|
const submitName = () => {
|
||||||
setName(value.replace(/,|'/g, ''));
|
if (settings.steps) stepsRef.current?.focus();
|
||||||
if (value.match(/,|'/))
|
else setsRef.current?.focus();
|
||||||
toast('Commas and single quotes would break CSV exports', 6000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSteps = (value: string) => {
|
|
||||||
setSteps(value.replace(/,|'/g, ''));
|
|
||||||
if (value.match(/,|'/))
|
|
||||||
toast('Commas and single quotes would break CSV exports', 6000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{padding: PADDING}}>
|
<>
|
||||||
<ScrollView style={{height: '90%'}}>
|
<StackHeader title={params.value.name ? "Edit workout" : "Add workout"} />
|
||||||
<MassiveInput
|
<View style={{ padding: PADDING, flex: 1 }}>
|
||||||
autoFocus
|
<ScrollView style={{ flex: 1 }}>
|
||||||
label="Name"
|
<AppInput
|
||||||
value={name}
|
autoFocus
|
||||||
onChangeText={handleName}
|
label="Name"
|
||||||
/>
|
value={name}
|
||||||
{!!settings.steps && (
|
onChangeText={setName}
|
||||||
<MassiveInput
|
onSubmitEditing={submitName}
|
||||||
selectTextOnFocus={false}
|
|
||||||
value={steps}
|
|
||||||
onChangeText={handleSteps}
|
|
||||||
label="Steps"
|
|
||||||
multiline
|
|
||||||
/>
|
/>
|
||||||
)}
|
{settings?.steps && (
|
||||||
<MassiveInput
|
<AppInput
|
||||||
value={sets}
|
innerRef={stepsRef}
|
||||||
onChangeText={setSets}
|
selectTextOnFocus={false}
|
||||||
label="Sets per workout"
|
value={steps}
|
||||||
keyboardType="numeric"
|
onChangeText={setSteps}
|
||||||
/>
|
label="Steps"
|
||||||
<MassiveInput
|
multiline
|
||||||
value={minutes}
|
onSubmitEditing={() => setsRef.current?.focus()}
|
||||||
onChangeText={setMinutes}
|
/>
|
||||||
label="Rest minutes"
|
)}
|
||||||
keyboardType="numeric"
|
<AppInput
|
||||||
/>
|
innerRef={setsRef}
|
||||||
<MassiveInput
|
value={sets}
|
||||||
value={seconds}
|
onChangeText={(newSets) => {
|
||||||
onChangeText={setSeconds}
|
const fixed = fixNumeric(newSets);
|
||||||
label="Rest seconds"
|
setSets(fixed);
|
||||||
keyboardType="numeric"
|
if (fixed.length !== newSets.length)
|
||||||
/>
|
toast("Sets must be a number");
|
||||||
{uri ? (
|
}}
|
||||||
<TouchableRipple
|
label="Sets per workout"
|
||||||
style={{marginBottom: MARGIN}}
|
keyboardType="numeric"
|
||||||
onPress={changeImage}
|
onSubmitEditing={() => minutesRef.current?.focus()}
|
||||||
onLongPress={() => setShowRemove(true)}>
|
/>
|
||||||
<Card.Cover source={{uri}} />
|
{settings?.alarm && (
|
||||||
</TouchableRipple>
|
<>
|
||||||
) : (
|
<AppInput
|
||||||
<Button
|
innerRef={minutesRef}
|
||||||
style={{marginBottom: MARGIN}}
|
onSubmitEditing={() => secondsRef.current?.focus()}
|
||||||
onPress={changeImage}
|
value={minutes}
|
||||||
icon="add-photo-alternate">
|
onChangeText={(newMinutes) => {
|
||||||
Image
|
const fixed = fixNumeric(newMinutes);
|
||||||
</Button>
|
setMinutes(fixed);
|
||||||
)}
|
if (fixed.length !== newMinutes.length)
|
||||||
</ScrollView>
|
toast("Reps must be a number");
|
||||||
<Button disabled={!name} mode="contained" icon="save" onPress={save}>
|
}}
|
||||||
Save
|
label="Rest minutes"
|
||||||
</Button>
|
keyboardType="numeric"
|
||||||
<ConfirmDialog
|
/>
|
||||||
title="Remove image"
|
<AppInput
|
||||||
onOk={handleRemove}
|
innerRef={secondsRef}
|
||||||
show={showRemove}
|
value={seconds}
|
||||||
setShow={setShowRemove}>
|
onChangeText={setSeconds}
|
||||||
Are you sure you want to remove the image?
|
label="Rest seconds"
|
||||||
</ConfirmDialog>
|
keyboardType="numeric"
|
||||||
</View>
|
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="outlined" 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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
4
Gemfile
4
Gemfile
|
@ -1,6 +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.7.5'
|
ruby ">= 2.6.10"
|
||||||
|
|
||||||
gem 'cocoapods', '~> 1.11', '>= 1.11.2'
|
gem 'cocoapods', '~> 1.12'
|
||||||
|
|
100
Gemfile.lock
Normal file
100
Gemfile.lock
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
GEM
|
||||||
|
remote: https://rubygems.org/
|
||||||
|
specs:
|
||||||
|
CFPropertyList (3.0.5)
|
||||||
|
rexml
|
||||||
|
activesupport (6.1.7)
|
||||||
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
|
i18n (>= 1.6, < 2)
|
||||||
|
minitest (>= 5.1)
|
||||||
|
tzinfo (~> 2.0)
|
||||||
|
zeitwerk (~> 2.3)
|
||||||
|
addressable (2.8.1)
|
||||||
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
|
algoliasearch (1.27.5)
|
||||||
|
httpclient (~> 2.8, >= 2.8.3)
|
||||||
|
json (>= 1.5.1)
|
||||||
|
atomos (0.1.3)
|
||||||
|
claide (1.1.0)
|
||||||
|
cocoapods (1.11.3)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
claide (>= 1.0.2, < 2.0)
|
||||||
|
cocoapods-core (= 1.11.3)
|
||||||
|
cocoapods-deintegrate (>= 1.0.3, < 2.0)
|
||||||
|
cocoapods-downloader (>= 1.4.0, < 2.0)
|
||||||
|
cocoapods-plugins (>= 1.0.0, < 2.0)
|
||||||
|
cocoapods-search (>= 1.0.0, < 2.0)
|
||||||
|
cocoapods-trunk (>= 1.4.0, < 2.0)
|
||||||
|
cocoapods-try (>= 1.1.0, < 2.0)
|
||||||
|
colored2 (~> 3.1)
|
||||||
|
escape (~> 0.0.4)
|
||||||
|
fourflusher (>= 2.3.0, < 3.0)
|
||||||
|
gh_inspector (~> 1.0)
|
||||||
|
molinillo (~> 0.8.0)
|
||||||
|
nap (~> 1.0)
|
||||||
|
ruby-macho (>= 1.0, < 3.0)
|
||||||
|
xcodeproj (>= 1.21.0, < 2.0)
|
||||||
|
cocoapods-core (1.11.3)
|
||||||
|
activesupport (>= 5.0, < 7)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
algoliasearch (~> 1.0)
|
||||||
|
concurrent-ruby (~> 1.1)
|
||||||
|
fuzzy_match (~> 2.0.4)
|
||||||
|
nap (~> 1.0)
|
||||||
|
netrc (~> 0.11)
|
||||||
|
public_suffix (~> 4.0)
|
||||||
|
typhoeus (~> 1.0)
|
||||||
|
cocoapods-deintegrate (1.0.5)
|
||||||
|
cocoapods-downloader (1.6.3)
|
||||||
|
cocoapods-plugins (1.0.0)
|
||||||
|
nap
|
||||||
|
cocoapods-search (1.0.1)
|
||||||
|
cocoapods-trunk (1.6.0)
|
||||||
|
nap (>= 0.8, < 2.0)
|
||||||
|
netrc (~> 0.11)
|
||||||
|
cocoapods-try (1.2.0)
|
||||||
|
colored2 (3.1.2)
|
||||||
|
concurrent-ruby (1.1.10)
|
||||||
|
escape (0.0.4)
|
||||||
|
ethon (0.16.0)
|
||||||
|
ffi (>= 1.15.0)
|
||||||
|
ffi (1.15.5)
|
||||||
|
fourflusher (2.3.1)
|
||||||
|
fuzzy_match (2.0.4)
|
||||||
|
gh_inspector (1.1.3)
|
||||||
|
httpclient (2.8.3)
|
||||||
|
i18n (1.12.0)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
json (2.6.2)
|
||||||
|
minitest (5.16.3)
|
||||||
|
molinillo (0.8.0)
|
||||||
|
nanaimo (0.3.0)
|
||||||
|
nap (1.1.0)
|
||||||
|
netrc (0.11.0)
|
||||||
|
public_suffix (4.0.7)
|
||||||
|
rexml (3.2.5)
|
||||||
|
ruby-macho (2.5.1)
|
||||||
|
typhoeus (1.4.0)
|
||||||
|
ethon (>= 0.9.0)
|
||||||
|
tzinfo (2.0.5)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
|
xcodeproj (1.22.0)
|
||||||
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
|
atomos (~> 0.1.3)
|
||||||
|
claide (>= 1.0.2, < 2.0)
|
||||||
|
colored2 (~> 3.1)
|
||||||
|
nanaimo (~> 0.3.0)
|
||||||
|
rexml (~> 3.2.4)
|
||||||
|
zeitwerk (2.6.6)
|
||||||
|
|
||||||
|
PLATFORMS
|
||||||
|
ruby
|
||||||
|
|
||||||
|
DEPENDENCIES
|
||||||
|
cocoapods (~> 1.11, >= 1.11.2)
|
||||||
|
|
||||||
|
RUBY VERSION
|
||||||
|
ruby 2.7.5p203
|
||||||
|
|
||||||
|
BUNDLED WITH
|
||||||
|
2.1.4
|
102
GraphsList.tsx
Normal file
102
GraphsList.tsx
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
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 { getBestSets } from "./best.service";
|
||||||
|
import { LIMIT } from "./constants";
|
||||||
|
import { settingsRepo } from "./db";
|
||||||
|
import DrawerHeader from "./DrawerHeader";
|
||||||
|
import { GraphsPageParams } from "./GraphsPage";
|
||||||
|
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<GraphsPageParams>>();
|
||||||
|
const [settings, setSettings] = useState<Settings>();
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = useCallback(async (value: string) => {
|
||||||
|
const result = await getBestSets({ term: value, offset: 0 });
|
||||||
|
setBests(result);
|
||||||
|
setOffset(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
refresh(term);
|
||||||
|
}, [refresh, 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 });
|
||||||
|
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", { best: item })}
|
||||||
|
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}
|
||||||
|
onEndReached={next}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
23
GraphsPage.tsx
Normal file
23
GraphsPage.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { createStackNavigator } from "@react-navigation/stack";
|
||||||
|
import GraphsList from "./GraphsList";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
import ViewGraph from "./ViewGraph";
|
||||||
|
|
||||||
|
const Stack = createStackNavigator<GraphsPageParams>();
|
||||||
|
export type GraphsPageParams = {
|
||||||
|
GraphsList: {};
|
||||||
|
ViewGraph: {
|
||||||
|
best: GymSet;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GraphsPage() {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator
|
||||||
|
screenOptions={{ headerShown: false, animationEnabled: false }}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="GraphsList" component={GraphsList} />
|
||||||
|
<Stack.Screen name="ViewGraph" component={ViewGraph} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
35
HomePage.tsx
35
HomePage.tsx
|
@ -1,36 +1,19 @@
|
||||||
import {DrawerNavigationProp} from '@react-navigation/drawer';
|
import { createStackNavigator } from "@react-navigation/stack";
|
||||||
import {useNavigation} from '@react-navigation/native';
|
import EditSet from "./EditSet";
|
||||||
import {createStackNavigator} from '@react-navigation/stack';
|
import EditSets from "./EditSets";
|
||||||
import React from 'react';
|
import { HomePageParams } from "./home-page-params";
|
||||||
import {IconButton} from 'react-native-paper';
|
import SetList from "./SetList";
|
||||||
import {DrawerParamList} from './drawer-param-list';
|
|
||||||
import EditSet from './EditSet';
|
|
||||||
import {HomePageParams} from './home-page-params';
|
|
||||||
import SetList from './SetList';
|
|
||||||
|
|
||||||
const Stack = createStackNavigator<HomePageParams>();
|
const Stack = createStackNavigator<HomePageParams>();
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack.Navigator
|
<Stack.Navigator
|
||||||
screenOptions={{headerShown: false, animationEnabled: false}}>
|
screenOptions={{ headerShown: false, animationEnabled: false }}
|
||||||
|
>
|
||||||
<Stack.Screen name="Sets" component={SetList} />
|
<Stack.Screen name="Sets" component={SetList} />
|
||||||
<Stack.Screen
|
<Stack.Screen name="EditSet" component={EditSet} />
|
||||||
name="EditSet"
|
<Stack.Screen name="EditSets" component={EditSets} />
|
||||||
component={EditSet}
|
|
||||||
listeners={{
|
|
||||||
beforeRemove: () => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<IconButton icon="menu" onPress={navigation.openDrawer} />
|
|
||||||
),
|
|
||||||
title: 'Home',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
94
ListMenu.tsx
Normal file
94
ListMenu.tsx
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Divider, IconButton, Menu } from "react-native-paper";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
|
||||||
|
export default function ListMenu({
|
||||||
|
onEdit,
|
||||||
|
onCopy,
|
||||||
|
onClear,
|
||||||
|
onDelete,
|
||||||
|
onSelect,
|
||||||
|
ids,
|
||||||
|
}: {
|
||||||
|
onEdit: () => void;
|
||||||
|
onCopy: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onSelect: () => void;
|
||||||
|
ids?: number[];
|
||||||
|
}) {
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
const [showRemove, setShowRemove] = useState(false);
|
||||||
|
|
||||||
|
const edit = () => {
|
||||||
|
setShowMenu(false);
|
||||||
|
onEdit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = () => {
|
||||||
|
setShowMenu(false);
|
||||||
|
onCopy();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
setShowMenu(false);
|
||||||
|
onClear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
setShowMenu(false);
|
||||||
|
setShowRemove(false);
|
||||||
|
onDelete();
|
||||||
|
};
|
||||||
|
|
||||||
|
const select = () => {
|
||||||
|
onSelect();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
visible={showMenu}
|
||||||
|
onDismiss={() => setShowMenu(false)}
|
||||||
|
anchor={<IconButton onPress={() => setShowMenu(true)} icon="more-vert" />}
|
||||||
|
>
|
||||||
|
<Menu.Item leadingIcon="done-all" title="Select all" onPress={select} />
|
||||||
|
<Menu.Item
|
||||||
|
leadingIcon="clear"
|
||||||
|
title="Clear"
|
||||||
|
onPress={clear}
|
||||||
|
disabled={ids?.length === 0}
|
||||||
|
/>
|
||||||
|
<Menu.Item
|
||||||
|
leadingIcon="edit"
|
||||||
|
title="Edit"
|
||||||
|
onPress={edit}
|
||||||
|
disabled={ids?.length === 0}
|
||||||
|
/>
|
||||||
|
<Menu.Item
|
||||||
|
leadingIcon="content-copy"
|
||||||
|
title="Copy"
|
||||||
|
onPress={copy}
|
||||||
|
disabled={ids?.length === 0}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<Menu.Item
|
||||||
|
leadingIcon="delete"
|
||||||
|
onPress={() => setShowRemove(true)}
|
||||||
|
title="Delete"
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
title={ids?.length === 0 ? "Delete all" : "Delete selected"}
|
||||||
|
show={showRemove}
|
||||||
|
setShow={setShowRemove}
|
||||||
|
onOk={remove}
|
||||||
|
onCancel={() => setShowMenu(false)}
|
||||||
|
>
|
||||||
|
{ids?.length === 0 ? (
|
||||||
|
<>This irreversibly deletes records from the app. Are you sure?</>
|
||||||
|
) : (
|
||||||
|
<>This will delete {ids?.length} record(s). Are you sure?</>
|
||||||
|
)}
|
||||||
|
</ConfirmDialog>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,27 +0,0 @@
|
||||||
import React, {useContext} from 'react';
|
|
||||||
import {FAB} from 'react-native-paper';
|
|
||||||
import {CustomTheme} from './App';
|
|
||||||
import {lightColors} from './colors';
|
|
||||||
|
|
||||||
export default function MassiveFab(
|
|
||||||
props: Partial<React.ComponentProps<typeof FAB>>,
|
|
||||||
) {
|
|
||||||
const {color} = useContext(CustomTheme);
|
|
||||||
const fabColor = lightColors.map(lightColor => lightColor.hex).includes(color)
|
|
||||||
? 'black'
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FAB
|
|
||||||
icon="add"
|
|
||||||
color={fabColor}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 10,
|
|
||||||
bottom: 60,
|
|
||||||
backgroundColor: color,
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import {useColorScheme} from 'react-native';
|
|
||||||
import {TextInput} from 'react-native-paper';
|
|
||||||
import {CombinedDefaultTheme} from './App';
|
|
||||||
import {MARGIN} from './constants';
|
|
||||||
|
|
||||||
export default function MassiveInput(
|
|
||||||
props: Partial<React.ComponentProps<typeof TextInput>> & {
|
|
||||||
innerRef?: React.Ref<any>;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const dark = useColorScheme() === 'dark';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextInput
|
|
||||||
selectionColor={dark ? '#2A2A2A' : CombinedDefaultTheme.colors.border}
|
|
||||||
mode="outlined"
|
|
||||||
style={{marginBottom: MARGIN, minWidth: 100}}
|
|
||||||
selectTextOnFocus
|
|
||||||
ref={props.innerRef}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,44 +0,0 @@
|
||||||
import React, {useContext, useState} from 'react';
|
|
||||||
import {useColorScheme} from 'react-native';
|
|
||||||
import {Snackbar} from 'react-native-paper';
|
|
||||||
import {CombinedDarkTheme, CustomTheme} from './App';
|
|
||||||
|
|
||||||
export const SnackbarContext = React.createContext<{
|
|
||||||
toast: (value: string, timeout: number) => void;
|
|
||||||
}>({toast: () => null});
|
|
||||||
|
|
||||||
export default function MassiveSnack({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: JSX.Element[] | JSX.Element;
|
|
||||||
}) {
|
|
||||||
const [snackbar, setSnackbar] = useState('');
|
|
||||||
const [timeoutId, setTimeoutId] = useState(0);
|
|
||||||
const dark = useColorScheme() === 'dark';
|
|
||||||
const {color} = useContext(CustomTheme);
|
|
||||||
|
|
||||||
const toast = (value: string, timeout: number) => {
|
|
||||||
setSnackbar(value);
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
const id = setTimeout(() => setSnackbar(''), timeout);
|
|
||||||
setTimeoutId(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SnackbarContext.Provider value={{toast}}>
|
|
||||||
{children}
|
|
||||||
</SnackbarContext.Provider>
|
|
||||||
<Snackbar
|
|
||||||
onDismiss={() => setSnackbar('')}
|
|
||||||
visible={!!snackbar}
|
|
||||||
action={{
|
|
||||||
label: 'Close',
|
|
||||||
onPress: () => setSnackbar(''),
|
|
||||||
color: dark ? CombinedDarkTheme.colors.background : color,
|
|
||||||
}}>
|
|
||||||
{snackbar}
|
|
||||||
</Snackbar>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
30
Page.tsx
30
Page.tsx
|
@ -1,39 +1,39 @@
|
||||||
import React from 'react';
|
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
||||||
import {StyleSheet, View} from 'react-native';
|
import { Searchbar } from "react-native-paper";
|
||||||
import {Searchbar} from 'react-native-paper';
|
import AppFab from "./AppFab";
|
||||||
import {PADDING} from './constants';
|
import { PADDING } from "./constants";
|
||||||
import MassiveFab from './MassiveFab';
|
|
||||||
|
|
||||||
export default function Page({
|
export default function Page({
|
||||||
onAdd,
|
onAdd,
|
||||||
children,
|
children,
|
||||||
|
term,
|
||||||
search,
|
search,
|
||||||
setSearch,
|
style,
|
||||||
}: {
|
}: {
|
||||||
children: JSX.Element | JSX.Element[];
|
children: JSX.Element | JSX.Element[];
|
||||||
onAdd?: () => void;
|
onAdd?: () => void;
|
||||||
search: string;
|
term: string;
|
||||||
setSearch: (value: string) => void;
|
search: (value: string) => void;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={[styles.view, style]}>
|
||||||
<Searchbar
|
<Searchbar
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
value={search}
|
value={term}
|
||||||
onChangeText={setSearch}
|
onChangeText={search}
|
||||||
icon="search"
|
icon="search"
|
||||||
clearIcon="clear"
|
clearIcon="clear"
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
{onAdd && <MassiveFab onPress={onAdd} />}
|
{onAdd && <AppFab onPress={onAdd} />}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
view: {
|
||||||
flexGrow: 1,
|
|
||||||
padding: PADDING,
|
padding: PADDING,
|
||||||
paddingBottom: '10%',
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
134
PlanItem.tsx
134
PlanItem.tsx
|
@ -1,53 +1,107 @@
|
||||||
import {NavigationProp, useNavigation} from '@react-navigation/native';
|
import {
|
||||||
import React, {useCallback, useState} from 'react';
|
NavigationProp,
|
||||||
import {GestureResponderEvent} from 'react-native';
|
useFocusEffect,
|
||||||
import {List, Menu} from 'react-native-paper';
|
useNavigation,
|
||||||
import {Plan} from './plan';
|
} from "@react-navigation/native";
|
||||||
import {PlanPageParams} from './plan-page-params';
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {deletePlan} from './plan.service';
|
import { Text } from "react-native";
|
||||||
|
import { List } from "react-native-paper";
|
||||||
|
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants";
|
||||||
|
import { setRepo } from "./db";
|
||||||
|
import { defaultSet } from "./gym-set";
|
||||||
|
import { Plan } from "./plan";
|
||||||
|
import { PlanPageParams } from "./plan-page-params";
|
||||||
|
import { DAYS } from "./time";
|
||||||
|
import useDark from "./use-dark";
|
||||||
|
|
||||||
export default function PlanItem({
|
export default function PlanItem({
|
||||||
item,
|
item,
|
||||||
onRemove,
|
setIds,
|
||||||
|
ids,
|
||||||
}: {
|
}: {
|
||||||
item: Plan;
|
item: Plan;
|
||||||
onRemove: () => void;
|
ids: number[];
|
||||||
|
setIds: (value: number[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [show, setShow] = useState(false);
|
const [today, setToday] = useState<string>();
|
||||||
const [anchor, setAnchor] = useState({x: 0, y: 0});
|
const dark = useDark();
|
||||||
|
const days = useMemo(() => item.days.split(","), [item.days]);
|
||||||
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
|
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
|
||||||
|
|
||||||
const remove = useCallback(async () => {
|
useFocusEffect(
|
||||||
if (typeof item.id === 'number') await deletePlan(item.id);
|
useCallback(() => {
|
||||||
setShow(false);
|
const newToday = DAYS[new Date().getDay()];
|
||||||
onRemove();
|
setToday(newToday);
|
||||||
}, [setShow, item.id, onRemove]);
|
}, [])
|
||||||
|
|
||||||
const longPress = useCallback(
|
|
||||||
(e: GestureResponderEvent) => {
|
|
||||||
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
|
|
||||||
setShow(true);
|
|
||||||
},
|
|
||||||
[setAnchor, setShow],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
const workout = item.workouts.split(",")[0];
|
||||||
|
let first = await setRepo.findOne({
|
||||||
|
where: { name: workout },
|
||||||
|
order: { created: "desc" },
|
||||||
|
});
|
||||||
|
if (!first) first = { ...defaultSet, name: workout };
|
||||||
|
delete first.id;
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return navigation.navigate("StartPlan", { plan: item, first });
|
||||||
|
}
|
||||||
|
const removing = ids.find((id) => id === item.id);
|
||||||
|
if (removing) setIds(ids.filter((id) => id !== item.id));
|
||||||
|
else setIds([...ids, item.id]);
|
||||||
|
}, [ids, setIds, item, navigation]);
|
||||||
|
|
||||||
|
const longPress = useCallback(() => {
|
||||||
|
if (ids.length > 0) return;
|
||||||
|
setIds([item.id]);
|
||||||
|
}, [ids.length, item.id, setIds]);
|
||||||
|
|
||||||
|
const currentDays = days.map((day, index) => (
|
||||||
|
<Text key={day}>
|
||||||
|
{day === today ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
day
|
||||||
|
)}
|
||||||
|
{index === days.length - 1 ? "" : ", "}
|
||||||
|
</Text>
|
||||||
|
));
|
||||||
|
|
||||||
|
const title = useMemo(
|
||||||
|
() =>
|
||||||
|
item.title ? (
|
||||||
|
<Text style={{ fontWeight: "bold" }}>{item.title}</Text>
|
||||||
|
) : (
|
||||||
|
currentDays
|
||||||
|
),
|
||||||
|
[item.title, currentDays]
|
||||||
|
);
|
||||||
|
|
||||||
|
const description = useMemo(
|
||||||
|
() => (item.title ? currentDays : item.workouts.replace(/,/g, ", ")),
|
||||||
|
[item.title, currentDays, item.workouts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const backgroundColor = useMemo(() => {
|
||||||
|
if (!ids.includes(item.id)) return;
|
||||||
|
if (dark) return DARK_RIPPLE;
|
||||||
|
return LIGHT_RIPPLE;
|
||||||
|
}, [dark, ids, item.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<List.Item
|
||||||
<List.Item
|
onPress={start}
|
||||||
onPress={() => navigation.navigate('EditPlan', {plan: item})}
|
title={title}
|
||||||
title={
|
description={description}
|
||||||
item.days
|
onLongPress={longPress}
|
||||||
? item.days.replace(/,/g, ', ')
|
style={{ backgroundColor }}
|
||||||
: item.workouts.replace(/,/g, ', ')
|
/>
|
||||||
}
|
|
||||||
description={item.days ? item.workouts.replace(/,/g, ', ') : null}
|
|
||||||
onLongPress={longPress}
|
|
||||||
right={() => (
|
|
||||||
<Menu anchor={anchor} visible={show} onDismiss={() => setShow(false)}>
|
|
||||||
<Menu.Item icon="delete" onPress={remove} title="Delete" />
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
130
PlanList.tsx
130
PlanList.tsx
|
@ -2,63 +2,119 @@ import {
|
||||||
NavigationProp,
|
NavigationProp,
|
||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
} from '@react-navigation/native';
|
} from "@react-navigation/native";
|
||||||
import React, {useCallback, useEffect, 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 DrawerMenu from './DrawerMenu';
|
import { Like } from "typeorm";
|
||||||
import Page from './Page';
|
import { planRepo } from "./db";
|
||||||
import {Plan} from './plan';
|
import DrawerHeader from "./DrawerHeader";
|
||||||
import {PlanPageParams} from './plan-page-params';
|
import ListMenu from "./ListMenu";
|
||||||
import {getPlans} from './plan.service';
|
import Page from "./Page";
|
||||||
import PlanItem from './PlanItem';
|
import { Plan } from "./plan";
|
||||||
|
import { PlanPageParams } from "./plan-page-params";
|
||||||
|
import PlanItem from "./PlanItem";
|
||||||
|
|
||||||
export default function PlanList() {
|
export default function PlanList() {
|
||||||
const [search, setSearch] = useState('');
|
const [term, setTerm] = useState("");
|
||||||
const [plans, setPlans] = useState<Plan[]>([]);
|
const [plans, setPlans] = useState<Plan[]>();
|
||||||
|
const [ids, setIds] = useState<number[]>([]);
|
||||||
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
|
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async (value: string) => {
|
||||||
getPlans(search).then(setPlans);
|
planRepo
|
||||||
}, [search]);
|
.find({
|
||||||
|
where: [
|
||||||
|
{ title: Like(`%${value.trim()}%`) },
|
||||||
|
{ days: Like(`%${value.trim()}%`) },
|
||||||
|
{ workouts: Like(`%${value.trim()}%`) },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.then(setPlans);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
refresh();
|
refresh(term);
|
||||||
navigation.getParent()?.setOptions({
|
}, [refresh, term])
|
||||||
headerRight: () => <DrawerMenu name="Plans" />,
|
|
||||||
});
|
|
||||||
}, [refresh, navigation]),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const search = useCallback(
|
||||||
refresh();
|
(value: string) => {
|
||||||
}, [search, refresh]);
|
setTerm(value);
|
||||||
|
refresh(value);
|
||||||
|
},
|
||||||
|
[refresh]
|
||||||
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({item}: {item: Plan}) => (
|
({ item }: { item: Plan }) => (
|
||||||
<PlanItem item={item} key={item.id} onRemove={refresh} />
|
<PlanItem ids={ids} setIds={setIds} item={item} key={item.id} />
|
||||||
),
|
),
|
||||||
[refresh],
|
[ids]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onAdd = () =>
|
const onAdd = () =>
|
||||||
navigation.navigate('EditPlan', {plan: {days: '', workouts: ''}});
|
navigation.navigate("EditPlan", {
|
||||||
|
plan: { title: "", days: "", workouts: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const edit = useCallback(async () => {
|
||||||
|
const plan = await planRepo.findOne({ where: { id: ids.pop() } });
|
||||||
|
navigation.navigate("EditPlan", { plan });
|
||||||
|
setIds([]);
|
||||||
|
}, [ids, navigation]);
|
||||||
|
|
||||||
|
const copy = useCallback(async () => {
|
||||||
|
const plan = await planRepo.findOne({
|
||||||
|
where: { id: ids.pop() },
|
||||||
|
});
|
||||||
|
delete plan.id;
|
||||||
|
navigation.navigate("EditPlan", { plan });
|
||||||
|
setIds([]);
|
||||||
|
}, [ids, navigation]);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setIds([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const remove = useCallback(async () => {
|
||||||
|
await planRepo.delete(ids.length > 0 ? ids : {});
|
||||||
|
await refresh(term);
|
||||||
|
setIds([]);
|
||||||
|
}, [ids, refresh, term]);
|
||||||
|
|
||||||
|
const select = useCallback(() => {
|
||||||
|
setIds(plans.map((plan) => plan.id));
|
||||||
|
}, [plans]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page onAdd={onAdd} search={search} setSearch={setSearch}>
|
<>
|
||||||
<FlatList
|
<DrawerHeader name={ids.length > 0 ? `${ids.length} selected` : "Plans"}>
|
||||||
style={{height: '99%'}}
|
<ListMenu
|
||||||
data={plans}
|
onClear={clear}
|
||||||
renderItem={renderItem}
|
onCopy={copy}
|
||||||
keyExtractor={set => set.id?.toString() || ''}
|
onDelete={remove}
|
||||||
ListEmptyComponent={
|
onEdit={edit}
|
||||||
|
ids={ids}
|
||||||
|
onSelect={select}
|
||||||
|
/>
|
||||||
|
</DrawerHeader>
|
||||||
|
<Page onAdd={onAdd} term={term} search={search}>
|
||||||
|
{plans?.length === 0 ? (
|
||||||
<List.Item
|
<List.Item
|
||||||
title="No plans yet"
|
title="No plans yet"
|
||||||
description="A plan is a list of workouts for certain days."
|
description="A plan is a list of workouts for certain days."
|
||||||
/>
|
/>
|
||||||
}
|
) : (
|
||||||
/>
|
<FlatList
|
||||||
</Page>
|
style={{ flex: 1 }}
|
||||||
|
data={plans}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={(set) => set.id?.toString() || ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
37
PlanPage.tsx
37
PlanPage.tsx
|
@ -1,36 +1,21 @@
|
||||||
import {DrawerNavigationProp} from '@react-navigation/drawer';
|
import { createStackNavigator } from "@react-navigation/stack";
|
||||||
import {useNavigation} from '@react-navigation/native';
|
import EditPlan from "./EditPlan";
|
||||||
import {createStackNavigator} from '@react-navigation/stack';
|
import EditSet from "./EditSet";
|
||||||
import React from 'react';
|
import { PlanPageParams } from "./plan-page-params";
|
||||||
import {IconButton} from 'react-native-paper';
|
import PlanList from "./PlanList";
|
||||||
import {DrawerParamList} from './drawer-param-list';
|
import StartPlan from "./StartPlan";
|
||||||
import EditPlan from './EditPlan';
|
|
||||||
import {PlanPageParams} from './plan-page-params';
|
|
||||||
import PlanList from './PlanList';
|
|
||||||
|
|
||||||
const Stack = createStackNavigator<PlanPageParams>();
|
const Stack = createStackNavigator<PlanPageParams>();
|
||||||
|
|
||||||
export default function PlanPage() {
|
export default function PlanPage() {
|
||||||
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack.Navigator
|
<Stack.Navigator
|
||||||
screenOptions={{headerShown: false, animationEnabled: false}}>
|
screenOptions={{ headerShown: false, animationEnabled: false }}
|
||||||
|
>
|
||||||
<Stack.Screen name="PlanList" component={PlanList} />
|
<Stack.Screen name="PlanList" component={PlanList} />
|
||||||
<Stack.Screen
|
<Stack.Screen name="EditPlan" component={EditPlan} />
|
||||||
name="EditPlan"
|
<Stack.Screen name="StartPlan" component={StartPlan} />
|
||||||
component={EditPlan}
|
<Stack.Screen name="EditSet" component={EditSet} />
|
||||||
listeners={{
|
|
||||||
beforeRemove: () => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<IconButton icon="menu" onPress={navigation.openDrawer} />
|
|
||||||
),
|
|
||||||
title: 'Plans',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ Massive tracks your reps and sets at the gym. No internet connectivity or high s
|
||||||
<img src="metadata/en-US/images/phoneScreenshots/timer.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/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"/>
|
||||||
|
|
93
Routes.tsx
93
Routes.tsx
|
@ -1,60 +1,57 @@
|
||||||
import {createDrawerNavigator} from '@react-navigation/drawer';
|
import { createDrawerNavigator } from "@react-navigation/drawer";
|
||||||
import React, {useContext, useEffect, useState} from 'react';
|
import { IconButton } from "react-native-paper";
|
||||||
import {useColorScheme} from 'react-native';
|
import GraphsPage from "./GraphsPage";
|
||||||
import {IconButton} from 'react-native-paper';
|
import { DrawerParamList } from "./drawer-param-list";
|
||||||
import {CustomTheme} from './App';
|
import HomePage from "./HomePage";
|
||||||
import BestPage from './BestPage';
|
import PlanPage from "./PlanPage";
|
||||||
import {runMigrations} from './db';
|
import SettingsPage from "./SettingsPage";
|
||||||
import {DrawerParamList} from './drawer-param-list';
|
import TimerPage from "./TimerPage";
|
||||||
import HomePage from './HomePage';
|
import useDark from "./use-dark";
|
||||||
import PlanPage from './PlanPage';
|
import WorkoutsPage from "./WorkoutsPage";
|
||||||
import Route from './route';
|
|
||||||
import {getSettings, settings} from './settings.service';
|
|
||||||
import SettingsPage from './SettingsPage';
|
|
||||||
import WorkoutsPage from './WorkoutsPage';
|
|
||||||
|
|
||||||
const Drawer = createDrawerNavigator<DrawerParamList>();
|
const Drawer = createDrawerNavigator<DrawerParamList>();
|
||||||
|
|
||||||
export default function Routes() {
|
export default function Routes() {
|
||||||
const [migrated, setMigrated] = useState(false);
|
const dark = useDark();
|
||||||
const dark = useColorScheme() === 'dark';
|
|
||||||
const {setColor} = useContext(CustomTheme);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
runMigrations()
|
|
||||||
.then(getSettings)
|
|
||||||
.then(() => {
|
|
||||||
setMigrated(true);
|
|
||||||
if (settings.color) setColor(settings.color);
|
|
||||||
});
|
|
||||||
}, [setColor]);
|
|
||||||
|
|
||||||
if (!migrated) return null;
|
|
||||||
|
|
||||||
const routes: Route[] = [
|
|
||||||
{name: 'Home', component: HomePage, icon: 'home'},
|
|
||||||
{name: 'Plans', component: PlanPage, icon: 'event'},
|
|
||||||
{name: 'Best', component: BestPage, icon: 'insights'},
|
|
||||||
{name: 'Workouts', component: WorkoutsPage, icon: 'fitness-center'},
|
|
||||||
{name: 'Settings', component: SettingsPage, icon: 'settings'},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer.Navigator
|
<Drawer.Navigator
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerTintColor: dark ? 'white' : 'black',
|
headerTintColor: dark ? "white" : "black",
|
||||||
swipeEdgeWidth: 1000,
|
swipeEdgeWidth: 1000,
|
||||||
}}>
|
headerShown: false,
|
||||||
{routes.map(route => (
|
}}
|
||||||
<Drawer.Screen
|
>
|
||||||
key={route.name}
|
<Drawer.Screen
|
||||||
name={route.name}
|
name="Home"
|
||||||
component={route.component}
|
component={HomePage}
|
||||||
options={{
|
options={{ drawerIcon: () => <IconButton icon="home" /> }}
|
||||||
drawerIcon: () => <IconButton icon={route.icon} />,
|
/>
|
||||||
}}
|
<Drawer.Screen
|
||||||
/>
|
name="Plans"
|
||||||
))}
|
component={PlanPage}
|
||||||
|
options={{ drawerIcon: () => <IconButton icon="event" /> }}
|
||||||
|
/>
|
||||||
|
<Drawer.Screen
|
||||||
|
name="Graphs"
|
||||||
|
component={GraphsPage}
|
||||||
|
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>
|
</Drawer.Navigator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
75
Select.tsx
Normal file
75
Select.tsx
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Button, Menu, Subheading, useTheme } from "react-native-paper";
|
||||||
|
import { ITEM_PADDING } from "./constants";
|
||||||
|
|
||||||
|
export interface Item {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
items,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
items: Item[];
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => items.find((item) => item.value === value) || items[0],
|
||||||
|
[items, value]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePress = useCallback(
|
||||||
|
(newValue: string) => {
|
||||||
|
onChange(newValue);
|
||||||
|
setShow(false);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingLeft: ITEM_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label && <Subheading style={{ width: 100 }}>{label}</Subheading>}
|
||||||
|
<Menu
|
||||||
|
visible={show}
|
||||||
|
onDismiss={() => setShow(false)}
|
||||||
|
anchor={
|
||||||
|
<Button
|
||||||
|
onPress={() => setShow(true)}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected?.label}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Menu.Item
|
||||||
|
titleStyle={{ color: item.color || colors.onSurface }}
|
||||||
|
key={item.value}
|
||||||
|
title={item.label}
|
||||||
|
onPress={() => handlePress(item.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(Select);
|
136
SetForm.tsx
136
SetForm.tsx
|
@ -1,136 +0,0 @@
|
||||||
import React, {useContext, useEffect, useRef, useState} from 'react';
|
|
||||||
import {ScrollView, View} from 'react-native';
|
|
||||||
import {Button, Text} from 'react-native-paper';
|
|
||||||
import MassiveInput from './MassiveInput';
|
|
||||||
import {SnackbarContext} from './MassiveSnack';
|
|
||||||
import Set from './set';
|
|
||||||
import {getSets} from './set.service';
|
|
||||||
import {settings} from './settings.service';
|
|
||||||
|
|
||||||
export default function SetForm({
|
|
||||||
save,
|
|
||||||
set,
|
|
||||||
workouts,
|
|
||||||
}: {
|
|
||||||
set: Set;
|
|
||||||
save: (set: Set) => void;
|
|
||||||
workouts: string[];
|
|
||||||
}) {
|
|
||||||
const [name, setName] = useState(set.name);
|
|
||||||
const [reps, setReps] = useState(set.reps.toString());
|
|
||||||
const [weight, setWeight] = useState(set.weight.toString());
|
|
||||||
const [unit, setUnit] = useState(set.unit);
|
|
||||||
const [uri, setUri] = useState(set.image);
|
|
||||||
const [selection, setSelection] = useState({
|
|
||||||
start: 0,
|
|
||||||
end: set.reps.toString().length,
|
|
||||||
});
|
|
||||||
const {toast} = useContext(SnackbarContext);
|
|
||||||
const weightRef = useRef<any>(null);
|
|
||||||
const repsRef = useRef<any>(null);
|
|
||||||
const unitRef = useRef<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('SetForm.useEffect:', {uri, name: set.name});
|
|
||||||
if (!uri)
|
|
||||||
getSets({search: set.name, limit: 1, offset: 0}).then(([s]) =>
|
|
||||||
setUri(s?.image),
|
|
||||||
);
|
|
||||||
}, [uri, set.name]);
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!name) return;
|
|
||||||
save({
|
|
||||||
name,
|
|
||||||
reps: Number(reps),
|
|
||||||
weight: Number(weight),
|
|
||||||
id: set.id,
|
|
||||||
unit,
|
|
||||||
image: uri,
|
|
||||||
minutes: Number(set.minutes ?? 3),
|
|
||||||
seconds: Number(set.seconds ?? 30),
|
|
||||||
sets: set.sets ?? 3,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleName = (value: string) => {
|
|
||||||
setName(value.replace(/,|'/g, ''));
|
|
||||||
if (value.match(/,|'/))
|
|
||||||
toast('Commas and single quotes would break CSV exports', 6000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnit = (value: string) => {
|
|
||||||
setUnit(value.replace(/,|'/g, ''));
|
|
||||||
if (value.match(/,|'/))
|
|
||||||
toast('Commas and single quotes would break CSV exports', 6000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ScrollView style={{height: '90%'}}>
|
|
||||||
<MassiveInput
|
|
||||||
label="Name"
|
|
||||||
value={name}
|
|
||||||
onChangeText={handleName}
|
|
||||||
autoCorrect={false}
|
|
||||||
autoFocus={!name}
|
|
||||||
blurOnSubmit={false}
|
|
||||||
onSubmitEditing={() => repsRef.current?.focus()}
|
|
||||||
/>
|
|
||||||
<MassiveInput
|
|
||||||
label="Reps"
|
|
||||||
keyboardType="numeric"
|
|
||||||
value={reps}
|
|
||||||
onChangeText={setReps}
|
|
||||||
onSubmitEditing={() => weightRef.current?.focus()}
|
|
||||||
selection={selection}
|
|
||||||
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
|
|
||||||
autoFocus={!!name}
|
|
||||||
blurOnSubmit={false}
|
|
||||||
innerRef={repsRef}
|
|
||||||
/>
|
|
||||||
<MassiveInput
|
|
||||||
label="Weight"
|
|
||||||
keyboardType="numeric"
|
|
||||||
value={weight}
|
|
||||||
onChangeText={setWeight}
|
|
||||||
onSubmitEditing={handleSubmit}
|
|
||||||
innerRef={weightRef}
|
|
||||||
/>
|
|
||||||
{!!settings.showUnit && (
|
|
||||||
<MassiveInput
|
|
||||||
autoCapitalize="none"
|
|
||||||
label="Unit"
|
|
||||||
value={unit}
|
|
||||||
onChangeText={handleUnit}
|
|
||||||
innerRef={unitRef}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{workouts.length > 0 && !!settings.workouts && (
|
|
||||||
<View style={{flexDirection: 'row'}}>
|
|
||||||
{workouts.map((workout, index) => (
|
|
||||||
<Text>
|
|
||||||
<Text
|
|
||||||
style={
|
|
||||||
workout === name
|
|
||||||
? {textDecorationLine: 'underline', fontWeight: 'bold'}
|
|
||||||
: null
|
|
||||||
}>
|
|
||||||
{workout}
|
|
||||||
</Text>
|
|
||||||
{index === workouts.length - 1 ? '' : ', '}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
<Button
|
|
||||||
disabled={!name}
|
|
||||||
mode="contained"
|
|
||||||
icon="save"
|
|
||||||
onPress={handleSubmit}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
144
SetItem.tsx
144
SetItem.tsx
|
@ -1,99 +1,77 @@
|
||||||
import {NavigationProp, useNavigation} from '@react-navigation/native';
|
import { NavigationProp, useNavigation } from "@react-navigation/native";
|
||||||
import React, {useCallback, useState} from 'react';
|
import { format } from "date-fns";
|
||||||
import {GestureResponderEvent, Image} from 'react-native';
|
import { useCallback, useMemo } from "react";
|
||||||
import {Divider, List, Menu, Text} from 'react-native-paper';
|
import { Image } from "react-native";
|
||||||
import {HomePageParams} from './home-page-params';
|
import { List, Text } from "react-native-paper";
|
||||||
import Set from './set';
|
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants";
|
||||||
import {deleteSet} from './set.service';
|
import GymSet from "./gym-set";
|
||||||
|
import { HomePageParams } from "./home-page-params";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import useDark from "./use-dark";
|
||||||
|
|
||||||
export default function SetItem({
|
export default function SetItem({
|
||||||
item,
|
item,
|
||||||
onRemove,
|
settings,
|
||||||
dates,
|
ids,
|
||||||
setDates,
|
setIds,
|
||||||
images,
|
|
||||||
setImages,
|
|
||||||
}: {
|
}: {
|
||||||
item: Set;
|
item: GymSet;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
dates: boolean;
|
settings: Settings;
|
||||||
setDates: (value: boolean) => void;
|
ids: number[];
|
||||||
images: boolean;
|
setIds: (value: number[]) => void;
|
||||||
setImages: (value: boolean) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const dark = useDark();
|
||||||
const [anchor, setAnchor] = useState({x: 0, y: 0});
|
|
||||||
const navigation = useNavigation<NavigationProp<HomePageParams>>();
|
const navigation = useNavigation<NavigationProp<HomePageParams>>();
|
||||||
|
|
||||||
const remove = useCallback(async () => {
|
const longPress = useCallback(() => {
|
||||||
if (typeof item.id === 'number') await deleteSet(item.id);
|
if (ids.length > 0) return;
|
||||||
setShowMenu(false);
|
setIds([item.id]);
|
||||||
onRemove();
|
}, [ids.length, item.id, setIds]);
|
||||||
}, [setShowMenu, onRemove, item.id]);
|
|
||||||
|
|
||||||
const copy = useCallback(() => {
|
const press = useCallback(() => {
|
||||||
const set: Set = {...item};
|
if (ids.length === 0) return navigation.navigate("EditSet", { set: item });
|
||||||
delete set.id;
|
const removing = ids.find((id) => id === item.id);
|
||||||
setShowMenu(false);
|
if (removing) setIds(ids.filter((id) => id !== item.id));
|
||||||
navigation.navigate('EditSet', {set, workouts: [], count: 0});
|
else setIds([...ids, item.id]);
|
||||||
}, [navigation, item]);
|
}, [ids, item, navigation, setIds]);
|
||||||
|
|
||||||
const longPress = useCallback(
|
const backgroundColor = useMemo(() => {
|
||||||
(e: GestureResponderEvent) => {
|
if (!ids.includes(item.id)) return;
|
||||||
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
|
if (dark) return DARK_RIPPLE;
|
||||||
setShowMenu(true);
|
return LIGHT_RIPPLE;
|
||||||
},
|
}, [dark, ids, item.id]);
|
||||||
[setShowMenu, setAnchor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleDates = useCallback(() => {
|
const left = useCallback(() => {
|
||||||
setDates(!dates);
|
if (!settings.images || !item.image) return null;
|
||||||
setShowMenu(false);
|
return (
|
||||||
}, [dates, setDates]);
|
<Image source={{ uri: item.image }} style={{ height: 75, width: 75 }} />
|
||||||
|
);
|
||||||
|
}, [item.image, settings.images]);
|
||||||
|
|
||||||
const toggleImages = useCallback(() => {
|
const right = useCallback(() => {
|
||||||
setImages(!images);
|
if (!settings.showDate) return null;
|
||||||
setShowMenu(false);
|
return (
|
||||||
}, [images, setImages]);
|
<Text
|
||||||
|
style={{
|
||||||
|
alignSelf: "center",
|
||||||
|
color: dark ? "#909090ff" : "#717171ff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{format(new Date(item.created), settings.date || "P")}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}, [settings.showDate, item.created, settings.date, dark]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<List.Item
|
||||||
<List.Item
|
onPress={press}
|
||||||
onPress={() =>
|
title={item.name}
|
||||||
navigation.navigate('EditSet', {set: item, workouts: [], count: 0})
|
description={`${item.reps} x ${item.weight}${item.unit || "kg"}`}
|
||||||
}
|
onLongPress={longPress}
|
||||||
title={item.name}
|
style={{ backgroundColor }}
|
||||||
description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
|
left={left}
|
||||||
onLongPress={longPress}
|
right={right}
|
||||||
left={() =>
|
/>
|
||||||
images &&
|
|
||||||
item.image && (
|
|
||||||
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
right={() => (
|
|
||||||
<>
|
|
||||||
{dates && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
alignSelf: 'center',
|
|
||||||
}}>
|
|
||||||
{item.created?.replace('T', ' ')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Menu
|
|
||||||
anchor={anchor}
|
|
||||||
visible={showMenu}
|
|
||||||
onDismiss={() => setShowMenu(false)}>
|
|
||||||
<Menu.Item icon="content-copy" onPress={copy} title="Copy" />
|
|
||||||
<Menu.Item icon="image" onPress={toggleImages} title="Images" />
|
|
||||||
<Menu.Item icon="event" onPress={toggleDates} title="Dates" />
|
|
||||||
<Divider />
|
|
||||||
<Menu.Item icon="delete" onPress={remove} title="Delete" />
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
210
SetList.tsx
210
SetList.tsx
|
@ -2,139 +2,157 @@ import {
|
||||||
NavigationProp,
|
NavigationProp,
|
||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
} from '@react-navigation/native';
|
} from "@react-navigation/native";
|
||||||
import React, {useCallback, useEffect, 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 {getBestSet} from './best.service';
|
import { Like } from "typeorm";
|
||||||
import DrawerMenu from './DrawerMenu';
|
import { LIMIT } from "./constants";
|
||||||
import {HomePageParams} from './home-page-params';
|
import { getNow, setRepo, settingsRepo } from "./db";
|
||||||
import Page from './Page';
|
import DrawerHeader from "./DrawerHeader";
|
||||||
import {getTodaysPlan} from './plan.service';
|
import GymSet, { defaultSet } from "./gym-set";
|
||||||
import Set from './set';
|
import { HomePageParams } from "./home-page-params";
|
||||||
import {countToday, defaultSet, getSets, getToday} from './set.service';
|
import ListMenu from "./ListMenu";
|
||||||
import SetItem from './SetItem';
|
import Page from "./Page";
|
||||||
import {settings} from './settings.service';
|
import SetItem from "./SetItem";
|
||||||
|
import Settings from "./settings";
|
||||||
const limit = 15;
|
|
||||||
|
|
||||||
export default function SetList() {
|
export default function SetList() {
|
||||||
const [sets, setSets] = useState<Set[]>();
|
const [sets, setSets] = useState<GymSet[]>([]);
|
||||||
const [set, setSet] = useState<Set>();
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
const [workouts, setWorkouts] = useState<string[]>([]);
|
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
const [search, setSearch] = useState('');
|
const [term, setTerm] = useState("");
|
||||||
const [end, setEnd] = useState(false);
|
const [end, setEnd] = useState(false);
|
||||||
const [dates, setDates] = useState(false);
|
const [settings, setSettings] = useState<Settings>();
|
||||||
const [images, setImages] = useState(true);
|
const [ids, setIds] = useState<number[]>([]);
|
||||||
const navigation = useNavigation<NavigationProp<HomePageParams>>();
|
const navigation = useNavigation<NavigationProp<HomePageParams>>();
|
||||||
|
|
||||||
const predict = useCallback(async () => {
|
const refresh = useCallback(async (value: string) => {
|
||||||
setCount(0);
|
const newSets = await setRepo.find({
|
||||||
setSet({...defaultSet});
|
where: { name: Like(`%${value.trim()}%`), hidden: 0 as any },
|
||||||
if (!settings.predict) return;
|
take: LIMIT,
|
||||||
const todaysPlan = await getTodaysPlan();
|
skip: 0,
|
||||||
if (todaysPlan.length === 0) return;
|
order: { created: "DESC" },
|
||||||
const todaysWorkouts = todaysPlan[0].workouts.split(',');
|
});
|
||||||
setWorkouts(todaysWorkouts);
|
console.log(`${SetList.name}.refresh:`, { value });
|
||||||
let workout = todaysWorkouts[0];
|
|
||||||
let best = await getBestSet(workout);
|
|
||||||
const todaysSet = await getToday();
|
|
||||||
if (!todaysSet || !todaysWorkouts.includes(todaysSet.name))
|
|
||||||
return setSet(best);
|
|
||||||
let _count = await countToday(todaysSet.name);
|
|
||||||
workout = todaysSet.name;
|
|
||||||
best = await getBestSet(workout);
|
|
||||||
const index = todaysWorkouts.indexOf(todaysSet.name) + 1;
|
|
||||||
if (_count >= Number(best.sets)) {
|
|
||||||
best = await getBestSet(todaysWorkouts[index]);
|
|
||||||
_count = 0;
|
|
||||||
}
|
|
||||||
if (best.name === '') setCount(0);
|
|
||||||
else setCount(_count);
|
|
||||||
setSet(best);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
predict();
|
|
||||||
const newSets = await getSets({search: `%${search}%`, limit, offset: 0});
|
|
||||||
console.log(`${SetList.name}.refresh:`, {first: newSets[0]});
|
|
||||||
if (newSets.length === 0) return setSets([]);
|
|
||||||
setSets(newSets);
|
setSets(newSets);
|
||||||
setOffset(0);
|
setOffset(0);
|
||||||
setEnd(false);
|
setEnd(false);
|
||||||
}, [search, predict]);
|
}, []);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
refresh();
|
refresh(term);
|
||||||
navigation.getParent()?.setOptions({
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
headerRight: () => <DrawerMenu name="Home" />,
|
}, [refresh, term])
|
||||||
});
|
|
||||||
setImages(!!settings.images);
|
|
||||||
}, [refresh, navigation]),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, [search, refresh]);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({item}: {item: Set}) => (
|
({ item }: { item: GymSet }) => (
|
||||||
<SetItem
|
<SetItem
|
||||||
dates={dates}
|
settings={settings}
|
||||||
setDates={setDates}
|
|
||||||
images={images}
|
|
||||||
setImages={setImages}
|
|
||||||
item={item}
|
item={item}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onRemove={refresh}
|
onRemove={() => refresh(term)}
|
||||||
|
ids={ids}
|
||||||
|
setIds={setIds}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[refresh, dates, setDates, images, setImages],
|
[refresh, term, settings, ids]
|
||||||
);
|
);
|
||||||
|
|
||||||
const next = useCallback(async () => {
|
const next = useCallback(async () => {
|
||||||
if (end) return;
|
if (end) return;
|
||||||
const newOffset = offset + limit;
|
const newOffset = offset + LIMIT;
|
||||||
console.log(`${SetList.name}.next:`, {offset, newOffset, search});
|
console.log(`${SetList.name}.next:`, { offset, newOffset, term });
|
||||||
const newSets = await getSets({
|
const newSets = await setRepo.find({
|
||||||
search: `%${search}%`,
|
where: { name: Like(`%${term}%`), hidden: 0 as any },
|
||||||
limit,
|
take: LIMIT,
|
||||||
offset: newOffset,
|
skip: newOffset,
|
||||||
|
order: { created: "DESC" },
|
||||||
});
|
});
|
||||||
if (newSets.length === 0) return setEnd(true);
|
if (newSets.length === 0) return setEnd(true);
|
||||||
if (!sets) return;
|
if (!sets) return;
|
||||||
setSets([...sets, ...newSets]);
|
setSets([...sets, ...newSets]);
|
||||||
if (newSets.length < limit) return setEnd(true);
|
if (newSets.length < LIMIT) return setEnd(true);
|
||||||
setOffset(newOffset);
|
setOffset(newOffset);
|
||||||
}, [search, end, offset, sets]);
|
}, [term, end, offset, sets]);
|
||||||
|
|
||||||
const onAdd = useCallback(async () => {
|
const onAdd = useCallback(async () => {
|
||||||
console.log(`${SetList.name}.onAdd`, {set, defaultSet, workouts});
|
const now = await getNow();
|
||||||
navigation.navigate('EditSet', {
|
let set = sets[0];
|
||||||
set: set || {...defaultSet},
|
if (!set) set = { ...defaultSet };
|
||||||
workouts,
|
set.created = now;
|
||||||
count,
|
delete set.id;
|
||||||
|
navigation.navigate("EditSet", { set });
|
||||||
|
}, [navigation, sets]);
|
||||||
|
|
||||||
|
const search = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setTerm(value);
|
||||||
|
refresh(value);
|
||||||
|
},
|
||||||
|
[refresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const edit = useCallback(() => {
|
||||||
|
navigation.navigate("EditSets", { ids });
|
||||||
|
setIds([]);
|
||||||
|
}, [ids, navigation]);
|
||||||
|
|
||||||
|
const copy = useCallback(async () => {
|
||||||
|
const set = await setRepo.findOne({
|
||||||
|
where: { id: ids.pop() },
|
||||||
});
|
});
|
||||||
}, [navigation, set, workouts, count]);
|
delete set.id;
|
||||||
|
delete set.created;
|
||||||
|
navigation.navigate("EditSet", { set });
|
||||||
|
setIds([]);
|
||||||
|
}, [ids, navigation]);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setIds([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const remove = useCallback(async () => {
|
||||||
|
setIds([]);
|
||||||
|
await setRepo.delete(ids.length > 0 ? ids : {});
|
||||||
|
await refresh(term);
|
||||||
|
}, [ids, refresh, term]);
|
||||||
|
|
||||||
|
const select = useCallback(() => {
|
||||||
|
setIds(sets.map((set) => set.id));
|
||||||
|
}, [sets]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page onAdd={onAdd} search={search} setSearch={setSearch}>
|
<>
|
||||||
<FlatList
|
<DrawerHeader name={ids.length > 0 ? `${ids.length} selected` : "Home"}>
|
||||||
data={sets}
|
<ListMenu
|
||||||
style={{height: '99%'}}
|
onClear={clear}
|
||||||
ListEmptyComponent={
|
onCopy={copy}
|
||||||
|
onDelete={remove}
|
||||||
|
onEdit={edit}
|
||||||
|
ids={ids}
|
||||||
|
onSelect={select}
|
||||||
|
/>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<Page onAdd={onAdd} term={term} search={search}>
|
||||||
|
{sets?.length === 0 ? (
|
||||||
<List.Item
|
<List.Item
|
||||||
title="No sets yet"
|
title="No sets yet"
|
||||||
description="A set is a group of repetitions. E.g. 8 reps of Squats."
|
description="A set is a group of repetitions. E.g. 8 reps of Squats."
|
||||||
/>
|
/>
|
||||||
}
|
) : (
|
||||||
renderItem={renderItem}
|
settings && (
|
||||||
keyExtractor={s => s.id!.toString()}
|
<FlatList
|
||||||
onEndReached={next}
|
data={sets}
|
||||||
/>
|
style={{ flex: 1 }}
|
||||||
</Page>
|
renderItem={renderItem}
|
||||||
|
onEndReached={next}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
33
SettingButton.tsx
Normal file
33
SettingButton.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Button, Subheading } from "react-native-paper";
|
||||||
|
import { ITEM_PADDING } from "./constants";
|
||||||
|
|
||||||
|
export default function SettingButton({
|
||||||
|
name: text,
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
if (label) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingLeft: ITEM_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Subheading style={{ width: 100 }}>{label}</Subheading>
|
||||||
|
<Button onPress={onPress}>{text}</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button style={{ alignSelf: "flex-start" }} onPress={onPress}>
|
||||||
|
{text}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
503
SettingsPage.tsx
503
SettingsPage.tsx
|
@ -1,213 +1,352 @@
|
||||||
import {Picker} from '@react-native-picker/picker';
|
import { NavigationProp, useNavigation } from "@react-navigation/native";
|
||||||
import {useFocusEffect} from '@react-navigation/native';
|
import { format } from "date-fns";
|
||||||
import React, {useCallback, useContext, useEffect, useState} from 'react';
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {NativeModules, ScrollView} from 'react-native';
|
import { useForm } from "react-hook-form";
|
||||||
import DocumentPicker from 'react-native-document-picker';
|
import { NativeModules, ScrollView } from "react-native";
|
||||||
import {Button} from 'react-native-paper';
|
import DocumentPicker from "react-native-document-picker";
|
||||||
import {CustomTheme} from './App';
|
import { Dirs, FileSystem } from "react-native-file-access";
|
||||||
import {darkColors, lightColors} from './colors';
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import ConfirmDialog from './ConfirmDialog';
|
import { MARGIN } from "./constants";
|
||||||
import {MARGIN} from './constants';
|
import { AppDataSource } from "./data-source";
|
||||||
import Input from './input';
|
import { setRepo, settingsRepo } from "./db";
|
||||||
import {SnackbarContext} from './MassiveSnack';
|
import { DrawerParamList } from "./drawer-param-list";
|
||||||
import Page from './Page';
|
import DrawerHeader from "./DrawerHeader";
|
||||||
import {getSettings, settings, updateSettings} from './settings.service';
|
import Input from "./input";
|
||||||
import Switch from './Switch';
|
import { darkOptions, lightOptions, themeOptions } from "./options";
|
||||||
|
import Page from "./Page";
|
||||||
|
import Select from "./Select";
|
||||||
|
import SettingButton from "./SettingButton";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import Switch from "./Switch";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
import { useTheme } from "./use-theme";
|
||||||
|
|
||||||
|
const twelveHours = [
|
||||||
|
"dd/LL/yyyy",
|
||||||
|
"dd/LL/yyyy, p",
|
||||||
|
"ccc p",
|
||||||
|
"p",
|
||||||
|
"yyyy-MM-d",
|
||||||
|
"yyyy-MM-d, p",
|
||||||
|
"yyyy.MM.d",
|
||||||
|
];
|
||||||
|
const twentyFours = [
|
||||||
|
"dd/LL/yyyy",
|
||||||
|
"dd/LL/yyyy, k:m",
|
||||||
|
"ccc k:m",
|
||||||
|
"k:m",
|
||||||
|
"yyyy-MM-d",
|
||||||
|
"yyyy-MM-d, k:m",
|
||||||
|
"yyyy.MM.d",
|
||||||
|
];
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [battery, setBattery] = useState(false);
|
|
||||||
const [ignoring, setIgnoring] = useState(false);
|
const [ignoring, setIgnoring] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [term, setTerm] = useState("");
|
||||||
const [vibrate, setVibrate] = useState(!!settings.vibrate);
|
const [formatOptions, setFormatOptions] = useState<string[]>(twelveHours);
|
||||||
const [alarm, setAlarm] = useState(!!settings.alarm);
|
const [importing, setImporting] = useState(false);
|
||||||
const [predict, setPredict] = useState(!!settings.predict);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [sound, setSound] = useState(settings.sound);
|
const { reset } = useNavigation<NavigationProp<DrawerParamList>>();
|
||||||
const [notify, setNotify] = useState(!!settings.notify);
|
|
||||||
const [images, setImages] = useState(!!settings.images);
|
|
||||||
const [showUnit, setShowUnit] = useState(!!settings.showUnit);
|
|
||||||
const [workouts, setWorkouts] = useState(!!settings.workouts);
|
|
||||||
const [steps, setSteps] = useState(!!settings.steps);
|
|
||||||
const {color, setColor} = useContext(CustomTheme);
|
|
||||||
const {toast} = useContext(SnackbarContext);
|
|
||||||
|
|
||||||
useFocusEffect(
|
const { watch, setValue } = useForm<Settings>({
|
||||||
useCallback(() => {
|
defaultValues: () => settingsRepo.findOne({ where: {} }),
|
||||||
NativeModules.AlarmModule.ignoringBattery(setIgnoring);
|
});
|
||||||
}, []),
|
const settings = watch();
|
||||||
);
|
|
||||||
|
const {
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
lightColor,
|
||||||
|
setLightColor,
|
||||||
|
darkColor,
|
||||||
|
setDarkColor,
|
||||||
|
} = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateSettings({
|
NativeModules.SettingsModule.ignoringBattery(setIgnoring);
|
||||||
vibrate: +vibrate,
|
NativeModules.SettingsModule.is24().then((is24: boolean) => {
|
||||||
alarm: +alarm,
|
console.log(`${SettingsPage.name}.focus:`, { is24 });
|
||||||
predict: +predict,
|
if (is24) setFormatOptions(twentyFours);
|
||||||
sound,
|
else setFormatOptions(twelveHours);
|
||||||
notify: +notify,
|
|
||||||
images: +images,
|
|
||||||
showUnit: +showUnit,
|
|
||||||
color,
|
|
||||||
workouts: +workouts,
|
|
||||||
steps: +steps,
|
|
||||||
});
|
});
|
||||||
getSettings();
|
}, []);
|
||||||
}, [
|
|
||||||
vibrate,
|
|
||||||
alarm,
|
|
||||||
predict,
|
|
||||||
sound,
|
|
||||||
notify,
|
|
||||||
images,
|
|
||||||
showUnit,
|
|
||||||
color,
|
|
||||||
workouts,
|
|
||||||
steps,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const changeAlarmEnabled = useCallback(
|
const update = useCallback((key: keyof Settings, value: unknown) => {
|
||||||
(enabled: boolean) => {
|
return settingsRepo
|
||||||
setAlarm(enabled);
|
.createQueryBuilder()
|
||||||
if (enabled) toast('Timers will now run after each set.', 4000);
|
.update()
|
||||||
else toast('Stopped timers running after each set.', 4000);
|
.set({ [key]: value })
|
||||||
if (enabled && !ignoring) setBattery(true);
|
.printSql()
|
||||||
},
|
.execute();
|
||||||
[setBattery, ignoring, toast],
|
}, []);
|
||||||
);
|
|
||||||
|
|
||||||
const changePredict = useCallback(
|
const soundString = useMemo(() => {
|
||||||
(enabled: boolean) => {
|
if (!settings.sound) return null;
|
||||||
setPredict(enabled);
|
const split = settings.sound.split("/");
|
||||||
if (enabled) toast('Predict your next set based on todays plan.', 4000);
|
return split.pop();
|
||||||
else toast('New sets will always be empty.', 4000);
|
}, [settings.sound]);
|
||||||
},
|
|
||||||
[setPredict, toast],
|
|
||||||
);
|
|
||||||
|
|
||||||
const changeVibrate = useCallback(
|
|
||||||
(enabled: boolean) => {
|
|
||||||
setVibrate(enabled);
|
|
||||||
if (enabled) toast('When a timer completes, vibrate your phone.', 4000);
|
|
||||||
else toast('Stop vibrating at the end of timers.', 4000);
|
|
||||||
},
|
|
||||||
[setVibrate, toast],
|
|
||||||
);
|
|
||||||
|
|
||||||
const changeSound = useCallback(async () => {
|
const changeSound = useCallback(async () => {
|
||||||
const {fileCopyUri} = await DocumentPicker.pickSingle({
|
const { fileCopyUri } = await DocumentPicker.pickSingle({
|
||||||
type: 'audio/*',
|
type: DocumentPicker.types.audio,
|
||||||
copyTo: 'documentDirectory',
|
copyTo: "documentDirectory",
|
||||||
});
|
});
|
||||||
if (!fileCopyUri) return;
|
if (!fileCopyUri) return;
|
||||||
setSound(fileCopyUri);
|
setValue("sound", fileCopyUri);
|
||||||
toast('This song will now play after rest timers complete.', 4000);
|
await update("sound", fileCopyUri);
|
||||||
}, [toast]);
|
toast("Sound will play after rest timers.");
|
||||||
|
}, [setValue, update]);
|
||||||
|
|
||||||
const changeNotify = useCallback(
|
const switches: Input<boolean>[] = useMemo(
|
||||||
(enabled: boolean) => {
|
() => [
|
||||||
setNotify(enabled);
|
{ name: "Rest timers", value: settings.alarm, key: "alarm" },
|
||||||
if (enabled) toast('Show when a set is a new record.', 4000);
|
{ name: "Vibrate", value: settings.vibrate, key: "vibrate" },
|
||||||
else toast('Stopped showing notifications for new records.', 4000);
|
{ name: "Disable sound", value: settings.noSound, key: "noSound" },
|
||||||
},
|
{ name: "Notifications", value: settings.notify, key: "notify" },
|
||||||
[toast],
|
{ 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" },
|
||||||
|
{ name: "Automatic backup", value: settings.backup, key: "backup" },
|
||||||
|
],
|
||||||
|
[settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeImages = useCallback(
|
const filter = useCallback(
|
||||||
(enabled: boolean) => {
|
({ name }) => name.toLowerCase().includes(term.toLowerCase()),
|
||||||
setImages(enabled);
|
[term]
|
||||||
if (enabled) toast('Show images for sets.', 4000);
|
|
||||||
else toast('Stopped showing images for sets.', 4000);
|
|
||||||
},
|
|
||||||
[toast],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeUnit = useCallback(
|
const changeBoolean = useCallback(
|
||||||
(enabled: boolean) => {
|
async (key: keyof Settings, value: boolean) => {
|
||||||
setShowUnit(enabled);
|
setValue(key, value);
|
||||||
if (enabled) toast('Show option to select unit for sets.', 4000);
|
await update(key, value);
|
||||||
else toast('Hid unit option for sets.', 4000);
|
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;
|
||||||
|
case "backup":
|
||||||
|
if (value) {
|
||||||
|
const result = await DocumentPicker.pickDirectory();
|
||||||
|
toast("Backup database daily.");
|
||||||
|
NativeModules.BackupModule.start(result.uri);
|
||||||
|
} else {
|
||||||
|
toast("Stopped backing up daily");
|
||||||
|
NativeModules.BackupModule.stop();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[toast],
|
[ignoring, setValue, update]
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeWorkouts = useCallback(
|
const renderSwitch = useCallback(
|
||||||
(enabled: boolean) => {
|
(item: Input<boolean>) => (
|
||||||
setWorkouts(enabled);
|
<Switch
|
||||||
if (enabled) toast('Show workout for sets.', 4000);
|
key={item.name}
|
||||||
else toast('Stopped showing workout for sets.', 4000);
|
value={item.value}
|
||||||
},
|
onChange={(value) => changeBoolean(item.key, value)}
|
||||||
[toast],
|
title={item.name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[changeBoolean]
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeSteps = useCallback(
|
const switchesMarkup = useMemo(
|
||||||
(enabled: boolean) => {
|
() => switches.filter(filter).map((s) => renderSwitch(s)),
|
||||||
setSteps(enabled);
|
[filter, switches, renderSwitch]
|
||||||
if (enabled) toast('Show steps for a workout.', 4000);
|
|
||||||
else toast('Stopped showing steps for workouts.', 4000);
|
|
||||||
},
|
|
||||||
[toast],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const switches: Input<boolean>[] = [
|
const changeString = useCallback(
|
||||||
{name: 'Rest timers', value: alarm, onChange: changeAlarmEnabled},
|
async (key: keyof Settings, value: string) => {
|
||||||
{name: 'Vibrate', value: vibrate, onChange: changeVibrate},
|
setValue(key, value);
|
||||||
{name: 'Predict sets', value: predict, onChange: changePredict},
|
await update(key, value);
|
||||||
{name: 'Record notifications', value: notify, onChange: changeNotify},
|
switch (key) {
|
||||||
{name: 'Show images', value: images, onChange: changeImages},
|
case "date":
|
||||||
{name: 'Show unit', value: showUnit, onChange: changeUnit},
|
return toast("Changed date format");
|
||||||
{name: 'Show workouts', value: workouts, onChange: changeWorkouts},
|
case "darkColor":
|
||||||
{name: 'Show steps', value: steps, onChange: changeSteps},
|
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, darkColor, formatOptions, theme, lightColor]);
|
||||||
|
|
||||||
|
const renderSelect = useCallback(
|
||||||
|
(input: Input<string>) => (
|
||||||
|
<Select
|
||||||
|
key={input.name}
|
||||||
|
value={input.value}
|
||||||
|
onChange={(value) => changeString(input.key, value)}
|
||||||
|
label={input.name}
|
||||||
|
items={input.items}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[changeString]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectsMarkup = useMemo(
|
||||||
|
() => selects.filter(filter).map(renderSelect),
|
||||||
|
[filter, selects, renderSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmDelete = useCallback(async () => {
|
||||||
|
setDeleting(false);
|
||||||
|
await AppDataSource.dropDatabase();
|
||||||
|
await AppDataSource.destroy();
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
toast("Database deleted.");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmImport = useCallback(async () => {
|
||||||
|
setImporting(false);
|
||||||
|
await AppDataSource.destroy();
|
||||||
|
const file = await DocumentPicker.pickSingle();
|
||||||
|
await FileSystem.cp(file.uri, Dirs.DatabaseDir + "/massive.db");
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
await setRepo.createQueryBuilder().update().set({ image: null }).execute();
|
||||||
|
await update("sound", null);
|
||||||
|
const { alarm, backup } = await settingsRepo.findOne({ where: {} });
|
||||||
|
console.log({ backup });
|
||||||
|
const directory = await DocumentPicker.pickDirectory();
|
||||||
|
if (backup) NativeModules.BackupModule.start(directory.uri);
|
||||||
|
else NativeModules.BackupModule.stop();
|
||||||
|
NativeModules.SettingsModule.ignoringBattery((isIgnoring: boolean) => {
|
||||||
|
if (alarm && !isIgnoring) NativeModules.SettingsModule.ignoreBattery();
|
||||||
|
reset({ index: 0, routes: [{ name: "Settings" }] });
|
||||||
|
});
|
||||||
|
}, [reset, update]);
|
||||||
|
|
||||||
|
const exportDatabase = useCallback(async () => {
|
||||||
|
const path = Dirs.DatabaseDir + "/massive.db";
|
||||||
|
await FileSystem.cpExternal(path, "massive.db", "downloads");
|
||||||
|
toast("Database exported. Check downloads.");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buttons = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: soundString || "Default",
|
||||||
|
onPress: changeSound,
|
||||||
|
label: "Alarm sound",
|
||||||
|
},
|
||||||
|
{ name: "Export database", onPress: exportDatabase },
|
||||||
|
{ name: "Import database", onPress: () => setImporting(true) },
|
||||||
|
{ name: "Delete database", onPress: () => setDeleting(true) },
|
||||||
|
],
|
||||||
|
[changeSound, exportDatabase, soundString]
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttonsMarkup = useMemo(
|
||||||
|
() =>
|
||||||
|
buttons
|
||||||
|
.filter(filter)
|
||||||
|
.map((button) => <SettingButton {...button} key={button.name} />),
|
||||||
|
[buttons, filter]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page search={search} setSearch={setSearch}>
|
<>
|
||||||
<ScrollView style={{marginTop: MARGIN}}>
|
<DrawerHeader name="Settings" />
|
||||||
{switches
|
|
||||||
.filter(input =>
|
<Page term={term} search={setTerm} style={{ flexGrow: 1 }}>
|
||||||
input.name.toLowerCase().includes(search.toLowerCase()),
|
<ScrollView style={{ marginTop: MARGIN, flex: 1 }}>
|
||||||
)
|
{switchesMarkup}
|
||||||
.map(input => (
|
{selectsMarkup}
|
||||||
<Switch
|
{buttonsMarkup}
|
||||||
onPress={() => input.onChange(!input.value)}
|
</ScrollView>
|
||||||
key={input.name}
|
</Page>
|
||||||
value={input.value}
|
|
||||||
onValueChange={input.onChange}>
|
|
||||||
{input.name}
|
|
||||||
</Switch>
|
|
||||||
))}
|
|
||||||
{'theme'.includes(search.toLowerCase()) && (
|
|
||||||
<Picker
|
|
||||||
style={{color}}
|
|
||||||
dropdownIconColor={color}
|
|
||||||
selectedValue={color}
|
|
||||||
onValueChange={value => setColor(value)}>
|
|
||||||
{darkColors.concat(lightColors).map(colorOption => (
|
|
||||||
<Picker.Item
|
|
||||||
key={colorOption.hex}
|
|
||||||
value={colorOption.hex}
|
|
||||||
label={`${colorOption.name} theme`}
|
|
||||||
color={colorOption.hex}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Picker>
|
|
||||||
)}
|
|
||||||
{'alarm sound'.includes(search.toLowerCase()) && (
|
|
||||||
<Button style={{alignSelf: 'flex-start'}} onPress={changeSound}>
|
|
||||||
Alarm sound
|
|
||||||
{sound
|
|
||||||
? ': ' + sound.split('/')[sound.split('/').length - 1]
|
|
||||||
: null}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Battery optimizations"
|
title="Are you sure?"
|
||||||
show={battery}
|
onOk={confirmImport}
|
||||||
setShow={setBattery}
|
setShow={setImporting}
|
||||||
onOk={() => {
|
show={importing}
|
||||||
NativeModules.AlarmModule.ignoreBattery();
|
>
|
||||||
setBattery(false);
|
Importing a database overwrites your current data. This action cannot be
|
||||||
}}>
|
reversed!
|
||||||
Disable battery optimizations for Massive to use rest timers.
|
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
</Page>
|
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Are you sure?"
|
||||||
|
onOk={confirmDelete}
|
||||||
|
setShow={setDeleting}
|
||||||
|
show={deleting}
|
||||||
|
>
|
||||||
|
Deleting your database wipes your current data. This action cannot be
|
||||||
|
reversed!
|
||||||
|
</ConfirmDialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
20
StackHeader.tsx
Normal file
20
StackHeader.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { Appbar, IconButton } from "react-native-paper";
|
||||||
|
|
||||||
|
export default function StackHeader({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children?: JSX.Element | JSX.Element[];
|
||||||
|
}) {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Appbar.Header>
|
||||||
|
<IconButton icon="arrow-back" onPress={navigation.goBack} />
|
||||||
|
<Appbar.Content title={title} />
|
||||||
|
{children}
|
||||||
|
</Appbar.Header>
|
||||||
|
);
|
||||||
|
}
|
226
StartPlan.tsx
Normal file
226
StartPlan.tsx
Normal file
|
@ -0,0 +1,226 @@
|
||||||
|
import {
|
||||||
|
NavigationProp,
|
||||||
|
RouteProp,
|
||||||
|
useFocusEffect,
|
||||||
|
useNavigation,
|
||||||
|
useRoute,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { FlatList, NativeModules, TextInput, View } from "react-native";
|
||||||
|
import { Button, IconButton, ProgressBar } from "react-native-paper";
|
||||||
|
import AppInput from "./AppInput";
|
||||||
|
import { getBestSet } from "./best.service";
|
||||||
|
import { MARGIN, PADDING } from "./constants";
|
||||||
|
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 { PlanPageParams } from "./plan-page-params";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import StackHeader from "./StackHeader";
|
||||||
|
import StartPlanItem from "./StartPlanItem";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
|
||||||
|
export default function StartPlan() {
|
||||||
|
const { params } = useRoute<RouteProp<PlanPageParams, "StartPlan">>();
|
||||||
|
const [reps, setReps] = useState(params.first?.reps.toString() || "0");
|
||||||
|
const [weight, setWeight] = useState(params.first?.weight.toString() || "0");
|
||||||
|
const [unit, setUnit] = useState<string>(params.first?.unit || "kg");
|
||||||
|
const [selected, setSelected] = useState(0);
|
||||||
|
const [settings, setSettings] = useState<Settings>();
|
||||||
|
const [counts, setCounts] = useState<CountMany[]>();
|
||||||
|
const weightRef = useRef<TextInput>(null);
|
||||||
|
const repsRef = useRef<TextInput>(null);
|
||||||
|
const unitRef = useRef<TextInput>(null);
|
||||||
|
const workouts = useMemo(() => params.plan.workouts.split(","), [params]);
|
||||||
|
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
|
||||||
|
|
||||||
|
const [selection, setSelection] = useState({
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const questions = workouts
|
||||||
|
.map((workout, index) => `('${workout}',${index})`)
|
||||||
|
.join(",");
|
||||||
|
const select = `
|
||||||
|
SELECT workouts.name, COUNT(sets.id) as total, sets.sets
|
||||||
|
FROM (select 0 as name, 0 as sequence union values ${questions}) as workouts
|
||||||
|
LEFT JOIN sets ON sets.name = workouts.name
|
||||||
|
AND sets.created LIKE STRFTIME('%Y-%m-%d%%', 'now', 'localtime')
|
||||||
|
AND NOT sets.hidden
|
||||||
|
GROUP BY workouts.name
|
||||||
|
ORDER BY workouts.sequence
|
||||||
|
LIMIT -1
|
||||||
|
OFFSET 1
|
||||||
|
`;
|
||||||
|
const newCounts = await AppDataSource.manager.query(select);
|
||||||
|
console.log(`${StartPlan.name}.focus:`, { newCounts });
|
||||||
|
setCounts(newCounts);
|
||||||
|
}, [workouts]);
|
||||||
|
|
||||||
|
const select = useCallback(
|
||||||
|
async (index: number, newCounts?: CountMany[]) => {
|
||||||
|
setSelected(index);
|
||||||
|
if (!counts && !newCounts) return;
|
||||||
|
const workout = counts ? counts[index] : newCounts[index];
|
||||||
|
console.log(`${StartPlan.name}.next:`, { workout });
|
||||||
|
const last = await setRepo.findOne({
|
||||||
|
where: { name: workout.name },
|
||||||
|
order: { created: "desc" },
|
||||||
|
});
|
||||||
|
console.log({ last });
|
||||||
|
if (!last) return;
|
||||||
|
delete last.id;
|
||||||
|
console.log(`${StartPlan.name}.select:`, { last });
|
||||||
|
setReps(last.reps.toString());
|
||||||
|
setWeight(last.weight.toString());
|
||||||
|
setUnit(last.unit);
|
||||||
|
},
|
||||||
|
[counts]
|
||||||
|
);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
|
refresh();
|
||||||
|
}, [refresh])
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const now = await getNow();
|
||||||
|
const workout = counts[selected];
|
||||||
|
const best = await getBestSet(workout.name);
|
||||||
|
delete best.id;
|
||||||
|
const newSet: GymSet = {
|
||||||
|
...best,
|
||||||
|
weight: +weight,
|
||||||
|
reps: +reps,
|
||||||
|
unit,
|
||||||
|
created: now,
|
||||||
|
hidden: false,
|
||||||
|
};
|
||||||
|
await setRepo.save(newSet);
|
||||||
|
await refresh();
|
||||||
|
if (
|
||||||
|
settings.notify &&
|
||||||
|
(+weight > best.weight || (+reps > best.reps && +weight === best.weight))
|
||||||
|
) {
|
||||||
|
toast("Great work King! That's a new record.");
|
||||||
|
}
|
||||||
|
if (!settings.alarm) return;
|
||||||
|
const milliseconds =
|
||||||
|
Number(best.minutes) * 60 * 1000 + Number(best.seconds) * 1000;
|
||||||
|
NativeModules.AlarmModule.timer(milliseconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StackHeader title={params.plan.days.replace(/,/g, ", ")}>
|
||||||
|
<IconButton
|
||||||
|
onPress={() => navigation.navigate("EditPlan", { plan: params.plan })}
|
||||||
|
icon="edit"
|
||||||
|
/>
|
||||||
|
</StackHeader>
|
||||||
|
<View style={{ padding: PADDING, flex: 1, flexDirection: "column" }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: MARGIN,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppInput
|
||||||
|
label="Reps"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={reps}
|
||||||
|
onChangeText={(newReps) => {
|
||||||
|
const fixed = fixNumeric(newReps);
|
||||||
|
setReps(fixed);
|
||||||
|
if (fixed.length !== newReps.length)
|
||||||
|
toast("Reps must be a number");
|
||||||
|
}}
|
||||||
|
onSubmitEditing={() => weightRef.current?.focus()}
|
||||||
|
selection={selection}
|
||||||
|
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
|
||||||
|
innerRef={repsRef}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="add"
|
||||||
|
onPress={() => setReps((Number(reps) + 1).toString())}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="remove"
|
||||||
|
onPress={() => setReps((Number(reps) - 1).toString())}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: MARGIN,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppInput
|
||||||
|
label="Weight"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="add"
|
||||||
|
onPress={() => setWeight((Number(weight) + 2.5).toString())}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="remove"
|
||||||
|
onPress={() => setWeight((Number(weight) - 2.5).toString())}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{settings?.showUnit && (
|
||||||
|
<AppInput
|
||||||
|
autoCapitalize="none"
|
||||||
|
label="Unit"
|
||||||
|
value={unit}
|
||||||
|
onChangeText={setUnit}
|
||||||
|
innerRef={unitRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{counts && (
|
||||||
|
<FlatList
|
||||||
|
data={counts}
|
||||||
|
renderItem={(props) => (
|
||||||
|
<View>
|
||||||
|
<StartPlanItem
|
||||||
|
{...props}
|
||||||
|
onUndo={refresh}
|
||||||
|
onSelect={select}
|
||||||
|
selected={selected}
|
||||||
|
/>
|
||||||
|
<ProgressBar
|
||||||
|
progress={(props.item.total || 0) / (props.item.sets || 3)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Button mode="outlined" icon="save" onPress={handleSubmit}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
112
StartPlanItem.tsx
Normal file
112
StartPlanItem.tsx
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import { NavigationProp, useNavigation } from "@react-navigation/native";
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { GestureResponderEvent, ListRenderItemInfo, View } from "react-native";
|
||||||
|
import { List, Menu, RadioButton, useTheme } from "react-native-paper";
|
||||||
|
import { Like } from "typeorm";
|
||||||
|
import CountMany from "./count-many";
|
||||||
|
import { getNow, setRepo } from "./db";
|
||||||
|
import { PlanPageParams } from "./plan-page-params";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
|
||||||
|
interface Props extends ListRenderItemInfo<CountMany> {
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
selected: number;
|
||||||
|
onUndo: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StartPlanItem(props: Props) {
|
||||||
|
const { index, item, onSelect, selected, onUndo } = props;
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const [anchor, setAnchor] = useState({ x: 0, y: 0 });
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
const { navigate } = useNavigation<NavigationProp<PlanPageParams>>();
|
||||||
|
|
||||||
|
const undo = useCallback(async () => {
|
||||||
|
const now = await getNow();
|
||||||
|
const created = now.split("T")[0];
|
||||||
|
const first = await setRepo.findOne({
|
||||||
|
where: {
|
||||||
|
name: item.name,
|
||||||
|
hidden: 0 as any,
|
||||||
|
created: Like(`${created}%`),
|
||||||
|
},
|
||||||
|
order: { created: "desc" },
|
||||||
|
});
|
||||||
|
setShowMenu(false);
|
||||||
|
if (!first) return toast("Nothing to undo.");
|
||||||
|
await setRepo.delete(first.id);
|
||||||
|
onUndo();
|
||||||
|
}, [setShowMenu, onUndo, item.name]);
|
||||||
|
|
||||||
|
const longPress = useCallback(
|
||||||
|
(e: GestureResponderEvent) => {
|
||||||
|
setAnchor({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
|
||||||
|
setShowMenu(true);
|
||||||
|
},
|
||||||
|
[setShowMenu, setAnchor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const edit = useCallback(async () => {
|
||||||
|
const now = await getNow();
|
||||||
|
const created = now.split("T")[0];
|
||||||
|
const first = await setRepo.findOne({
|
||||||
|
where: {
|
||||||
|
name: item.name,
|
||||||
|
hidden: 0 as any,
|
||||||
|
created: Like(`${created}%`),
|
||||||
|
},
|
||||||
|
order: { created: "desc" },
|
||||||
|
});
|
||||||
|
setShowMenu(false);
|
||||||
|
if (!first) return toast("Nothing to edit.");
|
||||||
|
navigate("EditSet", { set: first });
|
||||||
|
}, [item.name, navigate]);
|
||||||
|
|
||||||
|
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="edit" onPress={edit} title="Edit" />
|
||||||
|
<Menu.Item leadingIcon="undo" onPress={undo} title="Undo" />
|
||||||
|
</Menu>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[anchor, showMenu, edit, undo]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
onLongPress={longPress}
|
||||||
|
title={item.name}
|
||||||
|
description={
|
||||||
|
item.sets ? `${item.total} / ${item.sets}` : item.total.toString()
|
||||||
|
}
|
||||||
|
onPress={() => onSelect(index)}
|
||||||
|
left={left}
|
||||||
|
right={right}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
49
Switch.tsx
49
Switch.tsx
|
@ -1,37 +1,42 @@
|
||||||
import React, {useContext} from 'react';
|
import React from "react";
|
||||||
import {Pressable} from 'react-native';
|
import { Platform, Pressable } from "react-native";
|
||||||
import {Switch as PaperSwitch, Text} from 'react-native-paper';
|
import { Switch as PaperSwitch, Text, useTheme } from "react-native-paper";
|
||||||
import {CustomTheme} from './App';
|
import { MARGIN } from "./constants";
|
||||||
import {MARGIN} from './constants';
|
|
||||||
|
|
||||||
export default function Switch({
|
function Switch({
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onChange,
|
||||||
onPress,
|
title,
|
||||||
children,
|
|
||||||
}: {
|
}: {
|
||||||
value?: boolean;
|
value?: boolean;
|
||||||
onValueChange: (value: boolean) => void;
|
onChange: (value: boolean) => void;
|
||||||
onPress: () => void;
|
title: string;
|
||||||
children: string;
|
|
||||||
}) {
|
}) {
|
||||||
const {color} = useContext(CustomTheme);
|
const { colors } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={() => onChange(!value)}
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
flexWrap: 'wrap',
|
flexWrap: "wrap",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
}}>
|
marginBottom: Platform.OS === "ios" ? MARGIN : null,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PaperSwitch
|
<PaperSwitch
|
||||||
color={color}
|
color={colors.primary}
|
||||||
style={{marginRight: MARGIN}}
|
style={{ marginRight: MARGIN }}
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={onValueChange}
|
onValueChange={onChange}
|
||||||
|
trackColor={{
|
||||||
|
true: colors.primary + "80",
|
||||||
|
false: colors.surfaceDisabled,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Text>{children}</Text>
|
<Text>{title}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo(Switch);
|
||||||
|
|
75
TimerPage.tsx
Normal file
75
TimerPage.tsx
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
110
ViewBest.tsx
110
ViewBest.tsx
|
@ -1,110 +0,0 @@
|
||||||
import {Picker} from '@react-native-picker/picker';
|
|
||||||
import {
|
|
||||||
RouteProp,
|
|
||||||
useFocusEffect,
|
|
||||||
useNavigation,
|
|
||||||
useRoute,
|
|
||||||
} from '@react-navigation/native';
|
|
||||||
import React, {useCallback, useEffect, useState} from 'react';
|
|
||||||
import {useColorScheme, View} from 'react-native';
|
|
||||||
import {FileSystem} from 'react-native-file-access';
|
|
||||||
import {IconButton} from 'react-native-paper';
|
|
||||||
import Share from 'react-native-share';
|
|
||||||
import {captureScreen} from 'react-native-view-shot';
|
|
||||||
import {getVolumes, getWeightsBy} from './best.service';
|
|
||||||
import {BestPageParams} from './BestPage';
|
|
||||||
import Chart from './Chart';
|
|
||||||
import {PADDING} from './constants';
|
|
||||||
import {Metrics} from './metrics';
|
|
||||||
import {Periods} from './periods';
|
|
||||||
import Set from './set';
|
|
||||||
import {formatMonth} from './time';
|
|
||||||
import Volume from './volume';
|
|
||||||
|
|
||||||
export default function ViewBest() {
|
|
||||||
const {params} = useRoute<RouteProp<BestPageParams, 'ViewBest'>>();
|
|
||||||
const dark = useColorScheme() === 'dark';
|
|
||||||
const [weights, setWeights] = useState<Set[]>([]);
|
|
||||||
const [volumes, setVolumes] = useState<Volume[]>([]);
|
|
||||||
const [metric, setMetric] = useState(Metrics.Weight);
|
|
||||||
const [period, setPeriod] = useState(Periods.Monthly);
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
console.log(`${ViewBest.name}.useFocusEffect`);
|
|
||||||
navigation.getParent()?.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<IconButton icon="arrow-back" onPress={() => navigation.goBack()} />
|
|
||||||
),
|
|
||||||
headerRight: () => (
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
title: params.best.name,
|
|
||||||
});
|
|
||||||
}, [navigation, params.best]),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (metric === Metrics.Weight)
|
|
||||||
getWeightsBy(params.best.name, period).then(setWeights);
|
|
||||||
else if (metric === Metrics.Volume)
|
|
||||||
getVolumes(params.best.name, period).then(setVolumes);
|
|
||||||
console.log(`${ViewBest.name}.useEffect`, {metric});
|
|
||||||
console.log(`${ViewBest.name}.useEffect`, {period});
|
|
||||||
}, [params.best.name, metric, period]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{padding: PADDING}}>
|
|
||||||
<Picker
|
|
||||||
style={{color: dark ? 'white' : 'black'}}
|
|
||||||
dropdownIconColor={dark ? 'white' : 'black'}
|
|
||||||
selectedValue={metric}
|
|
||||||
onValueChange={value => setMetric(value)}>
|
|
||||||
<Picker.Item value={Metrics.Volume} label={Metrics.Volume} />
|
|
||||||
<Picker.Item value={Metrics.Weight} label={Metrics.Weight} />
|
|
||||||
</Picker>
|
|
||||||
<Picker
|
|
||||||
style={{color: dark ? 'white' : 'black'}}
|
|
||||||
dropdownIconColor={dark ? 'white' : 'black'}
|
|
||||||
selectedValue={period}
|
|
||||||
onValueChange={value => setPeriod(value)}>
|
|
||||||
<Picker.Item value={Periods.Weekly} label={Periods.Weekly} />
|
|
||||||
<Picker.Item value={Periods.Monthly} label={Periods.Monthly} />
|
|
||||||
<Picker.Item value={Periods.Yearly} label={Periods.Yearly} />
|
|
||||||
</Picker>
|
|
||||||
{metric === Metrics.Volume && (
|
|
||||||
<Chart
|
|
||||||
yData={volumes.map(v => v.value)}
|
|
||||||
yFormat={(value: number) =>
|
|
||||||
`${value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${
|
|
||||||
volumes[0].unit
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
xData={weights}
|
|
||||||
xFormat={(_value, index) => formatMonth(weights[index].created!)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{metric === Metrics.Weight && (
|
|
||||||
<Chart
|
|
||||||
yData={weights.map(set => set.weight)}
|
|
||||||
yFormat={value => `${value}${weights[0].unit}`}
|
|
||||||
xData={weights}
|
|
||||||
xFormat={(_value, index) => formatMonth(weights[index].created!)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
154
ViewGraph.tsx
Normal file
154
ViewGraph.tsx
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
import { RouteProp, useRoute } from "@react-navigation/native";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { 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 Chart from "./Chart";
|
||||||
|
import { GraphsPageParams } from "./GraphsPage";
|
||||||
|
import Select from "./Select";
|
||||||
|
import StackHeader from "./StackHeader";
|
||||||
|
import { PADDING } from "./constants";
|
||||||
|
import { setRepo } from "./db";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
import { Metrics } from "./metrics";
|
||||||
|
import { Periods } from "./periods";
|
||||||
|
import Volume from "./volume";
|
||||||
|
|
||||||
|
export default function ViewGraph() {
|
||||||
|
const { params } = useRoute<RouteProp<GraphsPageParams, "ViewGraph">>();
|
||||||
|
const [weights, setWeights] = useState<GymSet[]>();
|
||||||
|
const [volumes, setVolumes] = useState<Volume[]>();
|
||||||
|
const [metric, setMetric] = useState(Metrics.Weight);
|
||||||
|
const [period, setPeriod] = useState(Periods.Monthly);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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}>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
112
WorkoutItem.tsx
112
WorkoutItem.tsx
|
@ -1,79 +1,93 @@
|
||||||
import {NavigationProp, useNavigation} from '@react-navigation/native';
|
import { NavigationProp, useNavigation } from "@react-navigation/native";
|
||||||
import React, {useCallback, useState} from 'react';
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {GestureResponderEvent, Image} from 'react-native';
|
import { GestureResponderEvent, Image } from "react-native";
|
||||||
import {List, Menu, Text} from 'react-native-paper';
|
import { List, Menu, Text } from "react-native-paper";
|
||||||
import ConfirmDialog from './ConfirmDialog';
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import Set from './set';
|
import { setRepo } from "./db";
|
||||||
import {deleteSetsBy} from './set.service';
|
import GymSet from "./gym-set";
|
||||||
import {WorkoutsPageParams} from './WorkoutsPage';
|
import { WorkoutsPageParams } from "./WorkoutsPage";
|
||||||
|
|
||||||
export default function WorkoutItem({
|
export default function WorkoutItem({
|
||||||
item,
|
item,
|
||||||
onRemoved,
|
onRemove,
|
||||||
|
images,
|
||||||
}: {
|
}: {
|
||||||
item: Set;
|
item: GymSet;
|
||||||
onRemoved: () => void;
|
onRemove: () => void;
|
||||||
|
images: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const [anchor, setAnchor] = useState({x: 0, y: 0});
|
const [anchor, setAnchor] = useState({ x: 0, y: 0 });
|
||||||
const [showRemove, setShowRemove] = useState('');
|
const [showRemove, setShowRemove] = useState("");
|
||||||
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
|
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
|
||||||
|
|
||||||
const remove = useCallback(async () => {
|
const remove = useCallback(async () => {
|
||||||
await deleteSetsBy(item.name);
|
await setRepo.delete({ name: item.name });
|
||||||
setShowMenu(false);
|
setShowMenu(false);
|
||||||
onRemoved();
|
onRemove();
|
||||||
}, [setShowMenu, onRemoved, item.name]);
|
}, [setShowMenu, onRemove, 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 minutes = item.minutes?.toString().padStart(2, '0');
|
const description = useMemo(() => {
|
||||||
const seconds = item.seconds?.toString().padStart(2, '0');
|
const seconds = item.seconds?.toString().padStart(2, "0");
|
||||||
|
return `${item.sets} x ${item.minutes || 0}:${seconds}`;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const left = useCallback(() => {
|
||||||
|
if (!images || !item.image) return null;
|
||||||
|
return (
|
||||||
|
<Image source={{ uri: item.image }} style={{ height: 75, width: 75 }} />
|
||||||
|
);
|
||||||
|
}, [item.image, images]);
|
||||||
|
|
||||||
|
const right = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
alignSelf: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
anchor={anchor}
|
||||||
|
visible={showMenu}
|
||||||
|
onDismiss={() => setShowMenu(false)}
|
||||||
|
>
|
||||||
|
<Menu.Item
|
||||||
|
leadingIcon="delete"
|
||||||
|
onPress={() => {
|
||||||
|
setShowRemove(item.name);
|
||||||
|
setShowMenu(false);
|
||||||
|
}}
|
||||||
|
title="Delete"
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}, [anchor, showMenu, item.name]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<List.Item
|
<List.Item
|
||||||
onPress={() => navigation.navigate('EditWorkout', {value: item})}
|
onPress={() => navigation.navigate("EditWorkout", { value: item })}
|
||||||
title={item.name}
|
title={item.name}
|
||||||
description={`${item.sets} sets ${minutes}:${seconds} rest`}
|
description={description}
|
||||||
onLongPress={longPress}
|
onLongPress={longPress}
|
||||||
left={() =>
|
left={left}
|
||||||
item.image && (
|
right={right}
|
||||||
<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
|
<ConfirmDialog
|
||||||
title={`Delete ${showRemove}`}
|
title={`Delete ${showRemove}`}
|
||||||
show={!!showRemove}
|
show={!!showRemove}
|
||||||
setShow={show => (show ? null : setShowRemove(''))}
|
setShow={(show) => (show ? null : setShowRemove(""))}
|
||||||
onOk={remove}>
|
onOk={remove}
|
||||||
|
>
|
||||||
This irreversibly deletes ALL sets related to this workout. Are you
|
This irreversibly deletes ALL sets related to this workout. Are you
|
||||||
sure?
|
sure?
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
131
WorkoutList.tsx
131
WorkoutList.tsx
|
@ -2,97 +2,120 @@ import {
|
||||||
NavigationProp,
|
NavigationProp,
|
||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
} from '@react-navigation/native';
|
} from "@react-navigation/native";
|
||||||
import React, {useCallback, useEffect, 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 Page from './Page';
|
import { LIMIT } from "./constants";
|
||||||
import Set from './set';
|
import { setRepo, settingsRepo } from "./db";
|
||||||
import {getDistinctSets} from './set.service';
|
import DrawerHeader from "./DrawerHeader";
|
||||||
import SetList from './SetList';
|
import GymSet from "./gym-set";
|
||||||
import WorkoutItem from './WorkoutItem';
|
import Page from "./Page";
|
||||||
import {WorkoutsPageParams} from './WorkoutsPage';
|
import SetList from "./SetList";
|
||||||
|
import Settings from "./settings";
|
||||||
const limit = 15;
|
import WorkoutItem from "./WorkoutItem";
|
||||||
|
import { WorkoutsPageParams } from "./WorkoutsPage";
|
||||||
|
|
||||||
export default function WorkoutList() {
|
export default function WorkoutList() {
|
||||||
const [workouts, setWorkouts] = useState<Set[]>();
|
const [workouts, setWorkouts] = useState<GymSet[]>();
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
const [search, setSearch] = useState('');
|
const [term, setTerm] = useState("");
|
||||||
const [end, setEnd] = useState(false);
|
const [end, setEnd] = useState(false);
|
||||||
|
const [settings, setSettings] = useState<Settings>();
|
||||||
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
|
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async (value: string) => {
|
||||||
const newWorkouts = await getDistinctSets({
|
const newWorkouts = await setRepo
|
||||||
search: `%${search}%`,
|
.createQueryBuilder()
|
||||||
limit,
|
.select()
|
||||||
offset: 0,
|
.where("name LIKE :name", { name: `%${value.trim()}%` })
|
||||||
});
|
.groupBy("name")
|
||||||
console.log(`${WorkoutList.name}`, {newWorkout: newWorkouts[0]});
|
.orderBy("name")
|
||||||
|
.limit(LIMIT)
|
||||||
|
.getMany();
|
||||||
|
console.log(`${WorkoutList.name}`, { newWorkout: newWorkouts[0] });
|
||||||
setWorkouts(newWorkouts);
|
setWorkouts(newWorkouts);
|
||||||
setOffset(0);
|
setOffset(0);
|
||||||
setEnd(false);
|
setEnd(false);
|
||||||
}, [search]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
}, [search, refresh]);
|
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
refresh();
|
refresh(term);
|
||||||
}, [refresh]),
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
|
}, [refresh, term])
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({item}: {item: Set}) => (
|
({ item }: { item: GymSet }) => (
|
||||||
<WorkoutItem item={item} key={item.name} onRemoved={refresh} />
|
<WorkoutItem
|
||||||
|
images={settings?.images}
|
||||||
|
item={item}
|
||||||
|
key={item.name}
|
||||||
|
onRemove={() => refresh(term)}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
[refresh],
|
[refresh, term, settings?.images]
|
||||||
);
|
);
|
||||||
|
|
||||||
const next = useCallback(async () => {
|
const next = useCallback(async () => {
|
||||||
if (end) return;
|
if (end) return;
|
||||||
const newOffset = offset + limit;
|
const newOffset = offset + LIMIT;
|
||||||
console.log(`${SetList.name}.next:`, {
|
console.log(`${SetList.name}.next:`, {
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit: LIMIT,
|
||||||
newOffset,
|
newOffset,
|
||||||
search,
|
term,
|
||||||
});
|
|
||||||
const newWorkouts = await getDistinctSets({
|
|
||||||
search: `%${search}%`,
|
|
||||||
limit,
|
|
||||||
offset: newOffset,
|
|
||||||
});
|
});
|
||||||
|
const newWorkouts = await setRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select()
|
||||||
|
.where("name LIKE :name", { name: `%${term.trim()}%` })
|
||||||
|
.groupBy("name")
|
||||||
|
.orderBy("name")
|
||||||
|
.limit(LIMIT)
|
||||||
|
.offset(newOffset)
|
||||||
|
.getMany();
|
||||||
if (newWorkouts.length === 0) return setEnd(true);
|
if (newWorkouts.length === 0) return setEnd(true);
|
||||||
if (!workouts) return;
|
if (!workouts) return;
|
||||||
setWorkouts([...workouts, ...newWorkouts]);
|
setWorkouts([...workouts, ...newWorkouts]);
|
||||||
if (newWorkouts.length < limit) return setEnd(true);
|
if (newWorkouts.length < LIMIT) return setEnd(true);
|
||||||
setOffset(newOffset);
|
setOffset(newOffset);
|
||||||
}, [search, end, offset, workouts]);
|
}, [term, end, offset, workouts]);
|
||||||
|
|
||||||
const onAdd = useCallback(async () => {
|
const onAdd = useCallback(async () => {
|
||||||
navigation.navigate('EditWorkout', {
|
navigation.navigate("EditWorkout", {
|
||||||
value: {name: '', sets: 3, image: '', steps: '', reps: 0, weight: 0},
|
value: new GymSet(),
|
||||||
});
|
});
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
|
const search = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setTerm(value);
|
||||||
|
refresh(value);
|
||||||
|
},
|
||||||
|
[refresh]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page onAdd={onAdd} search={search} setSearch={setSearch}>
|
<>
|
||||||
<FlatList
|
<DrawerHeader name="Workouts" />
|
||||||
data={workouts}
|
<Page onAdd={onAdd} term={term} search={search}>
|
||||||
style={{height: '99%'}}
|
{workouts?.length === 0 ? (
|
||||||
ListEmptyComponent={
|
|
||||||
<List.Item
|
<List.Item
|
||||||
title="No workouts yet."
|
title="No workouts yet."
|
||||||
description="A workout is something you do at the gym. For example Deadlifts are a workout."
|
description="A workout is something you do at the gym. For example Deadlifts are a workout."
|
||||||
/>
|
/>
|
||||||
}
|
) : (
|
||||||
renderItem={renderItem}
|
<FlatList
|
||||||
keyExtractor={w => w.name}
|
data={workouts}
|
||||||
onEndReached={next}
|
style={{ flex: 1 }}
|
||||||
/>
|
renderItem={renderItem}
|
||||||
</Page>
|
keyExtractor={(w) => w.name}
|
||||||
|
onEndReached={next}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,43 +1,24 @@
|
||||||
import {DrawerNavigationProp} from '@react-navigation/drawer';
|
import { createStackNavigator } from "@react-navigation/stack";
|
||||||
import {useNavigation} from '@react-navigation/native';
|
import EditWorkout from "./EditWorkout";
|
||||||
import {createStackNavigator} from '@react-navigation/stack';
|
import GymSet from "./gym-set";
|
||||||
import React from 'react';
|
import WorkoutList from "./WorkoutList";
|
||||||
import {IconButton} from 'react-native-paper';
|
|
||||||
import {DrawerParamList} from './drawer-param-list';
|
|
||||||
import EditWorkout from './EditWorkout';
|
|
||||||
import Set from './set';
|
|
||||||
import WorkoutList from './WorkoutList';
|
|
||||||
|
|
||||||
export type WorkoutsPageParams = {
|
export type WorkoutsPageParams = {
|
||||||
WorkoutList: {};
|
WorkoutList: {};
|
||||||
EditWorkout: {
|
EditWorkout: {
|
||||||
value: Set;
|
value: GymSet;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const Stack = createStackNavigator<WorkoutsPageParams>();
|
const Stack = createStackNavigator<WorkoutsPageParams>();
|
||||||
|
|
||||||
export default function WorkoutsPage() {
|
export default function WorkoutsPage() {
|
||||||
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack.Navigator
|
<Stack.Navigator
|
||||||
screenOptions={{headerShown: false, animationEnabled: false}}>
|
screenOptions={{ headerShown: false, animationEnabled: false }}
|
||||||
|
>
|
||||||
<Stack.Screen name="WorkoutList" component={WorkoutList} />
|
<Stack.Screen name="WorkoutList" component={WorkoutList} />
|
||||||
<Stack.Screen
|
<Stack.Screen name="EditWorkout" component={EditWorkout} />
|
||||||
name="EditWorkout"
|
|
||||||
component={EditWorkout}
|
|
||||||
listeners={{
|
|
||||||
beforeRemove: () => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<IconButton icon="menu" onPress={navigation.openDrawer} />
|
|
||||||
),
|
|
||||||
title: 'Workouts',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
3
android/Gemfile
Normal file
3
android/Gemfile
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "fastlane"
|
219
android/Gemfile.lock
Normal file
219
android/Gemfile.lock
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
GEM
|
||||||
|
remote: https://rubygems.org/
|
||||||
|
specs:
|
||||||
|
CFPropertyList (3.0.6)
|
||||||
|
rexml
|
||||||
|
addressable (2.8.4)
|
||||||
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
|
artifactory (3.0.15)
|
||||||
|
atomos (0.1.3)
|
||||||
|
aws-eventstream (1.2.0)
|
||||||
|
aws-partitions (1.780.0)
|
||||||
|
aws-sdk-core (3.175.0)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
|
aws-partitions (~> 1, >= 1.651.0)
|
||||||
|
aws-sigv4 (~> 1.5)
|
||||||
|
jmespath (~> 1, >= 1.6.1)
|
||||||
|
aws-sdk-kms (1.67.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.174.0)
|
||||||
|
aws-sigv4 (~> 1.1)
|
||||||
|
aws-sdk-s3 (1.126.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.174.0)
|
||||||
|
aws-sdk-kms (~> 1)
|
||||||
|
aws-sigv4 (~> 1.4)
|
||||||
|
aws-sigv4 (1.5.2)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
|
babosa (1.0.4)
|
||||||
|
claide (1.1.0)
|
||||||
|
colored (1.2)
|
||||||
|
colored2 (3.1.2)
|
||||||
|
commander (4.6.0)
|
||||||
|
highline (~> 2.0.0)
|
||||||
|
declarative (0.0.20)
|
||||||
|
digest-crc (0.6.4)
|
||||||
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
|
domain_name (0.5.20190701)
|
||||||
|
unf (>= 0.0.5, < 1.0.0)
|
||||||
|
dotenv (2.8.1)
|
||||||
|
emoji_regex (3.2.3)
|
||||||
|
excon (0.100.0)
|
||||||
|
faraday (1.10.3)
|
||||||
|
faraday-em_http (~> 1.0)
|
||||||
|
faraday-em_synchrony (~> 1.0)
|
||||||
|
faraday-excon (~> 1.1)
|
||||||
|
faraday-httpclient (~> 1.0)
|
||||||
|
faraday-multipart (~> 1.0)
|
||||||
|
faraday-net_http (~> 1.0)
|
||||||
|
faraday-net_http_persistent (~> 1.0)
|
||||||
|
faraday-patron (~> 1.0)
|
||||||
|
faraday-rack (~> 1.0)
|
||||||
|
faraday-retry (~> 1.0)
|
||||||
|
ruby2_keywords (>= 0.0.4)
|
||||||
|
faraday-cookie_jar (0.0.7)
|
||||||
|
faraday (>= 0.8.0)
|
||||||
|
http-cookie (~> 1.0.0)
|
||||||
|
faraday-em_http (1.0.0)
|
||||||
|
faraday-em_synchrony (1.0.0)
|
||||||
|
faraday-excon (1.1.0)
|
||||||
|
faraday-httpclient (1.0.1)
|
||||||
|
faraday-multipart (1.0.4)
|
||||||
|
multipart-post (~> 2)
|
||||||
|
faraday-net_http (1.0.1)
|
||||||
|
faraday-net_http_persistent (1.2.0)
|
||||||
|
faraday-patron (1.0.0)
|
||||||
|
faraday-rack (1.0.0)
|
||||||
|
faraday-retry (1.0.3)
|
||||||
|
faraday_middleware (1.2.0)
|
||||||
|
faraday (~> 1.0)
|
||||||
|
fastimage (2.2.7)
|
||||||
|
fastlane (2.213.0)
|
||||||
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
|
addressable (>= 2.8, < 3.0.0)
|
||||||
|
artifactory (~> 3.0)
|
||||||
|
aws-sdk-s3 (~> 1.0)
|
||||||
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
|
bundler (>= 1.12.0, < 3.0.0)
|
||||||
|
colored
|
||||||
|
commander (~> 4.6)
|
||||||
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
|
faraday (~> 1.0)
|
||||||
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
|
faraday_middleware (~> 1.0)
|
||||||
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
|
google-cloud-storage (~> 1.31)
|
||||||
|
highline (~> 2.0)
|
||||||
|
json (< 3.0.0)
|
||||||
|
jwt (>= 2.1.0, < 3)
|
||||||
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
|
naturally (~> 2.2)
|
||||||
|
optparse (~> 0.1.1)
|
||||||
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
|
security (= 0.1.3)
|
||||||
|
simctl (~> 1.6.3)
|
||||||
|
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||||
|
terminal-table (>= 1.4.5, < 2.0.0)
|
||||||
|
tty-screen (>= 0.6.3, < 1.0.0)
|
||||||
|
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||||
|
word_wrap (~> 1.0.0)
|
||||||
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
|
xcpretty (~> 0.3.0)
|
||||||
|
xcpretty-travis-formatter (>= 0.0.3)
|
||||||
|
gh_inspector (1.1.3)
|
||||||
|
google-apis-androidpublisher_v3 (0.43.0)
|
||||||
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
|
google-apis-core (0.11.0)
|
||||||
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
|
httpclient (>= 2.8.1, < 3.a)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
representable (~> 3.0)
|
||||||
|
retriable (>= 2.0, < 4.a)
|
||||||
|
rexml
|
||||||
|
webrick
|
||||||
|
google-apis-iamcredentials_v1 (0.17.0)
|
||||||
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
|
google-apis-playcustomapp_v1 (0.13.0)
|
||||||
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
|
google-apis-storage_v1 (0.19.0)
|
||||||
|
google-apis-core (>= 0.9.0, < 2.a)
|
||||||
|
google-cloud-core (1.6.0)
|
||||||
|
google-cloud-env (~> 1.0)
|
||||||
|
google-cloud-errors (~> 1.0)
|
||||||
|
google-cloud-env (1.6.0)
|
||||||
|
faraday (>= 0.17.3, < 3.0)
|
||||||
|
google-cloud-errors (1.3.1)
|
||||||
|
google-cloud-storage (1.44.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
digest-crc (~> 0.4)
|
||||||
|
google-apis-iamcredentials_v1 (~> 0.1)
|
||||||
|
google-apis-storage_v1 (~> 0.19.0)
|
||||||
|
google-cloud-core (~> 1.6)
|
||||||
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
googleauth (1.5.2)
|
||||||
|
faraday (>= 0.17.3, < 3.a)
|
||||||
|
jwt (>= 1.4, < 3.0)
|
||||||
|
memoist (~> 0.16)
|
||||||
|
multi_json (~> 1.11)
|
||||||
|
os (>= 0.9, < 2.0)
|
||||||
|
signet (>= 0.16, < 2.a)
|
||||||
|
highline (2.0.3)
|
||||||
|
http-cookie (1.0.5)
|
||||||
|
domain_name (~> 0.5)
|
||||||
|
httpclient (2.8.3)
|
||||||
|
jmespath (1.6.2)
|
||||||
|
json (2.6.3)
|
||||||
|
jwt (2.7.1)
|
||||||
|
memoist (0.16.2)
|
||||||
|
mini_magick (4.12.0)
|
||||||
|
mini_mime (1.1.2)
|
||||||
|
multi_json (1.15.0)
|
||||||
|
multipart-post (2.3.0)
|
||||||
|
nanaimo (0.3.0)
|
||||||
|
naturally (2.2.1)
|
||||||
|
optparse (0.1.1)
|
||||||
|
os (1.1.4)
|
||||||
|
plist (3.7.0)
|
||||||
|
public_suffix (5.0.1)
|
||||||
|
rake (13.0.6)
|
||||||
|
representable (3.2.0)
|
||||||
|
declarative (< 0.1.0)
|
||||||
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
|
uber (< 0.2.0)
|
||||||
|
retriable (3.1.2)
|
||||||
|
rexml (3.2.5)
|
||||||
|
rouge (2.0.7)
|
||||||
|
ruby2_keywords (0.0.5)
|
||||||
|
rubyzip (2.3.2)
|
||||||
|
security (0.1.3)
|
||||||
|
signet (0.17.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
faraday (>= 0.17.5, < 3.a)
|
||||||
|
jwt (>= 1.5, < 3.0)
|
||||||
|
multi_json (~> 1.10)
|
||||||
|
simctl (1.6.10)
|
||||||
|
CFPropertyList
|
||||||
|
naturally
|
||||||
|
terminal-notifier (2.0.0)
|
||||||
|
terminal-table (1.8.0)
|
||||||
|
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||||
|
trailblazer-option (0.1.2)
|
||||||
|
tty-cursor (0.7.1)
|
||||||
|
tty-screen (0.8.1)
|
||||||
|
tty-spinner (0.9.3)
|
||||||
|
tty-cursor (~> 0.7)
|
||||||
|
uber (0.1.0)
|
||||||
|
unf (0.1.4)
|
||||||
|
unf_ext
|
||||||
|
unf_ext (0.0.8.2)
|
||||||
|
unicode-display_width (1.8.0)
|
||||||
|
webrick (1.8.1)
|
||||||
|
word_wrap (1.0.0)
|
||||||
|
xcodeproj (1.22.0)
|
||||||
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
|
atomos (~> 0.1.3)
|
||||||
|
claide (>= 1.0.2, < 2.0)
|
||||||
|
colored2 (~> 3.1)
|
||||||
|
nanaimo (~> 0.3.0)
|
||||||
|
rexml (~> 3.2.4)
|
||||||
|
xcpretty (0.3.0)
|
||||||
|
rouge (~> 2.0.7)
|
||||||
|
xcpretty-travis-formatter (1.0.1)
|
||||||
|
xcpretty (~> 0.2, >= 0.0.7)
|
||||||
|
|
||||||
|
PLATFORMS
|
||||||
|
ruby
|
||||||
|
x86_64-linux
|
||||||
|
|
||||||
|
DEPENDENCIES
|
||||||
|
fastlane
|
||||||
|
|
||||||
|
BUNDLED WITH
|
||||||
|
2.3.25
|
|
@ -1,117 +1,92 @@
|
||||||
apply plugin: "com.android.application"
|
apply plugin: "com.android.application"
|
||||||
|
apply plugin: "com.facebook.react"
|
||||||
apply plugin: "kotlin-android"
|
apply plugin: "kotlin-android"
|
||||||
|
|
||||||
import com.android.build.OutputFile
|
/**
|
||||||
|
* This is the configuration block to customize your React Native Android app.
|
||||||
|
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||||
|
*/
|
||||||
|
react {
|
||||||
|
/* Folders */
|
||||||
|
// The root of your project, i.e. where "package.json" lives. Default is '..'
|
||||||
|
// root = file("../")
|
||||||
|
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
|
||||||
|
// reactNativeDir = file("../node_modules/react-native")
|
||||||
|
// The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen
|
||||||
|
// codegenDir = file("../node_modules/@react-native/codegen")
|
||||||
|
// The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js
|
||||||
|
// cliFile = file("../node_modules/react-native/cli.js")
|
||||||
|
|
||||||
project.ext.react = [
|
/* Variants */
|
||||||
enableHermes: false, // clean and rebuild if changing
|
// The list of variants to that are debuggable. For those we're going to
|
||||||
]
|
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||||
|
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||||
|
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||||
|
|
||||||
project.ext.vectoricons = [
|
/* Bundling */
|
||||||
iconFontNames: ['MaterialIcons.ttf']
|
// A list containing the node command and its flags. Default is just 'node'.
|
||||||
]
|
// nodeExecutableAndArgs = ["node"]
|
||||||
|
//
|
||||||
|
// The command to run when bundling. By default is 'bundle'
|
||||||
|
// bundleCommand = "ram-bundle"
|
||||||
|
//
|
||||||
|
// The path to the CLI configuration file. Default is empty.
|
||||||
|
// bundleConfig = file(../rn-cli.config.js)
|
||||||
|
//
|
||||||
|
// The name of the generated asset file containing your JS bundle
|
||||||
|
// bundleAssetName = "MyApplication.android.bundle"
|
||||||
|
//
|
||||||
|
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||||
|
// entryFile = file("../js/MyApplication.android.js")
|
||||||
|
//
|
||||||
|
// A list of extra flags to pass to the 'bundle' commands.
|
||||||
|
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||||
|
// extraPackagerArgs = []
|
||||||
|
|
||||||
apply from: "../../node_modules/react-native/react.gradle"
|
/* Hermes Commands */
|
||||||
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
|
// The hermes compiler command to run. By default it is 'hermesc'
|
||||||
|
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||||
def enableSeparateBuildPerCPUArchitecture = true
|
//
|
||||||
def enableProguardInReleaseBuilds = true
|
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||||
def jscFlavor = 'org.webkit:android-jsc:+'
|
// hermesFlags = ["-O", "-output-source-map"]
|
||||||
def enableHermes = project.ext.react.get("enableHermes", false);
|
|
||||||
|
|
||||||
def reactNativeArchitectures() {
|
|
||||||
def value = project.getProperties().get("reactNativeArchitectures")
|
|
||||||
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
/**
|
||||||
java {
|
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
*/
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
def enableProguardInReleaseBuilds = true
|
||||||
}
|
|
||||||
|
|
||||||
packagingOptions {
|
/**
|
||||||
jniLibs {
|
* The preferred build flavor of JavaScriptCore (JSC)
|
||||||
pickFirsts += ['**/armeabi-v7a/libfolly_runtime.so', '**/x86/libfolly_runtime.so', '**/arm64-v8a/libfolly_runtime.so', '**/x86_64/libfolly_runtime.so']
|
*
|
||||||
}
|
* For example, to use the international variant, you can use:
|
||||||
}
|
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||||
|
*
|
||||||
|
* The international variant includes ICU i18n library and necessary data
|
||||||
|
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||||
|
* give correct results when using with locales other than en-US. Note that
|
||||||
|
* this variant is about 6MiB larger per architecture than default.
|
||||||
|
*/
|
||||||
|
def jscFlavor = 'org.webkit:android-jsc:+'
|
||||||
|
|
||||||
|
android {
|
||||||
ndkVersion rootProject.ext.ndkVersion
|
ndkVersion rootProject.ext.ndkVersion
|
||||||
|
|
||||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
|
namespace "com.massive"
|
||||||
|
|
||||||
|
lintOptions {
|
||||||
|
checkReleaseBuilds false
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.massive"
|
applicationId "com.massive"
|
||||||
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
|
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 36037
|
versionCode 36174
|
||||||
versionName "1.11"
|
versionName "1.148"
|
||||||
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
|
|
||||||
|
|
||||||
if (isNewArchitectureEnabled()) {
|
|
||||||
externalNativeBuild {
|
|
||||||
ndkBuild {
|
|
||||||
arguments "APP_PLATFORM=android-21",
|
|
||||||
"APP_STL=c++_shared",
|
|
||||||
"NDK_TOOLCHAIN_VERSION=clang",
|
|
||||||
"GENERATED_SRC_DIR=$buildDir/generated/source",
|
|
||||||
"PROJECT_BUILD_DIR=$buildDir",
|
|
||||||
"REACT_ANDROID_DIR=$rootDir/../node_modules/react-native/ReactAndroid",
|
|
||||||
"REACT_ANDROID_BUILD_DIR=$rootDir/../node_modules/react-native/ReactAndroid/build",
|
|
||||||
"NODE_MODULES_DIR=$rootDir/../node_modules"
|
|
||||||
cFlags "-Wall", "-Werror", "-fexceptions", "-frtti", "-DWITH_INSPECTOR=1"
|
|
||||||
cppFlags "-std=c++17"
|
|
||||||
targets "massive_appmodules"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!enableSeparateBuildPerCPUArchitecture) {
|
|
||||||
ndk {
|
|
||||||
abiFilters (*reactNativeArchitectures())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNewArchitectureEnabled()) {
|
|
||||||
externalNativeBuild {
|
|
||||||
ndkBuild {
|
|
||||||
path "$projectDir/src/main/jni/Android.mk"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
def reactAndroidProjectDir = project(':ReactAndroid').projectDir
|
|
||||||
def packageReactNdkDebugLibs = tasks.register("packageReactNdkDebugLibs", Copy) {
|
|
||||||
dependsOn(":ReactAndroid:packageReactNdkDebugLibsForBuck")
|
|
||||||
from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib")
|
|
||||||
into("$buildDir/react-ndk/exported")
|
|
||||||
}
|
|
||||||
def packageReactNdkReleaseLibs = tasks.register("packageReactNdkReleaseLibs", Copy) {
|
|
||||||
dependsOn(":ReactAndroid:packageReactNdkReleaseLibsForBuck")
|
|
||||||
from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib")
|
|
||||||
into("$buildDir/react-ndk/exported")
|
|
||||||
}
|
|
||||||
afterEvaluate {
|
|
||||||
preDebugBuild.dependsOn(packageReactNdkDebugLibs)
|
|
||||||
preReleaseBuild.dependsOn(packageReactNdkReleaseLibs)
|
|
||||||
|
|
||||||
configureNdkBuildRelease.dependsOn(preReleaseBuild)
|
|
||||||
configureNdkBuildDebug.dependsOn(preDebugBuild)
|
|
||||||
reactNativeArchitectures().each { architecture ->
|
|
||||||
tasks.findByName("configureNdkBuildDebug[${architecture}]")?.configure {
|
|
||||||
dependsOn("preDebugBuild")
|
|
||||||
}
|
|
||||||
tasks.findByName("configureNdkBuildRelease[${architecture}]")?.configure {
|
|
||||||
dependsOn("preReleaseBuild")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
splits {
|
|
||||||
abi {
|
|
||||||
reset()
|
|
||||||
enable enableSeparateBuildPerCPUArchitecture
|
|
||||||
universalApk false
|
|
||||||
include (*reactNativeArchitectures())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
release {
|
release {
|
||||||
|
@ -136,7 +111,9 @@ android {
|
||||||
signingConfig signingConfigs.debug
|
signingConfig signingConfigs.debug
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
signingConfig signingConfigs.release
|
// Caution! In production, you need to generate your own keystore file.
|
||||||
|
// see https://reactnative.dev/docs/signed-apk-android.
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
minifyEnabled enableProguardInReleaseBuilds
|
minifyEnabled enableProguardInReleaseBuilds
|
||||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||||
}
|
}
|
||||||
|
@ -144,61 +121,28 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
def work_version = "2.7.1"
|
// The version of react-native is set by the React Native Gradle Plugin
|
||||||
|
implementation("com.facebook.react:react-android")
|
||||||
implementation "androidx.work:work-runtime:$work_version"
|
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
||||||
implementation "androidx.work:work-rxjava2:$work_version"
|
|
||||||
androidTestImplementation "androidx.work:work-testing:$work_version"
|
|
||||||
implementation "androidx.work:work-multiprocess:$work_version"
|
|
||||||
implementation fileTree(dir: "libs", include: ["*.jar"])
|
|
||||||
implementation "com.facebook.react:react-native:+" // From node_modules
|
|
||||||
implementation "androidx.core:core-ktx:1.8.0"
|
|
||||||
implementation project(':react-native-sqlite-storage')
|
implementation project(':react-native-sqlite-storage')
|
||||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
|
implementation project(':react-native-vector-icons')
|
||||||
|
|
||||||
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
|
|
||||||
exclude group:'com.facebook.fbjni'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
|
||||||
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
|
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
|
||||||
exclude group:'com.facebook.flipper'
|
|
||||||
exclude group:'com.squareup.okhttp3', module:'okhttp'
|
exclude group:'com.squareup.okhttp3', module:'okhttp'
|
||||||
}
|
}
|
||||||
|
|
||||||
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") {
|
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}")
|
||||||
exclude group:'com.facebook.flipper'
|
if (hermesEnabled.toBoolean()) {
|
||||||
}
|
implementation("com.facebook.react:hermes-android")
|
||||||
|
|
||||||
if (enableHermes) {
|
|
||||||
implementation("com.facebook.react:hermes-engine:+") { // From node_modules
|
|
||||||
exclude group:'com.facebook.fbjni'
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
implementation jscFlavor
|
implementation jscFlavor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNewArchitectureEnabled()) {
|
|
||||||
configurations.all {
|
|
||||||
resolutionStrategy.dependencySubstitution {
|
|
||||||
substitute(module("com.facebook.react:react-native"))
|
|
||||||
.using(project(":ReactAndroid"))
|
|
||||||
.because("On New Architecture we're building React Native from source")
|
|
||||||
substitute(module("com.facebook.react:hermes-engine"))
|
|
||||||
.using(project(":ReactAndroid:hermes-engine"))
|
|
||||||
.because("On New Architecture we're building Hermes from source")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
task copyDownloadableDepsToLibs(type: Copy) {
|
|
||||||
from configurations.implementation
|
|
||||||
into 'libs'
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
|
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
|
||||||
|
project.ext.vectoricons = [
|
||||||
def isNewArchitectureEnabled() {
|
iconFontNames: ['MaterialIcons.ttf']
|
||||||
return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
|
]
|
||||||
}
|
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
|
||||||
|
|
3
android/app/proguard-rules.pro
vendored
3
android/app/proguard-rules.pro
vendored
|
@ -44,3 +44,6 @@
|
||||||
-dontwarn java.nio.file.*
|
-dontwarn java.nio.file.*
|
||||||
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
|
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
|
||||||
-dontwarn okio.**
|
-dontwarn okio.**
|
||||||
|
|
||||||
|
-keep class com.facebook.hermes.unicode.** { *; }
|
||||||
|
-keep class com.facebook.jni.** { *; }
|
||||||
|
|
|
@ -17,7 +17,6 @@ import com.facebook.flipper.plugins.inspector.DescriptorMapping;
|
||||||
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
|
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
|
||||||
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
|
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
|
||||||
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
|
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
|
||||||
import com.facebook.flipper.plugins.react.ReactFlipperPlugin;
|
|
||||||
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
|
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
|
||||||
import com.facebook.react.ReactInstanceEventListener;
|
import com.facebook.react.ReactInstanceEventListener;
|
||||||
import com.facebook.react.ReactInstanceManager;
|
import com.facebook.react.ReactInstanceManager;
|
||||||
|
@ -25,13 +24,16 @@ import com.facebook.react.bridge.ReactContext;
|
||||||
import com.facebook.react.modules.network.NetworkingModule;
|
import com.facebook.react.modules.network.NetworkingModule;
|
||||||
import okhttp3.OkHttpClient;
|
import okhttp3.OkHttpClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class responsible of loading Flipper inside your React Native application. This is the debug
|
||||||
|
* flavor of it. Here you can add your own plugins and customize the Flipper setup.
|
||||||
|
*/
|
||||||
public class ReactNativeFlipper {
|
public class ReactNativeFlipper {
|
||||||
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
|
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
|
||||||
if (FlipperUtils.shouldEnableFlipper(context)) {
|
if (FlipperUtils.shouldEnableFlipper(context)) {
|
||||||
final FlipperClient client = AndroidFlipperClient.getInstance(context);
|
final FlipperClient client = AndroidFlipperClient.getInstance(context);
|
||||||
|
|
||||||
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
|
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
|
||||||
client.addPlugin(new ReactFlipperPlugin());
|
|
||||||
client.addPlugin(new DatabasesFlipperPlugin(context));
|
client.addPlugin(new DatabasesFlipperPlugin(context));
|
||||||
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
|
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
|
||||||
client.addPlugin(CrashReporterPlugin.getInstance());
|
client.addPlugin(CrashReporterPlugin.getInstance());
|
||||||
|
|
|
@ -1,35 +1,56 @@
|
||||||
<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.INTERNET" />
|
||||||
|
<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.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_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 tools:node="remove" android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.ACCESS_NETWORK_STATE"
|
||||||
|
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:label="@string/app_name"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:allowBackup="false"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme">
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
<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"
|
android:launchMode="singleTask"
|
||||||
android:exported="true"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:launchMode="singleTask"
|
android:exported="true">
|
||||||
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:exported="true" android:process=":remote" android:name=".StopAlarm" />
|
<activity
|
||||||
<service android:name=".StopTimer" android:exported="true" android:process=":remote" />
|
android:name=".TimerDone"
|
||||||
<service android:name=".AlarmService" android:exported="true" />
|
android:exported="false">
|
||||||
<service android:name=".TimerService" android:exported="true" />
|
<meta-data
|
||||||
|
android:name="android.app.lib_name"
|
||||||
|
android:value="" />
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".StopAlarm"
|
||||||
|
android:exported="true"
|
||||||
|
android:process=":remote" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".AlarmService"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
@ -1,90 +1,191 @@
|
||||||
package com.massive
|
package com.massive
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.ActivityNotFoundException
|
import android.app.*
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.content.IntentFilter
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.PowerManager
|
import android.os.CountDownTimer
|
||||||
import android.provider.Settings
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import com.facebook.react.bridge.Callback
|
import androidx.core.app.NotificationCompat
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
import com.facebook.react.bridge.*
|
||||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||||
import com.facebook.react.bridge.ReactMethod
|
import kotlin.math.floor
|
||||||
|
|
||||||
|
|
||||||
class AlarmModule internal constructor(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)
|
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun add(milliseconds: Int, vibrate: Boolean, sound: String?) {
|
fun add() {
|
||||||
Log.d("AlarmModule", "Add 1 min to alarm.")
|
Log.d("AlarmModule", "Add 1 min to alarm.")
|
||||||
val addIntent = Intent(reactApplicationContext, TimerService::class.java)
|
countdownTimer?.cancel()
|
||||||
addIntent.action = "add"
|
val newMs = if (running) currentMs.toInt().plus(60000) else 60000
|
||||||
addIntent.putExtra("vibrate", vibrate)
|
countdownTimer = getTimer(newMs)
|
||||||
addIntent.putExtra("sound", sound)
|
countdownTimer?.start()
|
||||||
addIntent.data = Uri.parse("$milliseconds")
|
running = true
|
||||||
reactApplicationContext.startService(addIntent)
|
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)
|
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun stop() {
|
fun stop() {
|
||||||
Log.d("AlarmModule", "Stop alarm.")
|
Log.d("AlarmModule", "Stop alarm.")
|
||||||
val timerIntent = Intent(reactApplicationContext, TimerService::class.java)
|
countdownTimer?.cancel()
|
||||||
reactApplicationContext.stopService(timerIntent)
|
running = false
|
||||||
val alarmIntent = Intent(reactApplicationContext, AlarmService::class.java)
|
val intent = Intent(reactApplicationContext, AlarmService::class.java)
|
||||||
reactApplicationContext.stopService(alarmIntent)
|
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)
|
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun timer(milliseconds: Int, vibrate: Boolean, sound: String?) {
|
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("vibrate", vibrate)
|
val intent = Intent(reactApplicationContext, AlarmService::class.java)
|
||||||
intent.putExtra("sound", sound)
|
reactApplicationContext.stopService(intent)
|
||||||
reactApplicationContext.startService(intent)
|
countdownTimer?.cancel()
|
||||||
|
countdownTimer = getTimer(milliseconds)
|
||||||
|
countdownTimer?.start()
|
||||||
|
running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.M)
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
@ReactMethod
|
private fun getTimer(
|
||||||
fun ignoringBattery(callback: Callback) {
|
endMs: Int,
|
||||||
val packageName = reactApplicationContext.packageName
|
): CountDownTimer {
|
||||||
val pm =
|
val builder = getBuilder()
|
||||||
reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
return object : CountDownTimer(endMs.toLong(), 1000) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
callback.invoke(pm.isIgnoringBatteryOptimizations(packageName))
|
override fun onTick(current: Long) {
|
||||||
} else {
|
currentMs = current
|
||||||
callback.invoke(true)
|
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("BatteryLife")
|
@SuppressLint("UnspecifiedImmutableFlag")
|
||||||
@RequiresApi(Build.VERSION_CODES.M)
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
@ReactMethod
|
private fun getBuilder(): NotificationCompat.Builder {
|
||||||
fun ignoreBattery() {
|
val context = reactApplicationContext
|
||||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
val contentIntent = Intent(context, MainActivity::class.java)
|
||||||
intent.data = Uri.parse("package:" + reactApplicationContext.packageName)
|
val pendingContent =
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
try {
|
val addBroadcast = Intent(ADD_BROADCAST).apply {
|
||||||
reactApplicationContext.startActivity(intent)
|
setPackage(context.packageName)
|
||||||
} catch (e: ActivityNotFoundException) {
|
|
||||||
Toast.makeText(
|
|
||||||
reactApplicationContext,
|
|
||||||
"Requests to ignore battery optimizations are disabled on your device.",
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,31 +1,65 @@
|
||||||
package com.massive
|
package com.massive
|
||||||
|
|
||||||
import android.app.Service
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.*
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.MediaPlayer.OnPreparedListener
|
|
||||||
import android.media.MediaPlayer
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.media.MediaPlayer.OnPreparedListener
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.*
|
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 {
|
class AlarmService : Service(), OnPreparedListener {
|
||||||
var mediaPlayer: MediaPlayer? = null
|
private var mediaPlayer: MediaPlayer? = null
|
||||||
private var vibrator: Vibrator? = null
|
private var vibrator: Vibrator? = null
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
private fun getBuilder(): NotificationCompat.Builder {
|
||||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
val context = applicationContext
|
||||||
if (intent.action == "stop") {
|
val contentIntent = Intent(context, MainActivity::class.java)
|
||||||
onDestroy()
|
val pendingContent =
|
||||||
return START_STICKY
|
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
val addBroadcast = Intent(AlarmModule.ADD_BROADCAST).apply {
|
||||||
|
setPackage(context.packageName)
|
||||||
}
|
}
|
||||||
val sound = intent.extras?.getString("sound")
|
val pendingAdd =
|
||||||
if (sound == null) {
|
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 = MediaPlayer.create(applicationContext, R.raw.argon)
|
||||||
mediaPlayer?.start()
|
mediaPlayer?.start()
|
||||||
mediaPlayer?.setOnCompletionListener { vibrator?.cancel() }
|
mediaPlayer?.setOnCompletionListener { vibrator?.cancel() }
|
||||||
} else {
|
} else if (settings.sound != null && !settings.noSound) {
|
||||||
mediaPlayer = MediaPlayer().apply {
|
mediaPlayer = MediaPlayer().apply {
|
||||||
setAudioAttributes(
|
setAudioAttributes(
|
||||||
AudioAttributes.Builder()
|
AudioAttributes.Builder()
|
||||||
|
@ -33,12 +67,56 @@ class AlarmService : Service(), OnPreparedListener {
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
setDataSource(applicationContext, Uri.parse(sound))
|
setDataSource(applicationContext, Uri.parse(settings.sound))
|
||||||
prepare()
|
prepare()
|
||||||
start()
|
start()
|
||||||
setOnCompletionListener { vibrator?.cancel() }
|
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)
|
val pattern = longArrayOf(0, 300, 1300, 300, 1300, 300)
|
||||||
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
val vibratorManager =
|
val vibratorManager =
|
||||||
|
@ -52,9 +130,7 @@ class AlarmService : Service(), OnPreparedListener {
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||||
.build()
|
.build()
|
||||||
val vibrate = intent.extras!!.getBoolean("vibrate")
|
vibrator!!.vibrate(VibrationEffect.createWaveform(pattern, 1), audioAttributes)
|
||||||
if (vibrate)
|
|
||||||
vibrator!!.vibrate(VibrationEffect.createWaveform(pattern, 1), audioAttributes)
|
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,4 +148,9 @@ class AlarmService : Service(), OnPreparedListener {
|
||||||
mediaPlayer?.release()
|
mediaPlayer?.release()
|
||||||
vibrator?.cancel()
|
vibrator?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_ID_DONE = "Alarm"
|
||||||
|
const val NOTIFICATION_ID_DONE = 2
|
||||||
|
}
|
||||||
}
|
}
|
84
android/app/src/main/java/com/massive/BackupModule.kt
Normal file
84
android/app/src/main/java/com/massive/BackupModule.kt
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
package com.massive
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.*
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
import com.facebook.react.bridge.ReactMethod
|
||||||
|
import java.io.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class BackupModule constructor(context: ReactApplicationContext?) :
|
||||||
|
ReactContextBaseJavaModule(context) {
|
||||||
|
val context: ReactApplicationContext = reactApplicationContext
|
||||||
|
private var targetDir: String? = null
|
||||||
|
|
||||||
|
private val copyReceiver = object : BroadcastReceiver() {
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
@ReactMethod
|
||||||
|
fun start(baseUri: String) {
|
||||||
|
targetDir = baseUri
|
||||||
|
val alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
val intent = Intent(COPY_BROADCAST)
|
||||||
|
val pendingIntent =
|
||||||
|
PendingIntent.getBroadcast(context, 0, 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
@ReactMethod
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
reactApplicationContext.registerReceiver(copyReceiver, IntentFilter(COPY_BROADCAST))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val COPY_BROADCAST = "copy-event"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getName(): String {
|
||||||
|
return "BackupModule"
|
||||||
|
}
|
||||||
|
}
|
22
android/app/src/main/java/com/massive/DatabaseHelper.kt
Normal file
22
android/app/src/main/java/com/massive/DatabaseHelper.kt
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
package com.massive
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import android.database.sqlite.SQLiteOpenHelper
|
||||||
|
|
||||||
|
class DatabaseHelper(context: Context) :
|
||||||
|
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||||
|
companion object {
|
||||||
|
private const val DATABASE_NAME = "massive.db"
|
||||||
|
private const val DATABASE_VERSION = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(db: SQLiteDatabase) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,51 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,32 +2,27 @@ package com.massive
|
||||||
|
|
||||||
import com.facebook.react.ReactActivity
|
import com.facebook.react.ReactActivity
|
||||||
import com.facebook.react.ReactActivityDelegate
|
import com.facebook.react.ReactActivityDelegate
|
||||||
import com.facebook.react.ReactRootView
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||||
import android.os.Bundle;
|
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||||
|
|
||||||
class MainActivity : ReactActivity() {
|
class MainActivity : ReactActivity() {
|
||||||
|
/**
|
||||||
|
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||||
|
* rendering of the component.
|
||||||
|
*/
|
||||||
override fun getMainComponentName(): String? {
|
override fun getMainComponentName(): String? {
|
||||||
return "massive"
|
return "massive"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the instance of the [ReactActivityDelegate]. Here we use a util class [ ] which allows you to easily enable Fabric and Concurrent React
|
||||||
|
* (aka React 18) with two boolean flags.
|
||||||
|
*/
|
||||||
override fun createReactActivityDelegate(): ReactActivityDelegate {
|
override fun createReactActivityDelegate(): ReactActivityDelegate {
|
||||||
return MainActivityDelegate(this, mainComponentName)
|
return DefaultReactActivityDelegate(
|
||||||
}
|
this,
|
||||||
|
mainComponentName!!, // If you opted-in for the New Architecture, we enable the Fabric Renderer.
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
fabricEnabled
|
||||||
super.onCreate(null)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
class MainActivityDelegate(activity: ReactActivity?, mainComponentName: String?) :
|
|
||||||
ReactActivityDelegate(activity, mainComponentName) {
|
|
||||||
override fun createRootView(): ReactRootView {
|
|
||||||
val reactRootView = ReactRootView(context)
|
|
||||||
reactRootView.setIsFabric(BuildConfig.IS_NEW_ARCHITECTURE_ENABLED)
|
|
||||||
return reactRootView
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isConcurrentRootEnabled(): Boolean {
|
|
||||||
return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,15 +1,16 @@
|
||||||
package com.massive
|
package com.massive
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import com.facebook.react.PackageList
|
||||||
import com.facebook.react.*
|
import com.facebook.react.ReactApplication
|
||||||
import com.facebook.react.config.ReactFeatureFlags
|
import com.facebook.react.ReactNativeHost
|
||||||
|
import com.facebook.react.ReactPackage
|
||||||
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
|
||||||
|
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||||
import com.facebook.soloader.SoLoader
|
import com.facebook.soloader.SoLoader
|
||||||
import com.massive.newarchitecture.MainApplicationReactNativeHost
|
|
||||||
import java.lang.reflect.InvocationTargetException
|
|
||||||
|
|
||||||
class MainApplication : Application(), ReactApplication {
|
class MainApplication : Application(), ReactApplication {
|
||||||
private val mReactNativeHost: ReactNativeHost = object : ReactNativeHost(this) {
|
private val mReactNativeHost: ReactNativeHost = object : DefaultReactNativeHost(this) {
|
||||||
override fun getUseDeveloperSupport(): Boolean {
|
override fun getUseDeveloperSupport(): Boolean {
|
||||||
return BuildConfig.DEBUG
|
return BuildConfig.DEBUG
|
||||||
}
|
}
|
||||||
|
@ -23,48 +24,24 @@ class MainApplication : Application(), ReactApplication {
|
||||||
override fun getJSMainModuleName(): String {
|
override fun getJSMainModuleName(): String {
|
||||||
return "index"
|
return "index"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val isNewArchEnabled: Boolean
|
||||||
|
protected get() = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
||||||
|
override val isHermesEnabled: Boolean
|
||||||
|
protected get() = BuildConfig.IS_HERMES_ENABLED
|
||||||
}
|
}
|
||||||
|
|
||||||
private val mNewArchitectureNativeHost: ReactNativeHost = MainApplicationReactNativeHost(this)
|
|
||||||
override fun getReactNativeHost(): ReactNativeHost {
|
override fun getReactNativeHost(): ReactNativeHost {
|
||||||
return if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
return mReactNativeHost
|
||||||
mNewArchitectureNativeHost
|
|
||||||
} else {
|
|
||||||
mReactNativeHost
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
ReactFeatureFlags.useTurboModules = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
SoLoader.init(this, /* native exopackage */false)
|
||||||
SoLoader.init(this, false)
|
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
||||||
initializeFlipper(this, reactNativeHost.reactInstanceManager)
|
// If you opted-in for the New Architecture, we load the native entry point for this app.
|
||||||
}
|
load()
|
||||||
|
|
||||||
companion object {
|
|
||||||
private fun initializeFlipper(
|
|
||||||
context: Context, reactInstanceManager: ReactInstanceManager
|
|
||||||
) {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
try {
|
|
||||||
val aClass = Class.forName("com.massive.ReactNativeFlipper")
|
|
||||||
aClass
|
|
||||||
.getMethod(
|
|
||||||
"initializeFlipper",
|
|
||||||
Context::class.java,
|
|
||||||
ReactInstanceManager::class.java
|
|
||||||
)
|
|
||||||
.invoke(null, context, reactInstanceManager)
|
|
||||||
} catch (e: ClassNotFoundException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
} catch (e: NoSuchMethodException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
} catch (e: IllegalAccessException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
} catch (e: InvocationTargetException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ReactNativeFlipper.initializeFlipper(this, reactNativeHost.reactInstanceManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,15 +0,0 @@
|
||||||
package com.massive
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import android.database.sqlite.SQLiteOpenHelper
|
|
||||||
|
|
||||||
class MassiveHelper(context: Context) : SQLiteOpenHelper(context, "massive.db", null, 1) {
|
|
||||||
override fun onCreate(db: SQLiteDatabase) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onUpgrade(p0: SQLiteDatabase?, p1: Int, p2: Int) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,8 +4,6 @@ import com.facebook.react.ReactPackage
|
||||||
import com.facebook.react.bridge.NativeModule
|
import com.facebook.react.bridge.NativeModule
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
import com.facebook.react.uimanager.ViewManager
|
import com.facebook.react.uimanager.ViewManager
|
||||||
import com.massive.AlarmModule
|
|
||||||
import java.util.ArrayList
|
|
||||||
|
|
||||||
class MassivePackage : ReactPackage {
|
class MassivePackage : ReactPackage {
|
||||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||||
|
@ -17,7 +15,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(BackupModule(reactContext))
|
||||||
return modules
|
return modules
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
58
android/app/src/main/java/com/massive/SettingsModule.kt
Normal file
58
android/app/src/main/java/com/massive/SettingsModule.kt
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
package com.massive
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import com.facebook.react.bridge.*
|
||||||
|
|
||||||
|
class SettingsModule constructor(context: ReactApplicationContext?) :
|
||||||
|
ReactContextBaseJavaModule(context) {
|
||||||
|
override fun getName(): String {
|
||||||
|
return "SettingsModule"
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
@ReactMethod
|
||||||
|
fun ignoringBattery(callback: Callback) {
|
||||||
|
val packageName = reactApplicationContext.packageName
|
||||||
|
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
callback.invoke(pm.isIgnoringBatteryOptimizations(packageName))
|
||||||
|
} else {
|
||||||
|
callback.invoke(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("BatteryLife")
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
@ReactMethod
|
||||||
|
fun ignoreBattery() {
|
||||||
|
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||||
|
intent.data = Uri.parse("package:" + reactApplicationContext.packageName)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
try {
|
||||||
|
reactApplicationContext.startActivity(intent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(
|
||||||
|
reactApplicationContext,
|
||||||
|
"Requests to ignore battery optimizations are disabled on your device.",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun is24(promise: Promise) {
|
||||||
|
val is24 = android.text.format.DateFormat.is24HourFormat(reactApplicationContext)
|
||||||
|
Log.d("SettingsModule", "is24=$is24")
|
||||||
|
promise.resolve(is24)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,18 +0,0 @@
|
||||||
package com.massive
|
|
||||||
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
class StopTimer : Service() {
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
applicationContext.stopService(Intent(applicationContext, TimerService::class.java))
|
|
||||||
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
|
|
||||||
return super.onStartCommand(intent, flags, startId)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(p0: Intent?): IBinder? {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
58
android/app/src/main/java/com/massive/TimerDone.kt
Normal file
58
android/app/src/main/java/com/massive/TimerDone.kt
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
package com.massive
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
|
||||||
|
class TimerDone : AppCompatActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_timer_done)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
|
fun stop(view: View) {
|
||||||
|
Log.d("TimerDone", "Stopping...")
|
||||||
|
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
|
||||||
|
val manager = getManager()
|
||||||
|
manager.cancel(AlarmService.NOTIFICATION_ID_DONE)
|
||||||
|
manager.cancel(AlarmModule.NOTIFICATION_ID_PENDING)
|
||||||
|
val intent = Intent(applicationContext, MainActivity::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,167 +0,0 @@
|
||||||
package com.massive
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.*
|
|
||||||
import android.app.NotificationManager.IMPORTANCE_HIGH
|
|
||||||
import android.app.NotificationManager.IMPORTANCE_LOW
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
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 manager: NotificationManager? = null
|
|
||||||
private var countdownTimer: CountDownTimer? = null
|
|
||||||
private var endMs: Int = 0
|
|
||||||
private var currentMs: Long = 0
|
|
||||||
private var vibrate: Boolean = true
|
|
||||||
private var sound: String? = null
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
vibrate = intent!!.extras!!.getBoolean("vibrate")
|
|
||||||
sound = intent.extras?.getString("sound")
|
|
||||||
manager?.cancel(NOTIFICATION_ID_DONE)
|
|
||||||
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
|
|
||||||
if (intent.action == "add") {
|
|
||||||
endMs = currentMs.toInt().plus(60000)
|
|
||||||
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
|
|
||||||
} else {
|
|
||||||
endMs = intent.extras!!.getInt("milliseconds")
|
|
||||||
}
|
|
||||||
Log.d("TimerService", "endMs=$endMs,currentMs=$currentMs,vibrate=$vibrate,sound=$sound")
|
|
||||||
manager = getManager(applicationContext)
|
|
||||||
val builder = getBuilder(applicationContext)
|
|
||||||
countdownTimer?.cancel()
|
|
||||||
countdownTimer = getTimer(builder)
|
|
||||||
countdownTimer?.start()
|
|
||||||
return super.onStartCommand(intent, flags, startId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getTimer(builder: NotificationCompat.Builder): CountDownTimer {
|
|
||||||
return object : CountDownTimer(endMs.toLong(), 1000) {
|
|
||||||
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
|
|
||||||
manager!!.notify(NOTIFICATION_ID_PENDING, builder.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.M)
|
|
||||||
override fun onFinish() {
|
|
||||||
val finishIntent = Intent(applicationContext, StopAlarm::class.java)
|
|
||||||
val finishPending =
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
applicationContext,
|
|
||||||
0,
|
|
||||||
finishIntent,
|
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
val stopIntent = Intent(applicationContext, StopTimer::class.java)
|
|
||||||
val pendingStop =
|
|
||||||
PendingIntent.getService(
|
|
||||||
applicationContext,
|
|
||||||
0,
|
|
||||||
stopIntent,
|
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
builder.setContentText("Timer finished.")
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setProgress(0, 0, false)
|
|
||||||
.setOngoing(false)
|
|
||||||
.setContentIntent(finishPending)
|
|
||||||
.setChannelId(CHANNEL_ID_DONE)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
|
||||||
.setDeleteIntent(pendingStop)
|
|
||||||
.priority = NotificationCompat.PRIORITY_HIGH
|
|
||||||
manager!!.notify(NOTIFICATION_ID_DONE, builder.build())
|
|
||||||
manager!!.cancel(NOTIFICATION_ID_PENDING)
|
|
||||||
val alarmIntent = Intent(applicationContext, AlarmService::class.java)
|
|
||||||
alarmIntent.putExtra("vibrate", vibrate)
|
|
||||||
alarmIntent.putExtra("sound", sound)
|
|
||||||
applicationContext.startService(alarmIntent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(p0: Intent?): IBinder? {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
Log.d("TimerService", "Destroying...")
|
|
||||||
countdownTimer?.cancel()
|
|
||||||
manager?.cancel(NOTIFICATION_ID_PENDING)
|
|
||||||
manager?.cancel(NOTIFICATION_ID_DONE)
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedImmutableFlag")
|
|
||||||
@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"
|
|
||||||
addIntent.putExtra("vibrate", vibrate)
|
|
||||||
addIntent.putExtra("sound", sound)
|
|
||||||
addIntent.data = Uri.parse("$currentMs")
|
|
||||||
val pendingAdd = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
PendingIntent.getService(context, 0, addIntent, PendingIntent.FLAG_MUTABLE)
|
|
||||||
} else {
|
|
||||||
PendingIntent.getService(context, 0, addIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
private fun getManager(context: Context): NotificationManager {
|
|
||||||
val alarmsChannel = NotificationChannel(
|
|
||||||
CHANNEL_ID_DONE,
|
|
||||||
CHANNEL_ID_DONE,
|
|
||||||
IMPORTANCE_HIGH
|
|
||||||
)
|
|
||||||
alarmsChannel.description = "Alarms for rest timers."
|
|
||||||
alarmsChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
|
||||||
val notificationManager = context.getSystemService(
|
|
||||||
NotificationManager::class.java
|
|
||||||
)
|
|
||||||
notificationManager.createNotificationChannel(alarmsChannel)
|
|
||||||
val timersChannel =
|
|
||||||
NotificationChannel(CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, IMPORTANCE_LOW)
|
|
||||||
timersChannel.setSound(null, null)
|
|
||||||
timersChannel.description = "Progress on rest timers."
|
|
||||||
notificationManager.createNotificationChannel(timersChannel)
|
|
||||||
return notificationManager
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val CHANNEL_ID_PENDING = "Timer"
|
|
||||||
private const val CHANNEL_ID_DONE = "Alarm"
|
|
||||||
private const val NOTIFICATION_ID_PENDING = 1
|
|
||||||
private const val NOTIFICATION_ID_DONE = 2
|
|
||||||
}
|
|
||||||
}
|
|
30
android/app/src/main/res/layout/activity_timer_done.xml
Normal file
30
android/app/src/main/res/layout/activity_timer_done.xml
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".TimerDone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Timer up"
|
||||||
|
android:textSize="28sp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/button"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Stop"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/textView"
|
||||||
|
android:onClick="stop" />
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
7
android/app/src/main/res/values-night/themes.xml
Normal file
7
android/app/src/main/res/values-night/themes.xml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="ThemeOverlay.Massive.FullscreenContainer" parent="">
|
||||||
|
<item name="fullscreenBackgroundColor">@color/light_blue_900</item>
|
||||||
|
<item name="fullscreenTextColor">@color/light_blue_A400</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
6
android/app/src/main/res/values/attrs.xml
Normal file
6
android/app/src/main/res/values/attrs.xml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<resources>
|
||||||
|
<declare-styleable name="FullscreenAttrs">
|
||||||
|
<attr name="fullscreenBackgroundColor" format="color" />
|
||||||
|
<attr name="fullscreenTextColor" format="color" />
|
||||||
|
</declare-styleable>
|
||||||
|
</resources>
|
7
android/app/src/main/res/values/colors.xml
Normal file
7
android/app/src/main/res/values/colors.xml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<resources>
|
||||||
|
<color name="light_blue_600">#FF039BE5</color>
|
||||||
|
<color name="light_blue_900">#FF01579B</color>
|
||||||
|
<color name="light_blue_A200">#FF40C4FF</color>
|
||||||
|
<color name="light_blue_A400">#FF00B0FF</color>
|
||||||
|
<color name="black_overlay">#66000000</color>
|
||||||
|
</resources>
|
|
@ -1,3 +1,8 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Massive</string>
|
<string name="app_name">Massive</string>
|
||||||
|
<string name="title_activity_fullscreen">FullscreenActivity</string>
|
||||||
|
<string name="dummy_button">Dummy Button</string>
|
||||||
|
<string name="dummy_content">DUMMY\nCONTENT</string>
|
||||||
|
<string name="rest_timer_up">Rest timer up</string>
|
||||||
|
<string name="stop">STOP</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -6,4 +6,13 @@
|
||||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style name="Widget.AppTheme.ActionBar.Fullscreen" parent="Widget.AppCompat.ActionBar">
|
||||||
|
<item name="android:background">@color/black_overlay</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Widget.AppTheme.ButtonBar.Fullscreen" parent="">
|
||||||
|
<item name="android:background">@color/black_overlay</item>
|
||||||
|
<item name="android:buttonBarStyle">?android:attr/buttonBarStyle</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|
13
android/app/src/main/res/values/themes.xml
Normal file
13
android/app/src/main/res/values/themes.xml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="AppTheme.Fullscreen" parent="AppTheme">
|
||||||
|
<item name="android:actionBarStyle">@style/Widget.AppTheme.ActionBar.Fullscreen</item>
|
||||||
|
<item name="android:windowActionBarOverlay">true</item>
|
||||||
|
<item name="android:windowBackground">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="ThemeOverlay.Massive.FullscreenContainer" parent="">
|
||||||
|
<item name="fullscreenBackgroundColor">@color/light_blue_600</item>
|
||||||
|
<item name="fullscreenTextColor">@color/light_blue_A200</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,55 +1,22 @@
|
||||||
import org.apache.tools.ant.taskdefs.condition.Os
|
|
||||||
|
|
||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
ext {
|
ext {
|
||||||
kotlin_version = '1.6.10'
|
buildToolsVersion = "33.0.0"
|
||||||
buildToolsVersion = "31.0.0"
|
|
||||||
minSdkVersion = 21
|
minSdkVersion = 21
|
||||||
compileSdkVersion = 31
|
compileSdkVersion = 33
|
||||||
targetSdkVersion = 31
|
targetSdkVersion = 33
|
||||||
|
|
||||||
if (System.properties['os.arch'] == "aarch64") {
|
// We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP.
|
||||||
// For M1 Users we need to use the NDK 24 which added support for aarch64
|
ndkVersion = "23.1.7779620"
|
||||||
ndkVersion = "24.0.8215888"
|
|
||||||
} else {
|
|
||||||
// Otherwise we default to the side-by-side NDK version from AGP.
|
|
||||||
ndkVersion = "21.4.7075529"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath('com.android.tools.build:gradle:7.2.1')
|
classpath("com.android.tools.build:gradle")
|
||||||
classpath("com.facebook.react:react-native-gradle-plugin")
|
classpath("com.facebook.react:react-native-gradle-plugin")
|
||||||
classpath("de.undercouch:gradle-download-task:5.0.1")
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22")
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
|
||||||
// in the individual module build.gradle files
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
allprojects {
|
|
||||||
repositories {
|
|
||||||
maven {
|
|
||||||
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
|
|
||||||
url("$rootDir/../node_modules/react-native/android")
|
|
||||||
}
|
|
||||||
maven {
|
|
||||||
// Android JSC is installed from npm
|
|
||||||
url("$rootDir/../node_modules/jsc-android/dist")
|
|
||||||
}
|
|
||||||
mavenCentral {
|
|
||||||
// We don't want to fetch react-native from Maven Central as there are
|
|
||||||
// older versions over there.
|
|
||||||
content {
|
|
||||||
excludeGroup "com.facebook.react"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
google()
|
|
||||||
maven { url 'https://www.jitpack.io' }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
2
android/fastlane/Appfile
Normal file
2
android/fastlane/Appfile
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
json_key_file("~/.config/googlePlay.json") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one
|
||||||
|
package_name("com.massive") # e.g. com.krausefx.app
|
38
android/fastlane/Fastfile
Normal file
38
android/fastlane/Fastfile
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
# This file contains the fastlane.tools configuration
|
||||||
|
# You can find the documentation at https://docs.fastlane.tools
|
||||||
|
#
|
||||||
|
# For a list of all available actions, check out
|
||||||
|
#
|
||||||
|
# https://docs.fastlane.tools/actions
|
||||||
|
#
|
||||||
|
# For a list of all available plugins, check out
|
||||||
|
#
|
||||||
|
# https://docs.fastlane.tools/plugins/available-plugins
|
||||||
|
#
|
||||||
|
|
||||||
|
# Uncomment the line if you want fastlane to automatically update itself
|
||||||
|
# update_fastlane
|
||||||
|
|
||||||
|
default_platform(:android)
|
||||||
|
|
||||||
|
platform :android do
|
||||||
|
desc "Runs all the tests"
|
||||||
|
lane :test do
|
||||||
|
gradle(task: "test")
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Submit a new Beta Build to Crashlytics Beta"
|
||||||
|
lane :beta do
|
||||||
|
gradle(task: "clean assembleRelease")
|
||||||
|
crashlytics
|
||||||
|
|
||||||
|
# sh "your_script.sh"
|
||||||
|
# You can also use other beta testing services here
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Deploy a new version to the Google Play"
|
||||||
|
lane :deploy do
|
||||||
|
gradle(task: "clean assembleRelease")
|
||||||
|
upload_to_play_store
|
||||||
|
end
|
||||||
|
end
|
48
android/fastlane/README.md
Normal file
48
android/fastlane/README.md
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
fastlane documentation
|
||||||
|
----
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Make sure you have the latest version of the Xcode command line tools installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||||
|
|
||||||
|
# Available Actions
|
||||||
|
|
||||||
|
## Android
|
||||||
|
|
||||||
|
### android test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane android test
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs all the tests
|
||||||
|
|
||||||
|
### android beta
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane android beta
|
||||||
|
```
|
||||||
|
|
||||||
|
Submit a new Beta Build to Crashlytics Beta
|
||||||
|
|
||||||
|
### android deploy
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane android deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy a new version to the Google Play
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|
||||||
|
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||||
|
|
||||||
|
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
|
@ -25,7 +25,7 @@ android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
|
|
||||||
# Version of flipper SDK to use with React Native
|
# Version of flipper SDK to use with React Native
|
||||||
FLIPPER_VERSION=0.125.0
|
FLIPPER_VERSION=0.182.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
|
||||||
|
@ -38,3 +38,7 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||||
# to write custom TurboModules/Fabric components OR use libraries that
|
# to write custom TurboModules/Fabric components OR use libraries that
|
||||||
# are providing them.
|
# are providing them.
|
||||||
newArchEnabled=false
|
newArchEnabled=false
|
||||||
|
|
||||||
|
# Use this property to enable or disable the Hermes JS engine.
|
||||||
|
# If set to false, you will be using JSC instead.
|
||||||
|
hermesEnabled=true
|
||||||
|
|
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
|
@ -1,5 +1,6 @@
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|
18
android/gradlew
vendored
18
android/gradlew
vendored
|
@ -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/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
# https://github.com/gradle/gradle/blob/HEAD/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,10 +80,10 @@ do
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
APP_NAME="Gradle"
|
|
||||||
APP_BASE_NAME=${0##*/}
|
APP_BASE_NAME=${0##*/}
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||||
|
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
# 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"'
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
@ -143,12 +143,16 @@ fi
|
||||||
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
|
||||||
|
@ -205,6 +209,12 @@ 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
15
android/gradlew.bat
vendored
|
@ -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,7 +25,8 @@
|
||||||
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%
|
||||||
|
|
||||||
|
@ -40,7 +41,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%" == "0" goto execute
|
if %ERRORLEVEL% equ 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.
|
||||||
|
@ -75,13 +76,15 @@ 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%"=="0" goto mainEnd
|
if %ERRORLEVEL% equ 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!
|
||||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
exit /b 1
|
if %EXIT_CODE% equ 0 set EXIT_CODE=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
|
||||||
|
|
|
@ -1,13 +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')
|
||||||
|
|
||||||
if (settings.hasProperty("newArchEnabled") && settings.newArchEnabled == "true") {
|
|
||||||
include(":ReactAndroid")
|
|
||||||
project(":ReactAndroid").projectDir = file('../node_modules/react-native/ReactAndroid')
|
|
||||||
include(":ReactAndroid:hermes-engine")
|
|
||||||
project(":ReactAndroid:hermes-engine").projectDir = file('../node_modules/react-native/ReactAndroid/hermes-engine')
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
presets: ['module:metro-react-native-babel-preset'],
|
presets: ['module:metro-react-native-babel-preset'],
|
||||||
plugins: ['react-native-reanimated/plugin', 'react-native-paper/babel'],
|
plugins: [
|
||||||
|
'@babel/plugin-transform-flow-strip-types',
|
||||||
|
['@babel/plugin-proposal-decorators', {legacy: true}],
|
||||||
|
['@babel/plugin-proposal-class-properties', {loose: true}],
|
||||||
|
'react-native-paper/babel',
|
||||||
|
'react-native-reanimated/plugin',
|
||||||
|
],
|
||||||
env: {
|
env: {
|
||||||
production: {
|
production: {
|
||||||
plugins: ['transform-remove-console'],
|
plugins: ['transform-remove-console'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
124
best.service.ts
124
best.service.ts
|
@ -1,90 +1,42 @@
|
||||||
import {db} from './db';
|
import { LIMIT } from "./constants";
|
||||||
import {Periods} from './periods';
|
import { setRepo } from "./db";
|
||||||
import Set from './set';
|
import GymSet from "./gym-set";
|
||||||
import {defaultSet} from './set.service';
|
|
||||||
import Volume from './volume';
|
|
||||||
|
|
||||||
export const getBestSet = async (name: string): Promise<Set> => {
|
export const getBestSet = async (name: string): Promise<GymSet> => {
|
||||||
const bestWeight = `
|
return setRepo
|
||||||
SELECT name, reps, unit, MAX(weight) AS weight
|
.createQueryBuilder()
|
||||||
FROM sets
|
.select()
|
||||||
WHERE name = ? AND NOT hidden
|
.addSelect("MAX(weight)", "weight")
|
||||||
GROUP BY name;
|
.where("name = :name", { name })
|
||||||
`;
|
.groupBy("name")
|
||||||
const bestReps = `
|
.addGroupBy("reps")
|
||||||
SELECT name, MAX(reps) as reps, unit, weight, sets, minutes, seconds
|
.orderBy("weight", "DESC")
|
||||||
FROM sets
|
.addOrderBy("reps", "DESC")
|
||||||
WHERE name = ? AND weight = ? AND NOT hidden
|
.getOne();
|
||||||
GROUP BY name;
|
|
||||||
`;
|
|
||||||
const [weightResult] = await db.executeSql(bestWeight, [name]);
|
|
||||||
if (!weightResult.rows.length) return {...defaultSet};
|
|
||||||
const [repsResult] = await db.executeSql(bestReps, [
|
|
||||||
name,
|
|
||||||
weightResult.rows.item(0).weight,
|
|
||||||
]);
|
|
||||||
return repsResult.rows.item(0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getWeightsBy = async (
|
export const getBestSets = ({
|
||||||
name: string,
|
term: term,
|
||||||
period: Periods,
|
offset,
|
||||||
): Promise<Set[]> => {
|
}: {
|
||||||
const select = `
|
term: string;
|
||||||
SELECT max(weight) AS weight,
|
offset?: number;
|
||||||
STRFTIME('%Y-%m-%d', created) as created, unit
|
}) => {
|
||||||
FROM sets
|
return setRepo
|
||||||
WHERE name = ? AND NOT hidden
|
.createQueryBuilder("gym_set")
|
||||||
AND DATE(created) >= DATE('now', 'weekday 0', ?)
|
.select(["gym_set.name", "gym_set.reps", "gym_set.weight"])
|
||||||
GROUP BY name, STRFTIME('%Y-%m-%d', created)
|
.groupBy("gym_set.name")
|
||||||
`;
|
.innerJoin(
|
||||||
let difference = '-7 days';
|
(qb) =>
|
||||||
if (period === Periods.Monthly) difference = '-1 months';
|
qb
|
||||||
else if (period === Periods.Yearly) difference = '-1 years';
|
.select(["gym_set2.name", "MAX(gym_set2.weight) AS max_weight"])
|
||||||
const [result] = await db.executeSql(select, [name, difference]);
|
.from(GymSet, "gym_set2")
|
||||||
return result.rows.raw();
|
.where("gym_set2.name LIKE (:name)", { name: `%${term.trim()}%` })
|
||||||
};
|
.groupBy("gym_set2.name"),
|
||||||
|
"subquery",
|
||||||
export const getVolumes = async (
|
"gym_set.name = subquery.gym_set2_name AND gym_set.weight = subquery.max_weight"
|
||||||
name: string,
|
)
|
||||||
period: Periods,
|
.limit(LIMIT)
|
||||||
): Promise<Volume[]> => {
|
.offset(offset || 0)
|
||||||
const select = `
|
.getMany();
|
||||||
SELECT sum(weight * reps) AS value,
|
|
||||||
STRFTIME('%Y-%m-%d', created) as created, unit
|
|
||||||
FROM sets
|
|
||||||
WHERE name = ? AND NOT hidden
|
|
||||||
AND DATE(created) >= DATE('now', 'weekday 0', ?)
|
|
||||||
GROUP BY name, STRFTIME('%Y-%m-%d', created)
|
|
||||||
`;
|
|
||||||
let difference = '-7 days';
|
|
||||||
if (period === Periods.Monthly) difference = '-1 months';
|
|
||||||
else if (period === Periods.Yearly) difference = '-1 years';
|
|
||||||
const [result] = await db.executeSql(select, [name, difference]);
|
|
||||||
return result.rows.raw();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBestWeights = async (search: string): Promise<Set[]> => {
|
|
||||||
const select = `
|
|
||||||
SELECT name, reps, unit, MAX(weight) AS weight
|
|
||||||
FROM sets
|
|
||||||
WHERE name LIKE ? AND NOT hidden
|
|
||||||
GROUP BY name;
|
|
||||||
`;
|
|
||||||
const [result] = await db.executeSql(select, [`%${search}%`]);
|
|
||||||
return result.rows.raw();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBestReps = async (
|
|
||||||
name: string,
|
|
||||||
weight: number,
|
|
||||||
): Promise<Set[]> => {
|
|
||||||
const select = `
|
|
||||||
SELECT name, MAX(reps) as reps, unit, weight, image
|
|
||||||
FROM sets
|
|
||||||
WHERE name = ? AND weight = ? AND NOT hidden
|
|
||||||
GROUP BY name;
|
|
||||||
`;
|
|
||||||
const [result] = await db.executeSql(select, [name, weight]);
|
|
||||||
return result.rows.raw();
|
|
||||||
};
|
};
|
||||||
|
|
44
colors.ts
44
colors.ts
|
@ -1,13 +1,41 @@
|
||||||
|
import { DefaultTheme, MD3DarkTheme } from "react-native-paper";
|
||||||
|
|
||||||
export const lightColors = [
|
export const lightColors = [
|
||||||
{hex: '#FA8072', name: 'Salmon'},
|
{ hex: MD3DarkTheme.colors.primary, name: "Purple" },
|
||||||
{hex: '#B3E5FC', name: 'Cyan'},
|
{ hex: "#B3E5FC", name: "Blue" },
|
||||||
{hex: '#FFC0CB', name: 'Pink'},
|
{ hex: "#FA8072", name: "Salmon" },
|
||||||
{hex: '#E9DCC9', name: 'Linen'},
|
{ hex: "#FFC0CB", name: "Pink" },
|
||||||
|
{ hex: "#E9DCC9", name: "Linen" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const darkColors = [
|
export const darkColors = [
|
||||||
{hex: '#8156A7', name: 'Purple'},
|
{ hex: DefaultTheme.colors.primary, name: "Purple" },
|
||||||
{hex: '#007AFF', name: 'Blue'},
|
{ hex: "#0051a9", name: "Blue" },
|
||||||
{hex: '#000000', name: 'Black'},
|
{ hex: "#000000", name: "Black" },
|
||||||
{hex: '#CD5C5C', name: 'Red'},
|
{ hex: "#863c3c", name: "Red" },
|
||||||
|
{ hex: "#1c6000", name: "Kermit" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const colorShade = (color: any, amount: number) => {
|
||||||
|
color = color.replace(/^#/, "");
|
||||||
|
if (color.length === 3) {
|
||||||
|
color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
let [r, g, b] = color.match(/.{2}/g);
|
||||||
|
[r, g, b] = [
|
||||||
|
parseInt(r, 16) + amount,
|
||||||
|
parseInt(g, 16) + amount,
|
||||||
|
parseInt(b, 16) + amount,
|
||||||
|
];
|
||||||
|
|
||||||
|
r = Math.max(Math.min(255, r), 0).toString(16);
|
||||||
|
g = Math.max(Math.min(255, g), 0).toString(16);
|
||||||
|
b = Math.max(Math.min(255, b), 0).toString(16);
|
||||||
|
|
||||||
|
const rr = (r.length < 2 ? "0" : "") + r;
|
||||||
|
const gg = (g.length < 2 ? "0" : "") + g;
|
||||||
|
const bb = (b.length < 2 ? "0" : "") + b;
|
||||||
|
|
||||||
|
return `#${rr}${gg}${bb}`;
|
||||||
|
};
|
||||||
|
|
|
@ -1,2 +1,6 @@
|
||||||
export const MARGIN = 10;
|
export const MARGIN = 10;
|
||||||
export const PADDING = 10;
|
export const PADDING = 10;
|
||||||
|
export const ITEM_PADDING = 8;
|
||||||
|
export const DARK_RIPPLE = "#444444";
|
||||||
|
export const LIGHT_RIPPLE = "#c2c2c2";
|
||||||
|
export const LIMIT = 15;
|
||||||
|
|
5
count-many.ts
Normal file
5
count-many.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export default interface CountMany {
|
||||||
|
name: string;
|
||||||
|
total: number;
|
||||||
|
sets?: number;
|
||||||
|
}
|
65
data-source.ts
Normal file
65
data-source.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
import { Sets1667185586014 as sets1667185586014 } from "./migrations/1667185586014-sets";
|
||||||
|
import { plans1667186124792 } from "./migrations/1667186124792-plans";
|
||||||
|
import { settings1667186130041 } from "./migrations/1667186130041-settings";
|
||||||
|
import { addSound1667186139844 } from "./migrations/1667186139844-add-sound";
|
||||||
|
import { addHidden1667186159379 } from "./migrations/1667186159379-add-hidden";
|
||||||
|
import { addNotify1667186166140 } from "./migrations/1667186166140-add-notify";
|
||||||
|
import { addImage1667186171548 } from "./migrations/1667186171548-add-image";
|
||||||
|
import { addImages1667186179488 } from "./migrations/1667186179488-add-images";
|
||||||
|
import { insertSettings1667186203827 } from "./migrations/1667186203827-insert-settings";
|
||||||
|
import { addSteps1667186211251 } from "./migrations/1667186211251-add-steps";
|
||||||
|
import { addSets1667186250618 } from "./migrations/1667186250618-add-sets";
|
||||||
|
import { addMinutes1667186255650 } from "./migrations/1667186255650-add-minutes";
|
||||||
|
import { addSeconds1667186259174 } from "./migrations/1667186259174-add-seconds";
|
||||||
|
import { addShowUnit1667186265588 } from "./migrations/1667186265588-add-show-unit";
|
||||||
|
import { addColor1667186320954 } from "./migrations/1667186320954-add-color";
|
||||||
|
import { addSteps1667186348425 } from "./migrations/1667186348425-add-steps";
|
||||||
|
import { addDate1667186431804 } from "./migrations/1667186431804-add-date";
|
||||||
|
import { addShowDate1667186435051 } from "./migrations/1667186435051-add-show-date";
|
||||||
|
import { addTheme1667186439366 } from "./migrations/1667186439366-add-theme";
|
||||||
|
import { addShowSets1667186443614 } from "./migrations/1667186443614-add-show-sets";
|
||||||
|
import { addSetsCreated1667186451005 } from "./migrations/1667186451005-add-sets-created";
|
||||||
|
import { addNoSound1667186456118 } from "./migrations/1667186456118-add-no-sound";
|
||||||
|
import { dropMigrations1667190214743 } from "./migrations/1667190214743-drop-migrations";
|
||||||
|
import { splitColor1669420187764 } from "./migrations/1669420187764-split-color";
|
||||||
|
import { addBackup1678334268359 } from "./migrations/1678334268359-add-backup";
|
||||||
|
import { Plan } from "./plan";
|
||||||
|
import Settings from "./settings";
|
||||||
|
|
||||||
|
export const AppDataSource = new DataSource({
|
||||||
|
type: "react-native",
|
||||||
|
database: "massive.db",
|
||||||
|
location: "default",
|
||||||
|
entities: [GymSet, Plan, Settings],
|
||||||
|
migrationsRun: true,
|
||||||
|
migrationsTableName: "typeorm_migrations",
|
||||||
|
migrations: [
|
||||||
|
sets1667185586014,
|
||||||
|
plans1667186124792,
|
||||||
|
settings1667186130041,
|
||||||
|
addSound1667186139844,
|
||||||
|
addHidden1667186159379,
|
||||||
|
addNotify1667186166140,
|
||||||
|
addImage1667186171548,
|
||||||
|
addImages1667186179488,
|
||||||
|
insertSettings1667186203827,
|
||||||
|
addSteps1667186211251,
|
||||||
|
addSets1667186250618,
|
||||||
|
addMinutes1667186255650,
|
||||||
|
addSeconds1667186259174,
|
||||||
|
addShowUnit1667186265588,
|
||||||
|
addColor1667186320954,
|
||||||
|
addSteps1667186348425,
|
||||||
|
addDate1667186431804,
|
||||||
|
addShowDate1667186435051,
|
||||||
|
addTheme1667186439366,
|
||||||
|
addShowSets1667186443614,
|
||||||
|
addSetsCreated1667186451005,
|
||||||
|
addNoSound1667186456118,
|
||||||
|
dropMigrations1667190214743,
|
||||||
|
splitColor1669420187764,
|
||||||
|
addBackup1678334268359,
|
||||||
|
],
|
||||||
|
});
|
137
db.ts
137
db.ts
|
@ -1,128 +1,15 @@
|
||||||
import {
|
import { AppDataSource } from "./data-source";
|
||||||
enablePromise,
|
import GymSet from "./gym-set";
|
||||||
openDatabase,
|
import { Plan } from "./plan";
|
||||||
SQLiteDatabase,
|
import Settings from "./settings";
|
||||||
} from 'react-native-sqlite-storage';
|
|
||||||
|
|
||||||
enablePromise(true);
|
export const setRepo = AppDataSource.manager.getRepository(GymSet);
|
||||||
|
export const planRepo = AppDataSource.manager.getRepository(Plan);
|
||||||
|
export const settingsRepo = AppDataSource.manager.getRepository(Settings);
|
||||||
|
|
||||||
const migrations = [
|
export const getNow = async (): Promise<string> => {
|
||||||
`
|
const query = await AppDataSource.manager.query(
|
||||||
CREATE TABLE IF NOT EXISTS sets (
|
"SELECT STRFTIME('%Y-%m-%dT%H:%M:%S','now','localtime') AS now"
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
);
|
||||||
name TEXT NOT NULL,
|
return query[0].now;
|
||||||
reps INTEGER NOT NULL,
|
|
||||||
weight INTEGER NOT NULL,
|
|
||||||
created TEXT NOT NULL,
|
|
||||||
unit TEXT DEFAULT 'kg'
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
CREATE TABLE IF NOT EXISTS plans (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
days TEXT NOT NULL,
|
|
||||||
workouts TEXT NOT NULL
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
|
||||||
minutes INTEGER NOT NULL DEFAULT 3,
|
|
||||||
seconds INTEGER NOT NULL DEFAULT 30,
|
|
||||||
alarm BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
vibrate BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
predict BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
sets INTEGER NOT NULL DEFAULT 3
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
`ALTER TABLE settings ADD COLUMN sound TEXT NULL`,
|
|
||||||
`
|
|
||||||
CREATE TABLE IF NOT EXISTS workouts(
|
|
||||||
name TEXT PRIMARY KEY,
|
|
||||||
sets INTEGER DEFAULT 3
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE sets ADD COLUMN hidden DEFAULT false
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE settings ADD COLUMN notify DEFAULT false
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE sets ADD COLUMN image TEXT NULL
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE settings ADD COLUMN images BOOLEAN DEFAULT false
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
SELECT * FROM settings LIMIT 1
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
INSERT INTO settings(minutes) VALUES(3)
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE workouts ADD COLUMN steps TEXT NULL
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
INSERT OR IGNORE INTO workouts (name) SELECT DISTINCT name FROM sets
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE sets ADD COLUMN sets INTEGER NOT NULL DEFAULT 3
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE sets ADD COLUMN minutes INTEGER NOT NULL DEFAULT 3
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE sets ADD COLUMN seconds INTEGER NOT NULL DEFAULT 30
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE settings ADD COLUMN showUnit BOOLEAN DEFAULT true
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE sets ADD COLUMN steps TEXT NULL
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
UPDATE sets SET steps = (
|
|
||||||
SELECT workouts.steps FROM workouts WHERE workouts.name = sets.name
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
DROP TABLE workouts
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE settings ADD COLUMN color TEXT NULL
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
UPDATE settings SET showUnit = 1
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE settings ADD COLUMN workouts BOOLEAN DEFAULT 1
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE settings ADD COLUMN steps BOOLEAN DEFAULT 1
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE settings ADD COLUMN nextAlarm TEXT NULL
|
|
||||||
`,
|
|
||||||
];
|
|
||||||
|
|
||||||
export let db: SQLiteDatabase;
|
|
||||||
|
|
||||||
export const runMigrations = async () => {
|
|
||||||
db = await openDatabase({name: 'massive.db'});
|
|
||||||
await db.executeSql(`
|
|
||||||
CREATE TABLE IF NOT EXISTS migrations(
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
command TEXT NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
const [result] = await db.executeSql(`SELECT * FROM migrations`);
|
|
||||||
const missing = migrations.slice(result.rows.length);
|
|
||||||
for (const command of missing) {
|
|
||||||
await db.executeSql(command).catch(console.error);
|
|
||||||
const insert = `
|
|
||||||
INSERT INTO migrations (command)
|
|
||||||
VALUES (?)
|
|
||||||
`;
|
|
||||||
await db.executeSql(insert, [command]);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
11
deno.json
Normal file
11
deno.json
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"fmt": {
|
||||||
|
"useTabs": false,
|
||||||
|
"lineWidth": 80,
|
||||||
|
"semiColons": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"include": ["src/"],
|
||||||
|
"exclude": ["src/testdata/", "data/fixtures/**/*.ts"]
|
||||||
|
}
|
||||||
|
}
|
39
deploy.sh
39
deploy.sh
|
@ -1,28 +1,39 @@
|
||||||
#!/bin/sh
|
#!/bin/bash
|
||||||
|
|
||||||
set -ex
|
set -ex
|
||||||
git push origin HEAD > /dev/null &
|
|
||||||
cd android || exit 1
|
cd android || exit 1
|
||||||
|
|
||||||
build=app/build.gradle
|
build=app/build.gradle
|
||||||
versionCode=$(
|
versionCode=$(
|
||||||
grep '^\s*versionCode [0-9]*$' "$build" | awk '{print $2+1}'
|
grep '^\s*versionCode [0-9]*$' "$build" | awk '{print $2+1}'
|
||||||
)
|
)
|
||||||
major=$(
|
major=$(
|
||||||
grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" \
|
grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" |
|
||||||
| sed 's/"//g' | cut -d '.' -f 1 | awk '{print $2}'
|
sed 's/"//g' | cut -d '.' -f 1 | awk '{print $2}'
|
||||||
)
|
)
|
||||||
minor=$(
|
minor=$(
|
||||||
grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" \
|
grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" |
|
||||||
| sed 's/"//g' | cut -d '.' -f 2
|
sed 's/"//g' | cut -d '.' -f 2
|
||||||
)
|
)
|
||||||
minor=$((minor+1))
|
minor=$((minor + 1))
|
||||||
|
|
||||||
sed -i "s/\(^\s*\)versionCode [0-9]*$/\1versionCode $versionCode/" \
|
sed -i "s/\(^\s*\)versionCode [0-9]*$/\1versionCode $versionCode/" \
|
||||||
"$build"
|
"$build"
|
||||||
sed -i "s/\(^\s*\)versionName \"[0-9]*.[0-9]*\"$/\1versionName \"$major.$minor\"/" "$build"
|
sed -i "s/\(^\s*\)versionName \"[0-9]*.[0-9]*\"$/\1versionName \"$major.$minor\"/" "$build"
|
||||||
sed -i "s/\"version\": \"[0-9]*.[0-9]*\"/\"version\": \"$major.$minor\"/" ../package.json
|
sed -i "s/\"version\": \"[0-9]*.[0-9]*\"/\"version\": \"$major.$minor\"/" ../package.json
|
||||||
[ "$1" != "--nobundle" ] && ./gradlew bundleRelease
|
|
||||||
|
if [ "$1" != "-n" ]; then
|
||||||
|
yarn tsc
|
||||||
|
yarn lint
|
||||||
|
./gradlew bundleRelease
|
||||||
|
bundle install
|
||||||
|
bundle exec fastlane supply --aab app/build/outputs/bundle/release/app-release.aab
|
||||||
|
fi
|
||||||
|
|
||||||
git add app/build.gradle ../package.json
|
git add app/build.gradle ../package.json
|
||||||
git commit --no-verify --message "Set versionCode=$versionCode"
|
git commit --amend --message \
|
||||||
git tag "$major.$minor"
|
"$(git log -1 --pretty=%B | sed " 1 s/.*/& - $major.$minor/")"
|
||||||
git push origin HEAD & git push --tags
|
git tag "$versionCode"
|
||||||
cd ..
|
git push origin HEAD
|
||||||
|
git push --tags
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
export type DrawerParamList = {
|
export type DrawerParamList = {
|
||||||
Home: {};
|
Home: {};
|
||||||
Settings: {};
|
Settings: {};
|
||||||
Best: {};
|
Graphs: {};
|
||||||
Plans: {};
|
Plans: {};
|
||||||
Workouts: {};
|
Workouts: {};
|
||||||
|
Timer: {};
|
||||||
};
|
};
|
||||||
|
|
11
fix-numeric.ts
Normal file
11
fix-numeric.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export const fixNumeric = (text: string) => {
|
||||||
|
let newText = text.replace(/[^0-9.-]/g, "");
|
||||||
|
let parts = newText.split(".");
|
||||||
|
if (parts.length > 2) {
|
||||||
|
newText = parts[0] + "." + parts.slice(1).join("");
|
||||||
|
}
|
||||||
|
if (newText.startsWith("-")) {
|
||||||
|
newText = "-" + newText.slice(1).replace(/-/g, "");
|
||||||
|
}
|
||||||
|
return newText;
|
||||||
|
};
|
53
gym-set.ts
Normal file
53
gym-set.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||||
|
|
||||||
|
@Entity("sets")
|
||||||
|
export default class GymSet {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id?: number;
|
||||||
|
|
||||||
|
@Column("text")
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column("int")
|
||||||
|
reps: number;
|
||||||
|
|
||||||
|
@Column("int")
|
||||||
|
weight: number;
|
||||||
|
|
||||||
|
@Column("int")
|
||||||
|
sets = 3;
|
||||||
|
|
||||||
|
@Column("int")
|
||||||
|
minutes = 3;
|
||||||
|
|
||||||
|
@Column("int")
|
||||||
|
seconds = 30;
|
||||||
|
|
||||||
|
@Column("boolean")
|
||||||
|
hidden = false;
|
||||||
|
|
||||||
|
@Column("text")
|
||||||
|
created: string;
|
||||||
|
|
||||||
|
@Column("text")
|
||||||
|
unit: string;
|
||||||
|
|
||||||
|
@Column("text")
|
||||||
|
image: string;
|
||||||
|
|
||||||
|
@Column("text")
|
||||||
|
steps?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultSet: GymSet = {
|
||||||
|
created: "",
|
||||||
|
name: "",
|
||||||
|
image: "",
|
||||||
|
hidden: false,
|
||||||
|
minutes: 3,
|
||||||
|
seconds: 30,
|
||||||
|
reps: 0,
|
||||||
|
sets: 0,
|
||||||
|
unit: "kg",
|
||||||
|
weight: 0,
|
||||||
|
};
|
|
@ -1,10 +1,11 @@
|
||||||
import Set from './set';
|
import GymSet from "./gym-set";
|
||||||
|
|
||||||
export type HomePageParams = {
|
export type HomePageParams = {
|
||||||
Sets: {};
|
Sets: {};
|
||||||
EditSet: {
|
EditSet: {
|
||||||
set: Set;
|
set: GymSet;
|
||||||
workouts: string[];
|
};
|
||||||
count: number;
|
EditSets: {
|
||||||
|
ids: number[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user