Compare commits
446 Commits
react-hook
...
master
Author | SHA1 | Date |
---|---|---|
Joseph | c2f93e832e | |
Brandon Presley | 26f1e95db0 | |
Brandon Presley | d0e76f574b | |
Brandon Presley | 52f642c2af | |
Brandon Presley | b7b974fb02 | |
Brandon Presley | 6d22bee440 | |
Joseph | 58b8488a27 | |
brandon.presley | 745f9fb046 | |
Joseph | b979d0943f | |
Joseph | 7c35da3f5b | |
Brandon Presley | 817ac089d3 | |
Brandon Presley | c77d1dbcfb | |
Brandon Presley | 2d5e0620af | |
Joseph | 12f906bfc3 | |
Brandon Presley | eb30d81003 | |
Brandon Presley | fd15d10028 | |
Brandon Presley | 7abcea5710 | |
Brandon Presley | e7c0460166 | |
Brandon Presley | c2accf7202 | |
Brandon Presley | 164d946b90 | |
Brandon Presley | 6ad7091503 | |
Brandon Presley | b681aa82d6 | |
Brandon Presley | d7599ff39b | |
Brandon Presley | 9f20954ff5 | |
Brandon Presley | be601ac7e4 | |
Brandon Presley | 6deb772f99 | |
Brandon Presley | 66d9894c19 | |
Brandon Presley | 5b066bccaf | |
Brandon Presley | d5fab6d6d2 | |
Brandon Presley | c6ac2cae86 | |
Brandon Presley | 8162724328 | |
Brandon Presley | d89e307950 | |
Brandon Presley | a9367cd53b | |
Brandon Presley | f5f96035a0 | |
Brandon Presley | 974d2207db | |
Brandon Presley | cc5089d4b4 | |
Brandon Presley | 2d9c69a3dd | |
Brandon Presley | 495d6b35b7 | |
Brandon Presley | 617fca0094 | |
Brandon Presley | eea6c96e8e | |
Brandon Presley | 9e3f2fea78 | |
Brandon Presley | a0dc62e761 | |
Brandon Presley | 47cfaa4b67 | |
Brandon Presley | 5355b0eb6a | |
Brandon Presley | 1e7c994209 | |
Brandon Presley | 6e604d7618 | |
Brandon Presley | e3d3aad153 | |
Brandon Presley | 3c0f4ce8ad | |
Brandon Presley | 183d609bea | |
Brandon Presley | a9acc6f216 | |
Brandon Presley | fd09758ccf | |
Brandon Presley | 5f2327de31 | |
Brandon Presley | f9fb190f80 | |
Brandon Presley | b24cb85a70 | |
Brandon Presley | 05b4aa75bb | |
Brandon Presley | c822acb544 | |
Brandon Presley | 9471d7ce18 | |
Brandon Presley | 000f53a9fb | |
Brandon Presley | 0a19d33b50 | |
Brandon Presley | 9ddd2e963c | |
Brandon Presley | 5e34bd4570 | |
Brandon Presley | bfc1b3d546 | |
Brandon Presley | 5e420ec9c4 | |
Brandon Presley | 47bff2d07c | |
Brandon Presley | 71d425ca03 | |
Brandon Presley | 20781ddafe | |
Brandon Presley | 655fe8ad53 | |
Brandon Presley | 6b60c41ac8 | |
Brandon Presley | 57bc6caffb | |
Brandon Presley | ff365c791b | |
Brandon Presley | 4d23cf6106 | |
Brandon Presley | 60eec2c482 | |
Brandon Presley | 00ae63c9ba | |
Brandon Presley | 9a4f2599a6 | |
Brandon Presley | 6ea65bcd16 | |
Brandon Presley | cf90b798ab | |
Brandon Presley | c9d297e769 | |
Brandon Presley | 89b62d69aa | |
Brandon Presley | 4c065a027b | |
Brandon Presley | 9c3d6772a9 | |
Brandon Presley | 1921ecb9f4 | |
Brandon Presley | f8a4157c33 | |
Brandon Presley | 9650a17b61 | |
Brandon Presley | 15557d1a02 | |
Brandon Presley | 5991a6de39 | |
Brandon Presley | 07c704841d | |
Brandon Presley | 1f6100607d | |
Brandon Presley | d648850892 | |
Brandon Presley | ab91bbe88f | |
Brandon Presley | 936a47b8b2 | |
Brandon Presley | 6a9e2224ec | |
Brandon Presley | 9b881c3d58 | |
Brandon Presley | 5ce3b9e69c | |
Brandon Presley | 9dd4e70d33 | |
Brandon Presley | 8fbc92920d | |
Brandon Presley | 31f9ddede3 | |
brandon.presley | b42fb6b2b6 | |
brandon.presley | 9b52aec554 | |
Brandon Presley | 426c557019 | |
Sven Frotscher | fe695de487 | |
Brandon Presley | 5a3b926dcf | |
Brandon Presley | 81421e5be7 | |
Brandon Presley | 19307a2a3c | |
Brandon Presley | 8e9a6be85d | |
Brandon Presley | 3ed87114d2 | |
Brandon Presley | 5a9030dae7 | |
Brandon Presley | fe68ddfae8 | |
Brandon Presley | 9ab07c0114 | |
Brandon Presley | c18072bdc0 | |
Brandon Presley | 610d55c932 | |
Brandon Presley | 0163788175 | |
Brandon Presley | 8a1e1b982a | |
Brandon Presley | 8cfdc354dc | |
Brandon Presley | c98706bd56 | |
Brandon Presley | 303d8fa819 | |
Brandon Presley | e20d07bcf8 | |
Brandon Presley | cb2fa2fb0c | |
Brandon Presley | abbe702f24 | |
Brandon Presley | 2a78d2e556 | |
Brandon Presley | 3882a95b65 | |
Brandon Presley | 7e764062f4 | |
Brandon Presley | 857af61eec | |
Brandon Presley | 15a28a0a81 | |
Brandon Presley | 2f24104d13 | |
Brandon Presley | cc6b37e16a | |
Brandon Presley | b485175082 | |
Brandon Presley | 086e3ea2df | |
Brandon Presley | 1b164aaaf1 | |
Brandon Presley | b44cbae131 | |
Brandon Presley | 608bb3e97a | |
Brandon Presley | f6a75d89cd | |
Brandon Presley | 0592a9d695 | |
Brandon Presley | 3544392002 | |
Brandon Presley | bdc648d811 | |
Brandon Presley | 50b3a2ef3d | |
Brandon Presley | 7a24d844c5 | |
Brandon Presley | ec17ad5805 | |
Brandon Presley | ba24649a52 | |
Brandon Presley | 70a318a0a4 | |
Brandon Presley | 6b168cccfe | |
Brandon Presley | b68587f514 | |
Brandon Presley | 1b217f1905 | |
Brandon Presley | c3a8034dd4 | |
Brandon Presley | 9cc0eae66a | |
Brandon Presley | 3ac1e74575 | |
Brandon Presley | c507370398 | |
Brandon Presley | 35a3ef75b6 | |
Brandon Presley | 859818e5b6 | |
Brandon Presley | ddceb91211 | |
Brandon Presley | 976bb7c189 | |
Brandon Presley | 8ee07823aa | |
Brandon Presley | 456af73e91 | |
Brandon Presley | a7db87c61a | |
Brandon Presley | b9473a8b01 | |
Brandon Presley | 5a06b7ee2c | |
Brandon Presley | bec564e18b | |
Brandon Presley | 4dd8a2950f | |
Brandon Presley | d0f6550f29 | |
Brandon Presley | be3af4db22 | |
Brandon Presley | 4b5e7011d6 | |
Brandon Presley | 315279e28d | |
Brandon Presley | d8eba22914 | |
Brandon Presley | 1ac78de724 | |
Brandon Presley | 6950cd04f4 | |
Brandon Presley | 49646c3107 | |
Brandon Presley | 7f4c0a5f10 | |
Brandon Presley | ec0fdbcec7 | |
Brandon Presley | 155eaddbdd | |
Brandon Presley | b6afbfcc17 | |
Brandon Presley | d0c0a52ab4 | |
Brandon Presley | 79e462efc2 | |
Brandon Presley | 84ff8a110b | |
Brandon Presley | bf6863000f | |
Brandon Presley | e65c053a62 | |
Brandon Presley | 054ae4557d | |
Brandon Presley | 92dd65ffee | |
Brandon Presley | afed5f1d54 | |
Brandon Presley | 52f04ad11c | |
Brandon Presley | ef63fcf470 | |
Brandon Presley | 901cc72fbd | |
Brandon Presley | 706d4d1bbd | |
Brandon Presley | 75263af8b3 | |
Brandon Presley | 70fec83ec3 | |
Brandon Presley | 1ff6a87155 | |
Brandon Presley | 9cbe261938 | |
Brandon Presley | 813928bdd3 | |
Brandon Presley | 8988e584ae | |
Brandon Presley | 6754e2a8ae | |
Brandon Presley | 3e1ea50914 | |
Brandon Presley | e5db6fe34b | |
Brandon Presley | 9fee26f7c8 | |
Brandon Presley | 307ad4c9dd | |
Brandon Presley | 2fdb220659 | |
Brandon Presley | 2e96398b38 | |
Brandon Presley | 1a289f1b7b | |
Brandon | c70d6541b2 | |
Brandon Presley | d41bafdecb | |
Brandon Presley | 31b11aefd6 | |
Brandon Presley | 8a88c8e7af | |
Brandon Presley | ec162911de | |
Brandon Presley | f1075c3b28 | |
Brandon Presley | 43ab666540 | |
Brandon Presley | 1a2d7a27a0 | |
Brandon Presley | 2f4574f231 | |
Brandon Presley | add8b01e4c | |
Brandon Presley | 5d45d33572 | |
Brandon Presley | 3d54f61a2c | |
Brandon Presley | 744ed928f0 | |
Brandon Presley | 9cd205686f | |
Brandon Presley | 54596a5fc3 | |
Brandon Presley | e8ee4a253e | |
Brandon Presley | b4154b336f | |
Brandon Presley | 589efb56bd | |
Brandon Presley | 347423698d | |
Brandon Presley | c3f44fba03 | |
Brandon Presley | 915f09848b | |
Brandon Presley | 7ea91eeca9 | |
Brandon Presley | 32da68e905 | |
Brandon Presley | 39b87ba932 | |
Brandon Presley | 2428a51a02 | |
Brandon Presley | 541e8741e8 | |
Brandon Presley | 1d13cb9c5d | |
Brandon Presley | 717c07d512 | |
Brandon Presley | e0b2adbb66 | |
Brandon Presley | 1c10e0f632 | |
Brandon Presley | f28406b4c4 | |
Brandon Presley | e106d2475b | |
Brandon Presley | a176036c33 | |
Brandon Presley | f61109cea3 | |
Brandon Presley | b1d77cbdce | |
Brandon Presley | 805f982ccf | |
Brandon Presley | ab107793e4 | |
Brandon Presley | cb5aa72552 | |
Brandon Presley | 28250f1862 | |
Brandon Presley | 80dc5d2b63 | |
Brandon Presley | a35aba7b97 | |
Brandon Presley | ff7cd2fe54 | |
Brandon Presley | 7928cab4c1 | |
Brandon Presley | 12dfa923e5 | |
Brandon Presley | 38167a47b9 | |
Brandon Presley | b508df0680 | |
Brandon Presley | a3b376badb | |
Brandon Presley | cfcc15600c | |
Brandon Presley | 44184516f7 | |
Brandon Presley | c88642b2ef | |
Brandon Presley | 4cca538d74 | |
Brandon Presley | 90006d3b82 | |
Brandon Presley | 22f5f3c9ee | |
Brandon Presley | b4f6f12b1a | |
Brandon Presley | edf54d047e | |
Brandon Presley | 9867dee514 | |
Brandon Presley | 57883266b8 | |
Brandon Presley | adbc87f462 | |
Brandon Presley | 07cb634883 | |
Brandon Presley | c480d3e382 | |
Brandon Presley | c9773af92d | |
Brandon Presley | 9ae311b94a | |
Brandon Presley | ec72824e3c | |
Brandon Presley | 0ba7616ea2 | |
Brandon Presley | 4b1bbf2395 | |
Brandon Presley | 7eabe63198 | |
Brandon Presley | 386a9a7bb2 | |
Brandon Presley | 103ae5587d | |
Brandon Presley | da72692616 | |
Brandon Presley | f1e8988e56 | |
Brandon Presley | 6b524dce34 | |
Brandon Presley | 82234a30a8 | |
Brandon Presley | 185ebd1824 | |
Brandon Presley | f0d5fc4fa6 | |
Brandon Presley | de25cead60 | |
Brandon Presley | a9b69638a6 | |
Brandon Presley | 94a5fa4ac7 | |
Brandon Presley | 1367f74280 | |
Brandon Presley | a294b76a4e | |
Brandon Presley | 24fd687856 | |
Brandon Presley | bd9746bddb | |
Brandon Presley | dd609a20e5 | |
Brandon Presley | d2cad451fe | |
Brandon Presley | 2a6ba3b36a | |
Brandon Presley | 0c5562a2f1 | |
Brandon Presley | 672931746b | |
Leon Babic | 314b09017b | |
Brandon Presley | 8e42e9c3e4 | |
Brandon Presley | 9fbae74a01 | |
Brandon Presley | 331597e3ee | |
Brandon Presley | dc5434991a | |
Brandon Presley | 79cde3a219 | |
Brandon Presley | 63e1db7349 | |
Brandon Presley | da17f8899c | |
Brandon Presley | 8648cf166e | |
Brandon Presley | af96ec8507 | |
Brandon Presley | f51284e4ea | |
Brandon Presley | f778426aba | |
Brandon Presley | 44283fc990 | |
Brandon Presley | c97ba1151e | |
Brandon Presley | 95681c0b3d | |
Brandon Presley | 158dd61668 | |
Brandon Presley | e628d345ca | |
Brandon Presley | 85915b9aa0 | |
Brandon Presley | 9833752bab | |
Brandon Presley | 556347e632 | |
Brandon Presley | 9dc188e6ec | |
Brandon Presley | 82da62f699 | |
Brandon Presley | 36d3de401b | |
Brandon Presley | 040d588b5a | |
Brandon Presley | 47d4532169 | |
Brandon Presley | 3e41c3bbd8 | |
Brandon Presley | b776d88327 | |
Brandon Presley | adc2d05b2c | |
Brandon Presley | c3a3e33e25 | |
Brandon Presley | 89606b9d21 | |
Brandon Presley | 6dabb7049f | |
Brandon Presley | 4b42ab5f21 | |
Brandon Presley | a7da93583d | |
Brandon Presley | 1b2cbab370 | |
Brandon Presley | 09354829a8 | |
Brandon Presley | 514efc6467 | |
Brandon Presley | 1603496424 | |
Brandon Presley | 0beb1397a6 | |
Brandon Presley | a5b6673e9a | |
Brandon Presley | 6a7bd632e5 | |
Brandon Presley | 4303fe2cc4 | |
Brandon Presley | 23ed95dcdb | |
Brandon Presley | 8f1f9f6e7d | |
Brandon Presley | bdd5e23f32 | |
Brandon Presley | 9c9a5fdd63 | |
Brandon Presley | 90db607190 | |
Brandon Presley | 457134df6b | |
Brandon Presley | db5cc566ea | |
Brandon Presley | 76e5aeacfd | |
Brandon Presley | 2fb46e1dcc | |
Brandon Presley | d1342c0efa | |
Brandon Presley | 288ae1ae0c | |
Brandon Presley | 0e7920bde9 | |
Brandon Presley | d2a1c432bb | |
Brandon Presley | 5dd569ef72 | |
Brandon Presley | dfc4f73ca4 | |
Brandon Presley | 79a48b1e47 | |
Brandon Presley | 13b340f5be | |
Brandon Presley | 4db820f10a | |
Brandon Presley | 7b401388b5 | |
Brandon Presley | a1643c349d | |
Brandon Presley | 640a25a0f4 | |
Brandon Presley | c9b1ab1f9d | |
Brandon Presley | 00d4edcfc3 | |
Brandon Presley | 8dd8f786ef | |
Brandon Presley | a84cab6bbf | |
Brandon Presley | f4db61aeec | |
Brandon Presley | 3af3e1faf2 | |
Brandon Presley | 7bc9c00a63 | |
Brandon | a03731c6ff | |
Brandon | f1e0911488 | |
Brandon | 1a75d8897d | |
Brandon | 9f7cbba80a | |
Brandon | de2aa67e6e | |
Brandon | 28ec021258 | |
Brandon Presley | 04ef72e48b | |
Brandon Presley | 467df629b0 | |
Brandon Presley | e7f85a9954 | |
Brandon Presley | 5e6896eaba | |
Brandon Presley | 6438a9c48a | |
Brandon Presley | 8e8961419c | |
Brandon Presley | b0696d1d58 | |
Brandon Presley | 73d9b1c617 | |
Brandon Presley | a6130b3a10 | |
Brandon Presley | 7bee8ae732 | |
Brandon Presley | 6c8731c17a | |
Brandon Presley | 6fa2bbb506 | |
Brandon Presley | 069f770c96 | |
Brandon Presley | b41c30d886 | |
Brandon Presley | 495b89fba3 | |
Brandon Presley | 42912040ff | |
Brandon Presley | c7952738b5 | |
Brandon Presley | cffc458338 | |
Brandon Presley | 05237fc293 | |
Brandon Presley | 5fd7e75908 | |
Brandon Presley | 705052f1b4 | |
Brandon Presley | efc97bdf47 | |
Brandon Presley | d0702b7675 | |
Brandon Presley | 24e230e8b9 | |
Brandon Presley | 67689f4af8 | |
Brandon Presley | a2721e9f12 | |
Brandon Presley | bafdecd3e3 | |
Brandon Presley | e432c1b711 | |
Brandon Presley | 08f91bf531 | |
Brandon Presley | 80f2dfdff5 | |
Brandon Presley | 3c9b93f0bc | |
Brandon Presley | f221ebb8df | |
Brandon Presley | a68d4d6a69 | |
Brandon Presley | 5d9df37778 | |
Brandon Presley | 8246155c13 | |
Brandon Presley | 58f1c905b2 | |
Brandon Presley | 805b7bdc34 | |
Brandon Presley | a772e36160 | |
Brandon Presley | ad95438120 | |
Brandon Presley | 3c953530a4 | |
Brandon Presley | 13e1d4cc21 | |
Brandon Presley | 21773e3b4f | |
Brandon Presley | 27ff4861d9 | |
Brandon Presley | e43188ccdf | |
Brandon Presley | 9287c31e70 | |
Brandon Presley | 5612df5d8c | |
Brandon Presley | a78e07dac8 | |
Brandon Presley | 86f01eb002 | |
Brandon Presley | 5335f4afbc | |
Brandon Presley | d71ad8c170 | |
Brandon Presley | 53799fdcc4 | |
Brandon Presley | 651b130caa | |
Brandon Presley | 73c7486eb3 | |
Brandon Presley | ea2ff913db | |
Brandon Presley | 0be8f03133 | |
Brandon Presley | 7863b9caa0 | |
Brandon Presley | 3fdc5900e3 | |
Brandon Presley | e51aad21f3 | |
Brandon Presley | f48124123c | |
Brandon Presley | 3603c67133 | |
Brandon Presley | a9000898f3 | |
Brandon Presley | dd7cb0406b | |
Brandon Presley | a5ddf5c94d | |
Brandon Presley | 9be10610d2 | |
Brandon Presley | 5e37490c2d | |
Brandon Presley | 7f1513f0a5 | |
Brandon Presley | 14edb66e28 | |
Brandon Presley | 3ed2d4f0cd | |
Brandon Presley | 46dd50adfb | |
Brandon Presley | e430873771 | |
Brandon Presley | a9266ba77b | |
Brandon Presley | 051df31925 | |
Brandon Presley | a3138c48b5 | |
Brandon Presley | 2b302bab73 | |
Brandon Presley | 7bf802ea45 | |
Brandon Presley | 5115055280 | |
Brandon Presley | 41ed9464c9 | |
Brandon Presley | 596b695c5b | |
Brandon Presley | c664a9603c | |
Brandon Presley | 186b7e0fe9 | |
Brandon Presley | d6e7d6158c | |
Brandon Presley | 2c6a773548 | |
Brandon Presley | b33a829816 | |
Brandon Presley | 85ea20640d | |
Tiffany Barclay | 2176acd924 | |
Tiffany Barclay | 7e81424f60 | |
Tiffany Barclay | 736cee5ccd | |
Brandon Presley | a9b86fb555 | |
Brandon Presley | 60cc619e39 | |
Brandon Presley | 48432188c3 |
|
@ -1,12 +1,12 @@
|
||||||
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', '*.js'],
|
files: ['*.ts', '*.tsx', '*.js'],
|
||||||
rules: {
|
rules: {
|
||||||
|
'jsx-quotes': 0,
|
||||||
|
'prettier/prettier': 0,
|
||||||
'@typescript-eslint/no-shadow': ['error'],
|
'@typescript-eslint/no-shadow': ['error'],
|
||||||
'no-shadow': 'off',
|
'no-shadow': 'off',
|
||||||
'no-undef': 'off',
|
'no-undef': 'off',
|
||||||
|
@ -18,4 +18,5 @@ module.exports = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
ignorePatterns: ['coverage/', 'mock-providers.tsx'],
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,3 +73,5 @@ massive-build
|
||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/sdks
|
!.yarn/sdks
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
coverage
|
||||||
|
profiles
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
arrowParens: 'avoid',
|
|
||||||
bracketSameLine: true,
|
|
||||||
bracketSpacing: false,
|
|
||||||
singleQuote: true,
|
|
||||||
trailingComma: 'all',
|
|
||||||
semi: false,
|
|
||||||
};
|
|
176
App.tsx
176
App.tsx
|
@ -1,23 +1,22 @@
|
||||||
import {
|
import {
|
||||||
|
NavigationContainer,
|
||||||
DarkTheme as NavigationDarkTheme,
|
DarkTheme as NavigationDarkTheme,
|
||||||
DefaultTheme as NavigationDefaultTheme,
|
DefaultTheme as NavigationDefaultTheme,
|
||||||
NavigationContainer,
|
} from "@react-navigation/native";
|
||||||
} from '@react-navigation/native'
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import {useEffect, useMemo, useState} from 'react'
|
import { useColorScheme } from "react-native";
|
||||||
import {DeviceEventEmitter, useColorScheme} from 'react-native'
|
|
||||||
import React from 'react'
|
|
||||||
import {
|
import {
|
||||||
DarkTheme as PaperDarkTheme,
|
MD3DarkTheme as PaperDarkTheme,
|
||||||
DefaultTheme as PaperDefaultTheme,
|
MD3LightTheme as PaperDefaultTheme,
|
||||||
Provider as PaperProvider,
|
Provider as PaperProvider,
|
||||||
Snackbar,
|
} from "react-native-paper";
|
||||||
} from 'react-native-paper'
|
import MaterialIcon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||||
import MaterialIcon from 'react-native-vector-icons/MaterialIcons'
|
import AppSnack from "./AppSnack";
|
||||||
import {AppDataSource} from './data-source'
|
import AppStack from "./AppStack";
|
||||||
import {settingsRepo} from './db'
|
import FatalError from "./FatalError";
|
||||||
import Routes from './Routes'
|
import { AppDataSource } from "./data-source";
|
||||||
import {TOAST} from './toast'
|
import { settingsRepo } from "./db";
|
||||||
import {ThemeContext} from './use-theme'
|
import { ThemeContext } from "./use-theme";
|
||||||
|
|
||||||
export const CombinedDefaultTheme = {
|
export const CombinedDefaultTheme = {
|
||||||
...NavigationDefaultTheme,
|
...NavigationDefaultTheme,
|
||||||
|
@ -26,7 +25,7 @@ export const CombinedDefaultTheme = {
|
||||||
...NavigationDefaultTheme.colors,
|
...NavigationDefaultTheme.colors,
|
||||||
...PaperDefaultTheme.colors,
|
...PaperDefaultTheme.colors,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export const CombinedDarkTheme = {
|
export const CombinedDarkTheme = {
|
||||||
...NavigationDarkTheme,
|
...NavigationDarkTheme,
|
||||||
|
@ -35,99 +34,92 @@ export const CombinedDarkTheme = {
|
||||||
...NavigationDarkTheme.colors,
|
...NavigationDarkTheme.colors,
|
||||||
...PaperDarkTheme.colors,
|
...PaperDarkTheme.colors,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const isDark = useColorScheme() === 'dark'
|
console.log("Re rendered app");
|
||||||
const [initialized, setInitialized] = useState(false)
|
const systemTheme = useColorScheme();
|
||||||
const [snackbar, setSnackbar] = useState('')
|
|
||||||
const [theme, setTheme] = useState('system')
|
|
||||||
|
|
||||||
const [lightColor, setLightColor] = useState<string>(
|
const [appSettings, setAppSettings] = useState({
|
||||||
CombinedDefaultTheme.colors.primary,
|
startup: undefined,
|
||||||
)
|
theme: "system",
|
||||||
|
lightColor: CombinedDefaultTheme.colors.primary,
|
||||||
const [darkColor, setDarkColor] = useState<string>(
|
darkColor: CombinedDarkTheme.colors.primary,
|
||||||
CombinedDarkTheme.colors.primary,
|
});
|
||||||
)
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
(async () => {
|
||||||
if (!AppDataSource.isInitialized) await AppDataSource.initialize()
|
if (!AppDataSource.isInitialized)
|
||||||
const settings = await settingsRepo.findOne({where: {}})
|
await AppDataSource.initialize().catch((e) => setError(e.toString()));
|
||||||
console.log(`${App.name}.useEffect:`, {gotSettings: settings})
|
|
||||||
setTheme(settings.theme)
|
const gotSettings = await settingsRepo.findOne({ where: {} });
|
||||||
if (settings.lightColor) setLightColor(settings.lightColor)
|
console.log(`${App.name}.mount`, { gotSettings });
|
||||||
if (settings.darkColor) setDarkColor(settings.darkColor)
|
setAppSettings({
|
||||||
setInitialized(true)
|
startup: gotSettings.startup,
|
||||||
}
|
theme: gotSettings.theme,
|
||||||
init()
|
lightColor:
|
||||||
const description = DeviceEventEmitter.addListener(
|
gotSettings.lightColor || CombinedDefaultTheme.colors.primary,
|
||||||
TOAST,
|
darkColor: gotSettings.darkColor || CombinedDarkTheme.colors.primary,
|
||||||
({value}: {value: string}) => {
|
});
|
||||||
setSnackbar(value)
|
})();
|
||||||
},
|
}, []);
|
||||||
)
|
|
||||||
return description.remove
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const paperTheme = useMemo(() => {
|
const paperTheme = useMemo(() => {
|
||||||
const darkTheme = lightColor
|
const darkTheme = {
|
||||||
? {
|
...CombinedDarkTheme,
|
||||||
...CombinedDarkTheme,
|
colors: {
|
||||||
colors: {...CombinedDarkTheme.colors, primary: darkColor},
|
...CombinedDarkTheme.colors,
|
||||||
}
|
primary: appSettings.darkColor,
|
||||||
: CombinedDarkTheme
|
},
|
||||||
const lightTheme = lightColor
|
};
|
||||||
? {
|
const lightTheme = {
|
||||||
...CombinedDefaultTheme,
|
...CombinedDefaultTheme,
|
||||||
colors: {...CombinedDefaultTheme.colors, primary: lightColor},
|
colors: {
|
||||||
}
|
...CombinedDefaultTheme.colors,
|
||||||
: CombinedDefaultTheme
|
primary: appSettings.lightColor,
|
||||||
let value = isDark ? darkTheme : lightTheme
|
},
|
||||||
if (theme === 'dark') value = darkTheme
|
};
|
||||||
else if (theme === 'light') value = lightTheme
|
let theme = systemTheme === "dark" ? darkTheme : lightTheme;
|
||||||
return value
|
if (appSettings.theme === "dark") theme = darkTheme;
|
||||||
}, [isDark, theme, lightColor, darkColor])
|
else if (appSettings.theme === "light") theme = lightTheme;
|
||||||
|
return theme;
|
||||||
const action = useMemo(
|
}, [systemTheme, appSettings]);
|
||||||
() => ({
|
|
||||||
label: 'Close',
|
|
||||||
onPress: () => setSnackbar(''),
|
|
||||||
color: paperTheme.colors.background,
|
|
||||||
}),
|
|
||||||
[paperTheme.colors.background],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PaperProvider
|
<PaperProvider
|
||||||
theme={paperTheme}
|
theme={paperTheme}
|
||||||
settings={{icon: props => <MaterialIcon {...props} />}}>
|
settings={{ icon: (props) => <MaterialIcon {...props} /> }}
|
||||||
|
>
|
||||||
<NavigationContainer theme={paperTheme}>
|
<NavigationContainer theme={paperTheme}>
|
||||||
{initialized && (
|
{error && (
|
||||||
|
<FatalError
|
||||||
|
message={error}
|
||||||
|
setAppSettings={setAppSettings}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{appSettings.startup !== undefined && (
|
||||||
<ThemeContext.Provider
|
<ThemeContext.Provider
|
||||||
value={{
|
value={{
|
||||||
theme,
|
theme: appSettings.theme,
|
||||||
setTheme,
|
setTheme: (theme) => setAppSettings({ ...appSettings, theme }),
|
||||||
lightColor,
|
lightColor: appSettings.lightColor,
|
||||||
setLightColor,
|
setLightColor: (color) =>
|
||||||
darkColor,
|
setAppSettings({ ...appSettings, lightColor: color }),
|
||||||
setDarkColor,
|
darkColor: appSettings.darkColor,
|
||||||
}}>
|
setDarkColor: (color) =>
|
||||||
<Routes />
|
setAppSettings({ ...appSettings, darkColor: color }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppStack startup={appSettings.startup} />
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
)}
|
)}
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|
||||||
<Snackbar
|
<AppSnack textColor={paperTheme.colors.background} />
|
||||||
duration={3000}
|
|
||||||
onDismiss={() => setSnackbar('')}
|
|
||||||
visible={!!snackbar}
|
|
||||||
action={action}>
|
|
||||||
{snackbar}
|
|
||||||
</Snackbar>
|
|
||||||
</PaperProvider>
|
</PaperProvider>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { createDrawerNavigator } from "@react-navigation/drawer";
|
||||||
|
import { StackScreenProps } from "@react-navigation/stack";
|
||||||
|
import { IconButton, useTheme, Banner } from "react-native-paper";
|
||||||
|
import { DrawerParams } from "./drawer-params";
|
||||||
|
import ExerciseList from "./ExerciseList";
|
||||||
|
import GraphsList from "./GraphList";
|
||||||
|
import InsightsPage from "./InsightsPage";
|
||||||
|
import PlanList from "./PlanList";
|
||||||
|
import SetList from "./SetList";
|
||||||
|
import SettingsPage from "./SettingsPage";
|
||||||
|
import WeightList from "./WeightList";
|
||||||
|
import Daily from "./Daily";
|
||||||
|
|
||||||
|
const Drawer = createDrawerNavigator<DrawerParams>();
|
||||||
|
|
||||||
|
interface AppDrawerParams {
|
||||||
|
startup: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppDrawer({
|
||||||
|
route,
|
||||||
|
}: StackScreenProps<{ startup: AppDrawerParams }>) {
|
||||||
|
const { dark } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerTintColor: dark ? "white" : "black",
|
||||||
|
swipeEdgeWidth: 1000,
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
initialRouteName={
|
||||||
|
(route.params.startup as keyof DrawerParams) || "History"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Drawer.Screen
|
||||||
|
name="History"
|
||||||
|
component={SetList}
|
||||||
|
options={{ drawerIcon: () => <IconButton icon="history" /> }}
|
||||||
|
/>
|
||||||
|
<Drawer.Screen
|
||||||
|
name="Exercises"
|
||||||
|
component={ExerciseList}
|
||||||
|
options={{ drawerIcon: () => <IconButton icon="dumbbell" /> }}
|
||||||
|
/>
|
||||||
|
<Drawer.Screen
|
||||||
|
name="Daily"
|
||||||
|
component={Daily}
|
||||||
|
options={{ drawerIcon: () => <IconButton icon="calendar-outline" /> }}
|
||||||
|
/>
|
||||||
|
<Drawer.Screen
|
||||||
|
name="Plans"
|
||||||
|
component={PlanList}
|
||||||
|
options={{ drawerIcon: () => <IconButton icon="checkbox-multiple-marked-outline" /> }}
|
||||||
|
/>
|
||||||
|
<Drawer.Screen
|
||||||
|
name="Graphs"
|
||||||
|
component={GraphsList}
|
||||||
|
options={{
|
||||||
|
drawerIcon: () => <IconButton icon="chart-bell-curve-cumulative" />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Drawer.Screen
|
||||||
|
name="Weight"
|
||||||
|
component={WeightList}
|
||||||
|
options={{ drawerIcon: () => <IconButton icon="scale-bathroom" /> }}
|
||||||
|
/>
|
||||||
|
<Drawer.Screen
|
||||||
|
name="Insights"
|
||||||
|
component={InsightsPage}
|
||||||
|
options={{
|
||||||
|
drawerIcon: () => <IconButton icon="lightbulb-on-outline" />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Drawer.Screen
|
||||||
|
name="Settings"
|
||||||
|
component={SettingsPage}
|
||||||
|
options={{ drawerIcon: () => <IconButton icon="cog-outline" /> }}
|
||||||
|
/>
|
||||||
|
</Drawer.Navigator>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { ComponentProps } from "react";
|
||||||
|
import { FAB, useTheme } from "react-native-paper";
|
||||||
|
|
||||||
|
export default function AppFab(props: Partial<ComponentProps<typeof FAB>>) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FAB
|
||||||
|
icon="plus"
|
||||||
|
testID="add"
|
||||||
|
color={colors.background}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 20,
|
||||||
|
bottom: 20,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import React, { ComponentProps, Ref } from "react";
|
||||||
|
import { TextInput, useTheme } from "react-native-paper";
|
||||||
|
import { CombinedDefaultTheme } from "./App";
|
||||||
|
import { MARGIN } from "./constants";
|
||||||
|
|
||||||
|
function AppInput(
|
||||||
|
props: Partial<ComponentProps<typeof TextInput>> & {
|
||||||
|
innerRef?: Ref<any>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { dark } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
selectionColor={dark ? "#2A2A2A" : CombinedDefaultTheme.colors.border}
|
||||||
|
style={{ marginBottom: MARGIN, minWidth: 100 }}
|
||||||
|
selectTextOnFocus
|
||||||
|
ref={props.innerRef}
|
||||||
|
blurOnSubmit={false}
|
||||||
|
mode="outlined"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(AppInput);
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useWindowDimensions } from "react-native";
|
||||||
|
import { LineChart } from "react-native-chart-kit";
|
||||||
|
import { AbstractChartConfig } from "react-native-chart-kit/dist/AbstractChart";
|
||||||
|
import { useTheme } from "react-native-paper";
|
||||||
|
|
||||||
|
interface ChartProps {
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppLineChart({ labels, data }: ChartProps) {
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
const config: AbstractChartConfig = {
|
||||||
|
backgroundGradientFrom: colors.background,
|
||||||
|
backgroundGradientTo: colors.elevation.level1,
|
||||||
|
color: () => colors.primary,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pruned = useMemo(() => {
|
||||||
|
if (labels.length < 3) return labels;
|
||||||
|
const newPruned = [labels[0]];
|
||||||
|
const centerIndex = Math.floor(labels.length / 2);
|
||||||
|
for (let i = 1; i < labels.length - 1; i++) {
|
||||||
|
if (i === centerIndex) newPruned[i] = labels[i];
|
||||||
|
else newPruned[i] = "";
|
||||||
|
}
|
||||||
|
newPruned.push(labels[labels.length - 1]);
|
||||||
|
return newPruned;
|
||||||
|
}, [labels]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LineChart
|
||||||
|
height={400}
|
||||||
|
width={width - 20}
|
||||||
|
data={{
|
||||||
|
labels: pruned,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
bezier
|
||||||
|
chartConfig={config}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { useWindowDimensions } from "react-native";
|
||||||
|
import { PieChart } from "react-native-chart-kit";
|
||||||
|
import { useTheme } from "react-native-paper";
|
||||||
|
|
||||||
|
export interface Option {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppPieChart({ options }: { options: Option[] }) {
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
const pieChartColors = [
|
||||||
|
"#FF7F50", // Coral
|
||||||
|
"#1E90FF", // Dodger Blue
|
||||||
|
"#32CD32", // Lime Green
|
||||||
|
"#BA55D3", // Medium Orchid
|
||||||
|
"#FFD700", // Gold
|
||||||
|
"#48D1CC", // Medium Turquoise
|
||||||
|
"#FF69B4", // Hot Pink
|
||||||
|
];
|
||||||
|
|
||||||
|
const data = options.map((option, index) => ({
|
||||||
|
name: option.label,
|
||||||
|
value: option.value,
|
||||||
|
color: pieChartColors[index],
|
||||||
|
legendFontColor: colors.onSurface,
|
||||||
|
legendFontSize: 15,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PieChart
|
||||||
|
data={data}
|
||||||
|
paddingLeft="0"
|
||||||
|
width={width}
|
||||||
|
height={220}
|
||||||
|
chartConfig={{
|
||||||
|
backgroundColor: "#e26a00",
|
||||||
|
backgroundGradientFrom: "#fb8c00",
|
||||||
|
backgroundGradientTo: "#ffa726",
|
||||||
|
color: (opacity = 1) => `rgba(255, 255, 255, ${opacity})`,
|
||||||
|
labelColor: (opacity = 1) => `rgba(255, 255, 255, ${opacity})`,
|
||||||
|
style: {
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
propsForDots: {
|
||||||
|
r: "6",
|
||||||
|
strokeWidth: "2",
|
||||||
|
stroke: "#ffa726",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
accessor={"value"}
|
||||||
|
backgroundColor={"transparent"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Snackbar } from "react-native-paper";
|
||||||
|
import { emitter } from "./emitter";
|
||||||
|
import { TOAST } from "./toast";
|
||||||
|
|
||||||
|
export default function AppSnack({ textColor }: { textColor: string }) {
|
||||||
|
const [snackbar, setSnackbar] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const description = emitter.addListener(
|
||||||
|
TOAST,
|
||||||
|
({ value }: { value: string }) => {
|
||||||
|
setSnackbar(value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return description.remove;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Snackbar
|
||||||
|
duration={3000}
|
||||||
|
onDismiss={() => setSnackbar("")}
|
||||||
|
visible={!!snackbar}
|
||||||
|
action={{
|
||||||
|
label: "Close",
|
||||||
|
onPress: () => setSnackbar(""),
|
||||||
|
textColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{snackbar}
|
||||||
|
</Snackbar>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { createStackNavigator } from "@react-navigation/stack";
|
||||||
|
import AppDrawer from "./AppDrawer";
|
||||||
|
import EditExercise from "./EditExercise";
|
||||||
|
import EditExercises from "./EditExercises";
|
||||||
|
import EditPlan from "./EditPlan";
|
||||||
|
import EditSet from "./EditSet";
|
||||||
|
import EditSets from "./EditSets";
|
||||||
|
import EditWeight from "./EditWeight";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
import { Plan } from "./plan";
|
||||||
|
import StartPlan from "./StartPlan";
|
||||||
|
import ViewGraph from "./ViewGraph";
|
||||||
|
import ViewSetList from "./ViewSetList";
|
||||||
|
import ViewWeightGraph from "./ViewWeightGraph";
|
||||||
|
import Weight from "./weight";
|
||||||
|
import { View, Text, StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
export type StackParams = {
|
||||||
|
Drawer: {};
|
||||||
|
EditSet: {
|
||||||
|
set: Partial<GymSet>;
|
||||||
|
};
|
||||||
|
EditSets: {
|
||||||
|
ids: number[];
|
||||||
|
};
|
||||||
|
EditPlan: {
|
||||||
|
plan: Partial<Plan>;
|
||||||
|
};
|
||||||
|
StartPlan: {
|
||||||
|
plan: Plan;
|
||||||
|
first: Partial<GymSet>;
|
||||||
|
};
|
||||||
|
ViewGraph: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
EditWeight: {
|
||||||
|
weight: Partial<Weight>;
|
||||||
|
};
|
||||||
|
ViewWeightGraph: {};
|
||||||
|
EditExercise: {
|
||||||
|
gymSet: Partial<GymSet>;
|
||||||
|
};
|
||||||
|
EditExercises: {
|
||||||
|
names: string[];
|
||||||
|
};
|
||||||
|
ViewSetList: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stack = createStackNavigator<StackParams>();
|
||||||
|
|
||||||
|
export default function AppStack({ startup }: { startup: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{__DEV__ && (
|
||||||
|
<View style={styles.debugBanner}>
|
||||||
|
<Text style={styles.debugText}>DEBUG</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Stack.Navigator
|
||||||
|
screenOptions={{ headerShown: false, animationEnabled: false }}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Drawer"
|
||||||
|
component={AppDrawer}
|
||||||
|
initialParams={{ startup }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name="EditSet" component={EditSet} />
|
||||||
|
<Stack.Screen name="EditSets" component={EditSets} />
|
||||||
|
<Stack.Screen name="EditPlan" component={EditPlan} />
|
||||||
|
<Stack.Screen name="StartPlan" component={StartPlan} />
|
||||||
|
<Stack.Screen name="ViewGraph" component={ViewGraph} />
|
||||||
|
<Stack.Screen name="EditWeight" component={EditWeight} />
|
||||||
|
<Stack.Screen name="ViewWeightGraph" component={ViewWeightGraph} />
|
||||||
|
<Stack.Screen name="EditExercise" component={EditExercise} />
|
||||||
|
<Stack.Screen name="EditExercises" component={EditExercises} />
|
||||||
|
<Stack.Screen name="ViewSetList" component={ViewSetList} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
debugBanner: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 20,
|
||||||
|
right: 100,
|
||||||
|
backgroundColor: 'red',
|
||||||
|
zIndex: 1000,
|
||||||
|
borderRadius: 5,
|
||||||
|
},
|
||||||
|
debugText: {
|
||||||
|
color: 'white',
|
||||||
|
padding: 5,
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
});
|
98
BestList.tsx
98
BestList.tsx
|
@ -1,98 +0,0 @@
|
||||||
import {
|
|
||||||
NavigationProp,
|
|
||||||
useFocusEffect,
|
|
||||||
useNavigation,
|
|
||||||
} from '@react-navigation/native'
|
|
||||||
import {useCallback, useState} from 'react'
|
|
||||||
import {FlatList, Image} from 'react-native'
|
|
||||||
import {List} from 'react-native-paper'
|
|
||||||
import {BestPageParams} from './BestPage'
|
|
||||||
import {setRepo, settingsRepo} from './db'
|
|
||||||
import DrawerHeader from './DrawerHeader'
|
|
||||||
import GymSet from './gym-set'
|
|
||||||
import Page from './Page'
|
|
||||||
import Settings from './settings'
|
|
||||||
|
|
||||||
export default function BestList() {
|
|
||||||
const [bests, setBests] = useState<GymSet[]>()
|
|
||||||
const [term, setTerm] = useState('')
|
|
||||||
const navigation = useNavigation<NavigationProp<BestPageParams>>()
|
|
||||||
const [settings, setSettings] = useState<Settings>()
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
settingsRepo.findOne({where: {}}).then(setSettings)
|
|
||||||
}, []),
|
|
||||||
)
|
|
||||||
|
|
||||||
const refresh = useCallback(async (value: string) => {
|
|
||||||
const weights = await setRepo
|
|
||||||
.createQueryBuilder()
|
|
||||||
.select()
|
|
||||||
.addSelect('MAX(weight)', 'weight')
|
|
||||||
.where('name LIKE :name', {name: `%${value}%`})
|
|
||||||
.andWhere('NOT hidden')
|
|
||||||
.groupBy('name')
|
|
||||||
.getMany()
|
|
||||||
console.log(`${BestList.name}.refresh:`, {length: weights.length})
|
|
||||||
let newBest: GymSet[] = []
|
|
||||||
for (const set of weights) {
|
|
||||||
const reps = await setRepo
|
|
||||||
.createQueryBuilder()
|
|
||||||
.select()
|
|
||||||
.addSelect('MAX(reps)', 'reps')
|
|
||||||
.where('name = :name', {name: set.name})
|
|
||||||
.andWhere('weight = :weight', {weight: set.weight})
|
|
||||||
.andWhere('NOT hidden')
|
|
||||||
.groupBy('name')
|
|
||||||
.getMany()
|
|
||||||
newBest.push(...reps)
|
|
||||||
}
|
|
||||||
setBests(newBest)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
refresh(term)
|
|
||||||
}, [refresh, term]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const search = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setTerm(value)
|
|
||||||
refresh(value)
|
|
||||||
},
|
|
||||||
[refresh],
|
|
||||||
)
|
|
||||||
|
|
||||||
const renderItem = ({item}: {item: GymSet}) => (
|
|
||||||
<List.Item
|
|
||||||
key={item.name}
|
|
||||||
title={item.name}
|
|
||||||
description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
|
|
||||||
onPress={() => navigation.navigate('ViewBest', {best: item})}
|
|
||||||
left={() =>
|
|
||||||
(settings.images && item.image && (
|
|
||||||
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
|
|
||||||
)) ||
|
|
||||||
null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DrawerHeader name="Best" />
|
|
||||||
<Page term={term} search={search}>
|
|
||||||
{bests?.length === 0 ? (
|
|
||||||
<List.Item
|
|
||||||
title="No exercises yet"
|
|
||||||
description="Once sets have been added, this will highlight your personal bests."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FlatList style={{flex: 1}} renderItem={renderItem} data={bests} />
|
|
||||||
)}
|
|
||||||
</Page>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
22
BestPage.tsx
22
BestPage.tsx
|
@ -1,22 +0,0 @@
|
||||||
import {createStackNavigator} from '@react-navigation/stack'
|
|
||||||
import BestList from './BestList'
|
|
||||||
import GymSet from './gym-set'
|
|
||||||
import ViewBest from './ViewBest'
|
|
||||||
|
|
||||||
const Stack = createStackNavigator<BestPageParams>()
|
|
||||||
export type BestPageParams = {
|
|
||||||
BestList: {}
|
|
||||||
ViewBest: {
|
|
||||||
best: GymSet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BestPage() {
|
|
||||||
return (
|
|
||||||
<Stack.Navigator
|
|
||||||
screenOptions={{headerShown: false, animationEnabled: false}}>
|
|
||||||
<Stack.Screen name="BestList" component={BestList} />
|
|
||||||
<Stack.Screen name="ViewBest" component={ViewBest} />
|
|
||||||
</Stack.Navigator>
|
|
||||||
)
|
|
||||||
}
|
|
68
Chart.tsx
68
Chart.tsx
|
@ -1,68 +0,0 @@
|
||||||
import {useTheme} from '@react-navigation/native'
|
|
||||||
import * as shape from 'd3-shape'
|
|
||||||
import {View} from 'react-native'
|
|
||||||
import {Grid, LineChart, XAxis, YAxis} from 'react-native-svg-charts'
|
|
||||||
import {CombinedDarkTheme, CombinedDefaultTheme} from './App'
|
|
||||||
import {MARGIN, PADDING} from './constants'
|
|
||||||
import GymSet from './gym-set'
|
|
||||||
import useDark from './use-dark'
|
|
||||||
|
|
||||||
export default function Chart({
|
|
||||||
yData,
|
|
||||||
xFormat,
|
|
||||||
xData,
|
|
||||||
yFormat,
|
|
||||||
}: {
|
|
||||||
yData: number[]
|
|
||||||
xData: GymSet[]
|
|
||||||
xFormat: (value: any, index: number) => string
|
|
||||||
yFormat: (value: any) => string
|
|
||||||
}) {
|
|
||||||
const {colors} = useTheme()
|
|
||||||
const dark = useDark()
|
|
||||||
const axesSvg = {
|
|
||||||
fontSize: 10,
|
|
||||||
fill: dark
|
|
||||||
? CombinedDarkTheme.colors.text
|
|
||||||
: CombinedDefaultTheme.colors.text,
|
|
||||||
}
|
|
||||||
const verticalContentInset = {top: 10, bottom: 10}
|
|
||||||
const xAxisHeight = 30
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 300,
|
|
||||||
padding: PADDING,
|
|
||||||
flexDirection: 'row',
|
|
||||||
}}>
|
|
||||||
<YAxis
|
|
||||||
data={yData}
|
|
||||||
style={{marginBottom: xAxisHeight}}
|
|
||||||
contentInset={verticalContentInset}
|
|
||||||
svg={axesSvg}
|
|
||||||
formatLabel={yFormat}
|
|
||||||
/>
|
|
||||||
<View style={{flex: 1, marginLeft: MARGIN}}>
|
|
||||||
<LineChart
|
|
||||||
style={{flex: 1}}
|
|
||||||
data={yData}
|
|
||||||
contentInset={verticalContentInset}
|
|
||||||
curve={shape.curveBasis}
|
|
||||||
svg={{
|
|
||||||
stroke: colors.primary,
|
|
||||||
}}>
|
|
||||||
<Grid />
|
|
||||||
</LineChart>
|
|
||||||
<XAxis
|
|
||||||
data={xData}
|
|
||||||
formatLabel={xFormat}
|
|
||||||
contentInset={{left: 15, right: 16}}
|
|
||||||
svg={axesSvg}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {Button, Dialog, Portal, Text} from 'react-native-paper'
|
import { Button, Dialog, Portal, Text } from "react-native-paper";
|
||||||
|
|
||||||
export default function ConfirmDialog({
|
export default function ConfirmDialog({
|
||||||
title,
|
title,
|
||||||
|
@ -8,17 +8,17 @@ export default function ConfirmDialog({
|
||||||
setShow,
|
setShow,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string;
|
||||||
children: JSX.Element | JSX.Element[] | string
|
children: JSX.Element | JSX.Element[] | string;
|
||||||
onOk: () => void
|
onOk: () => void;
|
||||||
show: boolean
|
show: boolean;
|
||||||
setShow: (show: boolean) => void
|
setShow: (show: boolean) => void;
|
||||||
onCancel?: () => void
|
onCancel?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
setShow(false)
|
setShow(false);
|
||||||
onCancel && onCancel()
|
onCancel && onCancel();
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
|
@ -33,5 +33,5 @@ export default function ConfirmDialog({
|
||||||
</Dialog.Actions>
|
</Dialog.Actions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Portal>
|
</Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { FlatList, View } from "react-native";
|
||||||
|
import { Button, IconButton, List } from "react-native-paper";
|
||||||
|
import AppFab from "./AppFab";
|
||||||
|
import DrawerHeader from "./DrawerHeader";
|
||||||
|
import { LIMIT, PADDING } from "./constants";
|
||||||
|
import GymSet, { defaultSet } from "./gym-set";
|
||||||
|
import { getNow, setRepo, settingsRepo } from "./db";
|
||||||
|
import { NavigationProp, useFocusEffect, useNavigation } from "@react-navigation/native";
|
||||||
|
import { Like } from "typeorm";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
|
||||||
|
import SetItem from "./SetItem";
|
||||||
|
import { StackParams } from "./AppStack";
|
||||||
|
|
||||||
|
export default function Daily() {
|
||||||
|
const [sets, setSets] = useState<GymSet[]>();
|
||||||
|
const [day, setDay] = useState<Date>()
|
||||||
|
const [settings, setSettings] = useState<Settings>();
|
||||||
|
const navigation = useNavigation<NavigationProp<StackParams>>();
|
||||||
|
|
||||||
|
const mounted = async () => {
|
||||||
|
const now = await getNow();
|
||||||
|
let created = now.split('T')[0];
|
||||||
|
setDay(new Date(created));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mounted();
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
if (!day) return;
|
||||||
|
const created = day.toISOString().split('T')[0]
|
||||||
|
const newSets = await setRepo.find({
|
||||||
|
where: { hidden: 0 as any, created: Like(`${created}%`) },
|
||||||
|
take: LIMIT,
|
||||||
|
skip: 0,
|
||||||
|
order: { created: "DESC" },
|
||||||
|
});
|
||||||
|
setSets(newSets);
|
||||||
|
settingsRepo.findOne({ where: {} }).then(setSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [day])
|
||||||
|
|
||||||
|
useFocusEffect(useCallback(() => {
|
||||||
|
refresh();
|
||||||
|
}, [day]))
|
||||||
|
|
||||||
|
const onAdd = async () => {
|
||||||
|
const now = await getNow();
|
||||||
|
let set: Partial<GymSet> = { ...sets[0] };
|
||||||
|
if (!set) set = { ...defaultSet };
|
||||||
|
set.created = now;
|
||||||
|
delete set.id;
|
||||||
|
navigation.navigate("EditSet", { set });
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRight = () => {
|
||||||
|
const newDay = new Date(day)
|
||||||
|
newDay.setDate(newDay.getDate() + 1)
|
||||||
|
setDay(newDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLeft = () => {
|
||||||
|
const newDay = new Date(day)
|
||||||
|
newDay.setDate(newDay.getDate() - 1)
|
||||||
|
setDay(newDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDate = () => {
|
||||||
|
DateTimePickerAndroid.open({
|
||||||
|
value: new Date(day),
|
||||||
|
onChange: (event, date) => {
|
||||||
|
if (event.type === 'dismissed') return;
|
||||||
|
setDay(date)
|
||||||
|
},
|
||||||
|
mode: 'date',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DrawerHeader name="Daily" />
|
||||||
|
|
||||||
|
|
||||||
|
<View style={{ padding: PADDING, flexGrow: 1 }}>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<IconButton style={{ marginRight: 'auto' }} icon="chevron-double-left" onPress={onLeft} />
|
||||||
|
<Button onPress={onDate}>{format(day ? new Date(day) : new Date(), "PPPP")}</Button>
|
||||||
|
<IconButton style={{ marginLeft: 'auto' }} icon="chevron-double-right" onPress={onRight} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{settings && (
|
||||||
|
<FlatList ListEmptyComponent={<List.Item title="No sets yet" />} style={{ flex: 1 }} data={sets} renderItem={({ item }) => <SetItem ids={[]} setIds={() => { }} item={item} settings={settings} />} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AppFab onPress={onAdd} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,28 +1,28 @@
|
||||||
import {DrawerNavigationProp} from '@react-navigation/drawer'
|
import { DrawerNavigationProp } from "@react-navigation/drawer";
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import { useNavigation } from "@react-navigation/native";
|
||||||
import {Appbar, IconButton} from 'react-native-paper'
|
import { Appbar, IconButton } from "react-native-paper";
|
||||||
import {DrawerParamList} from './drawer-param-list'
|
import { DrawerParams } from "./drawer-params";
|
||||||
import useDark from './use-dark'
|
|
||||||
|
|
||||||
export default function DrawerHeader({
|
export default function DrawerHeader({
|
||||||
name,
|
name,
|
||||||
children,
|
children,
|
||||||
|
ids,
|
||||||
|
unSelect,
|
||||||
}: {
|
}: {
|
||||||
name: keyof DrawerParamList
|
name: string;
|
||||||
children?: JSX.Element | JSX.Element[]
|
children?: JSX.Element | JSX.Element[];
|
||||||
|
ids?: unknown[],
|
||||||
|
unSelect?: () => void,
|
||||||
}) {
|
}) {
|
||||||
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>()
|
const navigation = useNavigation<DrawerNavigationProp<DrawerParams>>();
|
||||||
const dark = useDark()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Appbar.Header>
|
<Appbar.Header>
|
||||||
<IconButton
|
{ids && ids.length > 0 ? (<IconButton icon="arrow-left" onPress={unSelect} />) : (
|
||||||
color={dark ? 'white' : 'white'}
|
<IconButton icon="menu" onPress={navigation.openDrawer} />
|
||||||
icon="menu"
|
)}
|
||||||
onPress={navigation.openDrawer}
|
|
||||||
/>
|
|
||||||
<Appbar.Content title={name} />
|
<Appbar.Content title={name} />
|
||||||
{children}
|
{children}
|
||||||
</Appbar.Header>
|
</Appbar.Header>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,231 @@
|
||||||
|
import {
|
||||||
|
NavigationProp,
|
||||||
|
RouteProp,
|
||||||
|
useFocusEffect,
|
||||||
|
useNavigation,
|
||||||
|
useRoute,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { ScrollView, TextInput, View } from "react-native";
|
||||||
|
import DocumentPicker from "react-native-document-picker";
|
||||||
|
import { Button, Card, IconButton, TouchableRipple } from "react-native-paper";
|
||||||
|
import AppInput from "./AppInput";
|
||||||
|
import { StackParams } from "./AppStack";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
import PrimaryButton from "./PrimaryButton";
|
||||||
|
import StackHeader from "./StackHeader";
|
||||||
|
import { MARGIN, PADDING } from "./constants";
|
||||||
|
import { getNow, planRepo, setRepo, settingsRepo } from "./db";
|
||||||
|
import { DrawerParams } from "./drawer-params";
|
||||||
|
import { fixNumeric } from "./fix-numeric";
|
||||||
|
import GymSet, { defaultSet } from "./gym-set";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
|
||||||
|
export default function EditExercise() {
|
||||||
|
const { params } = useRoute<RouteProp<StackParams, "EditExercise">>();
|
||||||
|
const [removeImage, setRemoveImage] = useState(false);
|
||||||
|
const [showRemoveImage, setShowRemoveImage] = useState(false);
|
||||||
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
|
const [name, setName] = useState(params.gymSet.name);
|
||||||
|
const [steps, setSteps] = useState(params.gymSet.steps);
|
||||||
|
const [uri, setUri] = useState(params.gymSet.image);
|
||||||
|
const [minutes, setMinutes] = useState(
|
||||||
|
params.gymSet.minutes?.toString() ?? "3"
|
||||||
|
);
|
||||||
|
const [seconds, setSeconds] = useState(
|
||||||
|
params.gymSet.seconds?.toString() ?? "30"
|
||||||
|
);
|
||||||
|
const [sets, setSets] = useState(params.gymSet.sets?.toString() ?? "3");
|
||||||
|
const { navigate } = useNavigation<NavigationProp<DrawerParams>>();
|
||||||
|
const setsRef = useRef<TextInput>(null);
|
||||||
|
const stepsRef = useRef<TextInput>(null);
|
||||||
|
const minutesRef = useRef<TextInput>(null);
|
||||||
|
const secondsRef = useRef<TextInput>(null);
|
||||||
|
const [settings, setSettings] = useState<Settings>();
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
settingsRepo.findOne({ where: {} }).then((gotSettings) => {
|
||||||
|
setSettings(gotSettings);
|
||||||
|
if (params.gymSet.id) return;
|
||||||
|
setSets(gotSettings.defaultSets?.toString() ?? "3");
|
||||||
|
setMinutes(gotSettings.defaultMinutes?.toString() ?? "3");
|
||||||
|
setSeconds(gotSettings.defaultSeconds?.toString() ?? "30");
|
||||||
|
});
|
||||||
|
}, [params.gymSet.id])
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = async () => {
|
||||||
|
const newExercise = {
|
||||||
|
name: name || params.gymSet.name,
|
||||||
|
sets: Number(sets),
|
||||||
|
minutes: Number(minutes),
|
||||||
|
seconds: Number(seconds),
|
||||||
|
steps,
|
||||||
|
image: removeImage ? "" : uri,
|
||||||
|
} as GymSet;
|
||||||
|
await setRepo.update({ name: params.gymSet.name }, newExercise);
|
||||||
|
await planRepo.query(
|
||||||
|
`UPDATE plans
|
||||||
|
SET exercises = REPLACE(exercises, $1, $2)
|
||||||
|
WHERE exercises LIKE $3`,
|
||||||
|
[params.gymSet.name, name, `%${params.gymSet.name}%`]
|
||||||
|
);
|
||||||
|
navigate("Exercises", { update: newExercise });
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
const now = await getNow();
|
||||||
|
await setRepo.save({
|
||||||
|
...defaultSet,
|
||||||
|
name,
|
||||||
|
hidden: true,
|
||||||
|
image: uri,
|
||||||
|
minutes: minutes ? Number(minutes) : 3,
|
||||||
|
seconds: seconds ? Number(seconds) : 30,
|
||||||
|
sets: sets ? Number(sets) : 3,
|
||||||
|
steps,
|
||||||
|
created: now,
|
||||||
|
});
|
||||||
|
navigate("Exercises");
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
await setRepo.delete({ name: params.gymSet.name });
|
||||||
|
navigate("Exercises");
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (params.gymSet.name) return update();
|
||||||
|
return add();
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeImage = useCallback(async () => {
|
||||||
|
const { fileCopyUri } = await DocumentPicker.pickSingle({
|
||||||
|
type: DocumentPicker.types.images,
|
||||||
|
copyTo: "documentDirectory",
|
||||||
|
});
|
||||||
|
if (fileCopyUri) setUri(fileCopyUri);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemove = useCallback(async () => {
|
||||||
|
setUri("");
|
||||||
|
setRemoveImage(true);
|
||||||
|
setShowRemoveImage(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitName = () => {
|
||||||
|
if (settings.steps) stepsRef.current?.focus();
|
||||||
|
else setsRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StackHeader
|
||||||
|
title={params.gymSet.name ? "Edit exercise" : "Add exercise"}
|
||||||
|
>
|
||||||
|
{typeof params.gymSet.id === "number" ? (
|
||||||
|
<IconButton onPress={() => setShowDelete(true)} icon="delete" />
|
||||||
|
) : null}
|
||||||
|
</StackHeader>
|
||||||
|
<View style={{ padding: PADDING, flex: 1 }}>
|
||||||
|
<ScrollView style={{ flex: 1 }}>
|
||||||
|
<AppInput
|
||||||
|
autoFocus
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
onSubmitEditing={submitName}
|
||||||
|
/>
|
||||||
|
<AppInput
|
||||||
|
innerRef={stepsRef}
|
||||||
|
selectTextOnFocus={false}
|
||||||
|
value={steps}
|
||||||
|
onChangeText={setSteps}
|
||||||
|
label="Steps"
|
||||||
|
multiline
|
||||||
|
onSubmitEditing={() => setsRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
<AppInput
|
||||||
|
innerRef={setsRef}
|
||||||
|
value={sets}
|
||||||
|
onChangeText={(newSets) => {
|
||||||
|
const fixed = fixNumeric(newSets);
|
||||||
|
setSets(fixed);
|
||||||
|
if (fixed.length !== newSets.length)
|
||||||
|
toast("Sets must be a number");
|
||||||
|
}}
|
||||||
|
label="Sets per exercise"
|
||||||
|
keyboardType="numeric"
|
||||||
|
onSubmitEditing={() => minutesRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
{settings?.alarm && (
|
||||||
|
<>
|
||||||
|
<AppInput
|
||||||
|
innerRef={minutesRef}
|
||||||
|
onSubmitEditing={() => secondsRef.current?.focus()}
|
||||||
|
value={minutes}
|
||||||
|
onChangeText={(newMinutes) => {
|
||||||
|
const fixed = fixNumeric(newMinutes);
|
||||||
|
setMinutes(fixed);
|
||||||
|
if (fixed.length !== newMinutes.length)
|
||||||
|
toast("Reps must be a number");
|
||||||
|
}}
|
||||||
|
label="Rest minutes"
|
||||||
|
keyboardType="numeric"
|
||||||
|
/>
|
||||||
|
<AppInput
|
||||||
|
innerRef={secondsRef}
|
||||||
|
value={seconds}
|
||||||
|
onChangeText={setSeconds}
|
||||||
|
label="Rest seconds"
|
||||||
|
keyboardType="numeric"
|
||||||
|
blurOnSubmit
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{settings?.images && uri && (
|
||||||
|
<TouchableRipple
|
||||||
|
style={{ marginBottom: MARGIN }}
|
||||||
|
onPress={changeImage}
|
||||||
|
onLongPress={() => setShowRemoveImage(true)}
|
||||||
|
>
|
||||||
|
<Card.Cover source={{ uri }} />
|
||||||
|
</TouchableRipple>
|
||||||
|
)}
|
||||||
|
{settings?.images && !uri && (
|
||||||
|
<Button
|
||||||
|
style={{ marginBottom: MARGIN }}
|
||||||
|
onPress={changeImage}
|
||||||
|
icon="image-plus"
|
||||||
|
>
|
||||||
|
Image
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
<PrimaryButton disabled={!name} icon="content-save" onPress={save}>
|
||||||
|
Save
|
||||||
|
</PrimaryButton>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Remove image"
|
||||||
|
onOk={handleRemove}
|
||||||
|
show={showRemoveImage}
|
||||||
|
setShow={setShowRemoveImage}
|
||||||
|
>
|
||||||
|
Are you sure you want to remove the image?
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Delete set"
|
||||||
|
show={showDelete}
|
||||||
|
onOk={remove}
|
||||||
|
setShow={setShowDelete}
|
||||||
|
>
|
||||||
|
<>Are you sure you want to delete {name}</>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,203 @@
|
||||||
|
import {
|
||||||
|
NavigationProp,
|
||||||
|
RouteProp,
|
||||||
|
useFocusEffect,
|
||||||
|
useNavigation,
|
||||||
|
useRoute,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { ScrollView, TextInput, View } from "react-native";
|
||||||
|
import DocumentPicker from "react-native-document-picker";
|
||||||
|
import { Button, Card, TouchableRipple } from "react-native-paper";
|
||||||
|
import { In } from "typeorm";
|
||||||
|
import AppInput from "./AppInput";
|
||||||
|
import { StackParams } from "./AppStack";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
import { MARGIN, PADDING } from "./constants";
|
||||||
|
import { planRepo, setRepo, settingsRepo } from "./db";
|
||||||
|
import { DrawerParams } from "./drawer-params";
|
||||||
|
import { fixNumeric } from "./fix-numeric";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import StackHeader from "./StackHeader";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
import PrimaryButton from "./PrimaryButton";
|
||||||
|
|
||||||
|
export default function EditExercises() {
|
||||||
|
const { params } = useRoute<RouteProp<StackParams, "EditExercises">>();
|
||||||
|
const [removeImage, setRemoveImage] = useState(false);
|
||||||
|
const [showRemove, setShowRemove] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [oldNames, setOldNames] = useState(params.names.join(", "));
|
||||||
|
const [steps, setSteps] = useState("");
|
||||||
|
const [oldSteps, setOldSteps] = useState("");
|
||||||
|
const [uri, setUri] = useState("");
|
||||||
|
const [minutes, setMinutes] = useState("");
|
||||||
|
const [oldMinutes, setOldMinutes] = useState("");
|
||||||
|
const [seconds, setSeconds] = useState("");
|
||||||
|
const [oldSeconds, setOldSeconds] = useState("");
|
||||||
|
const [sets, setSets] = useState("");
|
||||||
|
const [oldSets, setOldSets] = useState("");
|
||||||
|
const navigation = useNavigation<NavigationProp<DrawerParams>>();
|
||||||
|
const setsRef = useRef<TextInput>(null);
|
||||||
|
const stepsRef = useRef<TextInput>(null);
|
||||||
|
const minutesRef = useRef<TextInput>(null);
|
||||||
|
const secondsRef = useRef<TextInput>(null);
|
||||||
|
const [settings, setSettings] = useState<Settings>();
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
|
setRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select()
|
||||||
|
.where("name IN (:...names)", { names: params.names })
|
||||||
|
.groupBy("name")
|
||||||
|
.getMany()
|
||||||
|
.then((gymSets) => {
|
||||||
|
console.log(`${EditExercises.name}.focus:`, { gymSets });
|
||||||
|
setOldNames(gymSets.map((set) => set.name).join(", "));
|
||||||
|
setOldSteps(gymSets.map((set) => set.steps).join(", "));
|
||||||
|
setOldMinutes(gymSets.map((set) => set.minutes).join(", "));
|
||||||
|
setOldSeconds(gymSets.map((set) => set.seconds).join(", "));
|
||||||
|
setOldSets(gymSets.map((set) => set.sets).join(", "));
|
||||||
|
});
|
||||||
|
}, [params.names])
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = async () => {
|
||||||
|
await setRepo.update(
|
||||||
|
{ name: In(params.names) },
|
||||||
|
{
|
||||||
|
name: name || undefined,
|
||||||
|
sets: sets ? Number(sets) : undefined,
|
||||||
|
minutes: minutes ? Number(minutes) : undefined,
|
||||||
|
seconds: seconds ? Number(seconds) : undefined,
|
||||||
|
steps: steps || undefined,
|
||||||
|
image: removeImage ? "" : uri,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
for (const oldName of params.names) {
|
||||||
|
await planRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update()
|
||||||
|
.set({
|
||||||
|
exercises: () => `REPLACE(exercises, '${oldName}', '${name}')`,
|
||||||
|
})
|
||||||
|
.where("exercises LIKE :name", { name: `%${oldName}%` })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
navigation.navigate("Exercises");
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeImage = useCallback(async () => {
|
||||||
|
const { fileCopyUri } = await DocumentPicker.pickSingle({
|
||||||
|
type: DocumentPicker.types.images,
|
||||||
|
copyTo: "documentDirectory",
|
||||||
|
});
|
||||||
|
if (fileCopyUri) setUri(fileCopyUri);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemove = useCallback(async () => {
|
||||||
|
setUri("");
|
||||||
|
setRemoveImage(true);
|
||||||
|
setShowRemove(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitName = () => {
|
||||||
|
if (settings.steps) stepsRef.current?.focus();
|
||||||
|
else setsRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StackHeader title={`Edit ${params.names.length} exercises`} />
|
||||||
|
<View style={{ padding: PADDING, flex: 1 }}>
|
||||||
|
<ScrollView style={{ flex: 1 }}>
|
||||||
|
<AppInput
|
||||||
|
autoFocus
|
||||||
|
label={`Names: ${oldNames}`}
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
onSubmitEditing={submitName}
|
||||||
|
/>
|
||||||
|
<AppInput
|
||||||
|
innerRef={stepsRef}
|
||||||
|
selectTextOnFocus={false}
|
||||||
|
value={steps}
|
||||||
|
onChangeText={setSteps}
|
||||||
|
label={`Steps: ${oldSteps}`}
|
||||||
|
multiline
|
||||||
|
onSubmitEditing={() => setsRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
<AppInput
|
||||||
|
innerRef={setsRef}
|
||||||
|
value={sets}
|
||||||
|
onChangeText={(newSets) => {
|
||||||
|
const fixed = fixNumeric(newSets);
|
||||||
|
setSets(fixed);
|
||||||
|
if (fixed.length !== newSets.length)
|
||||||
|
toast("Sets must be a number");
|
||||||
|
}}
|
||||||
|
label={`Sets: ${oldSets}`}
|
||||||
|
keyboardType="numeric"
|
||||||
|
onSubmitEditing={() => minutesRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
{settings?.alarm && (
|
||||||
|
<>
|
||||||
|
<AppInput
|
||||||
|
innerRef={minutesRef}
|
||||||
|
onSubmitEditing={() => secondsRef.current?.focus()}
|
||||||
|
value={minutes}
|
||||||
|
onChangeText={(newMinutes) => {
|
||||||
|
const fixed = fixNumeric(newMinutes);
|
||||||
|
setMinutes(fixed);
|
||||||
|
if (fixed.length !== newMinutes.length)
|
||||||
|
toast("Reps must be a number");
|
||||||
|
}}
|
||||||
|
label={`Rest minutes: ${oldMinutes}`}
|
||||||
|
keyboardType="numeric"
|
||||||
|
/>
|
||||||
|
<AppInput
|
||||||
|
innerRef={secondsRef}
|
||||||
|
value={seconds}
|
||||||
|
onChangeText={setSeconds}
|
||||||
|
label={`Rest seconds: ${oldSeconds}`}
|
||||||
|
keyboardType="numeric"
|
||||||
|
blurOnSubmit
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{settings?.images && uri && (
|
||||||
|
<TouchableRipple
|
||||||
|
style={{ marginBottom: MARGIN }}
|
||||||
|
onPress={changeImage}
|
||||||
|
onLongPress={() => setShowRemove(true)}
|
||||||
|
>
|
||||||
|
<Card.Cover source={{ uri }} />
|
||||||
|
</TouchableRipple>
|
||||||
|
)}
|
||||||
|
{settings?.images && !uri && (
|
||||||
|
<Button
|
||||||
|
style={{ marginBottom: MARGIN }}
|
||||||
|
onPress={changeImage}
|
||||||
|
icon="image-plus"
|
||||||
|
>
|
||||||
|
Image
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
<PrimaryButton disabled={!name} icon="content-save" onPress={update}>
|
||||||
|
Save
|
||||||
|
</PrimaryButton>
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Remove image"
|
||||||
|
onOk={handleRemove}
|
||||||
|
show={showRemove}
|
||||||
|
setShow={setShowRemove}
|
||||||
|
>
|
||||||
|
Are you sure you want to remove the image?
|
||||||
|
</ConfirmDialog>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
278
EditPlan.tsx
278
EditPlan.tsx
|
@ -3,117 +3,228 @@ import {
|
||||||
RouteProp,
|
RouteProp,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
useRoute,
|
useRoute,
|
||||||
} from '@react-navigation/native'
|
} from "@react-navigation/native";
|
||||||
import {useCallback, useEffect, useState} from 'react'
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import {ScrollView, StyleSheet, View} from 'react-native'
|
import {
|
||||||
import {Button, Text} from 'react-native-paper'
|
FlatList,
|
||||||
import {MARGIN, PADDING} from './constants'
|
Pressable,
|
||||||
import {planRepo, setRepo} from './db'
|
ScrollView,
|
||||||
import {DrawerParamList} from './drawer-param-list'
|
StyleSheet,
|
||||||
import {PlanPageParams} from './plan-page-params'
|
View,
|
||||||
import StackHeader from './StackHeader'
|
} from "react-native";
|
||||||
import Switch from './Switch'
|
import {
|
||||||
import {DAYS} from './time'
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Switch as PaperSwitch,
|
||||||
|
Text,
|
||||||
|
} from "react-native-paper";
|
||||||
|
import AppInput from "./AppInput";
|
||||||
|
import { StackParams } from "./AppStack";
|
||||||
|
import PrimaryButton from "./PrimaryButton";
|
||||||
|
import StackHeader from "./StackHeader";
|
||||||
|
import Switch from "./Switch";
|
||||||
|
import { MARGIN, PADDING } from "./constants";
|
||||||
|
import { DAYS } from "./days";
|
||||||
|
import { planRepo, setRepo } from "./db";
|
||||||
|
import { DrawerParams } from "./drawer-params";
|
||||||
|
import GymSet, { defaultSet } from "./gym-set";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
|
||||||
export default function EditPlan() {
|
export default function EditPlan() {
|
||||||
const {params} = useRoute<RouteProp<PlanPageParams, 'EditPlan'>>()
|
const { params } = useRoute<RouteProp<StackParams, "EditPlan">>();
|
||||||
const {plan} = params
|
const { plan } = params;
|
||||||
|
const [title, setTitle] = useState<string>(plan?.title);
|
||||||
|
const [names, setNames] = useState<string[]>();
|
||||||
|
|
||||||
const [days, setDays] = useState<string[]>(
|
const [days, setDays] = useState<string[]>(
|
||||||
plan.days ? plan.days.split(',') : [],
|
plan.days ? plan.days.split(",") : []
|
||||||
)
|
);
|
||||||
const [workouts, setWorkouts] = useState<string[]>(
|
|
||||||
plan.workouts ? plan.workouts.split(',') : [],
|
const [exercises, setExercises] = useState<string[]>(
|
||||||
)
|
plan.exercises ? plan.exercises.split(",") : []
|
||||||
const [names, setNames] = useState<string[]>([])
|
);
|
||||||
const navigation = useNavigation<NavigationProp<DrawerParamList>>()
|
|
||||||
|
const { navigate: drawerNavigate } =
|
||||||
|
useNavigation<NavigationProp<DrawerParams>>();
|
||||||
|
const { navigate: stackNavigate } =
|
||||||
|
useNavigation<NavigationProp<StackParams>>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRepo
|
setRepo
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.select('name')
|
.select("name")
|
||||||
.distinct(true)
|
.distinct(true)
|
||||||
|
.orderBy("name")
|
||||||
.getRawMany()
|
.getRawMany()
|
||||||
.then(values => {
|
.then((values) => {
|
||||||
console.log(EditPlan.name, {values})
|
const newNames = values.map((value) => value.name);
|
||||||
setNames(values.map(value => value.name))
|
console.log(EditPlan.name, { newNames });
|
||||||
})
|
setNames(newNames);
|
||||||
}, [])
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
console.log(`${EditPlan.name}.save`, {days, workouts, plan})
|
console.log(`${EditPlan.name}.save`, { days, exercises, plan });
|
||||||
if (!days || !workouts) return
|
if (!days || !exercises) return;
|
||||||
const newWorkouts = workouts.filter(workout => workout).join(',')
|
const newExercises = exercises.filter((exercise) => exercise).join(",");
|
||||||
const newDays = days.filter(day => day).join(',')
|
const newDays = days.filter((day) => day).join(",");
|
||||||
await planRepo.save({days: newDays, workouts: newWorkouts, id: plan.id})
|
const saved = await planRepo.save({
|
||||||
navigation.goBack()
|
title: title,
|
||||||
}, [days, workouts, plan, navigation])
|
days: newDays,
|
||||||
|
exercises: newExercises,
|
||||||
|
id: plan.id,
|
||||||
|
});
|
||||||
|
if (saved.id === 1) toast("Tap your plan again to begin using it");
|
||||||
|
}, [title, days, exercises, plan]);
|
||||||
|
|
||||||
const toggleWorkout = useCallback(
|
const toggleExercise = useCallback(
|
||||||
(on: boolean, name: string) => {
|
(on: boolean, name: string) => {
|
||||||
if (on) {
|
if (on) {
|
||||||
setWorkouts([...workouts, name])
|
setExercises([...exercises, name]);
|
||||||
} else {
|
} else {
|
||||||
setWorkouts(workouts.filter(workout => workout !== name))
|
setExercises(exercises.filter((exercise) => exercise !== name));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setWorkouts, workouts],
|
[setExercises, exercises]
|
||||||
)
|
);
|
||||||
|
|
||||||
const toggleDay = useCallback(
|
const toggleDay = useCallback(
|
||||||
(on: boolean, day: string) => {
|
(on: boolean, day: string) => {
|
||||||
if (on) {
|
if (on) {
|
||||||
setDays([...days, day])
|
setDays([...days, day]);
|
||||||
} else {
|
} else {
|
||||||
setDays(days.filter(d => d !== day))
|
setDays(days.filter((d) => d !== day));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setDays, days],
|
[setDays, days]
|
||||||
)
|
);
|
||||||
|
|
||||||
|
const renderDay = (day: string) => (
|
||||||
|
<Switch
|
||||||
|
key={day}
|
||||||
|
onChange={(value) => toggleDay(value, day)}
|
||||||
|
value={days.includes(day)}
|
||||||
|
title={day}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderExercise = (name: string, index: number, movable: boolean) => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => toggleExercise(!exercises.includes(name), name)}
|
||||||
|
style={{ flexDirection: "row", alignItems: "center" }}
|
||||||
|
key={name}
|
||||||
|
>
|
||||||
|
<PaperSwitch
|
||||||
|
value={exercises.includes(name)}
|
||||||
|
style={{ marginRight: MARGIN }}
|
||||||
|
onValueChange={(value) => toggleExercise(value, name)}
|
||||||
|
/>
|
||||||
|
<Text>{name}</Text>
|
||||||
|
{movable && (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
icon="arrow-up"
|
||||||
|
style={{ marginLeft: "auto" }}
|
||||||
|
onPressIn={() => moveUp(index)}
|
||||||
|
/>
|
||||||
|
<IconButton icon="arrow-down" onPressIn={() => moveDown(index)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
|
const moveDown = (from: number) => {
|
||||||
|
if (from === exercises.length - 1) return;
|
||||||
|
const to = from + 1;
|
||||||
|
const newExercises = [...exercises];
|
||||||
|
const copy = newExercises[from];
|
||||||
|
newExercises[from] = newExercises[to];
|
||||||
|
newExercises[to] = copy;
|
||||||
|
setExercises(newExercises);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveUp = (from: number) => {
|
||||||
|
if (from === 0) return;
|
||||||
|
const to = from - 1;
|
||||||
|
const newExercises = [...exercises];
|
||||||
|
const copy = newExercises[from];
|
||||||
|
newExercises[from] = newExercises[to];
|
||||||
|
newExercises[to] = copy;
|
||||||
|
setExercises(newExercises);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StackHeader title="Edit plan" />
|
<StackHeader
|
||||||
<View style={{padding: PADDING, flex: 1}}>
|
title={typeof plan.id === "number" ? "Edit plan" : "Add plan"}
|
||||||
<ScrollView style={{flex: 1}}>
|
>
|
||||||
<Text style={styles.title}>Days</Text>
|
{typeof plan.id === "number" && (
|
||||||
{DAYS.map(day => (
|
<IconButton
|
||||||
<Switch
|
onPress={async () => {
|
||||||
key={day}
|
await save();
|
||||||
onValueChange={value => toggleDay(value, day)}
|
const newPlan = await planRepo.findOne({
|
||||||
onPress={() => toggleDay(!days.includes(day), day)}
|
where: { id: plan.id },
|
||||||
value={days.includes(day)}>
|
});
|
||||||
{day}
|
let first: Partial<GymSet> = await setRepo.findOne({
|
||||||
</Switch>
|
where: { name: exercises[0] },
|
||||||
))}
|
order: { created: "desc" },
|
||||||
<Text style={[styles.title, {marginTop: MARGIN}]}>Workouts</Text>
|
});
|
||||||
{names.length === 0 ? (
|
if (!first) first = { ...defaultSet, name: exercises[0] };
|
||||||
<View>
|
delete first.id;
|
||||||
<Text>No workouts found.</Text>
|
stackNavigate("StartPlan", { plan: newPlan, first });
|
||||||
</View>
|
}}
|
||||||
) : (
|
icon="play"
|
||||||
names.map(name => (
|
/>
|
||||||
<Switch
|
)}
|
||||||
key={name}
|
</StackHeader>
|
||||||
onValueChange={value => toggleWorkout(value, name)}
|
<ScrollView style={{ padding: PADDING, flex: 1 }}>
|
||||||
value={workouts.includes(name)}
|
<AppInput
|
||||||
onPress={() => toggleWorkout(!workouts.includes(name), name)}>
|
label="Title"
|
||||||
{name}
|
value={title}
|
||||||
</Switch>
|
placeholder={days.join(", ")}
|
||||||
))
|
onChangeText={(value) => setTitle(value)}
|
||||||
)}
|
/>
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<Button
|
<Text style={styles.title}>Days</Text>
|
||||||
disabled={workouts.length === 0 && days.length === 0}
|
{DAYS.map((day) => renderDay(day))}
|
||||||
style={styles.button}
|
|
||||||
mode="contained"
|
<Text style={[styles.title, { marginTop: MARGIN }]}>Exercises</Text>
|
||||||
icon="save"
|
{exercises.map((exercise, index) =>
|
||||||
onPress={save}>
|
renderExercise(exercise, index, true)
|
||||||
Save
|
)}
|
||||||
</Button>
|
{names?.length === 0 && (
|
||||||
</View>
|
<>
|
||||||
|
<Text>No exercises yet.</Text>
|
||||||
|
<Button
|
||||||
|
onPress={() =>
|
||||||
|
stackNavigate("EditExercise", { gymSet: defaultSet })
|
||||||
|
}
|
||||||
|
style={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Add some?
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{names !== undefined &&
|
||||||
|
names
|
||||||
|
.filter((name) => !exercises.includes(name))
|
||||||
|
.map((name, index) => renderExercise(name, index, false))}
|
||||||
|
<View style={{ marginBottom: MARGIN }}></View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<PrimaryButton
|
||||||
|
disabled={exercises.length === 0 && days.length === 0}
|
||||||
|
icon="content-save"
|
||||||
|
onPress={async () => {
|
||||||
|
await save();
|
||||||
|
drawerNavigate("Plans");
|
||||||
|
}}
|
||||||
|
style={{ margin: MARGIN }}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</PrimaryButton>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
@ -121,5 +232,4 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
marginBottom: MARGIN,
|
marginBottom: MARGIN,
|
||||||
},
|
},
|
||||||
button: {},
|
});
|
||||||
})
|
|
||||||
|
|
428
EditSet.tsx
428
EditSet.tsx
|
@ -1,206 +1,370 @@
|
||||||
|
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
|
||||||
import {
|
import {
|
||||||
|
NavigationProp,
|
||||||
RouteProp,
|
RouteProp,
|
||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
useRoute,
|
useRoute,
|
||||||
} from '@react-navigation/native'
|
} from "@react-navigation/native";
|
||||||
import {format} from 'date-fns'
|
import { format } from "date-fns";
|
||||||
import {useCallback, useRef, useState} from 'react'
|
import { useCallback, useRef, useState } from "react";
|
||||||
import {NativeModules, TextInput, View} from 'react-native'
|
import { NativeModules, TextInput, View } from "react-native";
|
||||||
import DocumentPicker from 'react-native-document-picker'
|
import DocumentPicker from "react-native-document-picker";
|
||||||
import {Button, Card, TouchableRipple} from 'react-native-paper'
|
import {
|
||||||
import ConfirmDialog from './ConfirmDialog'
|
Button,
|
||||||
import {MARGIN, PADDING} from './constants'
|
Card,
|
||||||
import {getNow, setRepo, settingsRepo} from './db'
|
IconButton,
|
||||||
import GymSet from './gym-set'
|
Menu,
|
||||||
import {HomePageParams} from './home-page-params'
|
TouchableRipple,
|
||||||
import MassiveInput from './MassiveInput'
|
} from "react-native-paper";
|
||||||
import Settings from './settings'
|
import { check, PERMISSIONS, request, RESULTS } from "react-native-permissions";
|
||||||
import StackHeader from './StackHeader'
|
import AppInput from "./AppInput";
|
||||||
import {toast} from './toast'
|
import { StackParams } from "./AppStack";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
import { MARGIN, PADDING } from "./constants";
|
||||||
|
import { convert } from "./conversions";
|
||||||
|
import { getNow, setRepo, settingsRepo } from "./db";
|
||||||
|
import { DrawerParams } from "./drawer-params";
|
||||||
|
import { fixNumeric } from "./fix-numeric";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
import Select from "./Select";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import StackHeader from "./StackHeader";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
import PrimaryButton from "./PrimaryButton";
|
||||||
|
|
||||||
export default function EditSet() {
|
export default function EditSet() {
|
||||||
const {params} = useRoute<RouteProp<HomePageParams, 'EditSet'>>()
|
const { params } = useRoute<RouteProp<StackParams, "EditSet">>();
|
||||||
const {set} = params
|
const { set } = params;
|
||||||
const navigation = useNavigation()
|
const { navigate } = useNavigation<NavigationProp<DrawerParams>>();
|
||||||
const [settings, setSettings] = useState<Settings>({} as Settings)
|
const [settings, setSettings] = useState<Settings>({} as Settings);
|
||||||
const [name, setName] = useState(set.name)
|
const [name, setName] = useState(set.name);
|
||||||
const [reps, setReps] = useState(set.reps?.toString())
|
const [reps, setReps] = useState(set.reps?.toString());
|
||||||
const [weight, setWeight] = useState(set.weight?.toString())
|
const [weight, setWeight] = useState(set.weight?.toString());
|
||||||
const [newImage, setNewImage] = useState(set.image)
|
const [newImage, setNewImage] = useState(set.image);
|
||||||
const [unit, setUnit] = useState(set.unit)
|
const [unit, setUnit] = useState(set.unit);
|
||||||
const [showRemove, setShowRemove] = useState(false)
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
const [removeImage, setRemoveImage] = useState(false)
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const weightRef = useRef<TextInput>(null)
|
const [created, setCreated] = useState<Date>(
|
||||||
const repsRef = useRef<TextInput>(null)
|
set.created ? new Date(set.created) : new Date()
|
||||||
const unitRef = useRef<TextInput>(null)
|
);
|
||||||
|
const [createdDirty, setCreatedDirty] = useState(false);
|
||||||
|
const [showRemoveImage, setShowRemoveImage] = useState(false);
|
||||||
|
const [removeImage, setRemoveImage] = useState(false);
|
||||||
|
const [setOptions, setSets] = useState<GymSet[]>([]);
|
||||||
|
const weightRef = useRef<TextInput>(null);
|
||||||
|
const repsRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
const [selection, setSelection] = useState({
|
const [selection, setSelection] = useState({
|
||||||
start: 0,
|
start: 0,
|
||||||
end: set.reps?.toString().length,
|
end: set.reps?.toString().length,
|
||||||
})
|
});
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
settingsRepo.findOne({where: {}}).then(setSettings)
|
settingsRepo.findOne({ where: {} }).then(gotSettings => {
|
||||||
}, []),
|
setSettings(gotSettings);
|
||||||
)
|
console.log(`${EditSet.name}.focus:`, { gotSettings })
|
||||||
|
});
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
const startTimer = useCallback(
|
const startTimer = useCallback(
|
||||||
async (value: string) => {
|
async (value: string) => {
|
||||||
if (!settings.alarm) return
|
if (!settings.alarm) return;
|
||||||
const first = await setRepo.findOne({where: {name: value}})
|
const first = await setRepo.findOne({ where: { name: value } });
|
||||||
const milliseconds =
|
const milliseconds =
|
||||||
(first?.minutes ?? 3) * 60 * 1000 + (first?.seconds ?? 0) * 1000
|
(first?.minutes ?? 3) * 60 * 1000 + (first?.seconds ?? 0) * 1000;
|
||||||
const {vibrate, sound, noSound} = settings
|
console.log(`${EditSet.name}.timer:`, { milliseconds });
|
||||||
const args = [milliseconds, vibrate, sound, noSound]
|
const canNotify = await check(PERMISSIONS.ANDROID.POST_NOTIFICATIONS);
|
||||||
NativeModules.AlarmModule.timer(...args)
|
if (canNotify === RESULTS.DENIED)
|
||||||
|
await request(PERMISSIONS.ANDROID.POST_NOTIFICATIONS);
|
||||||
|
if (milliseconds) NativeModules.AlarmModule.timer(milliseconds, `${first.name}`);
|
||||||
},
|
},
|
||||||
[settings],
|
[settings]
|
||||||
)
|
);
|
||||||
|
|
||||||
const added = useCallback(
|
const notify = (value: Partial<GymSet>) => {
|
||||||
async (value: GymSet) => {
|
if (!settings.notify) return navigate("History");
|
||||||
startTimer(value.name)
|
if (
|
||||||
console.log(`${EditSet.name}.add`, {set: value})
|
value.weight > set.weight ||
|
||||||
if (!settings.notify) return
|
(value.reps > set.reps && value.weight === set.weight)
|
||||||
if (
|
) {
|
||||||
value.weight > set.weight ||
|
toast("Great work King! That's a new record.");
|
||||||
(value.reps > set.reps && value.weight === set.weight)
|
}
|
||||||
)
|
};
|
||||||
toast("Great work King! That's a new record.")
|
|
||||||
},
|
const added = async (value: GymSet) => {
|
||||||
[startTimer, set, settings],
|
console.log(`${EditSet.name}.added:`, value);
|
||||||
)
|
startTimer(value.name);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
console.log(`${EditSet.name}.handleSubmit:`, {set, uri: newImage, name})
|
if (!name) return;
|
||||||
if (!name) return
|
|
||||||
let image = newImage
|
|
||||||
if (!newImage && !removeImage)
|
|
||||||
image = await setRepo.findOne({where: {name}}).then(s => s?.image)
|
|
||||||
|
|
||||||
console.log(`${EditSet.name}.handleSubmit:`, {image})
|
let newWeight = Number(weight || 0);
|
||||||
const [{now}] = await getNow()
|
let newUnit = unit;
|
||||||
const saved = await setRepo.save({
|
if (settings.autoConvert && unit !== settings.autoConvert) {
|
||||||
|
newUnit = settings.autoConvert;
|
||||||
|
newWeight = convert(newWeight, unit, settings.autoConvert);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSet: Partial<GymSet> = {
|
||||||
id: set.id,
|
id: set.id,
|
||||||
name,
|
name,
|
||||||
created: set.created || now,
|
reps: Number(reps || 0),
|
||||||
reps: Number(reps),
|
weight: newWeight,
|
||||||
weight: Number(weight),
|
unit: newUnit,
|
||||||
unit,
|
|
||||||
image,
|
|
||||||
minutes: Number(set.minutes ?? 3),
|
minutes: Number(set.minutes ?? 3),
|
||||||
seconds: Number(set.seconds ?? 30),
|
seconds: Number(set.seconds ?? 30),
|
||||||
sets: set.sets ?? 3,
|
sets: set.sets ?? 3,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
})
|
};
|
||||||
if (typeof set.id !== 'number') added(saved)
|
|
||||||
navigation.goBack()
|
newSet.image = newImage;
|
||||||
}
|
if (!newImage && !removeImage) {
|
||||||
|
newSet.image = await setRepo
|
||||||
|
.findOne({ where: { name } })
|
||||||
|
.then((s) => s?.image);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdDirty) newSet.created = created.toISOString();
|
||||||
|
if (typeof set.id !== "number") newSet.created = await getNow();
|
||||||
|
|
||||||
|
const saved = await setRepo.save(newSet);
|
||||||
|
notify(newSet);
|
||||||
|
if (typeof set.id !== "number") added(saved);
|
||||||
|
navigate("History");
|
||||||
|
};
|
||||||
|
|
||||||
const changeImage = useCallback(async () => {
|
const changeImage = useCallback(async () => {
|
||||||
const {fileCopyUri} = await DocumentPicker.pickSingle({
|
const { fileCopyUri } = await DocumentPicker.pickSingle({
|
||||||
type: DocumentPicker.types.images,
|
type: DocumentPicker.types.images,
|
||||||
copyTo: 'documentDirectory',
|
copyTo: "documentDirectory",
|
||||||
})
|
});
|
||||||
if (fileCopyUri) setNewImage(fileCopyUri)
|
if (fileCopyUri) setNewImage(fileCopyUri);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleRemove = useCallback(async () => {
|
const handleRemove = useCallback(async () => {
|
||||||
setNewImage('')
|
setNewImage("");
|
||||||
setRemoveImage(true)
|
setRemoveImage(true);
|
||||||
setShowRemove(false)
|
setShowRemoveImage(false);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
|
const pickDate = useCallback(() => {
|
||||||
|
DateTimePickerAndroid.open({
|
||||||
|
value: created,
|
||||||
|
onChange: (event, date) => {
|
||||||
|
if (event.type === 'dismissed') return;
|
||||||
|
if (date === created) return;
|
||||||
|
setCreated(date);
|
||||||
|
setCreatedDirty(true);
|
||||||
|
DateTimePickerAndroid.open({
|
||||||
|
value: date,
|
||||||
|
onChange: (__, time) => setCreated(time),
|
||||||
|
mode: "time",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
mode: "date",
|
||||||
|
});
|
||||||
|
}, [created]);
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
await setRepo.delete(set.id);
|
||||||
|
navigate("History");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMenu = async () => {
|
||||||
|
if (setOptions.length > 0) return setShowMenu(true);
|
||||||
|
const latestSets = await setRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select()
|
||||||
|
.addSelect("MAX(created) as created")
|
||||||
|
.groupBy("name")
|
||||||
|
.getMany();
|
||||||
|
setSets(latestSets);
|
||||||
|
setShowMenu(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const select = (setOption: GymSet) => {
|
||||||
|
setName(setOption.name);
|
||||||
|
setReps(setOption.reps.toString());
|
||||||
|
setWeight(setOption.weight.toString());
|
||||||
|
setNewImage(setOption.image);
|
||||||
|
setUnit(setOption.unit);
|
||||||
|
setSelection({
|
||||||
|
start: 0,
|
||||||
|
end: setOption.reps.toString().length,
|
||||||
|
});
|
||||||
|
setShowMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StackHeader title="Edit set" />
|
<StackHeader title={typeof set.id === "number" ? "Edit set" : "Add set"}>
|
||||||
|
{typeof set.id === "number" ? (
|
||||||
|
<IconButton onPress={() => setShowDelete(true)} icon="delete" />
|
||||||
|
) : null}
|
||||||
|
</StackHeader>
|
||||||
|
|
||||||
<View style={{padding: PADDING, flex: 1}}>
|
<View style={{ padding: PADDING, flex: 1 }}>
|
||||||
<MassiveInput
|
<View>
|
||||||
label="Name"
|
<AppInput
|
||||||
value={name}
|
label="Name"
|
||||||
onChangeText={setName}
|
value={name}
|
||||||
autoCorrect={false}
|
onChangeText={setName}
|
||||||
autoFocus={!name}
|
autoCorrect={false}
|
||||||
onSubmitEditing={() => repsRef.current?.focus()}
|
autoFocus={!name}
|
||||||
/>
|
onSubmitEditing={() => repsRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", right: 0, flexDirection: "row" }}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
visible={showMenu}
|
||||||
|
onDismiss={() => setShowMenu(false)}
|
||||||
|
anchor={<IconButton icon="menu-down" onPress={openMenu} />}
|
||||||
|
>
|
||||||
|
{setOptions.map((setOption) => (
|
||||||
|
<Menu.Item
|
||||||
|
title={setOption.name}
|
||||||
|
key={setOption.id}
|
||||||
|
onPress={() => select(setOption)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<MassiveInput
|
<View>
|
||||||
label="Reps"
|
<AppInput
|
||||||
keyboardType="numeric"
|
label="Reps"
|
||||||
value={reps}
|
keyboardType="numeric"
|
||||||
onChangeText={setReps}
|
value={reps}
|
||||||
onSubmitEditing={() => weightRef.current?.focus()}
|
onChangeText={(newReps) => {
|
||||||
selection={selection}
|
const fixed = fixNumeric(newReps);
|
||||||
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
|
setReps(fixed.replace(/-/g, ''))
|
||||||
autoFocus={!!name}
|
if (fixed.length !== newReps.length)
|
||||||
innerRef={repsRef}
|
toast("Reps must be a number");
|
||||||
/>
|
else if (fixed.includes('-'))
|
||||||
|
toast("Reps must be a positive value")
|
||||||
|
}}
|
||||||
|
onSubmitEditing={() => weightRef.current?.focus()}
|
||||||
|
selection={selection}
|
||||||
|
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
|
||||||
|
innerRef={repsRef}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", right: 0, flexDirection: "row" }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="plus"
|
||||||
|
onPress={() => setReps((Number(reps) + 1).toString())}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="minus"
|
||||||
|
onPress={() => setReps((Number(reps) - 1).toString())}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<MassiveInput
|
<View>
|
||||||
label="Weight"
|
<AppInput
|
||||||
keyboardType="numeric"
|
label="Weight"
|
||||||
value={weight}
|
keyboardType="numeric"
|
||||||
onChangeText={setWeight}
|
value={weight}
|
||||||
onSubmitEditing={handleSubmit}
|
onChangeText={(newWeight) => {
|
||||||
innerRef={weightRef}
|
const fixed = fixNumeric(newWeight);
|
||||||
/>
|
setWeight(fixed);
|
||||||
|
if (fixed.length !== newWeight.length)
|
||||||
|
toast("Weight must be a number");
|
||||||
|
}}
|
||||||
|
onSubmitEditing={handleSubmit}
|
||||||
|
innerRef={weightRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", right: 0, flexDirection: "row" }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="plus"
|
||||||
|
onPress={() => setWeight((Number(weight) + 2.5).toString())}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="minus"
|
||||||
|
onPress={() => setWeight((Number(weight) - 2.5).toString())}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{settings.showUnit && (
|
{settings.showUnit && (
|
||||||
<MassiveInput
|
<Select
|
||||||
autoCapitalize="none"
|
|
||||||
label="Unit"
|
|
||||||
value={unit}
|
value={unit}
|
||||||
onChangeText={setUnit}
|
onChange={setUnit}
|
||||||
innerRef={unitRef}
|
items={[
|
||||||
|
{ label: "kg", value: "kg" },
|
||||||
|
{ label: "lb", value: "lb" },
|
||||||
|
{ label: "stone", value: "stone" },
|
||||||
|
]}
|
||||||
|
label="Unit"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{typeof set.id === 'number' && settings.showDate && (
|
{settings.showDate && (
|
||||||
<MassiveInput
|
<AppInput
|
||||||
label="Created"
|
label="Created"
|
||||||
disabled
|
value={format(created, settings.date || "Pp")}
|
||||||
value={format(new Date(set.created), settings.date)}
|
onPressOut={pickDate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{settings.images && newImage && (
|
{settings.images && newImage && (
|
||||||
<TouchableRipple
|
<TouchableRipple
|
||||||
style={{marginBottom: MARGIN}}
|
style={{ marginBottom: MARGIN }}
|
||||||
onPress={changeImage}
|
onPress={changeImage}
|
||||||
onLongPress={() => setShowRemove(true)}>
|
onLongPress={() => setShowRemoveImage(true)}
|
||||||
<Card.Cover source={{uri: newImage}} />
|
>
|
||||||
|
<Card.Cover source={{ uri: newImage }} />
|
||||||
</TouchableRipple>
|
</TouchableRipple>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{settings.images && !newImage && (
|
{settings.images && !newImage && (
|
||||||
<Button
|
<Button
|
||||||
style={{marginBottom: MARGIN}}
|
style={{ marginBottom: MARGIN }}
|
||||||
onPress={changeImage}
|
onPress={changeImage}
|
||||||
icon="add-photo-alternate">
|
icon="image-plus"
|
||||||
|
>
|
||||||
Image
|
Image
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<PrimaryButton
|
||||||
disabled={!name}
|
disabled={!name}
|
||||||
mode="contained"
|
icon="content-save"
|
||||||
icon="save"
|
style={{ margin: MARGIN }}
|
||||||
style={{margin: MARGIN}}
|
onPress={handleSubmit}
|
||||||
onPress={handleSubmit}>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</PrimaryButton>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Remove image"
|
title="Remove image"
|
||||||
onOk={handleRemove}
|
onOk={handleRemove}
|
||||||
show={showRemove}
|
show={showRemoveImage}
|
||||||
setShow={setShowRemove}>
|
setShow={setShowRemoveImage}
|
||||||
|
>
|
||||||
Are you sure you want to remove the image?
|
Are you sure you want to remove the image?
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Delete set"
|
||||||
|
show={showDelete}
|
||||||
|
onOk={remove}
|
||||||
|
setShow={setShowDelete}
|
||||||
|
>
|
||||||
|
<>Are you sure you want to delete {name}</>
|
||||||
|
</ConfirmDialog>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
233
EditSets.tsx
233
EditSets.tsx
|
@ -1,87 +1,93 @@
|
||||||
import {
|
import {
|
||||||
|
NavigationProp,
|
||||||
RouteProp,
|
RouteProp,
|
||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
useRoute,
|
useRoute,
|
||||||
} from '@react-navigation/native'
|
} from "@react-navigation/native";
|
||||||
import {useCallback, useState} from 'react'
|
import { useCallback, useState } from "react";
|
||||||
import {View} from 'react-native'
|
import { View } from "react-native";
|
||||||
import DocumentPicker from 'react-native-document-picker'
|
import DocumentPicker from "react-native-document-picker";
|
||||||
import {Button, Card, TouchableRipple} from 'react-native-paper'
|
import { Button, Card, IconButton, TouchableRipple } from "react-native-paper";
|
||||||
import {In} from 'typeorm'
|
import { In } from "typeorm";
|
||||||
import ConfirmDialog from './ConfirmDialog'
|
import AppInput from "./AppInput";
|
||||||
import {MARGIN, PADDING} from './constants'
|
import { StackParams } from "./AppStack";
|
||||||
import {setRepo, settingsRepo} from './db'
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import GymSet from './gym-set'
|
import Select from "./Select";
|
||||||
import {HomePageParams} from './home-page-params'
|
import StackHeader from "./StackHeader";
|
||||||
import MassiveInput from './MassiveInput'
|
import { MARGIN, PADDING } from "./constants";
|
||||||
import Settings from './settings'
|
import { setRepo, settingsRepo } from "./db";
|
||||||
import StackHeader from './StackHeader'
|
import { DrawerParams } from "./drawer-params";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import PrimaryButton from "./PrimaryButton";
|
||||||
|
import { fixNumeric } from "./fix-numeric";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
|
||||||
export default function EditSets() {
|
export default function EditSets() {
|
||||||
const {params} = useRoute<RouteProp<HomePageParams, 'EditSets'>>()
|
const { params } = useRoute<RouteProp<StackParams, "EditSets">>();
|
||||||
const {ids} = params
|
const { ids } = params;
|
||||||
const navigation = useNavigation()
|
const { navigate } = useNavigation<NavigationProp<DrawerParams>>();
|
||||||
const [settings, setSettings] = useState<Settings>({} as Settings)
|
const [settings, setSettings] = useState<Settings>({} as Settings);
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState("");
|
||||||
const [reps, setReps] = useState('')
|
const [reps, setReps] = useState("");
|
||||||
const [weight, setWeight] = useState('')
|
const [weight, setWeight] = useState("");
|
||||||
const [newImage, setNewImage] = useState('')
|
const [newImage, setNewImage] = useState("");
|
||||||
const [unit, setUnit] = useState('')
|
const [unit, setUnit] = useState("");
|
||||||
const [showRemove, setShowRemove] = useState(false)
|
const [showRemove, setShowRemove] = useState(false);
|
||||||
const [names, setNames] = useState('')
|
const [names, setNames] = useState("");
|
||||||
const [oldReps, setOldReps] = useState('')
|
const [oldReps, setOldReps] = useState("");
|
||||||
const [weights, setWeights] = useState('')
|
const [weights, setWeights] = useState("");
|
||||||
const [units, setUnits] = useState('')
|
const [units, setUnits] = useState("");
|
||||||
|
|
||||||
const [selection, setSelection] = useState({
|
const [selection, setSelection] = useState({
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 1,
|
end: 1,
|
||||||
})
|
});
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
settingsRepo.findOne({where: {}}).then(setSettings)
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
setRepo.find({where: {id: In(ids)}}).then(sets => {
|
setRepo.find({ where: { id: In(ids) } }).then((sets) => {
|
||||||
setNames(sets.map(set => set.name).join(', '))
|
setNames(sets.map((set) => set.name).join(", "));
|
||||||
setOldReps(sets.map(set => set.reps).join(', '))
|
setOldReps(sets.map((set) => set.reps).join(", "));
|
||||||
setWeights(sets.map(set => set.weight).join(', '))
|
setWeights(sets.map((set) => set.weight).join(", "));
|
||||||
setUnits(sets.map(set => set.unit).join(', '))
|
setUnits(sets.map((set) => set.unit).join(", "));
|
||||||
})
|
});
|
||||||
}, [ids]),
|
}, [ids])
|
||||||
)
|
);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const save = async () => {
|
||||||
console.log(`${EditSets.name}.handleSubmit:`, {uri: newImage, name})
|
console.log(`${EditSets.name}.save:`, { uri: newImage, name });
|
||||||
const update: Partial<GymSet> = {}
|
const update: Partial<GymSet> = {};
|
||||||
if (name) update.name = name
|
if (name) update.name = name;
|
||||||
if (reps) update.reps = Number(reps)
|
if (reps) update.reps = Number(reps);
|
||||||
if (weight) update.weight = Number(weight)
|
if (weight) update.weight = Number(weight);
|
||||||
if (unit) update.unit = unit
|
if (unit) update.unit = unit;
|
||||||
if (newImage) update.image = newImage
|
if (newImage) update.image = newImage;
|
||||||
if (Object.keys(update).length > 0) await setRepo.update(ids, update)
|
if (Object.keys(update).length > 0) await setRepo.update(ids, update);
|
||||||
navigation.goBack()
|
navigate("History");
|
||||||
}
|
};
|
||||||
|
|
||||||
const changeImage = useCallback(async () => {
|
const changeImage = useCallback(async () => {
|
||||||
const {fileCopyUri} = await DocumentPicker.pickSingle({
|
const { fileCopyUri } = await DocumentPicker.pickSingle({
|
||||||
type: DocumentPicker.types.images,
|
type: DocumentPicker.types.images,
|
||||||
copyTo: 'documentDirectory',
|
copyTo: "documentDirectory",
|
||||||
})
|
});
|
||||||
if (fileCopyUri) setNewImage(fileCopyUri)
|
if (fileCopyUri) setNewImage(fileCopyUri);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleRemove = useCallback(async () => {
|
const handleRemove = useCallback(async () => {
|
||||||
setNewImage('')
|
setNewImage("");
|
||||||
setShowRemove(false)
|
setShowRemove(false);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StackHeader title={`Edit ${ids.length} sets`} />
|
<StackHeader title={`Edit ${ids.length} sets`} />
|
||||||
|
|
||||||
<View style={{padding: PADDING, flex: 1}}>
|
<View style={{ padding: PADDING, flex: 1 }}>
|
||||||
<MassiveInput
|
<AppInput
|
||||||
label={`Names: ${names}`}
|
label={`Names: ${names}`}
|
||||||
value={name}
|
value={name}
|
||||||
onChangeText={setName}
|
onChangeText={setName}
|
||||||
|
@ -89,66 +95,109 @@ export default function EditSets() {
|
||||||
autoFocus={!name}
|
autoFocus={!name}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MassiveInput
|
<View>
|
||||||
label={`Reps: ${oldReps}`}
|
<AppInput
|
||||||
keyboardType="numeric"
|
label={`Reps: ${oldReps}`}
|
||||||
value={reps}
|
keyboardType="numeric"
|
||||||
onChangeText={setReps}
|
value={reps}
|
||||||
selection={selection}
|
onChangeText={(newReps) => {
|
||||||
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
|
const fixed = fixNumeric(newReps);
|
||||||
autoFocus={!!name}
|
setReps(fixed.replace(/-/g, ''))
|
||||||
/>
|
if (fixed.length !== newReps.length)
|
||||||
|
toast("Reps must be a number");
|
||||||
|
else if (fixed.includes('-'))
|
||||||
|
toast("Reps must be a positive value")
|
||||||
|
}}
|
||||||
|
selection={selection}
|
||||||
|
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
|
||||||
|
autoFocus={!!name}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", right: 0, flexDirection: "row" }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="plus"
|
||||||
|
onPress={() => setReps((Number(reps) + 1).toString())}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="minus"
|
||||||
|
onPress={() => setReps((Number(reps) - 1).toString())}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<MassiveInput
|
<View>
|
||||||
label={`Weights: ${weights}`}
|
<AppInput
|
||||||
keyboardType="numeric"
|
label={`Weights: ${weights}`}
|
||||||
value={weight}
|
keyboardType="numeric"
|
||||||
onChangeText={setWeight}
|
value={weight}
|
||||||
onSubmitEditing={handleSubmit}
|
onChangeText={setWeight}
|
||||||
/>
|
onSubmitEditing={save}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", right: 0, flexDirection: "row" }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="plus"
|
||||||
|
onPress={() => setWeight((Number(weight) + 2.5).toString())}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="minus"
|
||||||
|
onPress={() => setWeight((Number(weight) - 2.5).toString())}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{settings.showUnit && (
|
{settings.showUnit && (
|
||||||
<MassiveInput
|
<Select
|
||||||
autoCapitalize="none"
|
|
||||||
label={`Units: ${units}`}
|
|
||||||
value={unit}
|
value={unit}
|
||||||
onChangeText={setUnit}
|
onChange={setUnit}
|
||||||
|
items={[
|
||||||
|
{ label: "", value: "" },
|
||||||
|
{ label: "kg", value: "kg" },
|
||||||
|
{ label: "lb", value: "lb" },
|
||||||
|
{ label: "stone", value: "stone" },
|
||||||
|
]}
|
||||||
|
label={`Units: ${units}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{settings.images && newImage && (
|
{settings.images && newImage && (
|
||||||
<TouchableRipple
|
<TouchableRipple
|
||||||
style={{marginBottom: MARGIN}}
|
style={{ marginBottom: MARGIN }}
|
||||||
onPress={changeImage}
|
onPress={changeImage}
|
||||||
onLongPress={() => setShowRemove(true)}>
|
onLongPress={() => setShowRemove(true)}
|
||||||
<Card.Cover source={{uri: newImage}} />
|
>
|
||||||
|
<Card.Cover source={{ uri: newImage }} />
|
||||||
</TouchableRipple>
|
</TouchableRipple>
|
||||||
)}
|
)}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Remove image"
|
title="Remove image"
|
||||||
onOk={handleRemove}
|
onOk={handleRemove}
|
||||||
show={showRemove}
|
show={showRemove}
|
||||||
setShow={setShowRemove}>
|
setShow={setShowRemove}
|
||||||
|
>
|
||||||
Are you sure you want to remove the image?
|
Are you sure you want to remove the image?
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
{settings.images && !newImage && (
|
{settings.images && !newImage && (
|
||||||
<Button
|
<Button
|
||||||
style={{marginBottom: MARGIN}}
|
style={{ marginBottom: MARGIN }}
|
||||||
onPress={changeImage}
|
onPress={changeImage}
|
||||||
icon="add-photo-alternate">
|
icon="image-plus"
|
||||||
|
>
|
||||||
Image
|
Image
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<PrimaryButton
|
||||||
mode="contained"
|
icon="content-save"
|
||||||
icon="save"
|
style={{ margin: MARGIN }}
|
||||||
style={{margin: MARGIN}}
|
onPress={save}
|
||||||
onPress={handleSubmit}>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</PrimaryButton>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
|
||||||
|
import {
|
||||||
|
NavigationProp,
|
||||||
|
RouteProp,
|
||||||
|
useFocusEffect,
|
||||||
|
useNavigation,
|
||||||
|
useRoute,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { TextInput, View } from "react-native";
|
||||||
|
import { IconButton } from "react-native-paper";
|
||||||
|
import AppInput from "./AppInput";
|
||||||
|
import { StackParams } from "./AppStack";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
import PrimaryButton from "./PrimaryButton";
|
||||||
|
import Select from "./Select";
|
||||||
|
import StackHeader from "./StackHeader";
|
||||||
|
import { MARGIN, PADDING } from "./constants";
|
||||||
|
import { AppDataSource } from "./data-source";
|
||||||
|
import { getNow, settingsRepo, weightRepo } from "./db";
|
||||||
|
import { DrawerParams } from "./drawer-params";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
import Weight from "./weight";
|
||||||
|
|
||||||
|
export default function EditWeight() {
|
||||||
|
const { params } = useRoute<RouteProp<StackParams, "EditWeight">>();
|
||||||
|
const { weight } = params;
|
||||||
|
const { navigate } = useNavigation<NavigationProp<DrawerParams>>();
|
||||||
|
const { navigate: stackNavigate, goBack } = useNavigation<NavigationProp<StackParams>>();
|
||||||
|
const [settings, setSettings] = useState<Settings>({} as Settings);
|
||||||
|
const [value, setValue] = useState(weight.value?.toString());
|
||||||
|
const [unit, setUnit] = useState(weight.unit);
|
||||||
|
const [created, setCreated] = useState<Date>(
|
||||||
|
weight.created ? new Date(weight.created) : new Date()
|
||||||
|
);
|
||||||
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
|
const [createdDirty, setCreatedDirty] = useState(false);
|
||||||
|
const unitRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
const newWeight: Partial<Weight> = {
|
||||||
|
id: weight.id,
|
||||||
|
value: Number(value),
|
||||||
|
unit,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createdDirty) newWeight.created = created.toISOString();
|
||||||
|
else if (typeof weight.id !== "number") newWeight.created = await getNow();
|
||||||
|
|
||||||
|
await weightRepo.save(newWeight);
|
||||||
|
if (settings.notify) await checkWeekly();
|
||||||
|
goBack();
|
||||||
|
stackNavigate("ViewWeightGraph");
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkWeekly = async () => {
|
||||||
|
const select = `
|
||||||
|
WITH weekly_weights AS (
|
||||||
|
SELECT
|
||||||
|
strftime('%W', created) AS week_number,
|
||||||
|
AVG(value) AS weekly_average
|
||||||
|
FROM weights
|
||||||
|
WHERE strftime('%W', created) = strftime('%W', 'now')
|
||||||
|
GROUP BY week_number
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
((SELECT value FROM weights WHERE strftime('%W', created) = strftime('%W', 'now') ORDER BY created LIMIT 1) - weekly_weights.weekly_average) / (SELECT value FROM weights WHERE strftime('%W', created) = strftime('%W', 'now') ORDER BY created LIMIT 1) * 100 AS loss
|
||||||
|
FROM weekly_weights
|
||||||
|
WHERE week_number = strftime('%W', 'now')
|
||||||
|
`;
|
||||||
|
const result = await AppDataSource.manager.query(select);
|
||||||
|
console.log(`${EditWeight.name}.checkWeekly:`, result);
|
||||||
|
if (result.length && result[0].loss > 1)
|
||||||
|
toast("Weight loss should be <= 1% per week.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickDate = useCallback(() => {
|
||||||
|
DateTimePickerAndroid.open({
|
||||||
|
value: created,
|
||||||
|
onChange: (_, date) => {
|
||||||
|
if (date === created) return;
|
||||||
|
setCreated(date);
|
||||||
|
setCreatedDirty(true);
|
||||||
|
},
|
||||||
|
mode: "date",
|
||||||
|
});
|
||||||
|
}, [created]);
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!weight.id) return;
|
||||||
|
await weightRepo.delete(weight.id);
|
||||||
|
navigate("Weight");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StackHeader
|
||||||
|
title={typeof weight.id === "number" ? "Edit weight" : "Add weight"}
|
||||||
|
>
|
||||||
|
{typeof weight.id === "number" ? (
|
||||||
|
<IconButton onPress={() => setShowDelete(true)} icon="delete" />
|
||||||
|
) : null}
|
||||||
|
</StackHeader>
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Delete weight"
|
||||||
|
show={showDelete}
|
||||||
|
onOk={remove}
|
||||||
|
setShow={setShowDelete}
|
||||||
|
>
|
||||||
|
<>Are you sure you want to delete {value}</>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<View style={{ padding: PADDING, flex: 1 }}>
|
||||||
|
<AppInput
|
||||||
|
label="Weight"
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
keyboardType="numeric"
|
||||||
|
onSubmitEditing={submit}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{settings.showUnit && (
|
||||||
|
<Select
|
||||||
|
value={unit}
|
||||||
|
onChange={setUnit}
|
||||||
|
items={[
|
||||||
|
{ label: "kg", value: "kg" },
|
||||||
|
{ label: "lb", value: "lb" },
|
||||||
|
{ label: "stone", value: "stone" },
|
||||||
|
]}
|
||||||
|
label="Unit"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings.showDate && (
|
||||||
|
<AppInput
|
||||||
|
label="Created"
|
||||||
|
value={format(created, settings.date || "Pp")}
|
||||||
|
onPressOut={pickDate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<PrimaryButton
|
||||||
|
disabled={!value}
|
||||||
|
icon="content-save"
|
||||||
|
style={{ margin: MARGIN }}
|
||||||
|
onPress={submit}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</PrimaryButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
189
EditWorkout.tsx
189
EditWorkout.tsx
|
@ -1,189 +0,0 @@
|
||||||
import {
|
|
||||||
RouteProp,
|
|
||||||
useFocusEffect,
|
|
||||||
useNavigation,
|
|
||||||
useRoute,
|
|
||||||
} from '@react-navigation/native'
|
|
||||||
import {useCallback, useRef, useState} from 'react'
|
|
||||||
import {ScrollView, TextInput, View} from 'react-native'
|
|
||||||
import DocumentPicker from 'react-native-document-picker'
|
|
||||||
import {Button, Card, TouchableRipple} from 'react-native-paper'
|
|
||||||
import ConfirmDialog from './ConfirmDialog'
|
|
||||||
import {MARGIN, PADDING} from './constants'
|
|
||||||
import {getNow, planRepo, setRepo, settingsRepo} from './db'
|
|
||||||
import {defaultSet} from './gym-set'
|
|
||||||
import MassiveInput from './MassiveInput'
|
|
||||||
import Settings from './settings'
|
|
||||||
import StackHeader from './StackHeader'
|
|
||||||
import {WorkoutsPageParams} from './WorkoutsPage'
|
|
||||||
|
|
||||||
export default function EditWorkout() {
|
|
||||||
const {params} = useRoute<RouteProp<WorkoutsPageParams, 'EditWorkout'>>()
|
|
||||||
const [removeImage, setRemoveImage] = useState(false)
|
|
||||||
const [showRemove, setShowRemove] = useState(false)
|
|
||||||
const [name, setName] = useState(params.value.name)
|
|
||||||
const [steps, setSteps] = useState(params.value.steps)
|
|
||||||
const [uri, setUri] = useState(params.value.image)
|
|
||||||
const [minutes, setMinutes] = useState(
|
|
||||||
params.value.minutes?.toString() ?? '3',
|
|
||||||
)
|
|
||||||
const [seconds, setSeconds] = useState(
|
|
||||||
params.value.seconds?.toString() ?? '30',
|
|
||||||
)
|
|
||||||
const [sets, setSets] = useState(params.value.sets?.toString() ?? '3')
|
|
||||||
const navigation = useNavigation()
|
|
||||||
const setsRef = useRef<TextInput>(null)
|
|
||||||
const stepsRef = useRef<TextInput>(null)
|
|
||||||
const minutesRef = useRef<TextInput>(null)
|
|
||||||
const secondsRef = useRef<TextInput>(null)
|
|
||||||
const [settings, setSettings] = useState<Settings>()
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
settingsRepo.findOne({where: {}}).then(setSettings)
|
|
||||||
}, []),
|
|
||||||
)
|
|
||||||
|
|
||||||
const update = async () => {
|
|
||||||
await setRepo.update(
|
|
||||||
{name: params.value.name},
|
|
||||||
{
|
|
||||||
name: name || params.value.name,
|
|
||||||
sets: Number(sets),
|
|
||||||
minutes: +minutes,
|
|
||||||
seconds: +seconds,
|
|
||||||
steps,
|
|
||||||
image: removeImage ? '' : uri,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await planRepo.query(
|
|
||||||
`UPDATE plans
|
|
||||||
SET workouts = REPLACE(workouts, $1, $2)
|
|
||||||
WHERE workouts LIKE $3`,
|
|
||||||
[params.value.name, name, `%${params.value.name}%`],
|
|
||||||
)
|
|
||||||
navigation.goBack()
|
|
||||||
}
|
|
||||||
|
|
||||||
const add = async () => {
|
|
||||||
const [{now}] = await getNow()
|
|
||||||
await setRepo.save({
|
|
||||||
...defaultSet,
|
|
||||||
name,
|
|
||||||
hidden: true,
|
|
||||||
image: uri,
|
|
||||||
minutes: minutes ? +minutes : 3,
|
|
||||||
seconds: seconds ? +seconds : 30,
|
|
||||||
sets: sets ? +sets : 3,
|
|
||||||
steps,
|
|
||||||
created: now,
|
|
||||||
})
|
|
||||||
navigation.goBack()
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (params.value.name) return update()
|
|
||||||
return add()
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeImage = useCallback(async () => {
|
|
||||||
const {fileCopyUri} = await DocumentPicker.pickSingle({
|
|
||||||
type: DocumentPicker.types.images,
|
|
||||||
copyTo: 'documentDirectory',
|
|
||||||
})
|
|
||||||
if (fileCopyUri) setUri(fileCopyUri)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleRemove = useCallback(async () => {
|
|
||||||
setUri('')
|
|
||||||
setRemoveImage(true)
|
|
||||||
setShowRemove(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const submitName = () => {
|
|
||||||
if (settings.steps) stepsRef.current?.focus()
|
|
||||||
else setsRef.current?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StackHeader title="Edit workout" />
|
|
||||||
<View style={{padding: PADDING, flex: 1}}>
|
|
||||||
<ScrollView style={{flex: 1}}>
|
|
||||||
<MassiveInput
|
|
||||||
autoFocus
|
|
||||||
label="Name"
|
|
||||||
value={name}
|
|
||||||
onChangeText={setName}
|
|
||||||
onSubmitEditing={submitName}
|
|
||||||
/>
|
|
||||||
{settings?.steps && (
|
|
||||||
<MassiveInput
|
|
||||||
innerRef={stepsRef}
|
|
||||||
selectTextOnFocus={false}
|
|
||||||
value={steps}
|
|
||||||
onChangeText={setSteps}
|
|
||||||
label="Steps"
|
|
||||||
multiline
|
|
||||||
onSubmitEditing={() => setsRef.current?.focus()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MassiveInput
|
|
||||||
innerRef={setsRef}
|
|
||||||
value={sets}
|
|
||||||
onChangeText={setSets}
|
|
||||||
label="Sets per workout"
|
|
||||||
keyboardType="numeric"
|
|
||||||
onSubmitEditing={() => minutesRef.current?.focus()}
|
|
||||||
/>
|
|
||||||
{settings?.alarm && (
|
|
||||||
<>
|
|
||||||
<MassiveInput
|
|
||||||
innerRef={minutesRef}
|
|
||||||
onSubmitEditing={() => secondsRef.current?.focus()}
|
|
||||||
value={minutes}
|
|
||||||
onChangeText={setMinutes}
|
|
||||||
label="Rest minutes"
|
|
||||||
keyboardType="numeric"
|
|
||||||
/>
|
|
||||||
<MassiveInput
|
|
||||||
innerRef={secondsRef}
|
|
||||||
value={seconds}
|
|
||||||
onChangeText={setSeconds}
|
|
||||||
label="Rest seconds"
|
|
||||||
keyboardType="numeric"
|
|
||||||
blurOnSubmit
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{settings?.images && uri && (
|
|
||||||
<TouchableRipple
|
|
||||||
style={{marginBottom: MARGIN}}
|
|
||||||
onPress={changeImage}
|
|
||||||
onLongPress={() => setShowRemove(true)}>
|
|
||||||
<Card.Cover source={{uri}} />
|
|
||||||
</TouchableRipple>
|
|
||||||
)}
|
|
||||||
{settings?.images && !uri && (
|
|
||||||
<Button
|
|
||||||
style={{marginBottom: MARGIN}}
|
|
||||||
onPress={changeImage}
|
|
||||||
icon="add-photo-alternate">
|
|
||||||
Image
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
<Button disabled={!name} mode="contained" icon="save" onPress={save}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<ConfirmDialog
|
|
||||||
title="Remove image"
|
|
||||||
onOk={handleRemove}
|
|
||||||
show={showRemove}
|
|
||||||
setShow={setShowRemove}>
|
|
||||||
Are you sure you want to remove the image?
|
|
||||||
</ConfirmDialog>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { NavigationProp, useNavigation } from "@react-navigation/native";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { Image } from "react-native";
|
||||||
|
import { List, useTheme } from "react-native-paper";
|
||||||
|
import { StackParams } from "./AppStack";
|
||||||
|
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
|
||||||
|
export default function ExerciseItem({
|
||||||
|
item,
|
||||||
|
setNames,
|
||||||
|
names,
|
||||||
|
images,
|
||||||
|
alarm,
|
||||||
|
}: {
|
||||||
|
item: GymSet;
|
||||||
|
images: boolean;
|
||||||
|
setNames: (value: string[]) => void;
|
||||||
|
names: string[];
|
||||||
|
alarm: boolean;
|
||||||
|
}) {
|
||||||
|
const navigation = useNavigation<NavigationProp<StackParams>>();
|
||||||
|
const { dark } = useTheme();
|
||||||
|
|
||||||
|
const description = useMemo(() => {
|
||||||
|
const seconds = item.seconds?.toString().padStart(2, "0");
|
||||||
|
const time = ` x ${item.minutes || 0}:${seconds}`;
|
||||||
|
if (alarm) return item.sets.toString() + time;
|
||||||
|
return item.sets.toString();
|
||||||
|
}, [item.sets, item.minutes, item.seconds, alarm]);
|
||||||
|
|
||||||
|
const left = useCallback(() => {
|
||||||
|
if (!images || !item.image) return null;
|
||||||
|
return (
|
||||||
|
<Image source={{ uri: item.image }} style={{ height: 75, width: 75 }} />
|
||||||
|
);
|
||||||
|
}, [item.image, images]);
|
||||||
|
|
||||||
|
const long = () => {
|
||||||
|
if (names.length > 0) return;
|
||||||
|
setNames([item.name]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const backgroundColor = useMemo(() => {
|
||||||
|
if (!names.includes(item.name)) return;
|
||||||
|
if (dark) return DARK_RIPPLE;
|
||||||
|
return LIGHT_RIPPLE;
|
||||||
|
}, [dark, names, item.name]);
|
||||||
|
|
||||||
|
const press = () => {
|
||||||
|
console.log(`${ExerciseItem.name}.press:`, { names });
|
||||||
|
if (names.length === 0)
|
||||||
|
return navigation.navigate("EditExercise", { gymSet: item });
|
||||||
|
const removing = names.find((name) => name === item.name);
|
||||||
|
if (removing) setNames(names.filter((name) => name !== item.name));
|
||||||
|
else setNames([...names, item.name]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
onPress={press}
|
||||||
|
title={item.name}
|
||||||
|
description={description}
|
||||||
|
onLongPress={long}
|
||||||
|
left={left}
|
||||||
|
style={{ backgroundColor }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,162 @@
|
||||||
|
import {
|
||||||
|
NavigationProp,
|
||||||
|
useFocusEffect,
|
||||||
|
useNavigation,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { FlatList } from "react-native";
|
||||||
|
import { List } from "react-native-paper";
|
||||||
|
import { In } from "typeorm";
|
||||||
|
import { StackParams } from "./AppStack";
|
||||||
|
import { LIMIT } from "./constants";
|
||||||
|
import { setRepo, settingsRepo } from "./db";
|
||||||
|
import DrawerHeader from "./DrawerHeader";
|
||||||
|
import ExerciseItem from "./ExerciseItem";
|
||||||
|
import GymSet, { defaultSet } from "./gym-set";
|
||||||
|
import ListMenu from "./ListMenu";
|
||||||
|
import Page from "./Page";
|
||||||
|
import SetList from "./SetList";
|
||||||
|
import Settings from "./settings";
|
||||||
|
|
||||||
|
export default function ExerciseList() {
|
||||||
|
const [exercises, setExercises] = useState<GymSet[]>();
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [term, setTerm] = useState("");
|
||||||
|
const [end, setEnd] = useState(false);
|
||||||
|
const [settings, setSettings] = useState<Settings>();
|
||||||
|
const [names, setNames] = useState<string[]>([]);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const navigation = useNavigation<NavigationProp<StackParams>>();
|
||||||
|
|
||||||
|
const reset = async (value: string) => {
|
||||||
|
console.log(`${ExerciseList.name}.reset`, value);
|
||||||
|
const newExercises = await setRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select()
|
||||||
|
.where("name LIKE :name", { name: `%${value.trim()}%` })
|
||||||
|
.groupBy("name")
|
||||||
|
.orderBy("name")
|
||||||
|
.limit(LIMIT)
|
||||||
|
.getMany();
|
||||||
|
setOffset(0);
|
||||||
|
console.log(`${ExerciseList.name}.reset`, { length: newExercises.length });
|
||||||
|
setEnd(newExercises.length < LIMIT);
|
||||||
|
setExercises(newExercises);
|
||||||
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
reset(term);
|
||||||
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
|
}, [term])
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item }: { item: GymSet }) => (
|
||||||
|
<ExerciseItem
|
||||||
|
images={settings?.images}
|
||||||
|
alarm={settings?.alarm}
|
||||||
|
item={item}
|
||||||
|
key={item.name}
|
||||||
|
names={names}
|
||||||
|
setNames={setNames}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[settings?.images, names, settings?.alarm]
|
||||||
|
);
|
||||||
|
|
||||||
|
const next = async () => {
|
||||||
|
console.log(`${SetList.name}.next:`, {
|
||||||
|
offset,
|
||||||
|
limit: LIMIT,
|
||||||
|
term,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
if (end) return;
|
||||||
|
const newOffset = offset + LIMIT;
|
||||||
|
const newExercises = await setRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select()
|
||||||
|
.where("name LIKE :name", { name: `%${term.trim()}%` })
|
||||||
|
.groupBy("name")
|
||||||
|
.orderBy("name")
|
||||||
|
.limit(LIMIT)
|
||||||
|
.offset(newOffset)
|
||||||
|
.getMany();
|
||||||
|
if (newExercises.length === 0) return setEnd(true);
|
||||||
|
if (!exercises) return;
|
||||||
|
setExercises([...exercises, ...newExercises]);
|
||||||
|
if (newExercises.length < LIMIT) return setEnd(true);
|
||||||
|
setOffset(newOffset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAdd = useCallback(async () => {
|
||||||
|
navigation.navigate("EditExercise", {
|
||||||
|
gymSet: defaultSet,
|
||||||
|
});
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
const search = (value: string) => {
|
||||||
|
setTerm(value);
|
||||||
|
reset(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setNames([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
setNames([]);
|
||||||
|
if (names.length > 0) await setRepo.delete({ name: In(names) });
|
||||||
|
await reset(term);
|
||||||
|
};
|
||||||
|
|
||||||
|
const select = () => {
|
||||||
|
if (!exercises) return;
|
||||||
|
if (names.length === exercises.length) return setNames([]);
|
||||||
|
setNames(exercises.map((exercise) => exercise.name));
|
||||||
|
};
|
||||||
|
|
||||||
|
const edit = () => {
|
||||||
|
navigation.navigate("EditExercises", { names });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DrawerHeader
|
||||||
|
name={names.length > 0 ? `${names.length} selected` : "Exercises"}
|
||||||
|
ids={names}
|
||||||
|
unSelect={() => setNames([])}
|
||||||
|
>
|
||||||
|
<ListMenu
|
||||||
|
onClear={clear}
|
||||||
|
onDelete={remove}
|
||||||
|
onEdit={edit}
|
||||||
|
ids={names}
|
||||||
|
onSelect={select}
|
||||||
|
/>
|
||||||
|
</DrawerHeader>
|
||||||
|
<Page onAdd={onAdd} term={term} search={search}>
|
||||||
|
{exercises?.length === 0 ? (
|
||||||
|
<List.Item
|
||||||
|
title="No exercises yet."
|
||||||
|
description="An exercise is something you do at the gym. E.g. Deadlifts"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={exercises}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={(w) => w.name}
|
||||||
|
onEndReached={next}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
reset("").finally(() => setRefreshing(false));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { View, useColorScheme } from "react-native";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { Dirs, FileSystem } from "react-native-file-access";
|
||||||
|
import { Button, Text } from "react-native-paper";
|
||||||
|
import { CombinedDarkTheme, CombinedDefaultTheme } from "./App";
|
||||||
|
import { MARGIN } from "./constants";
|
||||||
|
import { AppDataSource } from "./data-source";
|
||||||
|
import { settingsRepo } from "./db";
|
||||||
|
|
||||||
|
export default function FatalError({
|
||||||
|
message,
|
||||||
|
setAppSettings,
|
||||||
|
setError,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
setAppSettings: (settings: {
|
||||||
|
startup: any;
|
||||||
|
theme: string;
|
||||||
|
lightColor: string;
|
||||||
|
darkColor: string;
|
||||||
|
}) => void;
|
||||||
|
setError: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const systemTheme = useColorScheme();
|
||||||
|
|
||||||
|
const resetDatabase = useCallback(async () => {
|
||||||
|
await FileSystem.cp("/dev/null", Dirs.DatabaseDir + "/massive.db");
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
const gotSettings = await settingsRepo.findOne({ where: {} });
|
||||||
|
setAppSettings({
|
||||||
|
startup: gotSettings.startup,
|
||||||
|
theme: gotSettings.theme,
|
||||||
|
lightColor: gotSettings.lightColor || CombinedDefaultTheme.colors.primary,
|
||||||
|
darkColor: gotSettings.darkColor || CombinedDarkTheme.colors.primary,
|
||||||
|
});
|
||||||
|
setError("");
|
||||||
|
}, [setAppSettings, setError]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: systemTheme === "dark" ? "white" : "black",
|
||||||
|
margin: MARGIN,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Database failed to initialize: {message}
|
||||||
|
</Text>
|
||||||
|
<Button mode="contained" onPress={resetDatabase}>
|
||||||
|
Reset database
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
7
Gemfile
7
Gemfile
|
@ -1,6 +1,9 @@
|
||||||
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'
|
# Cocoapods 1.15 introduced a bug which break the build. We will remove the upper
|
||||||
|
# bound in the template on Cocoapods with next React Native release.
|
||||||
|
gem 'cocoapods', '>= 1.13', '< 1.15'
|
||||||
|
gem 'activesupport', '>= 6.1.7.5', '< 7.1.0'
|
|
@ -0,0 +1,109 @@
|
||||||
|
import {
|
||||||
|
NavigationProp,
|
||||||
|
useFocusEffect,
|
||||||
|
useNavigation,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { FlatList, Image } from "react-native";
|
||||||
|
import { List } from "react-native-paper";
|
||||||
|
import { StackParams } from "./AppStack";
|
||||||
|
import { getBestSets } from "./best.service";
|
||||||
|
import { LIMIT } from "./constants";
|
||||||
|
import { settingsRepo } from "./db";
|
||||||
|
import DrawerHeader from "./DrawerHeader";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
import Page from "./Page";
|
||||||
|
import Settings from "./settings";
|
||||||
|
|
||||||
|
export default function GraphsList() {
|
||||||
|
const [bests, setBests] = useState<GymSet[]>();
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [end, setEnd] = useState(false);
|
||||||
|
const [term, setTerm] = useState("");
|
||||||
|
const navigation = useNavigation<NavigationProp<StackParams>>();
|
||||||
|
const [settings, setSettings] = useState<Settings>();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(
|
||||||
|
async (value: string) => {
|
||||||
|
if (refreshing) return;
|
||||||
|
const result = await getBestSets({ term: value, offset: 0 });
|
||||||
|
setBests(result);
|
||||||
|
setOffset(0);
|
||||||
|
},
|
||||||
|
[refreshing]
|
||||||
|
);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
refresh(term);
|
||||||
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, [term])
|
||||||
|
);
|
||||||
|
|
||||||
|
const next = useCallback(async () => {
|
||||||
|
if (end) return;
|
||||||
|
const newOffset = offset + LIMIT;
|
||||||
|
console.log(`${GraphsList.name}.next:`, { offset, newOffset, term });
|
||||||
|
const newBests = await getBestSets({ term, offset: newOffset });
|
||||||
|
if (newBests.length === 0) return setEnd(true);
|
||||||
|
if (!bests) return;
|
||||||
|
setBests([...bests, ...newBests]);
|
||||||
|
if (newBests.length < LIMIT) return setEnd(true);
|
||||||
|
setOffset(newOffset);
|
||||||
|
}, [term, end, offset, bests]);
|
||||||
|
|
||||||
|
const search = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setTerm(value);
|
||||||
|
refresh(value);
|
||||||
|
},
|
||||||
|
[refresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: GymSet }) => (
|
||||||
|
<List.Item
|
||||||
|
key={item.name}
|
||||||
|
title={item.name}
|
||||||
|
description={`${item.reps} x ${item.weight}${item.unit || "kg"}`}
|
||||||
|
onPress={() => navigation.navigate("ViewGraph", { name: item.name })}
|
||||||
|
left={() =>
|
||||||
|
(settings?.images && item.image && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: item.image }}
|
||||||
|
style={{ height: 75, width: 75 }}
|
||||||
|
/>
|
||||||
|
)) ||
|
||||||
|
null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DrawerHeader name="Graphs" />
|
||||||
|
<Page term={term} search={search}>
|
||||||
|
{bests?.length === 0 ? (
|
||||||
|
<List.Item
|
||||||
|
title="No exercises yet"
|
||||||
|
description="Once sets have been added, this will highlight your personal bests."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
renderItem={renderItem}
|
||||||
|
data={bests}
|
||||||
|
keyExtractor={(set) => set.name}
|
||||||
|
onEndReached={next}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
refresh(term).finally(() => setRefreshing(false));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
18
HomePage.tsx
18
HomePage.tsx
|
@ -1,18 +0,0 @@
|
||||||
import {createStackNavigator} from '@react-navigation/stack'
|
|
||||||
import EditSet from './EditSet'
|
|
||||||
import EditSets from './EditSets'
|
|
||||||
import {HomePageParams} from './home-page-params'
|
|
||||||
import SetList from './SetList'
|
|
||||||
|
|
||||||
const Stack = createStackNavigator<HomePageParams>()
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
return (
|
|
||||||
<Stack.Navigator
|
|
||||||
screenOptions={{headerShown: false, animationEnabled: false}}>
|
|
||||||
<Stack.Screen name="Sets" component={SetList} />
|
|
||||||
<Stack.Screen name="EditSet" component={EditSet} />
|
|
||||||
<Stack.Screen name="EditSets" component={EditSets} />
|
|
||||||
</Stack.Navigator>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -0,0 +1,216 @@
|
||||||
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { ActivityIndicator, ScrollView, View } from "react-native";
|
||||||
|
import { IconButton, Text } from "react-native-paper";
|
||||||
|
import AppPieChart from "./AppPieChart";
|
||||||
|
import AppLineChart from "./AppLineChart";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
import { MARGIN, PADDING } from "./constants";
|
||||||
|
import { AppDataSource } from "./data-source";
|
||||||
|
import { DAYS } from "./days";
|
||||||
|
import DrawerHeader from "./DrawerHeader";
|
||||||
|
import { Periods } from "./periods";
|
||||||
|
import Select from "./Select";
|
||||||
|
|
||||||
|
interface WeekCount {
|
||||||
|
week: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HourCount {
|
||||||
|
hour: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InsightsPage() {
|
||||||
|
const [weekCounts, setWeekCounts] = useState<WeekCount[]>();
|
||||||
|
const [hourCounts, setHourCounts] = useState<HourCount[]>();
|
||||||
|
const [loadingWeeks, setLoadingWeeks] = useState(true);
|
||||||
|
const [loadingHours, setLoadingHours] = useState(true);
|
||||||
|
const [period, setPeriod] = useState(Periods.Monthly);
|
||||||
|
const [showWeek, setShowWeek] = useState(false);
|
||||||
|
const [showHour, setShowHour] = useState(false);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
let difference = "-1 months";
|
||||||
|
if (period === Periods.TwoMonths) difference = "-2 months";
|
||||||
|
if (period === Periods.ThreeMonths) difference = "-3 months";
|
||||||
|
if (period === Periods.SixMonths) difference = "-6 months";
|
||||||
|
|
||||||
|
const selectWeeks = `
|
||||||
|
SELECT strftime('%w', created) as week, COUNT(*) as count
|
||||||
|
FROM sets
|
||||||
|
WHERE DATE(created) >= DATE('now', 'weekday 0', '${difference}')
|
||||||
|
GROUP BY week
|
||||||
|
HAVING week IS NOT NULL
|
||||||
|
ORDER BY count DESC;
|
||||||
|
`;
|
||||||
|
const selectHours = `
|
||||||
|
SELECT strftime('%H', created) AS hour, COUNT(*) AS count
|
||||||
|
FROM sets
|
||||||
|
WHERE DATE(created) >= DATE('now', 'weekday 0', '${difference}')
|
||||||
|
GROUP BY hour
|
||||||
|
having hour is not null
|
||||||
|
ORDER BY hour
|
||||||
|
`;
|
||||||
|
|
||||||
|
setLoadingWeeks(true);
|
||||||
|
setLoadingHours(true);
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
AppDataSource.manager
|
||||||
|
.query(selectWeeks)
|
||||||
|
.then(setWeekCounts)
|
||||||
|
.then(() => setLoadingWeeks(false))
|
||||||
|
.then(() =>
|
||||||
|
AppDataSource.manager.query(selectHours).then(setHourCounts)
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
setLoadingWeeks(false);
|
||||||
|
setLoadingHours(false);
|
||||||
|
}),
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}, [period])
|
||||||
|
);
|
||||||
|
|
||||||
|
const hourLabel = (hour: string) => {
|
||||||
|
let twelveHour = Number(hour);
|
||||||
|
if (twelveHour === 0) return "12AM";
|
||||||
|
let amPm = "AM";
|
||||||
|
if (twelveHour >= 12) amPm = "PM";
|
||||||
|
if (twelveHour > 12) twelveHour -= 12;
|
||||||
|
return `${twelveHour} ${amPm}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hourCharts = useMemo(() => {
|
||||||
|
if (loadingHours) return <ActivityIndicator />
|
||||||
|
if (hourCounts?.length === 0) return (<Text style={{ marginBottom: MARGIN }}>
|
||||||
|
No entries yet! Start recording sets to see your most active days of
|
||||||
|
the week.
|
||||||
|
</Text>)
|
||||||
|
return <AppLineChart
|
||||||
|
data={hourCounts.map((hc) => hc.count)}
|
||||||
|
labels={hourCounts.map((hc) => hourLabel(hc.hour))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
}, [hourCounts, loadingHours])
|
||||||
|
|
||||||
|
const weekCharts = useMemo(() => {
|
||||||
|
if (loadingWeeks) return <ActivityIndicator />
|
||||||
|
if (weekCounts?.length === 0) return (<Text style={{ marginBottom: MARGIN }}>
|
||||||
|
No entries yet! Start recording sets to see your most active days of
|
||||||
|
the week.
|
||||||
|
</Text>)
|
||||||
|
return <AppPieChart
|
||||||
|
options={weekCounts.map((weekCount) => ({
|
||||||
|
label: DAYS[weekCount.week],
|
||||||
|
value: weekCount.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
}, [weekCounts, loadingWeeks])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DrawerHeader name="Insights" />
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingLeft: PADDING,
|
||||||
|
paddingTop: PADDING,
|
||||||
|
paddingRight: PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
label="Period"
|
||||||
|
items={[
|
||||||
|
{ value: Periods.Monthly, label: Periods.Monthly },
|
||||||
|
{ value: Periods.TwoMonths, label: Periods.TwoMonths },
|
||||||
|
{ value: Periods.ThreeMonths, label: Periods.ThreeMonths },
|
||||||
|
{ value: Periods.SixMonths, label: Periods.SixMonths },
|
||||||
|
]}
|
||||||
|
value={period}
|
||||||
|
onChange={(value) => setPeriod(value as Periods)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
style={{
|
||||||
|
padding: PADDING,
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
alignContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant="titleLarge"
|
||||||
|
style={{
|
||||||
|
marginBottom: MARGIN,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Most active days of the week
|
||||||
|
</Text>
|
||||||
|
<IconButton
|
||||||
|
icon="help-circle-outline"
|
||||||
|
size={25}
|
||||||
|
style={{ padding: 0, margin: 0, paddingBottom: 10 }}
|
||||||
|
onPress={() => setShowWeek(true)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{weekCharts}
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
alignContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant="titleLarge"
|
||||||
|
style={{
|
||||||
|
marginBottom: MARGIN,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Most active hours of the day
|
||||||
|
</Text>
|
||||||
|
<IconButton
|
||||||
|
icon="help-circle-outline"
|
||||||
|
size={25}
|
||||||
|
style={{ padding: 0, margin: 0, paddingBottom: 10 }}
|
||||||
|
onPress={() => setShowHour(true)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{hourCharts}
|
||||||
|
<View style={{ marginBottom: MARGIN }} />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Most active days of the week"
|
||||||
|
show={showWeek}
|
||||||
|
setShow={setShowWeek}
|
||||||
|
onOk={() => setShowWeek(false)}
|
||||||
|
>
|
||||||
|
Are mondays your weak-spot? Find out here. This counts the # of sets you
|
||||||
|
tend to do based on the day of the week.
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Most active hours of the day"
|
||||||
|
show={showHour}
|
||||||
|
setShow={setShowHour}
|
||||||
|
onOk={() => setShowHour(false)}
|
||||||
|
>
|
||||||
|
If you find yourself giving up on the gym after 5pm, consider starting
|
||||||
|
earlier! Or vice-versa. This counts the # of sets you tend to do, based
|
||||||
|
on what time of day you began your workout.
|
||||||
|
</ConfirmDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
139
ListMenu.tsx
139
ListMenu.tsx
|
@ -1,7 +1,6 @@
|
||||||
import {useState} from 'react'
|
import { useState } from "react";
|
||||||
import {Divider, IconButton, Menu} from 'react-native-paper'
|
import { Divider, IconButton, Menu } from "react-native-paper";
|
||||||
import ConfirmDialog from './ConfirmDialog'
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import useDark from './use-dark'
|
|
||||||
|
|
||||||
export default function ListMenu({
|
export default function ListMenu({
|
||||||
onEdit,
|
onEdit,
|
||||||
|
@ -11,91 +10,95 @@ export default function ListMenu({
|
||||||
onSelect,
|
onSelect,
|
||||||
ids,
|
ids,
|
||||||
}: {
|
}: {
|
||||||
onEdit: () => void
|
onEdit: () => void;
|
||||||
onCopy: () => void
|
onCopy?: () => void;
|
||||||
onClear: () => void
|
onClear: () => void;
|
||||||
onDelete: () => void
|
onDelete: () => void;
|
||||||
onSelect: () => void
|
onSelect: () => void;
|
||||||
ids?: number[]
|
ids?: unknown[];
|
||||||
}) {
|
}) {
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const [showRemove, setShowRemove] = useState(false)
|
const [showRemove, setShowRemove] = useState(false);
|
||||||
const dark = useDark()
|
|
||||||
|
|
||||||
const edit = () => {
|
const edit = () => {
|
||||||
setShowMenu(false)
|
setShowMenu(false);
|
||||||
onEdit()
|
onEdit();
|
||||||
}
|
};
|
||||||
|
|
||||||
const copy = () => {
|
const copy = () => {
|
||||||
setShowMenu(false)
|
setShowMenu(false);
|
||||||
onCopy()
|
onCopy();
|
||||||
}
|
};
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
setShowMenu(false)
|
setShowMenu(false);
|
||||||
onClear()
|
onClear();
|
||||||
}
|
};
|
||||||
|
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
setShowMenu(false)
|
setShowMenu(false);
|
||||||
setShowRemove(false)
|
setShowRemove(false);
|
||||||
onDelete()
|
onDelete();
|
||||||
}
|
};
|
||||||
|
|
||||||
const select = () => {
|
const select = () => {
|
||||||
setShowMenu(false)
|
setShowMenu(false);
|
||||||
onSelect()
|
onSelect();
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<>
|
||||||
visible={showMenu}
|
{ids.length > 0 && (
|
||||||
onDismiss={() => setShowMenu(false)}
|
<IconButton icon="delete" onPress={() => setShowRemove(true)} />
|
||||||
anchor={
|
)}
|
||||||
<IconButton
|
<Menu
|
||||||
color={dark ? 'white' : 'white'}
|
visible={showMenu}
|
||||||
onPress={() => setShowMenu(true)}
|
onDismiss={() => setShowMenu(false)}
|
||||||
icon="more-vert"
|
anchor={
|
||||||
|
<IconButton onPress={() => setShowMenu(true)} icon="dots-vertical" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Menu.Item leadingIcon="check-all" title="Select all" onPress={select} />
|
||||||
|
<Menu.Item
|
||||||
|
leadingIcon="close"
|
||||||
|
title="Clear"
|
||||||
|
onPress={clear}
|
||||||
|
disabled={ids?.length === 0}
|
||||||
/>
|
/>
|
||||||
}>
|
<Menu.Item
|
||||||
<Menu.Item icon="done-all" title="Select all" onPress={select} />
|
leadingIcon="pencil"
|
||||||
<Menu.Item
|
title="Edit"
|
||||||
icon="clear"
|
onPress={edit}
|
||||||
title="Clear"
|
disabled={ids?.length === 0}
|
||||||
onPress={clear}
|
/>
|
||||||
disabled={ids?.length === 0}
|
{onCopy && (
|
||||||
/>
|
<Menu.Item
|
||||||
<Menu.Item
|
leadingIcon="content-copy"
|
||||||
icon="edit"
|
title="Copy"
|
||||||
title="Edit"
|
onPress={copy}
|
||||||
onPress={edit}
|
disabled={ids?.length === 0}
|
||||||
disabled={ids?.length === 0}
|
/>
|
||||||
/>
|
)}
|
||||||
<Menu.Item
|
<Divider />
|
||||||
icon="content-copy"
|
<Menu.Item
|
||||||
title="Copy"
|
leadingIcon="delete"
|
||||||
onPress={copy}
|
onPress={() => setShowRemove(true)}
|
||||||
disabled={ids?.length === 0}
|
title="Delete"
|
||||||
/>
|
/>
|
||||||
<Divider />
|
</Menu>
|
||||||
<Menu.Item
|
|
||||||
icon="delete"
|
|
||||||
onPress={() => setShowRemove(true)}
|
|
||||||
title="Delete"
|
|
||||||
/>
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title={ids?.length === 0 ? 'Delete all' : 'Delete selected'}
|
title={ids?.length === 0 ? "Delete all" : "Delete selected"}
|
||||||
show={showRemove}
|
show={showRemove}
|
||||||
setShow={setShowRemove}
|
setShow={setShowRemove}
|
||||||
onOk={remove}
|
onOk={remove}
|
||||||
onCancel={() => setShowMenu(false)}>
|
onCancel={() => setShowMenu(false)}
|
||||||
|
>
|
||||||
{ids?.length === 0 ? (
|
{ids?.length === 0 ? (
|
||||||
<>This irreversibly deletes records from the app. Are you sure?</>
|
<>This irreversibly deletes records from the app. Are you sure?</>
|
||||||
) : (
|
) : (
|
||||||
<>This will delete {ids?.length} record(s). Are you sure?</>
|
<>This will delete {ids.length} {ids?.length > 1 ? "records" : "record"}. Are you sure?</>
|
||||||
)}
|
)}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
</Menu>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
import {ComponentProps, useMemo} from 'react'
|
|
||||||
import {FAB, useTheme} from 'react-native-paper'
|
|
||||||
import {CombinedDarkTheme, CombinedDefaultTheme} from './App'
|
|
||||||
import {lightColors} from './colors'
|
|
||||||
|
|
||||||
export default function MassiveFab(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"
|
|
||||||
color={fabColor}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 20,
|
|
||||||
bottom: 20,
|
|
||||||
backgroundColor: colors.primary,
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
import {ComponentProps, Ref} from 'react'
|
|
||||||
import {TextInput} from 'react-native-paper'
|
|
||||||
import {CombinedDefaultTheme} from './App'
|
|
||||||
import {MARGIN} from './constants'
|
|
||||||
import useDark from './use-dark'
|
|
||||||
|
|
||||||
export default function MassiveInput(
|
|
||||||
props: Partial<ComponentProps<typeof TextInput>> & {
|
|
||||||
innerRef?: Ref<any>
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const dark = useDark()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextInput
|
|
||||||
selectionColor={dark ? '#2A2A2A' : CombinedDefaultTheme.colors.border}
|
|
||||||
mode="outlined"
|
|
||||||
style={{marginBottom: MARGIN, minWidth: 100}}
|
|
||||||
selectTextOnFocus
|
|
||||||
ref={props.innerRef}
|
|
||||||
blurOnSubmit={false}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
28
Page.tsx
28
Page.tsx
|
@ -1,7 +1,7 @@
|
||||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
||||||
import {Searchbar} from 'react-native-paper'
|
import { Searchbar } from "react-native-paper";
|
||||||
import {PADDING} from './constants'
|
import AppFab from "./AppFab";
|
||||||
import MassiveFab from './MassiveFab'
|
import { PADDING } from "./constants";
|
||||||
|
|
||||||
export default function Page({
|
export default function Page({
|
||||||
onAdd,
|
onAdd,
|
||||||
|
@ -10,11 +10,11 @@ export default function Page({
|
||||||
search,
|
search,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
children: JSX.Element | JSX.Element[]
|
children: JSX.Element | JSX.Element[];
|
||||||
onAdd?: () => void
|
onAdd?: () => void;
|
||||||
term: string
|
term: string;
|
||||||
search: (value: string) => void
|
search: (value: string) => void;
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.view, style]}>
|
<View style={[styles.view, style]}>
|
||||||
|
@ -22,13 +22,13 @@ export default function Page({
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
value={term}
|
value={term}
|
||||||
onChangeText={search}
|
onChangeText={search}
|
||||||
icon="search"
|
icon="magnify"
|
||||||
clearIcon="clear"
|
clearIcon="close"
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
{onAdd && <MassiveFab onPress={onAdd} />}
|
{onAdd && <AppFab onPress={onAdd} />}
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
@ -36,4 +36,4 @@ const styles = StyleSheet.create({
|
||||||
padding: PADDING,
|
padding: PADDING,
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
130
PlanItem.tsx
130
PlanItem.tsx
|
@ -2,83 +2,97 @@ import {
|
||||||
NavigationProp,
|
NavigationProp,
|
||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
} from '@react-navigation/native'
|
} from "@react-navigation/native";
|
||||||
import {useCallback, useMemo, useState} from 'react'
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {Text} from 'react-native'
|
import { Text } from "react-native";
|
||||||
import {List} from 'react-native-paper'
|
import { List, useTheme } from "react-native-paper";
|
||||||
import {getBestSet} from './best.service'
|
import { StackParams } from "./AppStack";
|
||||||
import {DARK_RIPPLE, LIGHT_RIPPLE} from './constants'
|
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants";
|
||||||
import {defaultSet} from './gym-set'
|
import { DAYS } from "./days";
|
||||||
import {Plan} from './plan'
|
import { setRepo } from "./db";
|
||||||
import {PlanPageParams} from './plan-page-params'
|
import GymSet, { defaultSet } from "./gym-set";
|
||||||
import {DAYS} from './time'
|
import { Plan } from "./plan";
|
||||||
import useDark from './use-dark'
|
|
||||||
|
|
||||||
export default function PlanItem({
|
export default function PlanItem({
|
||||||
item,
|
item,
|
||||||
setIds,
|
setIds,
|
||||||
ids,
|
ids,
|
||||||
}: {
|
}: {
|
||||||
item: Plan
|
item: Plan;
|
||||||
ids: number[]
|
ids: number[];
|
||||||
setIds: (value: number[]) => void
|
setIds: (value: number[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [today, setToday] = useState<string>()
|
const [today, setToday] = useState<string>();
|
||||||
const dark = useDark()
|
const { dark } = useTheme();
|
||||||
const days = useMemo(() => item.days.split(','), [item.days])
|
const days = useMemo(() => item.days.split(","), [item.days]);
|
||||||
const navigation = useNavigation<NavigationProp<PlanPageParams>>()
|
const navigation = useNavigation<NavigationProp<StackParams>>();
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
const newToday = DAYS[new Date().getDay()]
|
const newToday = DAYS[new Date().getDay()];
|
||||||
setToday(newToday)
|
setToday(newToday);
|
||||||
}, []),
|
}, [])
|
||||||
)
|
);
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
const workout = item.workouts.split(',')[0]
|
const exercise = item.exercises.split(",")[0];
|
||||||
let first = await getBestSet(workout)
|
let first: Partial<GymSet> = await setRepo.findOne({
|
||||||
if (!first) first = {...defaultSet, name: workout}
|
where: { name: exercise },
|
||||||
delete first.id
|
order: { created: "desc" },
|
||||||
if (ids.length === 0)
|
});
|
||||||
return navigation.navigate('StartPlan', {plan: item, first})
|
if (!first) first = { ...defaultSet, name: exercise };
|
||||||
const removing = ids.find(id => id === item.id)
|
delete first.id;
|
||||||
if (removing) setIds(ids.filter(id => id !== item.id))
|
if (ids.length === 0) {
|
||||||
else setIds([...ids, item.id])
|
return navigation.navigate("StartPlan", { plan: item, first });
|
||||||
}, [ids, setIds, item, navigation])
|
}
|
||||||
|
const removing = ids.find((id) => id === item.id);
|
||||||
|
if (removing) setIds(ids.filter((id) => id !== item.id));
|
||||||
|
else setIds([...ids, item.id]);
|
||||||
|
}, [ids, setIds, item, navigation]);
|
||||||
|
|
||||||
const longPress = useCallback(() => {
|
const longPress = useCallback(() => {
|
||||||
if (ids.length > 0) return
|
if (ids.length > 0) return;
|
||||||
setIds([item.id])
|
setIds([item.id]);
|
||||||
}, [ids.length, item.id, setIds])
|
}, [ids.length, item.id, setIds]);
|
||||||
|
|
||||||
|
const currentDays = days.map((day, index) => (
|
||||||
|
<Text key={day}>
|
||||||
|
{day === today ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
day
|
||||||
|
)}
|
||||||
|
{index === days.length - 1 ? "" : ", "}
|
||||||
|
</Text>
|
||||||
|
));
|
||||||
|
|
||||||
const title = useMemo(
|
const title = useMemo(
|
||||||
() =>
|
() =>
|
||||||
days.map((day, index) => (
|
item.title ? (
|
||||||
<Text key={day}>
|
<Text style={{ fontWeight: "bold" }}>{item.title}</Text>
|
||||||
{day === today ? (
|
) : (
|
||||||
<Text style={{fontWeight: 'bold', textDecorationLine: 'underline'}}>
|
currentDays
|
||||||
{day}
|
),
|
||||||
</Text>
|
[item.title, currentDays]
|
||||||
) : (
|
);
|
||||||
day
|
|
||||||
)}
|
|
||||||
{index === days.length - 1 ? '' : ', '}
|
|
||||||
</Text>
|
|
||||||
)),
|
|
||||||
[days, today],
|
|
||||||
)
|
|
||||||
|
|
||||||
const description = useMemo(
|
const description = useMemo(
|
||||||
() => item.workouts.replace(/,/g, ', '),
|
() => (item.title ? currentDays : item.exercises.replace(/,/g, ", ")),
|
||||||
[item.workouts],
|
[item.title, currentDays, item.exercises]
|
||||||
)
|
);
|
||||||
|
|
||||||
const backgroundColor = useMemo(() => {
|
const backgroundColor = useMemo(() => {
|
||||||
if (!ids.includes(item.id)) return
|
if (!ids.includes(item.id)) return;
|
||||||
if (dark) return DARK_RIPPLE
|
if (dark) return DARK_RIPPLE;
|
||||||
return LIGHT_RIPPLE
|
return LIGHT_RIPPLE;
|
||||||
}, [dark, ids, item.id])
|
}, [dark, ids, item.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List.Item
|
<List.Item
|
||||||
|
@ -86,7 +100,7 @@ export default function PlanItem({
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
onLongPress={longPress}
|
onLongPress={longPress}
|
||||||
style={{backgroundColor}}
|
style={{ backgroundColor }}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
119
PlanList.tsx
119
PlanList.tsx
|
@ -2,89 +2,102 @@ import {
|
||||||
NavigationProp,
|
NavigationProp,
|
||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
} from '@react-navigation/native'
|
} from "@react-navigation/native";
|
||||||
import {useCallback, useState} from 'react'
|
import { useCallback, useState } from "react";
|
||||||
import {FlatList} from 'react-native'
|
import { FlatList } from "react-native";
|
||||||
import {List} from 'react-native-paper'
|
import { List } from "react-native-paper";
|
||||||
import {Like} from 'typeorm'
|
import { Like } from "typeorm";
|
||||||
import {planRepo} from './db'
|
import { StackParams } from "./AppStack";
|
||||||
import DrawerHeader from './DrawerHeader'
|
import { planRepo } from "./db";
|
||||||
import ListMenu from './ListMenu'
|
import DrawerHeader from "./DrawerHeader";
|
||||||
import Page from './Page'
|
import ListMenu from "./ListMenu";
|
||||||
import {Plan} from './plan'
|
import Page from "./Page";
|
||||||
import {PlanPageParams} from './plan-page-params'
|
import { defaultPlan, Plan } from "./plan";
|
||||||
import PlanItem from './PlanItem'
|
import PlanItem from "./PlanItem";
|
||||||
|
|
||||||
export default function PlanList() {
|
export default function PlanList() {
|
||||||
const [term, setTerm] = useState('')
|
const [term, setTerm] = useState("");
|
||||||
const [plans, setPlans] = useState<Plan[]>()
|
const [plans, setPlans] = useState<Plan[]>();
|
||||||
const [ids, setIds] = useState<number[]>([])
|
const [ids, setIds] = useState<number[]>([]);
|
||||||
const navigation = useNavigation<NavigationProp<PlanPageParams>>()
|
const navigation = useNavigation<NavigationProp<StackParams>>();
|
||||||
|
|
||||||
const refresh = useCallback(async (value: string) => {
|
const refresh = useCallback(async (value: string) => {
|
||||||
|
console.log(`${PlanList.name}.refresh:`, value);
|
||||||
planRepo
|
planRepo
|
||||||
.find({
|
.find({
|
||||||
where: [{days: Like(`%${value}%`)}, {workouts: Like(`%${value}%`)}],
|
where: [
|
||||||
|
{ title: Like(`%${value.trim()}%`) },
|
||||||
|
{ days: Like(`%${value.trim()}%`) },
|
||||||
|
{ exercises: Like(`%${value.trim()}%`) },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
.then(setPlans)
|
.then(setPlans);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
refresh(term)
|
refresh(term);
|
||||||
}, [refresh, term]),
|
// eslint-disable-next-line
|
||||||
)
|
}, [term])
|
||||||
|
);
|
||||||
|
|
||||||
const search = useCallback(
|
const search = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
setTerm(value)
|
setTerm(value);
|
||||||
refresh(value)
|
refresh(value);
|
||||||
},
|
},
|
||||||
[refresh],
|
[refresh]
|
||||||
)
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({item}: {item: Plan}) => (
|
({ item }: { item: Plan }) => (
|
||||||
<PlanItem ids={ids} setIds={setIds} item={item} key={item.id} />
|
<PlanItem ids={ids} setIds={setIds} item={item} key={item.id} />
|
||||||
),
|
),
|
||||||
[ids],
|
[ids]
|
||||||
)
|
);
|
||||||
|
|
||||||
const onAdd = () =>
|
const onAdd = () =>
|
||||||
navigation.navigate('EditPlan', {plan: {days: '', workouts: ''}})
|
navigation.navigate("EditPlan", {
|
||||||
|
plan: defaultPlan,
|
||||||
|
});
|
||||||
|
|
||||||
const edit = useCallback(async () => {
|
const edit = useCallback(async () => {
|
||||||
const plan = await planRepo.findOne({where: {id: ids.pop()}})
|
const plan = await planRepo.findOne({ where: { id: ids.pop() } });
|
||||||
navigation.navigate('EditPlan', {plan})
|
navigation.navigate("EditPlan", { plan });
|
||||||
setIds([])
|
setIds([]);
|
||||||
}, [ids, navigation])
|
}, [ids, navigation]);
|
||||||
|
|
||||||
const copy = useCallback(async () => {
|
const copy = useCallback(async () => {
|
||||||
const plan = await planRepo.findOne({
|
const plan = await planRepo.findOne({
|
||||||
where: {id: ids.pop()},
|
where: { id: ids.pop() },
|
||||||
})
|
});
|
||||||
delete plan.id
|
delete plan.id;
|
||||||
navigation.navigate('EditPlan', {plan})
|
navigation.navigate("EditPlan", { plan });
|
||||||
setIds([])
|
setIds([]);
|
||||||
}, [ids, navigation])
|
}, [ids, navigation]);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
setIds([])
|
setIds([]);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const remove = useCallback(async () => {
|
const remove = useCallback(async () => {
|
||||||
await planRepo.delete(ids.length > 0 ? ids : {})
|
await planRepo.delete(ids.length > 0 ? ids : {});
|
||||||
await refresh(term)
|
await refresh(term);
|
||||||
setIds([])
|
setIds([]);
|
||||||
}, [ids, refresh, term])
|
}, [ids, refresh, term]);
|
||||||
|
|
||||||
const select = useCallback(() => {
|
const select = useCallback(() => {
|
||||||
setIds(plans.map(plan => plan.id))
|
if (!plans) return;
|
||||||
}, [plans])
|
if (ids.length === plans.length) return setIds([]);
|
||||||
|
setIds(plans.map((plan) => plan.id));
|
||||||
|
}, [plans, ids.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DrawerHeader name="Plans">
|
<DrawerHeader name={ids.length > 0 ? `${ids.length} selected` : "Plans"}
|
||||||
|
ids={ids}
|
||||||
|
unSelect={() => setIds([])}
|
||||||
|
>
|
||||||
<ListMenu
|
<ListMenu
|
||||||
onClear={clear}
|
onClear={clear}
|
||||||
onCopy={copy}
|
onCopy={copy}
|
||||||
|
@ -98,17 +111,17 @@ export default function PlanList() {
|
||||||
{plans?.length === 0 ? (
|
{plans?.length === 0 ? (
|
||||||
<List.Item
|
<List.Item
|
||||||
title="No plans yet"
|
title="No plans yet"
|
||||||
description="A plan is a list of workouts for certain days."
|
description="A plan is a list of exercises for certain days."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
style={{flex: 1}}
|
style={{ flex: 1 }}
|
||||||
data={plans}
|
data={plans}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={set => set.id?.toString() || ''}
|
keyExtractor={(set) => set.id?.toString() || ""}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
20
PlanPage.tsx
20
PlanPage.tsx
|
@ -1,20 +0,0 @@
|
||||||
import {createStackNavigator} from '@react-navigation/stack'
|
|
||||||
import EditPlan from './EditPlan'
|
|
||||||
import EditSet from './EditSet'
|
|
||||||
import {PlanPageParams} from './plan-page-params'
|
|
||||||
import PlanList from './PlanList'
|
|
||||||
import StartPlan from './StartPlan'
|
|
||||||
|
|
||||||
const Stack = createStackNavigator<PlanPageParams>()
|
|
||||||
|
|
||||||
export default function PlanPage() {
|
|
||||||
return (
|
|
||||||
<Stack.Navigator
|
|
||||||
screenOptions={{headerShown: false, animationEnabled: false}}>
|
|
||||||
<Stack.Screen name="PlanList" component={PlanList} />
|
|
||||||
<Stack.Screen name="EditPlan" component={EditPlan} />
|
|
||||||
<Stack.Screen name="StartPlan" component={StartPlan} />
|
|
||||||
<Stack.Screen name="EditSet" component={EditSet} />
|
|
||||||
</Stack.Navigator>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { ComponentProps } from "react";
|
||||||
|
import { Button, useTheme } from "react-native-paper";
|
||||||
|
|
||||||
|
type PrimaryButtonProps = Omit<Partial<ComponentProps<typeof Button>>, "mode">;
|
||||||
|
|
||||||
|
export default function PrimaryButton(props: PrimaryButtonProps) {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button mode="contained" textColor={colors.background} {...props}>
|
||||||
|
{props.children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
27
README.md
27
README.md
|
@ -23,13 +23,14 @@ Massive tracks your reps and sets at the gym. No internet connectivity or high s
|
||||||
|
|
||||||
<img src="metadata/en-US/images/phoneScreenshots/home.png" width="318"/>
|
<img src="metadata/en-US/images/phoneScreenshots/home.png" width="318"/>
|
||||||
<img src="metadata/en-US/images/phoneScreenshots/edit.png" width="318"/>
|
<img src="metadata/en-US/images/phoneScreenshots/edit.png" width="318"/>
|
||||||
<img src="metadata/en-US/images/phoneScreenshots/timer.png" width="318"/>
|
|
||||||
<img src="metadata/en-US/images/phoneScreenshots/plans.png" width="318"/>
|
<img src="metadata/en-US/images/phoneScreenshots/plans.png" width="318"/>
|
||||||
<img src="metadata/en-US/images/phoneScreenshots/plan-edit.png" width="318"/>
|
<img src="metadata/en-US/images/phoneScreenshots/plan-edit.png" width="318"/>
|
||||||
<img src="metadata/en-US/images/phoneScreenshots/plan-start.png" width="318"/>
|
<img src="metadata/en-US/images/phoneScreenshots/plan-start.png" width="318"/>
|
||||||
<img src="metadata/en-US/images/phoneScreenshots/best-view.png" width="318"/>
|
<img src="metadata/en-US/images/phoneScreenshots/best-view.png" width="318"/>
|
||||||
<img src="metadata/en-US/images/phoneScreenshots/settings.png" width="318"/>
|
<img src="metadata/en-US/images/phoneScreenshots/settings.png" width="318"/>
|
||||||
<img src="metadata/en-US/images/phoneScreenshots/drawer.png" width="318"/>
|
<img src="metadata/en-US/images/phoneScreenshots/drawer.png" width="318"/>
|
||||||
|
<img src="metadata/en-US/images/phoneScreenshots/exercises.png" width="318"/>
|
||||||
|
<img src="metadata/en-US/images/phoneScreenshots/exercise-edit.png" width="318"/>
|
||||||
|
|
||||||
# Building from Source
|
# Building from Source
|
||||||
|
|
||||||
|
@ -40,43 +41,29 @@ cd android
|
||||||
./gradlew assembleRelease
|
./gradlew assembleRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
The apk file can be found at `android/app/build/outputs/apk/release/app-*-release.apk`
|
The apk file can be found at `android/app/build/outputs/apk/release/app-release.apk`
|
||||||
The APKs are separated by architecture, for example we have:
|
|
||||||
- `app-arm64-v8a-release.apk`
|
|
||||||
- `app-armeabi-v7a-release.apk`
|
|
||||||
- `app-x86_64-release.apk`
|
|
||||||
- `app-x86-release.apk`
|
|
||||||
|
|
||||||
Your phone is probably `app-arm64-v8a-release.apk`.
|
|
||||||
|
|
||||||
# Running in Development
|
# Running in Development
|
||||||
|
|
||||||
First ensure Node.js dependencies are installed:
|
First ensure Node.js dependencies are installed:
|
||||||
|
|
||||||
```
|
```
|
||||||
yarn install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
Then start the metro server:
|
Then start the metro server:
|
||||||
|
|
||||||
```
|
```
|
||||||
yarn start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
Then (in a separate terminal) run the `android` script:
|
Then (in a separate terminal) run the `android` script:
|
||||||
|
|
||||||
```
|
```
|
||||||
yarn android
|
npm run android
|
||||||
```
|
```
|
||||||
|
|
||||||
# Fdroid Metadata
|
# Fdroid Metadata
|
||||||
|
|
||||||
You can find the metadata yaml file in the fdroiddata repository:
|
You can find the metadata yaml file in the fdroiddata repository:
|
||||||
https://gitlab.com/fdroid/fdroiddata/-/blob/master/metadata/com.massive.yml
|
https://gitlab.com/fdroid/fdroiddata/-/blob/master/metadata/com.massive.yml
|
||||||
|
|
||||||
# Relevant Documentation
|
|
||||||
|
|
||||||
- Android https://developer.android.com/docs
|
|
||||||
- TypeScript https://www.typescriptlang.org/docs/
|
|
||||||
- JavaScript https://developer.mozilla.org/en-US/docs/Web/JavaScript
|
|
||||||
- SQLite https://sqlite.org/docs.html
|
|
||||||
|
|
BIN
README.md.pdf
BIN
README.md.pdf
Binary file not shown.
57
Routes.tsx
57
Routes.tsx
|
@ -1,57 +0,0 @@
|
||||||
import {createDrawerNavigator} from '@react-navigation/drawer'
|
|
||||||
import {useMemo} from 'react'
|
|
||||||
import {Platform} from 'react-native'
|
|
||||||
import {IconButton} from 'react-native-paper'
|
|
||||||
import BestPage from './BestPage'
|
|
||||||
import {DrawerParamList} from './drawer-param-list'
|
|
||||||
import HomePage from './HomePage'
|
|
||||||
import PlanPage from './PlanPage'
|
|
||||||
import Route from './route'
|
|
||||||
import SettingsPage from './SettingsPage'
|
|
||||||
import TimerPage from './TimerPage'
|
|
||||||
import useDark from './use-dark'
|
|
||||||
import WorkoutsPage from './WorkoutsPage'
|
|
||||||
|
|
||||||
const Drawer = createDrawerNavigator<DrawerParamList>()
|
|
||||||
|
|
||||||
export default function Routes() {
|
|
||||||
const dark = useDark()
|
|
||||||
|
|
||||||
const routes: Route[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{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: 'Timer', component: TimerPage, icon: 'access-time'},
|
|
||||||
{name: 'Settings', component: SettingsPage, icon: 'settings'},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerTintColor: dark ? 'white' : 'black',
|
|
||||||
swipeEdgeWidth: 1000,
|
|
||||||
headerShown: false,
|
|
||||||
}}>
|
|
||||||
{}
|
|
||||||
{routes
|
|
||||||
.filter(route => {
|
|
||||||
if (Platform.OS === 'ios' && route.name === 'Timer') return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map(route => (
|
|
||||||
<Drawer.Screen
|
|
||||||
key={route.name}
|
|
||||||
name={route.name}
|
|
||||||
component={route.component}
|
|
||||||
options={{
|
|
||||||
drawerIcon: () => <IconButton icon={route.icon} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Drawer.Navigator>
|
|
||||||
)
|
|
||||||
}
|
|
109
Select.tsx
109
Select.tsx
|
@ -1,70 +1,75 @@
|
||||||
import {useCallback, useMemo, useState} from 'react'
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
import {View} from 'react-native'
|
import { Pressable, View } from "react-native";
|
||||||
import {Button, Menu, Subheading, useTheme} from 'react-native-paper'
|
import { IconButton, Menu, useTheme } from "react-native-paper";
|
||||||
import {ITEM_PADDING} from './constants'
|
import AppInput from "./AppInput";
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
value: string
|
value: string;
|
||||||
label: string
|
label: string;
|
||||||
color?: string
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Select({
|
function Select({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
items,
|
items,
|
||||||
label,
|
label,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string;
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void;
|
||||||
items: Item[]
|
items: Item[];
|
||||||
label?: string
|
label?: string;
|
||||||
}) {
|
}) {
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false);
|
||||||
const {colors} = useTheme()
|
const { colors } = useTheme();
|
||||||
|
let menuButton: React.Ref<View> = null;
|
||||||
|
|
||||||
const selected = useMemo(
|
const selected = useMemo(
|
||||||
() => items.find(item => item.value === value) || items[0],
|
() => items.find((item) => item.value === value) || items[0],
|
||||||
[items, value],
|
[items, value]
|
||||||
)
|
);
|
||||||
|
|
||||||
const handlePress = useCallback(
|
const press = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
onChange(newValue)
|
onChange(newValue);
|
||||||
setShow(false)
|
setShow(false);
|
||||||
},
|
},
|
||||||
[onChange],
|
[onChange]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<Menu
|
||||||
style={{
|
visible={show}
|
||||||
flexDirection: 'row',
|
onDismiss={() => setShow(false)}
|
||||||
alignItems: 'center',
|
anchor={
|
||||||
paddingLeft: ITEM_PADDING,
|
<View>
|
||||||
}}>
|
<Pressable onPress={() => setShow(true)}>
|
||||||
{label && <Subheading style={{width: 100}}>{label}</Subheading>}
|
<AppInput label={label} value={selected.label} editable={false} />
|
||||||
<Menu
|
</Pressable>
|
||||||
visible={show}
|
<View
|
||||||
onDismiss={() => setShow(false)}
|
style={{ position: "absolute", right: 0, flexDirection: "row" }}
|
||||||
anchor={
|
>
|
||||||
<Button
|
<IconButton
|
||||||
onPress={() => setShow(true)}
|
ref={menuButton}
|
||||||
style={{
|
icon="menu-down"
|
||||||
alignSelf: 'flex-start',
|
onPress={() => setShow(true)}
|
||||||
}}>
|
/>
|
||||||
{selected?.label}
|
</View>
|
||||||
</Button>
|
</View>
|
||||||
}>
|
}
|
||||||
{items.map(item => (
|
>
|
||||||
<Menu.Item
|
{items.map((item) => (
|
||||||
key={item.value}
|
<Menu.Item
|
||||||
titleStyle={{color: item.color || colors.text}}
|
title={item.label}
|
||||||
title={item.label}
|
key={item.value}
|
||||||
onPress={() => handlePress(item.value)}
|
onPress={() => press(item.value)}
|
||||||
/>
|
titleStyle={{ color: item.color || colors.onSurface }}
|
||||||
))}
|
leadingIcon={item.icon}
|
||||||
</Menu>
|
/>
|
||||||
</View>
|
))}
|
||||||
)
|
</Menu>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo(Select);
|
||||||
|
|
142
SetItem.tsx
142
SetItem.tsx
|
@ -1,75 +1,91 @@
|
||||||
import {NavigationProp, useNavigation} from '@react-navigation/native'
|
import { NavigationProp, useNavigation } from "@react-navigation/native";
|
||||||
import {format} from 'date-fns'
|
import { format } from "date-fns";
|
||||||
import {useCallback, useMemo} from 'react'
|
import React, { useCallback, useMemo } from "react";
|
||||||
import {Image} from 'react-native'
|
import { Image } from "react-native";
|
||||||
import {List, Text} from 'react-native-paper'
|
import { List, Text, useTheme } from "react-native-paper";
|
||||||
import {DARK_RIPPLE, LIGHT_RIPPLE} from './constants'
|
import { StackParams } from "./AppStack";
|
||||||
import GymSet from './gym-set'
|
import {
|
||||||
import {HomePageParams} from './home-page-params'
|
DARK_RIPPLE,
|
||||||
import Settings from './settings'
|
DARK_SUBDUED,
|
||||||
import useDark from './use-dark'
|
LIGHT_RIPPLE,
|
||||||
|
LIGHT_SUBDUED,
|
||||||
|
} from "./constants";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
import Settings from "./settings";
|
||||||
|
|
||||||
export default function SetItem({
|
const SetItem = React.memo(
|
||||||
item,
|
({
|
||||||
settings,
|
item,
|
||||||
ids,
|
settings,
|
||||||
setIds,
|
ids,
|
||||||
}: {
|
setIds,
|
||||||
item: GymSet
|
disablePress,
|
||||||
onRemove: () => void
|
customBg,
|
||||||
settings: Settings
|
}: {
|
||||||
ids: number[]
|
item: GymSet;
|
||||||
setIds: (value: number[]) => void
|
settings: Settings;
|
||||||
}) {
|
ids: number[];
|
||||||
const dark = useDark()
|
setIds: (value: number[]) => void;
|
||||||
const navigation = useNavigation<NavigationProp<HomePageParams>>()
|
disablePress?: boolean;
|
||||||
|
customBg?: string;
|
||||||
|
}) => {
|
||||||
|
const { dark } = useTheme();
|
||||||
|
const navigation = useNavigation<NavigationProp<StackParams>>();
|
||||||
|
|
||||||
const longPress = useCallback(() => {
|
const longPress = useCallback(() => {
|
||||||
if (ids.length > 0) return
|
if (ids.length > 0) return;
|
||||||
setIds([item.id])
|
setIds([item.id]);
|
||||||
}, [ids.length, item.id, setIds])
|
}, [ids.length, item.id, setIds]);
|
||||||
|
|
||||||
const press = useCallback(() => {
|
const press = useCallback(() => {
|
||||||
if (ids.length === 0) return navigation.navigate('EditSet', {set: item})
|
if (disablePress) return;
|
||||||
const removing = ids.find(id => id === item.id)
|
if (ids.length === 0)
|
||||||
if (removing) setIds(ids.filter(id => id !== item.id))
|
return navigation.navigate("EditSet", { set: item });
|
||||||
else setIds([...ids, item.id])
|
const removing = ids.find((id) => id === item.id);
|
||||||
}, [ids, item, navigation, setIds])
|
if (removing) setIds(ids.filter((id) => id !== item.id));
|
||||||
|
else setIds([...ids, item.id]);
|
||||||
|
}, [ids, item, navigation, setIds, disablePress]);
|
||||||
|
|
||||||
const backgroundColor = useMemo(() => {
|
const backgroundColor = useMemo(() => {
|
||||||
if (!ids.includes(item.id)) return
|
if (!ids.includes(item.id)) return;
|
||||||
if (dark) return DARK_RIPPLE
|
if (dark) return DARK_RIPPLE;
|
||||||
return LIGHT_RIPPLE
|
return LIGHT_RIPPLE;
|
||||||
}, [dark, ids, item.id])
|
}, [dark, ids, item.id]);
|
||||||
|
|
||||||
return (
|
const image = useCallback(() => {
|
||||||
<>
|
if (!settings.images || !item.image) return null;
|
||||||
|
return (
|
||||||
|
<Image source={{ uri: item.image }} style={{ height: 75, width: 75 }} />
|
||||||
|
);
|
||||||
|
}, [item.image, settings.images]);
|
||||||
|
|
||||||
|
return (
|
||||||
<List.Item
|
<List.Item
|
||||||
onPress={press}
|
onPress={press}
|
||||||
title={item.name}
|
title={item.name}
|
||||||
description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
|
description={
|
||||||
onLongPress={longPress}
|
settings.showDate ? (
|
||||||
style={{backgroundColor}}
|
<Text style={{ color: dark ? DARK_SUBDUED : LIGHT_SUBDUED }}>
|
||||||
left={() =>
|
{format(new Date(item.created), settings.date || "Pp")}
|
||||||
settings.images &&
|
</Text>
|
||||||
item.image && (
|
) : null
|
||||||
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
onLongPress={longPress}
|
||||||
|
style={{ backgroundColor: customBg || backgroundColor }}
|
||||||
|
left={image}
|
||||||
right={() => (
|
right={() => (
|
||||||
<>
|
<Text
|
||||||
{settings.showDate && (
|
style={{
|
||||||
<Text
|
alignSelf: "center",
|
||||||
style={{
|
color: dark ? DARK_SUBDUED : LIGHT_SUBDUED,
|
||||||
alignSelf: 'center',
|
}}
|
||||||
color: dark ? '#909090ff' : '#717171ff',
|
>
|
||||||
}}>
|
{`${item.reps} x ${item.weight}${item.unit || "kg"}`}
|
||||||
{format(new Date(item.created), settings.date || 'P')}
|
</Text>
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
);
|
||||||
)
|
}
|
||||||
}
|
);
|
||||||
|
|
||||||
|
export default SetItem;
|
||||||
|
|
244
SetList.tsx
244
SetList.tsx
|
@ -2,135 +2,171 @@ import {
|
||||||
NavigationProp,
|
NavigationProp,
|
||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
useNavigation,
|
useNavigation,
|
||||||
} from '@react-navigation/native'
|
} from "@react-navigation/native";
|
||||||
import {useCallback, useState} from 'react'
|
import { useCallback, useState } from "react";
|
||||||
import {FlatList} from 'react-native'
|
import { FlatList } from "react-native";
|
||||||
import {List} from 'react-native-paper'
|
import { List } from "react-native-paper";
|
||||||
import {Like} from 'typeorm'
|
import { Like } from "typeorm";
|
||||||
import {getNow, setRepo, settingsRepo} from './db'
|
import { StackParams } from "./AppStack";
|
||||||
import DrawerHeader from './DrawerHeader'
|
import DrawerHeader from "./DrawerHeader";
|
||||||
import GymSet, {defaultSet} from './gym-set'
|
import ListMenu from "./ListMenu";
|
||||||
import {HomePageParams} from './home-page-params'
|
import Page from "./Page";
|
||||||
import ListMenu from './ListMenu'
|
import SetItem from "./SetItem";
|
||||||
import Page from './Page'
|
import { LIMIT } from "./constants";
|
||||||
import SetItem from './SetItem'
|
import { getNow, setRepo, settingsRepo } from "./db";
|
||||||
import Settings from './settings'
|
import GymSet, { defaultSet } from "./gym-set";
|
||||||
|
import Settings from "./settings";
|
||||||
const limit = 15
|
|
||||||
|
|
||||||
export default function SetList() {
|
export default function SetList() {
|
||||||
const [sets, setSets] = useState<GymSet[]>([])
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [offset, setOffset] = useState(0)
|
const [sets, setSets] = useState<GymSet[]>();
|
||||||
const [term, setTerm] = useState('')
|
const [offset, setOffset] = useState(0);
|
||||||
const [end, setEnd] = useState(false)
|
const [end, setEnd] = useState(false);
|
||||||
const [settings, setSettings] = useState<Settings>()
|
const [settings, setSettings] = useState<Settings>();
|
||||||
const [ids, setIds] = useState<number[]>([])
|
const [ids, setIds] = useState<number[]>([]);
|
||||||
const navigation = useNavigation<NavigationProp<HomePageParams>>()
|
const navigation = useNavigation<NavigationProp<StackParams>>();
|
||||||
|
const [term, setTerm] = useState("");
|
||||||
|
|
||||||
const refresh = useCallback(async (value: string) => {
|
const reset = useCallback(
|
||||||
const newSets = await setRepo.find({
|
async (value: string) => {
|
||||||
where: {name: Like(`%${value}%`), hidden: 0 as any},
|
const newSets = await setRepo.find({
|
||||||
take: limit,
|
where: { name: Like(`%${value.trim()}%`), hidden: 0 as any },
|
||||||
skip: 0,
|
take: LIMIT,
|
||||||
order: {created: 'DESC'},
|
skip: 0,
|
||||||
})
|
order: { created: "DESC" },
|
||||||
console.log(`${SetList.name}.refresh:`, {
|
});
|
||||||
value,
|
setSets(newSets);
|
||||||
limit,
|
console.log(`${SetList.name}.reset:`, { value, offset });
|
||||||
length: newSets.length,
|
setEnd(false);
|
||||||
})
|
},
|
||||||
setSets(newSets)
|
[offset]
|
||||||
setOffset(0)
|
);
|
||||||
setEnd(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
refresh(term)
|
console.log(`${SetList.name}.focus:`, { term });
|
||||||
settingsRepo.findOne({where: {}}).then(setSettings)
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
}, [refresh, term]),
|
reset(term);
|
||||||
)
|
// eslint-disable-next-line
|
||||||
|
}, [term])
|
||||||
|
);
|
||||||
|
|
||||||
|
const search = (value: string) => {
|
||||||
|
console.log(`${SetList.name}.search:`, value);
|
||||||
|
setTerm(value);
|
||||||
|
setOffset(0);
|
||||||
|
reset(value);
|
||||||
|
};
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({item}: {item: GymSet}) => (
|
({ item }: { item: GymSet }) => (
|
||||||
<SetItem
|
<SetItem
|
||||||
settings={settings}
|
settings={settings}
|
||||||
item={item}
|
item={item}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onRemove={() => refresh(term)}
|
|
||||||
ids={ids}
|
ids={ids}
|
||||||
setIds={setIds}
|
setIds={setIds}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[refresh, term, settings, ids],
|
[settings, ids]
|
||||||
)
|
);
|
||||||
|
|
||||||
const next = useCallback(async () => {
|
const next = async () => {
|
||||||
if (end) return
|
console.log(`${SetList.name}.next:`, { end, refreshing });
|
||||||
const newOffset = offset + limit
|
if (end || refreshing) return;
|
||||||
console.log(`${SetList.name}.next:`, {offset, newOffset, term})
|
const newOffset = offset + LIMIT;
|
||||||
|
console.log(`${SetList.name}.next:`, { offset, newOffset, term });
|
||||||
const newSets = await setRepo.find({
|
const newSets = await setRepo.find({
|
||||||
where: {name: Like(`%${term}%`), hidden: 0 as any},
|
where: { name: Like(`%${term}%`), hidden: 0 as any },
|
||||||
take: limit,
|
take: LIMIT,
|
||||||
skip: newOffset,
|
skip: newOffset,
|
||||||
order: {created: 'DESC'},
|
order: { created: "DESC" },
|
||||||
})
|
});
|
||||||
if (newSets.length === 0) return setEnd(true)
|
if (newSets.length === 0) return setEnd(true);
|
||||||
if (!sets) return
|
if (!sets) return;
|
||||||
setSets([...sets, ...newSets])
|
const map = new Map<number, GymSet>();
|
||||||
if (newSets.length < limit) return setEnd(true)
|
for (const set of sets) map.set(set.id, set);
|
||||||
setOffset(newOffset)
|
for (const set of newSets) map.set(set.id, set);
|
||||||
}, [term, end, offset, sets])
|
const unique = Array.from(map.values());
|
||||||
|
setSets(unique);
|
||||||
|
if (newSets.length < LIMIT) return setEnd(true);
|
||||||
|
setOffset(newOffset);
|
||||||
|
};
|
||||||
|
|
||||||
const onAdd = useCallback(async () => {
|
const onAdd = useCallback(async () => {
|
||||||
const [{now}] = await getNow()
|
const now = await getNow();
|
||||||
let set = sets[0]
|
let set: Partial<GymSet> = { ...sets[0] };
|
||||||
if (!set) set = {...defaultSet}
|
if (!set) set = { ...defaultSet };
|
||||||
set.created = now
|
set.created = now;
|
||||||
delete set.id
|
delete set.id;
|
||||||
navigation.navigate('EditSet', {set})
|
navigation.navigate("EditSet", { set });
|
||||||
}, [navigation, sets])
|
}, [navigation, sets]);
|
||||||
|
|
||||||
const search = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setTerm(value)
|
|
||||||
refresh(value)
|
|
||||||
},
|
|
||||||
[refresh],
|
|
||||||
)
|
|
||||||
|
|
||||||
const edit = useCallback(() => {
|
const edit = useCallback(() => {
|
||||||
navigation.navigate('EditSets', {ids})
|
navigation.navigate("EditSets", { ids });
|
||||||
setIds([])
|
setIds([]);
|
||||||
}, [ids, navigation])
|
}, [ids, navigation]);
|
||||||
|
|
||||||
const copy = useCallback(async () => {
|
const copy = useCallback(async () => {
|
||||||
const set = await setRepo.findOne({
|
const set = await setRepo.findOne({
|
||||||
where: {id: ids.pop()},
|
where: { id: ids.pop() },
|
||||||
})
|
});
|
||||||
delete set.id
|
delete set.id;
|
||||||
delete set.created
|
delete set.created;
|
||||||
navigation.navigate('EditSet', {set})
|
navigation.navigate("EditSet", { set });
|
||||||
setIds([])
|
setIds([]);
|
||||||
}, [ids, navigation])
|
}, [ids, navigation]);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
setIds([])
|
setIds([]);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const remove = useCallback(async () => {
|
const remove = async () => {
|
||||||
setIds([])
|
setIds([]);
|
||||||
await setRepo.delete(ids.length > 0 ? ids : {})
|
await setRepo.delete(ids.length > 0 ? ids : {});
|
||||||
await refresh(term)
|
return reset(term);
|
||||||
}, [ids, refresh, term])
|
};
|
||||||
|
|
||||||
const select = useCallback(() => {
|
const select = useCallback(() => {
|
||||||
setIds(sets.map(set => set.id))
|
if (!sets) return;
|
||||||
}, [sets])
|
if (ids.length === sets.length) return setIds([]);
|
||||||
|
setIds(sets.map((set) => set.id));
|
||||||
|
}, [sets, ids]);
|
||||||
|
|
||||||
|
const getContent = () => {
|
||||||
|
if (!settings || sets === undefined) return null;
|
||||||
|
if (sets.length === 0)
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
title="No sets yet"
|
||||||
|
description="A set is a group of repetitions. E.g. 8 reps of Squats."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={sets ?? []}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
renderItem={renderItem}
|
||||||
|
onEndReached={next}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
refreshing={refreshing}
|
||||||
|
keyExtractor={(set) => set.id.toString()}
|
||||||
|
onRefresh={() => {
|
||||||
|
setOffset(0);
|
||||||
|
setRefreshing(true);
|
||||||
|
reset(term).finally(() => setRefreshing(false));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DrawerHeader name="Home">
|
<DrawerHeader
|
||||||
|
name={ids.length > 0 ? `${ids.length} selected` : "History"}
|
||||||
|
ids={ids}
|
||||||
|
unSelect={() => setIds([])}
|
||||||
|
>
|
||||||
<ListMenu
|
<ListMenu
|
||||||
onClear={clear}
|
onClear={clear}
|
||||||
onCopy={copy}
|
onCopy={copy}
|
||||||
|
@ -142,22 +178,8 @@ export default function SetList() {
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<Page onAdd={onAdd} term={term} search={search}>
|
<Page onAdd={onAdd} term={term} search={search}>
|
||||||
{sets?.length === 0 ? (
|
{getContent()}
|
||||||
<List.Item
|
|
||||||
title="No sets yet"
|
|
||||||
description="A set is a group of repetitions. E.g. 8 reps of Squats."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
settings && (
|
|
||||||
<FlatList
|
|
||||||
data={sets}
|
|
||||||
style={{flex: 1}}
|
|
||||||
renderItem={renderItem}
|
|
||||||
onEndReached={next}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Page>
|
</Page>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
877
SettingsPage.tsx
877
SettingsPage.tsx
|
@ -1,332 +1,605 @@
|
||||||
import {
|
import { NavigationProp, useNavigation } from "@react-navigation/native";
|
||||||
NavigationProp,
|
import { format } from "date-fns";
|
||||||
useFocusEffect,
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
useNavigation,
|
import { useForm } from "react-hook-form";
|
||||||
} from '@react-navigation/native'
|
import { FlatList, NativeModules } from "react-native";
|
||||||
import {format} from 'date-fns'
|
import DocumentPicker from "react-native-document-picker";
|
||||||
import {useCallback, useMemo, useState} from 'react'
|
import { Dirs, FileSystem } from "react-native-file-access";
|
||||||
import {DeviceEventEmitter, NativeModules, Platform, View} from 'react-native'
|
import { Button } from "react-native-paper";
|
||||||
import DocumentPicker from 'react-native-document-picker'
|
import AppInput from "./AppInput";
|
||||||
import {Dirs, FileSystem} from 'react-native-file-access'
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import {Button, Subheading} from 'react-native-paper'
|
import { PADDING } from "./constants";
|
||||||
import ConfirmDialog from './ConfirmDialog'
|
import { AppDataSource } from "./data-source";
|
||||||
import {ITEM_PADDING, MARGIN} from './constants'
|
import { setRepo, settingsRepo } from "./db";
|
||||||
import {AppDataSource} from './data-source'
|
import { DrawerParams } from "./drawer-params";
|
||||||
import {setRepo, settingsRepo} from './db'
|
import DrawerHeader from "./DrawerHeader";
|
||||||
import {DrawerParamList} from './drawer-param-list'
|
import { darkOptions, lightOptions, themeOptions } from "./options";
|
||||||
import DrawerHeader from './DrawerHeader'
|
import Page from "./Page";
|
||||||
import Input from './input'
|
import Select from "./Select";
|
||||||
import {darkOptions, lightOptions, themeOptions} from './options'
|
import Settings from "./settings";
|
||||||
import Page from './Page'
|
import Switch from "./Switch";
|
||||||
import Select from './Select'
|
import { toast } from "./toast";
|
||||||
import Switch from './Switch'
|
import { useAppTheme } from "./use-theme";
|
||||||
import {toast} from './toast'
|
|
||||||
import {useTheme} from './use-theme'
|
|
||||||
|
|
||||||
const defaultFormats = ['P', 'Pp', 'ccc p', 'p']
|
const twelveHours = [
|
||||||
|
"dd/LL/yyyy",
|
||||||
|
"dd/LL/yyyy, p",
|
||||||
|
"ccc p",
|
||||||
|
"p",
|
||||||
|
"yyyy-MM-dd",
|
||||||
|
"yyyy-MM-dd, p",
|
||||||
|
"yyyy.MM.dd",
|
||||||
|
];
|
||||||
|
|
||||||
|
const twentyFours = [
|
||||||
|
"dd/LL/yyyy",
|
||||||
|
"dd/LL/yyyy, k:mm",
|
||||||
|
"ccc k:mm",
|
||||||
|
"k:mm",
|
||||||
|
"yyyy-MM-dd",
|
||||||
|
"yyyy-MM-dd, k:mm",
|
||||||
|
"yyyy.MM.dd",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
name: string;
|
||||||
|
renderItem: (name: string) => React.JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [ignoring, setIgnoring] = useState(false)
|
const [ignoring, setIgnoring] = useState(false);
|
||||||
const [term, setTerm] = useState('')
|
const [term, setTerm] = useState("");
|
||||||
const [vibrate, setVibrate] = useState(false)
|
const [formatOptions, setFormatOptions] = useState<string[]>(twelveHours);
|
||||||
const [alarm, setAlarm] = useState(false)
|
const [importing, setImporting] = useState(false);
|
||||||
const [sound, setSound] = useState('')
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [notify, setNotify] = useState(false)
|
const [error, setError] = useState("");
|
||||||
const [images, setImages] = useState(false)
|
const { reset } = useNavigation<NavigationProp<DrawerParams>>();
|
||||||
const [showUnit, setShowUnit] = useState(false)
|
|
||||||
const [steps, setSteps] = useState(false)
|
|
||||||
const [date, setDate] = useState('P')
|
|
||||||
const {theme, setTheme, lightColor, setLightColor, darkColor, setDarkColor} =
|
|
||||||
useTheme()
|
|
||||||
const [showDate, setShowDate] = useState(false)
|
|
||||||
const [noSound, setNoSound] = useState(false)
|
|
||||||
const [formatOptions, setFormatOptions] = useState<string[]>(defaultFormats)
|
|
||||||
const [importing, setImporting] = useState(false)
|
|
||||||
const {reset} = useNavigation<NavigationProp<DrawerParamList>>()
|
|
||||||
const today = new Date()
|
|
||||||
|
|
||||||
useFocusEffect(
|
const { watch, setValue } = useForm<Settings>({
|
||||||
useCallback(() => {
|
defaultValues: () => settingsRepo.findOne({ where: {} }),
|
||||||
settingsRepo.findOne({where: {}}).then(settings => {
|
});
|
||||||
setAlarm(settings.alarm)
|
const settings = watch();
|
||||||
setVibrate(settings.vibrate)
|
|
||||||
setSound(settings.sound)
|
|
||||||
setNotify(settings.notify)
|
|
||||||
setImages(settings.images)
|
|
||||||
setShowUnit(settings.showUnit)
|
|
||||||
setSteps(settings.steps)
|
|
||||||
setDate(settings.date)
|
|
||||||
setShowDate(settings.showDate)
|
|
||||||
setNoSound(settings.noSound)
|
|
||||||
})
|
|
||||||
if (Platform.OS !== 'android') return
|
|
||||||
NativeModules.SettingsModule.ignoringBattery(setIgnoring)
|
|
||||||
NativeModules.SettingsModule.is24().then((is24: boolean) => {
|
|
||||||
console.log(`${SettingsPage.name}.focus:`, {is24})
|
|
||||||
if (is24) setFormatOptions(['P', 'P, k:m', 'ccc k:m', 'k:m'])
|
|
||||||
else setFormatOptions(defaultFormats)
|
|
||||||
})
|
|
||||||
}, []),
|
|
||||||
)
|
|
||||||
|
|
||||||
const changeAlarmEnabled = useCallback(
|
const {
|
||||||
(enabled: boolean) => {
|
theme,
|
||||||
if (enabled)
|
setTheme,
|
||||||
DeviceEventEmitter.emit('toast', {
|
lightColor,
|
||||||
value: 'Timers will now run after each set',
|
setLightColor,
|
||||||
timeout: 4000,
|
darkColor,
|
||||||
})
|
setDarkColor,
|
||||||
else toast('Stopped timers running after each set.')
|
} = useAppTheme();
|
||||||
if (enabled && !ignoring) NativeModules.SettingsModule.ignoreBattery()
|
|
||||||
setAlarm(enabled)
|
|
||||||
settingsRepo.update({}, {alarm: enabled})
|
|
||||||
},
|
|
||||||
[ignoring],
|
|
||||||
)
|
|
||||||
|
|
||||||
const changeVibrate = useCallback((enabled: boolean) => {
|
useEffect(() => {
|
||||||
if (enabled) toast('When a timer completes, vibrate your phone.')
|
NativeModules.SettingsModule.ignoringBattery().then(setIgnoring);
|
||||||
else toast('Stop vibrating at the end of timers.')
|
NativeModules.SettingsModule.is24().then((is24: boolean) => {
|
||||||
setVibrate(enabled)
|
console.log(`${SettingsPage.name}.focus:`, { is24 });
|
||||||
settingsRepo.update({}, {vibrate: enabled})
|
if (is24) setFormatOptions(twentyFours);
|
||||||
}, [])
|
else setFormatOptions(twelveHours);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const changeSound = useCallback(async () => {
|
const backupString = useMemo(() => {
|
||||||
const {fileCopyUri} = await DocumentPicker.pickSingle({
|
if (!settings.backupDir) return null;
|
||||||
type: 'audio/*',
|
const split = decodeURIComponent(settings.backupDir).split(":");
|
||||||
copyTo: 'documentDirectory',
|
return split.pop();
|
||||||
})
|
}, [settings.backupDir]);
|
||||||
if (!fileCopyUri) return
|
|
||||||
settingsRepo.update({}, {sound: fileCopyUri})
|
|
||||||
setSound(fileCopyUri)
|
|
||||||
toast('This song will now play after rest timers complete.')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const changeNotify = useCallback((enabled: boolean) => {
|
|
||||||
setNotify(enabled)
|
|
||||||
settingsRepo.update({}, {notify: enabled})
|
|
||||||
if (enabled) toast('Show when a set is a new record.')
|
|
||||||
else toast('Stopped showing notifications for new records.')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const changeImages = useCallback((enabled: boolean) => {
|
|
||||||
setImages(enabled)
|
|
||||||
settingsRepo.update({}, {images: enabled})
|
|
||||||
if (enabled) toast('Show images for sets.')
|
|
||||||
else toast('Stopped showing images for sets.')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const changeUnit = useCallback((enabled: boolean) => {
|
|
||||||
setShowUnit(enabled)
|
|
||||||
settingsRepo.update({}, {showUnit: enabled})
|
|
||||||
if (enabled) toast('Show option to select unit for sets.')
|
|
||||||
else toast('Hid unit option for sets.')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const changeSteps = useCallback((enabled: boolean) => {
|
|
||||||
setSteps(enabled)
|
|
||||||
settingsRepo.update({}, {steps: enabled})
|
|
||||||
if (enabled) toast('Show steps for a workout.')
|
|
||||||
else toast('Stopped showing steps for workouts.')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const changeShowDate = useCallback((enabled: boolean) => {
|
|
||||||
setShowDate(enabled)
|
|
||||||
settingsRepo.update({}, {showDate: enabled})
|
|
||||||
if (enabled) toast('Show date for sets by default.')
|
|
||||||
else toast('Stopped showing date for sets by default.')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const changeNoSound = useCallback((enabled: boolean) => {
|
|
||||||
setNoSound(enabled)
|
|
||||||
settingsRepo.update({}, {noSound: enabled})
|
|
||||||
if (enabled) toast('Disable sound on rest timer alarms.')
|
|
||||||
else toast('Enabled sound for rest timer alarms.')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const switches: Input<boolean>[] = [
|
|
||||||
{name: 'Rest timers', value: alarm, onChange: changeAlarmEnabled},
|
|
||||||
{name: 'Vibrate', value: vibrate, onChange: changeVibrate},
|
|
||||||
{name: 'Disable sound', value: noSound, onChange: changeNoSound},
|
|
||||||
{name: 'Notifications', value: notify, onChange: changeNotify},
|
|
||||||
{name: 'Show images', value: images, onChange: changeImages},
|
|
||||||
{name: 'Show unit', value: showUnit, onChange: changeUnit},
|
|
||||||
{name: 'Show steps', value: steps, onChange: changeSteps},
|
|
||||||
{name: 'Show date', value: showDate, onChange: changeShowDate},
|
|
||||||
].filter(({name}) => name.toLowerCase().includes(term.toLowerCase()))
|
|
||||||
|
|
||||||
const changeTheme = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
settingsRepo.update({}, {theme: value})
|
|
||||||
setTheme(value)
|
|
||||||
},
|
|
||||||
[setTheme],
|
|
||||||
)
|
|
||||||
|
|
||||||
const changeDate = useCallback((value: string) => {
|
|
||||||
settingsRepo.update({}, {date: value})
|
|
||||||
setDate(value)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const soundString = useMemo(() => {
|
const soundString = useMemo(() => {
|
||||||
if (!sound) return null
|
if (!settings.sound) return null;
|
||||||
const split = sound.split('/')
|
const split = settings.sound.split("/");
|
||||||
return split.pop()
|
return split.pop();
|
||||||
}, [sound])
|
}, [settings.sound]);
|
||||||
|
|
||||||
const changeDarkColor = useCallback(
|
const confirmDelete = useCallback(async () => {
|
||||||
(value: string) => {
|
setDeleting(false);
|
||||||
setDarkColor(value)
|
await AppDataSource.dropDatabase();
|
||||||
settingsRepo.update({}, {darkColor: value})
|
await AppDataSource.destroy();
|
||||||
},
|
await AppDataSource.initialize();
|
||||||
[setDarkColor],
|
toast("Database deleted.");
|
||||||
)
|
}, []);
|
||||||
|
|
||||||
const changeLightColor = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setLightColor(value)
|
|
||||||
settingsRepo.update({}, {lightColor: value})
|
|
||||||
},
|
|
||||||
[setLightColor],
|
|
||||||
)
|
|
||||||
|
|
||||||
const renderSwitch = useCallback(
|
|
||||||
(item: Input<boolean>) => (
|
|
||||||
<Switch
|
|
||||||
onPress={() => item.onChange(!item.value)}
|
|
||||||
key={item.name}
|
|
||||||
value={item.value}
|
|
||||||
onValueChange={item.onChange}>
|
|
||||||
{item.name}
|
|
||||||
</Switch>
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const selects: Input<string>[] = [
|
|
||||||
{name: 'Theme', value: theme, onChange: changeTheme, items: themeOptions},
|
|
||||||
{
|
|
||||||
name: 'Dark color',
|
|
||||||
value: darkColor,
|
|
||||||
onChange: changeDarkColor,
|
|
||||||
items: lightOptions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Light color',
|
|
||||||
value: lightColor,
|
|
||||||
onChange: changeLightColor,
|
|
||||||
items: darkOptions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Date format',
|
|
||||||
value: date,
|
|
||||||
onChange: changeDate,
|
|
||||||
items: formatOptions.map(option => ({
|
|
||||||
label: format(today, option),
|
|
||||||
value: option,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
].filter(({name}) => name.toLowerCase().includes(term.toLowerCase()))
|
|
||||||
|
|
||||||
const renderSelect = useCallback(
|
|
||||||
(item: Input<string>) => (
|
|
||||||
<Select
|
|
||||||
key={item.name}
|
|
||||||
value={item.value}
|
|
||||||
onChange={item.onChange}
|
|
||||||
label={item.name}
|
|
||||||
items={item.items}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const confirmImport = useCallback(async () => {
|
const confirmImport = useCallback(async () => {
|
||||||
setImporting(false)
|
setImporting(false);
|
||||||
await AppDataSource.destroy()
|
await FileSystem.cp(
|
||||||
const result = await DocumentPicker.pickSingle()
|
Dirs.DatabaseDir + "/massive.db",
|
||||||
await FileSystem.cp(result.uri, Dirs.DatabaseDir + '/massive.db')
|
Dirs.DatabaseDir + "/massive-backup.db"
|
||||||
await AppDataSource.initialize()
|
);
|
||||||
await setRepo.createQueryBuilder().update().set({image: null}).execute()
|
await AppDataSource.destroy();
|
||||||
await settingsRepo
|
const file = await DocumentPicker.pickSingle();
|
||||||
.createQueryBuilder()
|
if (!file.uri.endsWith('.db'))
|
||||||
.update()
|
return toast("File name must end with .db")
|
||||||
.set({sound: null})
|
await FileSystem.cp(file.uri, Dirs.DatabaseDir + "/massive.db");
|
||||||
.execute()
|
|
||||||
reset({index: 0, routes: [{name: 'Settings'}]})
|
|
||||||
}, [reset])
|
|
||||||
|
|
||||||
const exportDatabase = useCallback(async () => {
|
try {
|
||||||
const path = Dirs.DatabaseDir + '/massive.db'
|
await AppDataSource.initialize();
|
||||||
await FileSystem.cpExternal(path, 'massive.db', 'downloads')
|
} catch (e) {
|
||||||
toast('Database exported. Check downloads.')
|
setError(e.toString());
|
||||||
}, [])
|
await FileSystem.cp(
|
||||||
|
Dirs.DatabaseDir + "/massive-backup.db",
|
||||||
|
Dirs.DatabaseDir + "/massive.db"
|
||||||
|
);
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const buttons = useMemo(
|
await setRepo.update({}, { image: null });
|
||||||
() => [
|
await settingsRepo.update({}, { sound: null, backup: false });
|
||||||
{
|
reset({ index: 0, routes: [{ name: "Settings" }] });
|
||||||
name: 'Alarm sound',
|
toast("Imported database successfully.")
|
||||||
element: (
|
}, [reset]);
|
||||||
<View
|
|
||||||
key="alarm-sound"
|
const today = new Date();
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
const data: Item[] = [
|
||||||
alignItems: 'center',
|
{
|
||||||
paddingLeft: ITEM_PADDING,
|
name: "Start up page",
|
||||||
}}>
|
renderItem: (name: string) => (
|
||||||
<Subheading style={{width: 100}}>Alarm sound</Subheading>
|
<Select
|
||||||
<Button onPress={changeSound}>{soundString || 'Default'}</Button>
|
label={name}
|
||||||
</View>
|
items={[
|
||||||
),
|
{ label: "History", value: "History", icon: 'history' },
|
||||||
},
|
{ label: "Exercises", value: "Exercises", icon: 'dumbbell' },
|
||||||
{
|
{ label: "Daily", value: "Daily", icon: 'calendar-outline' },
|
||||||
name: 'Export database',
|
{ label: "Plans", value: "Plans", icon: 'checkbox-multiple-marked-outline' },
|
||||||
element: (
|
{ label: "Graphs", value: "Graphs", icon: 'chart-bell-curve-cumulative' },
|
||||||
<Button
|
{ label: "Timer", value: "Timer", icon: 'timer-outline' },
|
||||||
key="export-db"
|
{ label: "Weight", value: "Weight", icon: 'scale-bathroom' },
|
||||||
style={{alignSelf: 'flex-start'}}
|
{ label: "Insights", value: "Insights", icon: 'lightbulb-on-outline' },
|
||||||
onPress={exportDatabase}>
|
{ label: "Settings", value: "Settings", icon: 'cog-outline' },
|
||||||
Export database
|
]}
|
||||||
</Button>
|
value={settings.startup}
|
||||||
),
|
onChange={async (value) => {
|
||||||
},
|
setValue("startup", value);
|
||||||
{
|
await settingsRepo.update({}, { startup: value });
|
||||||
name: 'Import database',
|
toast(`App will always start on ${value}`);
|
||||||
element: (
|
}}
|
||||||
<Button
|
/>
|
||||||
key="import-db"
|
),
|
||||||
style={{alignSelf: 'flex-start'}}
|
},
|
||||||
onPress={() => setImporting(true)}>
|
{
|
||||||
Import database
|
name: "Theme",
|
||||||
</Button>
|
renderItem: (name: string) => (
|
||||||
),
|
<Select
|
||||||
},
|
label={name}
|
||||||
],
|
items={themeOptions}
|
||||||
[changeSound, exportDatabase, soundString],
|
value={theme}
|
||||||
)
|
onChange={async (value) => {
|
||||||
|
setValue("theme", value);
|
||||||
|
setTheme(value);
|
||||||
|
await settingsRepo.update({}, { theme: value });
|
||||||
|
if (value === "dark") toast("Theme will always be dark.");
|
||||||
|
else if (value === "light") toast("Theme will always be light.");
|
||||||
|
else if (value === "system") toast("Theme will follow system.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Date format",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Select
|
||||||
|
label={name}
|
||||||
|
items={formatOptions.map((option) => ({
|
||||||
|
label: format(today, option),
|
||||||
|
value: option,
|
||||||
|
}))}
|
||||||
|
value={settings.date}
|
||||||
|
onChange={async (value) => {
|
||||||
|
setValue("date", value);
|
||||||
|
await settingsRepo.update({}, { date: value });
|
||||||
|
toast("Changed date format.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Auto convert",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Select
|
||||||
|
label={name}
|
||||||
|
items={[
|
||||||
|
{ label: "Off", value: "", icon: 'scale-off' },
|
||||||
|
{ label: "Kilograms", value: "kg", icon: 'weight-kilogram' },
|
||||||
|
{ label: "Pounds", value: "lb", icon: 'weight-pound' },
|
||||||
|
{ label: "Stone", value: "stone", icon: 'weight' },
|
||||||
|
]}
|
||||||
|
value={settings.autoConvert}
|
||||||
|
onChange={async (value) => {
|
||||||
|
setValue("autoConvert", value);
|
||||||
|
await settingsRepo.update({}, { autoConvert: value });
|
||||||
|
if (value) toast(`Sets now automatically convert to ${value}`);
|
||||||
|
else toast("Stopped automatically converting sets.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Vibration duration (ms)",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<AppInput
|
||||||
|
value={settings.duration?.toString() ?? "300"}
|
||||||
|
label={name}
|
||||||
|
onChangeText={(value) => setValue("duration", Number(value))}
|
||||||
|
onSubmitEditing={async (e) => {
|
||||||
|
const value = Number(e.nativeEvent.text);
|
||||||
|
setValue("duration", value);
|
||||||
|
await settingsRepo.update({}, { duration: value });
|
||||||
|
toast("Changed duration of alarm vibrations.");
|
||||||
|
}}
|
||||||
|
keyboardType="numeric"
|
||||||
|
blurOnSubmit
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Default sets",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<AppInput
|
||||||
|
value={settings.defaultSets?.toString() ?? "3"}
|
||||||
|
label={name}
|
||||||
|
onChangeText={(value) => setValue("defaultSets", Number(value))}
|
||||||
|
onSubmitEditing={async (e) => {
|
||||||
|
const value = Number(e.nativeEvent.text);
|
||||||
|
setValue("defaultSets", value);
|
||||||
|
await settingsRepo.update({}, { defaultSets: value });
|
||||||
|
toast(`New exercises now have ${value} sets by default.`);
|
||||||
|
}}
|
||||||
|
keyboardType="numeric"
|
||||||
|
blurOnSubmit
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Default minutes",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<AppInput
|
||||||
|
value={settings.defaultMinutes?.toString() ?? "3"}
|
||||||
|
label={name}
|
||||||
|
onChangeText={(value) => setValue("defaultMinutes", Number(value))}
|
||||||
|
onSubmitEditing={async (e) => {
|
||||||
|
const value = Number(e.nativeEvent.text);
|
||||||
|
setValue("defaultMinutes", value);
|
||||||
|
await settingsRepo.update({}, { defaultMinutes: value });
|
||||||
|
toast(`New exercises now wait ${value} minutes by default.`);
|
||||||
|
}}
|
||||||
|
keyboardType="numeric"
|
||||||
|
blurOnSubmit
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Default seconds",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<AppInput
|
||||||
|
value={settings.defaultSeconds?.toString() ?? "30"}
|
||||||
|
label={name}
|
||||||
|
onChangeText={(value) => setValue("defaultSeconds", Number(value))}
|
||||||
|
onSubmitEditing={async (e) => {
|
||||||
|
const value = Number(e.nativeEvent.text);
|
||||||
|
setValue("defaultSeconds", value);
|
||||||
|
await settingsRepo.update({}, { defaultSeconds: value });
|
||||||
|
toast(`New exercises now wait ${value} seconds by default.`);
|
||||||
|
}}
|
||||||
|
keyboardType="numeric"
|
||||||
|
blurOnSubmit
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Dark color",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Select
|
||||||
|
label={name}
|
||||||
|
items={lightOptions}
|
||||||
|
value={darkColor}
|
||||||
|
onChange={async (value) => {
|
||||||
|
setValue("darkColor", value);
|
||||||
|
setDarkColor(value);
|
||||||
|
await settingsRepo.update({}, { darkColor: value });
|
||||||
|
toast("Set primary color for dark mode.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Light color",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Select
|
||||||
|
label={name}
|
||||||
|
items={darkOptions}
|
||||||
|
value={lightColor}
|
||||||
|
onChange={async (value) => {
|
||||||
|
setValue("lightColor", value);
|
||||||
|
setLightColor(value);
|
||||||
|
await settingsRepo.update({}, { lightColor: value });
|
||||||
|
toast("Set primary color for light mode.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rest timers",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Switch
|
||||||
|
value={settings.alarm}
|
||||||
|
onChange={async (value) => {
|
||||||
|
setValue("alarm", value);
|
||||||
|
if (value && !ignoring) {
|
||||||
|
NativeModules.SettingsModule.ignoreBattery();
|
||||||
|
}
|
||||||
|
await settingsRepo.update({}, { alarm: value });
|
||||||
|
if (value) toast("Timers will now run after each set.");
|
||||||
|
else toast("Stopped timers running after each set.");
|
||||||
|
}}
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Vibrate",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Switch
|
||||||
|
value={settings.vibrate}
|
||||||
|
onChange={async (value) => {
|
||||||
|
setValue("vibrate", value);
|
||||||
|
await settingsRepo.update({}, { vibrate: value });
|
||||||
|
if (value) toast("Alarms will vibrate.");
|
||||||
|
else toast("Stopped alarms from vibrating.");
|
||||||
|
}}
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sound",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Switch
|
||||||
|
value={!settings.noSound}
|
||||||
|
onChange={async (value) => {
|
||||||
|
setValue("noSound", !value);
|
||||||
|
await settingsRepo.update({}, { noSound: !value });
|
||||||
|
if (!value) toast("Alarms will no longer make a sound.");
|
||||||
|
else toast("Enabled sound for alarms.");
|
||||||
|
}}
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Notifications",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Switch
|
||||||
|
value={settings.notify}
|
||||||
|
onChange={async (value) => {
|
||||||
|
setValue("notify", value);
|
||||||
|
await settingsRepo.update({}, { notify: value });
|
||||||
|
if (value) toast("Show notifications for new records.");
|
||||||
|
else toast("Stopped notifications for new records.");
|
||||||
|
}}
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Show images",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Switch
|
||||||
|
value={settings.images}
|
||||||
|
onChange={async (value) => {
|
||||||
|
setValue("images", value);
|
||||||
|
await settingsRepo.update({}, { images: value });
|
||||||
|
if (value) toast("Show images for sets.");
|
||||||
|
else toast("Hid images for sets.");
|
||||||
|
}}
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Show unit",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Switch
|
||||||
|
value={settings.showUnit}
|
||||||
|
onChange={async (value) => {
|
||||||
|
setValue("showUnit", value);
|
||||||
|
await settingsRepo.update({}, { showUnit: value });
|
||||||
|
if (value) toast("Show option to select unit for sets.");
|
||||||
|
else toast("Hid unit option for sets.");
|
||||||
|
}}
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Show date",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Switch
|
||||||
|
value={settings.showDate}
|
||||||
|
onChange={async (value) => {
|
||||||
|
setValue("showDate", value);
|
||||||
|
await settingsRepo.update({}, { showDate: value });
|
||||||
|
if (value) toast("Show date for sets.");
|
||||||
|
else toast("Hid date on sets.");
|
||||||
|
}}
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Automatic backup",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Switch
|
||||||
|
value={settings.backup}
|
||||||
|
onChange={async (value) => {
|
||||||
|
setValue("backup", value);
|
||||||
|
await settingsRepo.update({}, { backup: value });
|
||||||
|
if (value) {
|
||||||
|
const result = await DocumentPicker.pickDirectory();
|
||||||
|
setValue("backupDir", result.uri);
|
||||||
|
await settingsRepo.update({}, { backupDir: result.uri });
|
||||||
|
console.log(`${SettingsPage.name}.backup:`, { result });
|
||||||
|
toast("Backup database daily.");
|
||||||
|
NativeModules.BackupModule.start(result.uri);
|
||||||
|
} else {
|
||||||
|
toast("Stopped backing up daily");
|
||||||
|
NativeModules.BackupModule.stop();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `Backup directory: ${backupString || "Not set yet!"}`,
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Button
|
||||||
|
style={{ alignSelf: "flex-start" }}
|
||||||
|
onPress={async () => {
|
||||||
|
const result = await DocumentPicker.pickDirectory();
|
||||||
|
setValue("backupDir", result.uri);
|
||||||
|
await settingsRepo.update({}, { backupDir: result.uri });
|
||||||
|
toast("Changed backup directory.");
|
||||||
|
if (!settings.backup) return;
|
||||||
|
NativeModules.BackupModule.stop();
|
||||||
|
NativeModules.BackupModule.start(result.uri);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `Alarm sound: ${soundString || "Default"}`,
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Button
|
||||||
|
style={{ alignSelf: "flex-start" }}
|
||||||
|
onPress={async () => {
|
||||||
|
const { fileCopyUri } = await DocumentPicker.pickSingle({
|
||||||
|
type: DocumentPicker.types.audio,
|
||||||
|
copyTo: "documentDirectory",
|
||||||
|
});
|
||||||
|
if (!fileCopyUri) return;
|
||||||
|
setValue("sound", fileCopyUri);
|
||||||
|
await settingsRepo.update({}, { sound: fileCopyUri });
|
||||||
|
toast("Sound will play after rest timers.");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Export database",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Button
|
||||||
|
style={{ alignSelf: "flex-start" }}
|
||||||
|
onPress={async () => {
|
||||||
|
const result = await DocumentPicker.pickDirectory();
|
||||||
|
const error = await NativeModules.BackupModule.once(result.uri);
|
||||||
|
if (error) toast(error);
|
||||||
|
else toast("Database exported.");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Export sets as CSV",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Button
|
||||||
|
style={{ alignSelf: "flex-start" }}
|
||||||
|
onPress={async () => {
|
||||||
|
const result = await DocumentPicker.pickDirectory();
|
||||||
|
await NativeModules.BackupModule.exportSets(result.uri);
|
||||||
|
toast("Exported sets as CSV.");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Export plans as CSV",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Button
|
||||||
|
style={{ alignSelf: "flex-start" }}
|
||||||
|
onPress={async () => {
|
||||||
|
const result = await DocumentPicker.pickDirectory();
|
||||||
|
await NativeModules.BackupModule.exportPlans(result.uri);
|
||||||
|
toast("Exported plans as CSV.");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Import database",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Button
|
||||||
|
style={{ alignSelf: "flex-start" }}
|
||||||
|
onPress={() => setImporting(true)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Delete database",
|
||||||
|
renderItem: (name: string) => (
|
||||||
|
<Button
|
||||||
|
style={{ alignSelf: "flex-start" }}
|
||||||
|
onPress={() => setDeleting(true)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DrawerHeader name="Settings" />
|
<DrawerHeader name="Settings" />
|
||||||
|
|
||||||
<Page term={term} search={setTerm} style={{flexGrow: 0}}>
|
<Page term={term} search={setTerm}>
|
||||||
<View style={{marginTop: MARGIN}}>
|
<FlatList
|
||||||
{switches.map(s => renderSwitch(s))}
|
data={data.filter((item) =>
|
||||||
{selects.map(s => renderSelect(s))}
|
item.name.toLowerCase().includes(term.toLowerCase())
|
||||||
{buttons
|
)}
|
||||||
.filter(b => b.name.includes(term.toLowerCase()))
|
renderItem={({ item }) => item.renderItem(item.name)}
|
||||||
.map(b => b.element)}
|
style={{ flex: 1, paddingTop: PADDING }}
|
||||||
</View>
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Failed to import database"
|
||||||
|
onOk={() => setError("")}
|
||||||
|
setShow={() => setError("")}
|
||||||
|
show={!!error}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Are you sure?"
|
title="Are you sure?"
|
||||||
onOk={confirmImport}
|
onOk={confirmImport}
|
||||||
setShow={setImporting}
|
setShow={setImporting}
|
||||||
show={importing}>
|
show={importing}
|
||||||
|
>
|
||||||
Importing a database overwrites your current data. This action cannot be
|
Importing a database overwrites your current data. This action cannot be
|
||||||
reversed!
|
reversed!
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Are you sure?"
|
||||||
|
onOk={confirmDelete}
|
||||||
|
setShow={setDeleting}
|
||||||
|
show={deleting}
|
||||||
|
>
|
||||||
|
Deleting your database wipes your current data. This action cannot be
|
||||||
|
reversed!
|
||||||
|
</ConfirmDialog>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,36 +1,20 @@
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import { useNavigation } from "@react-navigation/native";
|
||||||
import Share from 'react-native-share'
|
import { Appbar, IconButton } from "react-native-paper";
|
||||||
import {FileSystem} from 'react-native-file-access'
|
|
||||||
import {Appbar, IconButton} from 'react-native-paper'
|
|
||||||
import {captureScreen} from 'react-native-view-shot'
|
|
||||||
import useDark from './use-dark'
|
|
||||||
|
|
||||||
export default function StackHeader({title}: {title: string}) {
|
export default function StackHeader({
|
||||||
const navigation = useNavigation()
|
title,
|
||||||
const dark = useDark()
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children?: JSX.Element | JSX.Element[];
|
||||||
|
}) {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Appbar.Header>
|
<Appbar.Header>
|
||||||
<IconButton
|
<IconButton icon="arrow-left" onPress={navigation.goBack} />
|
||||||
color={dark ? 'white' : 'white'}
|
|
||||||
icon="arrow-back"
|
|
||||||
onPress={navigation.goBack}
|
|
||||||
/>
|
|
||||||
<Appbar.Content title={title} />
|
<Appbar.Content title={title} />
|
||||||
<IconButton
|
{children}
|
||||||
color={dark ? 'white' : 'white'}
|
|
||||||
onPress={() =>
|
|
||||||
captureScreen().then(async uri => {
|
|
||||||
const base64 = await FileSystem.readFile(uri, 'base64')
|
|
||||||
const url = `data:image/jpeg;base64,${base64}`
|
|
||||||
Share.open({
|
|
||||||
type: 'image/jpeg',
|
|
||||||
url,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
icon="share"
|
|
||||||
/>
|
|
||||||
</Appbar.Header>
|
</Appbar.Header>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
301
StartPlan.tsx
301
StartPlan.tsx
|
@ -1,149 +1,228 @@
|
||||||
import {RouteProp, useFocusEffect, useRoute} from '@react-navigation/native'
|
import {
|
||||||
import {useCallback, useMemo, useRef, useState} from 'react'
|
NavigationProp,
|
||||||
import {NativeModules, TextInput, View} from 'react-native'
|
RouteProp,
|
||||||
import {FlatList} from 'react-native-gesture-handler'
|
useFocusEffect,
|
||||||
import {Button, ProgressBar} from 'react-native-paper'
|
useNavigation,
|
||||||
import {getBestSet} from './best.service'
|
useRoute,
|
||||||
import {PADDING} from './constants'
|
} from "@react-navigation/native";
|
||||||
import CountMany from './count-many'
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import {AppDataSource} from './data-source'
|
import { FlatList, NativeModules, TextInput, View } from "react-native";
|
||||||
import {getNow, setRepo, settingsRepo} from './db'
|
import { IconButton, ProgressBar } from "react-native-paper";
|
||||||
import GymSet from './gym-set'
|
import { PERMISSIONS, RESULTS, check, request } from "react-native-permissions";
|
||||||
import MassiveInput from './MassiveInput'
|
import AppInput from "./AppInput";
|
||||||
import {PlanPageParams} from './plan-page-params'
|
import { StackParams } from "./AppStack";
|
||||||
import Settings from './settings'
|
import PrimaryButton from "./PrimaryButton";
|
||||||
import StackHeader from './StackHeader'
|
import Select from "./Select";
|
||||||
import StartPlanItem from './StartPlanItem'
|
import StackHeader from "./StackHeader";
|
||||||
import {toast} from './toast'
|
import StartPlanItem from "./StartPlanItem";
|
||||||
|
import { getBestSet } from "./best.service";
|
||||||
|
import { PADDING } from "./constants";
|
||||||
|
import { convert } from "./conversions";
|
||||||
|
import CountMany from "./count-many";
|
||||||
|
import { AppDataSource } from "./data-source";
|
||||||
|
import { getNow, setRepo, settingsRepo } from "./db";
|
||||||
|
import { fixNumeric } from "./fix-numeric";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
|
||||||
export default function StartPlan() {
|
export default function StartPlan() {
|
||||||
const {params} = useRoute<RouteProp<PlanPageParams, 'StartPlan'>>()
|
const { params } = useRoute<RouteProp<StackParams, "StartPlan">>();
|
||||||
const [reps, setReps] = useState(params.first?.reps.toString() || '0')
|
const [reps, setReps] = useState(params.first?.reps.toString() || "0");
|
||||||
const [weight, setWeight] = useState(params.first?.weight.toString() || '0')
|
const [weight, setWeight] = useState(params.first?.weight.toString() || "0");
|
||||||
const [unit, setUnit] = useState<string>(params.first?.unit || 'kg')
|
const [unit, setUnit] = useState<string>(params.first?.unit || "kg");
|
||||||
const [selected, setSelected] = useState(0)
|
const [selected, setSelected] = useState<number>(0);
|
||||||
const [settings, setSettings] = useState<Settings>()
|
const [settings, setSettings] = useState<Settings>();
|
||||||
const [counts, setCounts] = useState<CountMany[]>()
|
const [counts, setCounts] = useState<CountMany[]>();
|
||||||
const weightRef = useRef<TextInput>(null)
|
const weightRef = useRef<TextInput>(null);
|
||||||
const repsRef = useRef<TextInput>(null)
|
const repsRef = useRef<TextInput>(null);
|
||||||
const unitRef = useRef<TextInput>(null)
|
const exercises = useMemo(() => params.plan.exercises.split(","), [params]);
|
||||||
const workouts = useMemo(() => params.plan.workouts.split(','), [params])
|
const navigation = useNavigation<NavigationProp<StackParams>>();
|
||||||
|
|
||||||
const [selection, setSelection] = useState({
|
const [selection, setSelection] = useState({
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 0,
|
end: 0,
|
||||||
})
|
});
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const questions = workouts
|
const questions = exercises
|
||||||
.map((workout, index) => `('${workout}',${index})`)
|
.map((exercise, index) => `('${exercise}',${index})`)
|
||||||
.join(',')
|
.join(",");
|
||||||
console.log({questions, workouts})
|
|
||||||
const select = `
|
const select = `
|
||||||
SELECT workouts.name, COUNT(sets.id) as total, sets.sets
|
SELECT exercises.name, COUNT(sets.id) as total, sets.sets
|
||||||
FROM (select 0 as name, 0 as sequence union values ${questions}) as workouts
|
FROM (select 0 as name, 0 as sequence union values ${questions}) as exercises
|
||||||
LEFT JOIN sets ON sets.name = workouts.name
|
LEFT JOIN sets ON sets.name = exercises.name
|
||||||
AND sets.created LIKE STRFTIME('%Y-%m-%d%%', 'now', 'localtime')
|
AND sets.created LIKE STRFTIME('%Y-%m-%d%%', 'now', 'localtime')
|
||||||
AND NOT sets.hidden
|
AND NOT sets.hidden
|
||||||
GROUP BY workouts.name
|
GROUP BY exercises.name
|
||||||
ORDER BY workouts.sequence
|
ORDER BY exercises.sequence
|
||||||
LIMIT -1
|
LIMIT -1
|
||||||
OFFSET 1
|
OFFSET 1
|
||||||
`
|
`;
|
||||||
const newCounts = await AppDataSource.manager.query(select)
|
const newCounts = await AppDataSource.manager.query(select);
|
||||||
console.log(`${StartPlan.name}.focus:`, {newCounts})
|
console.log(`${StartPlan.name}.focus:`, { newCounts });
|
||||||
setCounts(newCounts)
|
setCounts(newCounts);
|
||||||
}, [workouts])
|
}, [exercises]);
|
||||||
|
|
||||||
const select = useCallback(
|
const select = useCallback(
|
||||||
async (index: number, newCounts?: CountMany[]) => {
|
async (index: number, newCounts?: CountMany[]) => {
|
||||||
setSelected(index)
|
setSelected(index);
|
||||||
if (!counts && !newCounts) return
|
if (!counts && !newCounts) return;
|
||||||
const workout = counts ? counts[index] : newCounts[index]
|
const exercise = counts ? counts[index] : newCounts[index];
|
||||||
console.log(`${StartPlan.name}.next:`, {workout})
|
console.log(`${StartPlan.name}.next:`, { exercise });
|
||||||
const newBest = await getBestSet(workout.name)
|
const last = await setRepo.findOne({
|
||||||
if (!newBest) return
|
where: { name: exercise.name },
|
||||||
delete newBest.id
|
order: { created: "desc" },
|
||||||
console.log(`${StartPlan.name}.next:`, {newBest})
|
});
|
||||||
setReps(newBest.reps.toString())
|
if (!last) return;
|
||||||
setWeight(newBest.weight.toString())
|
delete last.id;
|
||||||
setUnit(newBest.unit)
|
console.log(`${StartPlan.name}.select:`, { last });
|
||||||
|
setReps(last.reps.toString());
|
||||||
|
setWeight(last.weight.toString());
|
||||||
|
setUnit(last.unit);
|
||||||
},
|
},
|
||||||
[counts],
|
[counts]
|
||||||
)
|
);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
settingsRepo.findOne({where: {}}).then(setSettings)
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
refresh()
|
refresh();
|
||||||
}, [refresh]),
|
// eslint-disable-next-line
|
||||||
)
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const [{now}] = await getNow()
|
const now = await getNow();
|
||||||
const workout = counts[selected]
|
const exercise = counts[selected];
|
||||||
const best = await getBestSet(workout.name)
|
const best = await getBestSet(exercise.name);
|
||||||
delete best.id
|
delete best.id;
|
||||||
|
|
||||||
|
let newWeight = Number(weight);
|
||||||
|
let newUnit = unit;
|
||||||
|
if (settings.autoConvert && unit !== settings.autoConvert) {
|
||||||
|
newUnit = settings.autoConvert;
|
||||||
|
newWeight = convert(newWeight, unit, settings.autoConvert);
|
||||||
|
}
|
||||||
|
|
||||||
const newSet: GymSet = {
|
const newSet: GymSet = {
|
||||||
...best,
|
...best,
|
||||||
weight: +weight,
|
weight: newWeight,
|
||||||
reps: +reps,
|
reps: Number(reps),
|
||||||
unit,
|
unit: newUnit,
|
||||||
created: now,
|
created: now,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
}
|
};
|
||||||
await setRepo.save(newSet)
|
await setRepo.save(newSet);
|
||||||
await refresh()
|
await refresh();
|
||||||
if (
|
if (
|
||||||
settings.notify &&
|
settings.notify &&
|
||||||
(+weight > best.weight || (+reps > best.reps && +weight === best.weight))
|
(Number(weight) > best.weight ||
|
||||||
)
|
(Number(reps) > best.reps && Number(weight) === best.weight))
|
||||||
toast("Great work King! That's a new record.")
|
) {
|
||||||
if (!settings.alarm) return
|
toast("Great work King! That's a new record.");
|
||||||
|
}
|
||||||
|
if (!settings.alarm) return;
|
||||||
const milliseconds =
|
const milliseconds =
|
||||||
Number(best.minutes) * 60 * 1000 + Number(best.seconds) * 1000
|
Number(best.minutes) * 60 * 1000 + Number(best.seconds) * 1000;
|
||||||
const {vibrate, sound, noSound} = settings
|
const canNotify = await check(PERMISSIONS.ANDROID.POST_NOTIFICATIONS);
|
||||||
const args = [milliseconds, vibrate, sound, noSound]
|
if (canNotify === RESULTS.DENIED)
|
||||||
NativeModules.AlarmModule.timer(...args)
|
await request(PERMISSIONS.ANDROID.POST_NOTIFICATIONS);
|
||||||
}
|
if (isNaN(exercise.total) ? 0 : exercise.total === best.sets - 1 && selected === exercises.length - 1)
|
||||||
|
return
|
||||||
|
NativeModules.AlarmModule.timer(milliseconds, `${exercise.name} (${exercise.total + 1}/${best.sets})`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StackHeader title={params.plan.days.replace(/,/g, ', ')} />
|
<StackHeader
|
||||||
<View style={{padding: PADDING, flex: 1, flexDirection: 'column'}}>
|
title={params.plan.title || params.plan.days.replace(/,/g, ", ")}
|
||||||
<View style={{flex: 1}}>
|
>
|
||||||
<MassiveInput
|
<IconButton
|
||||||
label="Reps"
|
onPress={() => navigation.navigate("EditPlan", { plan: params.plan })}
|
||||||
keyboardType="numeric"
|
icon="pencil"
|
||||||
value={reps}
|
/>
|
||||||
onChangeText={setReps}
|
</StackHeader>
|
||||||
onSubmitEditing={() => weightRef.current?.focus()}
|
<View style={{ padding: PADDING, flex: 1, flexDirection: "column" }}>
|
||||||
selection={selection}
|
<View style={{ flex: 1 }}>
|
||||||
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
|
<View>
|
||||||
innerRef={repsRef}
|
<AppInput
|
||||||
/>
|
label="Reps"
|
||||||
<MassiveInput
|
keyboardType="numeric"
|
||||||
label="Weight"
|
value={reps}
|
||||||
keyboardType="numeric"
|
onChangeText={(newReps) => {
|
||||||
value={weight}
|
const fixed = fixNumeric(newReps);
|
||||||
onChangeText={setWeight}
|
setReps(fixed.replace(/-/g, ''))
|
||||||
onSubmitEditing={handleSubmit}
|
if (fixed.length !== newReps.length)
|
||||||
innerRef={weightRef}
|
toast("Reps must be a number");
|
||||||
blurOnSubmit
|
else if (fixed.includes('-'))
|
||||||
/>
|
toast("Reps must be a positive value")
|
||||||
|
}}
|
||||||
|
onSubmitEditing={() => weightRef.current?.focus()}
|
||||||
|
selection={selection}
|
||||||
|
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
|
||||||
|
innerRef={repsRef}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", right: 0, flexDirection: "row" }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="plus"
|
||||||
|
onPress={() => setReps((Number(reps) + 1).toString())}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="minus"
|
||||||
|
onPress={() => setReps((Number(reps) - 1).toString())}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<AppInput
|
||||||
|
label="Weight"
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={weight}
|
||||||
|
onChangeText={(newWeight) => {
|
||||||
|
const fixed = fixNumeric(newWeight);
|
||||||
|
setWeight(fixed);
|
||||||
|
if (fixed.length !== newWeight.length)
|
||||||
|
toast("Weight must be a number");
|
||||||
|
}}
|
||||||
|
onSubmitEditing={handleSubmit}
|
||||||
|
innerRef={weightRef}
|
||||||
|
blurOnSubmit
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", right: 0, flexDirection: "row" }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="plus"
|
||||||
|
onPress={() => setWeight((Number(weight) + 2.5).toString())}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="minus"
|
||||||
|
onPress={() => setWeight((Number(weight) - 2.5).toString())}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{settings?.showUnit && (
|
{settings?.showUnit && (
|
||||||
<MassiveInput
|
<Select
|
||||||
autoCapitalize="none"
|
|
||||||
label="Unit"
|
|
||||||
value={unit}
|
value={unit}
|
||||||
onChangeText={setUnit}
|
onChange={setUnit}
|
||||||
innerRef={unitRef}
|
items={[
|
||||||
|
{ label: "kg", value: "kg" },
|
||||||
|
{ label: "lb", value: "lb" },
|
||||||
|
{ label: "stone", value: "stone" },
|
||||||
|
]}
|
||||||
|
label="Unit"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{counts && (
|
{counts !== undefined && (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={counts}
|
data={counts}
|
||||||
renderItem={props => (
|
keyExtractor={(count) => count.name}
|
||||||
|
renderItem={(props) => (
|
||||||
<View>
|
<View>
|
||||||
<StartPlanItem
|
<StartPlanItem
|
||||||
{...props}
|
{...props}
|
||||||
|
@ -159,10 +238,10 @@ export default function StartPlan() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Button mode="contained" icon="save" onPress={handleSubmit}>
|
<PrimaryButton icon="content-save" onPress={handleSubmit}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</PrimaryButton>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,66 +1,123 @@
|
||||||
import {NavigationProp, useNavigation} from '@react-navigation/native'
|
import { NavigationProp, useNavigation } from "@react-navigation/native";
|
||||||
import React, {useCallback, useState} from 'react'
|
import React, { useCallback, useState } from "react";
|
||||||
import {GestureResponderEvent, ListRenderItemInfo, View} from 'react-native'
|
import {
|
||||||
import {List, Menu, RadioButton, useTheme} from 'react-native-paper'
|
GestureResponderEvent,
|
||||||
import {Like} from 'typeorm'
|
ListRenderItemInfo,
|
||||||
import CountMany from './count-many'
|
NativeModules,
|
||||||
import {getNow, setRepo} from './db'
|
View,
|
||||||
import {PlanPageParams} from './plan-page-params'
|
} from "react-native";
|
||||||
import {toast} from './toast'
|
import { List, Menu, RadioButton, useTheme } from "react-native-paper";
|
||||||
|
import { Like } from "typeorm";
|
||||||
|
import { StackParams } from "./AppStack";
|
||||||
|
import CountMany from "./count-many";
|
||||||
|
import { getNow, setRepo } from "./db";
|
||||||
|
import { toast } from "./toast";
|
||||||
|
|
||||||
interface Props extends ListRenderItemInfo<CountMany> {
|
interface Props extends ListRenderItemInfo<CountMany> {
|
||||||
onSelect: (index: number) => void
|
onSelect: (index: number) => void;
|
||||||
selected: number
|
selected: number;
|
||||||
onUndo: () => void
|
onUndo: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StartPlanItem(props: Props) {
|
export default function StartPlanItem(props: Props) {
|
||||||
const {index, item, onSelect, selected, onUndo} = props
|
const { index, item, onSelect, selected, onUndo } = props;
|
||||||
const {colors} = useTheme()
|
const { colors } = useTheme();
|
||||||
const [anchor, setAnchor] = useState({x: 0, y: 0})
|
const [anchor, setAnchor] = useState({ x: 0, y: 0 });
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const {navigate} = useNavigation<NavigationProp<PlanPageParams>>()
|
const { navigate: stackNavigate } =
|
||||||
|
useNavigation<NavigationProp<StackParams>>();
|
||||||
|
|
||||||
const undo = useCallback(async () => {
|
const undo = useCallback(async () => {
|
||||||
const [{now}] = await getNow()
|
const now = await getNow();
|
||||||
const created = now.split('T')[0]
|
const created = now.split("T")[0];
|
||||||
const first = await setRepo.findOne({
|
const first = await setRepo.findOne({
|
||||||
where: {
|
where: {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
hidden: 0 as any,
|
hidden: 0 as any,
|
||||||
created: Like(`${created}%`),
|
created: Like(`${created}%`),
|
||||||
},
|
},
|
||||||
order: {created: 'desc'},
|
order: { created: "desc" },
|
||||||
})
|
});
|
||||||
setShowMenu(false)
|
setShowMenu(false);
|
||||||
if (!first) return toast('Nothing to undo.')
|
if (!first) return toast("Nothing to undo.");
|
||||||
await setRepo.delete(first.id)
|
await setRepo.delete(first.id);
|
||||||
onUndo()
|
onUndo();
|
||||||
}, [setShowMenu, onUndo, item.name])
|
}, [setShowMenu, onUndo, item.name]);
|
||||||
|
|
||||||
const longPress = useCallback(
|
const longPress = useCallback(
|
||||||
(e: GestureResponderEvent) => {
|
(e: GestureResponderEvent) => {
|
||||||
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY})
|
setAnchor({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
|
||||||
setShowMenu(true)
|
setShowMenu(true);
|
||||||
},
|
},
|
||||||
[setShowMenu, setAnchor],
|
[setShowMenu, setAnchor]
|
||||||
)
|
);
|
||||||
|
|
||||||
const edit = async () => {
|
const edit = useCallback(async () => {
|
||||||
const [{now}] = await getNow()
|
const now = await getNow();
|
||||||
const created = now.split('T')[0]
|
const created = now.split("T")[0];
|
||||||
const first = await setRepo.findOne({
|
const first = await setRepo.findOne({
|
||||||
where: {
|
where: {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
hidden: 0 as any,
|
hidden: 0 as any,
|
||||||
created: Like(`${created}%`),
|
created: Like(`${created}%`),
|
||||||
},
|
},
|
||||||
order: {created: 'desc'},
|
order: { created: "desc" },
|
||||||
})
|
});
|
||||||
setShowMenu(false)
|
setShowMenu(false);
|
||||||
if (!first) return toast('Nothing to edit.')
|
if (!first) return toast("Nothing to edit.");
|
||||||
navigate('EditSet', {set: first})
|
stackNavigate("EditSet", { set: first });
|
||||||
}
|
}, [item.name, stackNavigate]);
|
||||||
|
|
||||||
|
const view = useCallback(() => {
|
||||||
|
setShowMenu(false);
|
||||||
|
stackNavigate("ViewSetList", { name: item.name });
|
||||||
|
}, [item.name, stackNavigate]);
|
||||||
|
|
||||||
|
const graph = useCallback(() => {
|
||||||
|
setShowMenu(false);
|
||||||
|
stackNavigate("ViewGraph", { name: item.name });
|
||||||
|
}, [item.name, stackNavigate]);
|
||||||
|
|
||||||
|
const left = useCallback(
|
||||||
|
() => (
|
||||||
|
<View style={{ alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<RadioButton
|
||||||
|
onPress={() => onSelect(index)}
|
||||||
|
value={index.toString()}
|
||||||
|
status={selected === index ? "checked" : "unchecked"}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[index, selected, colors.primary, onSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
const right = useCallback(
|
||||||
|
() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "25%",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
anchor={anchor}
|
||||||
|
visible={showMenu}
|
||||||
|
onDismiss={() => setShowMenu(false)}
|
||||||
|
>
|
||||||
|
<Menu.Item leadingIcon="eye-outline" onPress={view} title="Peek" />
|
||||||
|
<Menu.Item
|
||||||
|
leadingIcon="chart-bell-curve-cumulative"
|
||||||
|
onPress={graph}
|
||||||
|
title="Graph"
|
||||||
|
/>
|
||||||
|
<Menu.Item leadingIcon="pencil" onPress={edit} title="Edit" />
|
||||||
|
<Menu.Item leadingIcon="undo" onPress={undo} title="Undo" />
|
||||||
|
</Menu>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[anchor, showMenu, edit, undo, view, graph]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List.Item
|
<List.Item
|
||||||
|
@ -70,31 +127,8 @@ export default function StartPlanItem(props: Props) {
|
||||||
item.sets ? `${item.total} / ${item.sets}` : item.total.toString()
|
item.sets ? `${item.total} / ${item.sets}` : item.total.toString()
|
||||||
}
|
}
|
||||||
onPress={() => onSelect(index)}
|
onPress={() => onSelect(index)}
|
||||||
left={() => (
|
left={left}
|
||||||
<View style={{alignItems: 'center', justifyContent: 'center'}}>
|
right={right}
|
||||||
<RadioButton
|
|
||||||
onPress={() => onSelect(index)}
|
|
||||||
value={index.toString()}
|
|
||||||
status={selected === index ? 'checked' : 'unchecked'}
|
|
||||||
color={colors.primary}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
right={() => (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: '25%',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
<Menu
|
|
||||||
anchor={anchor}
|
|
||||||
visible={showMenu}
|
|
||||||
onDismiss={() => setShowMenu(false)}>
|
|
||||||
<Menu.Item icon="edit" onPress={edit} title="Edit" />
|
|
||||||
<Menu.Item icon="undo" onPress={undo} title="Undo" />
|
|
||||||
</Menu>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
46
Switch.tsx
46
Switch.tsx
|
@ -1,36 +1,38 @@
|
||||||
import {Platform, Pressable} from 'react-native'
|
import React from "react";
|
||||||
import {Switch as PaperSwitch, Text, useTheme} from 'react-native-paper'
|
import { Platform, Pressable } from "react-native";
|
||||||
import {MARGIN} from './constants'
|
import { Switch as PaperSwitch, Text, useTheme } from "react-native-paper";
|
||||||
|
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 {colors} = useTheme()
|
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,
|
marginBottom: Platform.OS === "ios" ? MARGIN : null,
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<PaperSwitch
|
<PaperSwitch
|
||||||
color={colors.primary}
|
color={colors.primary}
|
||||||
style={{marginRight: MARGIN}}
|
style={{ marginRight: MARGIN }}
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={onValueChange}
|
onValueChange={onChange}
|
||||||
/>
|
/>
|
||||||
<Text>{children}</Text>
|
<Text>{title}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo(Switch);
|
||||||
|
|
|
@ -1,79 +0,0 @@
|
||||||
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 {MARGIN, PADDING} from './constants'
|
|
||||||
import {settingsRepo} from './db'
|
|
||||||
import DrawerHeader from './DrawerHeader'
|
|
||||||
import MassiveFab from './MassiveFab'
|
|
||||||
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)
|
|
||||||
const params = [settings.vibrate, settings.sound, settings.noSound]
|
|
||||||
NativeModules.AlarmModule.add(...params)
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
|
||||||
return (Number(minutes) * 60 + Number(seconds)) / 210
|
|
||||||
}, [minutes, seconds])
|
|
||||||
|
|
||||||
const left = useMemo(() => {
|
|
||||||
return Dimensions.get('screen').width * 0.5 - 85
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DrawerHeader name="Timer" />
|
|
||||||
<View style={{flexGrow: 1, padding: PADDING}}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}>
|
|
||||||
<View>
|
|
||||||
<Text style={{fontSize: 70, top: 150}}>
|
|
||||||
{minutes}:{seconds}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<ProgressCircle
|
|
||||||
style={{height: 300, width: 500, marginBottom: MARGIN, top: -50}}
|
|
||||||
progress={progress}
|
|
||||||
strokeWidth={10}
|
|
||||||
progressColor={colors.text}
|
|
||||||
backgroundColor={colors.placeholder}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
onPress={add}
|
|
||||||
style={{position: 'absolute', top: '85%', left: left + 25}}>
|
|
||||||
Add 1 min
|
|
||||||
</Button>
|
|
||||||
<MassiveFab icon="stop" onPress={stop} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
131
ViewBest.tsx
131
ViewBest.tsx
|
@ -1,131 +0,0 @@
|
||||||
import {RouteProp, useRoute} from '@react-navigation/native'
|
|
||||||
import {format} from 'date-fns'
|
|
||||||
import {useEffect, useMemo, useState} from 'react'
|
|
||||||
import {View} from 'react-native'
|
|
||||||
import {List} from 'react-native-paper'
|
|
||||||
import {BestPageParams} from './BestPage'
|
|
||||||
import Chart from './Chart'
|
|
||||||
import {PADDING} from './constants'
|
|
||||||
import {setRepo} from './db'
|
|
||||||
import GymSet from './gym-set'
|
|
||||||
import {Metrics} from './metrics'
|
|
||||||
import {Periods} from './periods'
|
|
||||||
import Select from './Select'
|
|
||||||
import StackHeader from './StackHeader'
|
|
||||||
import Volume from './volume'
|
|
||||||
|
|
||||||
export default function ViewBest() {
|
|
||||||
const {params} = useRoute<RouteProp<BestPageParams, 'ViewBest'>>()
|
|
||||||
const [weights, setWeights] = useState<GymSet[]>([])
|
|
||||||
const [volumes, setVolumes] = useState<Volume[]>([])
|
|
||||||
const [metric, setMetric] = useState(Metrics.Weight)
|
|
||||||
const [period, setPeriod] = useState(Periods.Monthly)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(`${ViewBest.name}.useEffect`, {metric})
|
|
||||||
console.log(`${ViewBest.name}.useEffect`, {period})
|
|
||||||
let difference = '-7 days'
|
|
||||||
if (period === Periods.Monthly) difference = '-1 months'
|
|
||||||
else if (period === Periods.Yearly) difference = '-1 years'
|
|
||||||
let group = '%Y-%m-%d'
|
|
||||||
if (period === Periods.Yearly) group = '%Y-%m'
|
|
||||||
const builder = setRepo
|
|
||||||
.createQueryBuilder()
|
|
||||||
.select("STRFTIME('%Y-%m-%d', created)", 'created')
|
|
||||||
.addSelect('unit')
|
|
||||||
.where('name = :name', {name: params.best.name})
|
|
||||||
.andWhere('NOT hidden')
|
|
||||||
.andWhere("DATE(created) >= DATE('now', 'weekday 0', :difference)", {
|
|
||||||
difference,
|
|
||||||
})
|
|
||||||
.groupBy('name')
|
|
||||||
.addGroupBy(`STRFTIME('${group}', created)`)
|
|
||||||
switch (metric) {
|
|
||||||
case Metrics.Weight:
|
|
||||||
builder.addSelect('MAX(weight)', 'weight').getRawMany().then(setWeights)
|
|
||||||
break
|
|
||||||
case Metrics.Volume:
|
|
||||||
builder
|
|
||||||
.addSelect('SUM(weight * reps)', 'value')
|
|
||||||
.getRawMany()
|
|
||||||
.then(setVolumes)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
// Brzycki formula https://en.wikipedia.org/wiki/One-repetition_maximum#Brzycki
|
|
||||||
builder
|
|
||||||
.addSelect('MAX(weight / (1.0278 - 0.0278 * reps))', '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)
|
|
||||||
return (
|
|
||||||
<Chart
|
|
||||||
yData={volumes.map(v => v.value)}
|
|
||||||
yFormat={(value: number) =>
|
|
||||||
`${value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${
|
|
||||||
volumes[0].unit || 'kg'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
xData={weights}
|
|
||||||
xFormat={(_value, index) =>
|
|
||||||
format(new Date(weights[index].created), 'd/M')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Chart
|
|
||||||
yData={weights.map(set => set.weight)}
|
|
||||||
yFormat={value => `${value}${weights[0].unit}`}
|
|
||||||
xData={weights}
|
|
||||||
xFormat={(_value, index) =>
|
|
||||||
format(new Date(weights[index].created), 'd/M')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}, [volumes, weights, metric])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StackHeader title={params.best.name} />
|
|
||||||
<View style={{padding: PADDING}}>
|
|
||||||
<Select
|
|
||||||
label="Metric"
|
|
||||||
items={[
|
|
||||||
{value: Metrics.Volume, label: Metrics.Volume},
|
|
||||||
{value: Metrics.OneRepMax, label: Metrics.OneRepMax},
|
|
||||||
{
|
|
||||||
label: Metrics.Weight,
|
|
||||||
value: Metrics.Weight,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onChange={value => setMetric(value as Metrics)}
|
|
||||||
value={metric}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Period"
|
|
||||||
items={[
|
|
||||||
{value: Periods.Weekly, label: Periods.Weekly},
|
|
||||||
{value: Periods.Monthly, label: Periods.Monthly},
|
|
||||||
{value: Periods.Yearly, label: Periods.Yearly},
|
|
||||||
]}
|
|
||||||
onChange={value => setPeriod(value as Periods)}
|
|
||||||
value={period}
|
|
||||||
/>
|
|
||||||
{charts}
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
|
||||||
|
import { RouteProp, useFocusEffect, useRoute } from "@react-navigation/native";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Keyboard, ScrollView, View } from "react-native";
|
||||||
|
import { FileSystem } from "react-native-file-access";
|
||||||
|
import { IconButton, List } from "react-native-paper";
|
||||||
|
import Share from "react-native-share";
|
||||||
|
import { captureScreen } from "react-native-view-shot";
|
||||||
|
import AppInput from "./AppInput";
|
||||||
|
import AppLineChart from "./AppLineChart";
|
||||||
|
import { StackParams } from "./AppStack";
|
||||||
|
import Select from "./Select";
|
||||||
|
import StackHeader from "./StackHeader";
|
||||||
|
import { MARGIN, PADDING } from "./constants";
|
||||||
|
import { setRepo, settingsRepo } from "./db";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
import { Metrics } from "./metrics";
|
||||||
|
import { Periods } from "./periods";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import Volume from "./volume";
|
||||||
|
import { convert } from "./conversions";
|
||||||
|
|
||||||
|
export default function ViewGraph() {
|
||||||
|
const { params } = useRoute<RouteProp<StackParams, "ViewGraph">>();
|
||||||
|
const [weights, setWeights] = useState<GymSet[]>();
|
||||||
|
const [volumes, setVolumes] = useState<Volume[]>();
|
||||||
|
const [metric, setMetric] = useState(Metrics.OneRepMax);
|
||||||
|
const [period, setPeriod] = useState(Periods.Monthly);
|
||||||
|
const [unit, setUnit] = useState("kg");
|
||||||
|
const [start, setStart] = useState<Date | null>(null);
|
||||||
|
const [end, setEnd] = useState<Date | null>(null);
|
||||||
|
const [settings, setSettings] = useState<Settings>({} as Settings);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let difference = "-7 days";
|
||||||
|
if (period === Periods.Monthly) difference = "-1 months";
|
||||||
|
else if (period === Periods.Yearly) difference = "-1 years";
|
||||||
|
else if (period === Periods.TwoMonths) difference = "-2 months";
|
||||||
|
else if (period === Periods.ThreeMonths) difference = "-3 months";
|
||||||
|
else if (period === Periods.SixMonths) difference = "-6 months";
|
||||||
|
else if (period === Periods.AllTime) difference = null;
|
||||||
|
|
||||||
|
let group = "%Y-%m-%d";
|
||||||
|
if (period === Periods.Yearly) group = "%Y-%m";
|
||||||
|
|
||||||
|
const builder = setRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select("STRFTIME('%Y-%m-%d', created)", "created")
|
||||||
|
.addSelect("unit")
|
||||||
|
.where("name = :name", { name: params.name })
|
||||||
|
.andWhere("NOT hidden");
|
||||||
|
|
||||||
|
if (start) builder.andWhere("DATE(created) >= :start", { start });
|
||||||
|
if (end) builder.andWhere("DATE(created) <= :end", { end });
|
||||||
|
if (difference)
|
||||||
|
builder.andWhere(
|
||||||
|
"DATE(created) >= DATE('now', 'weekday 0', :difference)",
|
||||||
|
{
|
||||||
|
difference,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.groupBy("name").addGroupBy(`STRFTIME('${group}', created)`);
|
||||||
|
switch (metric) {
|
||||||
|
case Metrics.Best:
|
||||||
|
builder
|
||||||
|
.addSelect("ROUND(MAX(weight), 2)", "weight")
|
||||||
|
.getRawMany()
|
||||||
|
.then((newWeights) =>
|
||||||
|
newWeights.map((set) => {
|
||||||
|
let weight = convert(set.weight, set.unit, unit);
|
||||||
|
if (isNaN(weight)) weight = 0;
|
||||||
|
return ({
|
||||||
|
...set,
|
||||||
|
weight: weight
|
||||||
|
});
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.then(setWeights);
|
||||||
|
break;
|
||||||
|
case Metrics.Volume:
|
||||||
|
builder
|
||||||
|
.addSelect("ROUND(SUM(weight * reps), 2)", "value")
|
||||||
|
.getRawMany()
|
||||||
|
.then((newWeights) =>
|
||||||
|
newWeights.map((set) => {
|
||||||
|
let weight = convert(set.value, set.unit, unit);
|
||||||
|
if (isNaN(weight)) weight = 0;
|
||||||
|
return ({
|
||||||
|
...set,
|
||||||
|
value: weight
|
||||||
|
});
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.then(setVolumes);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Brzycki formula https://en.wikipedia.org/wiki/One-repetition_maximum#Brzycki
|
||||||
|
builder
|
||||||
|
.addSelect(
|
||||||
|
"ROUND(MAX(weight / (1.0278 - 0.0278 * reps)), 2)",
|
||||||
|
"weight"
|
||||||
|
)
|
||||||
|
.getRawMany()
|
||||||
|
.then((newWeights) =>
|
||||||
|
newWeights.map((set) => {
|
||||||
|
let weight = convert(set.weight, set.unit, unit);
|
||||||
|
if (isNaN(weight)) weight = 0;
|
||||||
|
return ({
|
||||||
|
...set,
|
||||||
|
weight: weight,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.then((newWeights) => {
|
||||||
|
console.log(`${ViewGraph.name}.oneRepMax:`, {
|
||||||
|
weights: newWeights,
|
||||||
|
});
|
||||||
|
setWeights(newWeights);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [params.name, metric, period, unit, start, end]);
|
||||||
|
|
||||||
|
const weightChart = useMemo(() => {
|
||||||
|
if (weights === undefined) return null;
|
||||||
|
|
||||||
|
if (weights.length === 0) return <List.Item title="No data yet." />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLineChart
|
||||||
|
data={weights.map((set) => set.weight)}
|
||||||
|
labels={weights.map((set) =>
|
||||||
|
format(new Date(set.created), "yyyy-MM-d")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [weights]);
|
||||||
|
|
||||||
|
const volumeChart = useMemo(() => {
|
||||||
|
if (volumes === undefined) return null;
|
||||||
|
if (volumes.length === 0) return <List.Item title="No data yet." />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLineChart
|
||||||
|
data={volumes.map((volume) => volume.value)}
|
||||||
|
labels={volumes.map((volume) =>
|
||||||
|
format(new Date(volume.created), "yyyy-MM-d")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [volumes]);
|
||||||
|
|
||||||
|
const pickStart = useCallback(() => {
|
||||||
|
DateTimePickerAndroid.open({
|
||||||
|
value: start || new Date(),
|
||||||
|
onChange: (event, date) => {
|
||||||
|
if (event.type === "dismissed") return;
|
||||||
|
if (date === start) return;
|
||||||
|
setStart(date);
|
||||||
|
setPeriod(Periods.AllTime);
|
||||||
|
Keyboard.dismiss();
|
||||||
|
},
|
||||||
|
mode: "date",
|
||||||
|
});
|
||||||
|
}, [start]);
|
||||||
|
|
||||||
|
const pickEnd = useCallback(() => {
|
||||||
|
DateTimePickerAndroid.open({
|
||||||
|
value: end || new Date(),
|
||||||
|
onChange: (event, date) => {
|
||||||
|
if (event.type === "dismissed") return;
|
||||||
|
if (date === end) return;
|
||||||
|
setEnd(date);
|
||||||
|
setPeriod(Periods.AllTime);
|
||||||
|
Keyboard.dismiss();
|
||||||
|
},
|
||||||
|
mode: "date",
|
||||||
|
});
|
||||||
|
}, [end]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StackHeader title={params.name}>
|
||||||
|
<IconButton
|
||||||
|
onPress={() =>
|
||||||
|
captureScreen().then(async (uri) => {
|
||||||
|
const base64 = await FileSystem.readFile(uri, "base64");
|
||||||
|
const url = `data:image/jpeg;base64,${base64}`;
|
||||||
|
Share.open({
|
||||||
|
type: "image/jpeg",
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
icon="share"
|
||||||
|
/>
|
||||||
|
</StackHeader>
|
||||||
|
<ScrollView style={{ padding: PADDING }}>
|
||||||
|
<Select
|
||||||
|
label="Metric"
|
||||||
|
items={[
|
||||||
|
{ value: Metrics.OneRepMax, label: Metrics.OneRepMax },
|
||||||
|
{ label: Metrics.Best, value: Metrics.Best },
|
||||||
|
{ value: Metrics.Volume, label: Metrics.Volume },
|
||||||
|
]}
|
||||||
|
onChange={(value) => setMetric(value as Metrics)}
|
||||||
|
value={metric}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Period"
|
||||||
|
items={[
|
||||||
|
{ value: Periods.Weekly, label: Periods.Weekly },
|
||||||
|
{ value: Periods.Monthly, label: Periods.Monthly },
|
||||||
|
{ value: Periods.TwoMonths, label: Periods.TwoMonths },
|
||||||
|
{ value: Periods.ThreeMonths, label: Periods.ThreeMonths },
|
||||||
|
{ value: Periods.SixMonths, label: Periods.SixMonths },
|
||||||
|
{ value: Periods.Yearly, label: Periods.Yearly },
|
||||||
|
{ value: Periods.AllTime, label: Periods.AllTime },
|
||||||
|
]}
|
||||||
|
onChange={(value) => {
|
||||||
|
setPeriod(value as Periods);
|
||||||
|
setStart(null);
|
||||||
|
setEnd(null);
|
||||||
|
}}
|
||||||
|
value={period}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: "row", marginBottom: MARGIN }}>
|
||||||
|
<AppInput
|
||||||
|
label="Start date"
|
||||||
|
value={start ? format(start, settings.date || "Pp") : null}
|
||||||
|
onPressOut={pickStart}
|
||||||
|
style={{ flex: 1, marginRight: MARGIN }}
|
||||||
|
/>
|
||||||
|
<AppInput
|
||||||
|
label="End date"
|
||||||
|
value={end ? format(end, settings.date || "Pp") : null}
|
||||||
|
onPressOut={pickEnd}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Unit"
|
||||||
|
value={unit}
|
||||||
|
onChange={setUnit}
|
||||||
|
items={[
|
||||||
|
{ label: "Pounds (lb)", value: "lb" },
|
||||||
|
{ label: "Kilograms (kg)", value: "kg" },
|
||||||
|
{ label: "Stone", value: "stone" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View style={{ paddingTop: PADDING }}>
|
||||||
|
{metric === Metrics.Volume ? volumeChart : weightChart}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { RouteProp, useRoute } from "@react-navigation/native";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FlatList } from "react-native";
|
||||||
|
import { List, Text, useTheme } from "react-native-paper";
|
||||||
|
import { Like } from "typeorm";
|
||||||
|
import { StackParams } from "./AppStack";
|
||||||
|
import StackHeader from "./StackHeader";
|
||||||
|
import { LIMIT } from "./constants";
|
||||||
|
import { setRepo, settingsRepo } from "./db";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
import Settings from "./settings";
|
||||||
|
|
||||||
|
interface ColorSet extends GymSet {
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewSetList() {
|
||||||
|
const [sets, setSets] = useState<ColorSet[]>();
|
||||||
|
const [settings, setSettings] = useState<Settings>();
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const { params } = useRoute<RouteProp<StackParams, "ViewSetList">>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
|
|
||||||
|
const reset = async () => {
|
||||||
|
const newSets: ColorSet[] = await setRepo.find({
|
||||||
|
where: { name: Like(`%${params.name}%`), hidden: 0 as any },
|
||||||
|
take: LIMIT,
|
||||||
|
skip: 0,
|
||||||
|
order: { created: "DESC" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let prevDate = null;
|
||||||
|
const elevate = colors.elevation.level2;
|
||||||
|
const transparent = colors.elevation.level0;
|
||||||
|
let color = elevate;
|
||||||
|
|
||||||
|
for (let i = 0; i < newSets.length; i++) {
|
||||||
|
let currDate = new Date(newSets[i].created).toDateString();
|
||||||
|
if (currDate !== prevDate)
|
||||||
|
color = color === elevate ? transparent : elevate;
|
||||||
|
newSets[i].color = color;
|
||||||
|
prevDate = currDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSets(newSets);
|
||||||
|
};
|
||||||
|
|
||||||
|
reset();
|
||||||
|
}, [params.name, colors]);
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: ColorSet; index: number }) => (
|
||||||
|
<List.Item
|
||||||
|
title={format(new Date(item.created), settings.date || "Pp")}
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
right={() => (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
alignSelf: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`${item.reps} x ${item.weight}${item.unit || "kg"}`}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const getContent = () => {
|
||||||
|
if (!settings) return null;
|
||||||
|
if (sets?.length === 0)
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
title="No sets yet"
|
||||||
|
description="A set is a group of repetitions. E.g. 8 reps of Squats."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={sets ?? []}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={(set) => set.id?.toString()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StackHeader title={params.name} />
|
||||||
|
|
||||||
|
{getContent()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Keyboard, ScrollView, View } from "react-native";
|
||||||
|
import { FileSystem } from "react-native-file-access";
|
||||||
|
import { IconButton, List } from "react-native-paper";
|
||||||
|
import Share from "react-native-share";
|
||||||
|
import { captureScreen } from "react-native-view-shot";
|
||||||
|
import AppLineChart from "./AppLineChart";
|
||||||
|
import { MARGIN, PADDING } from "./constants";
|
||||||
|
import { settingsRepo, weightRepo } from "./db";
|
||||||
|
import { Periods } from "./periods";
|
||||||
|
import Select from "./Select";
|
||||||
|
import StackHeader from "./StackHeader";
|
||||||
|
import Weight from "./weight";
|
||||||
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
|
||||||
|
import AppInput from "./AppInput";
|
||||||
|
|
||||||
|
export default function ViewWeightGraph() {
|
||||||
|
const [weights, setWeights] = useState<Weight[]>();
|
||||||
|
const [period, setPeriod] = useState(Periods.TwoMonths);
|
||||||
|
const [start, setStart] = useState<Date | null>(null)
|
||||||
|
const [end, setEnd] = useState<Date | null>(null)
|
||||||
|
const [settings, setSettings] = useState<Settings>({} as Settings);
|
||||||
|
|
||||||
|
useFocusEffect(useCallback(() => {
|
||||||
|
settingsRepo.findOne({ where: {} }).then(setSettings)
|
||||||
|
}, []))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let difference = "-7 days";
|
||||||
|
if (period === Periods.Monthly) difference = "-1 months";
|
||||||
|
else if (period === Periods.TwoMonths) difference = "-2 months";
|
||||||
|
else if (period === Periods.ThreeMonths) difference = "-3 months";
|
||||||
|
else if (period === Periods.SixMonths) difference = "-6 months";
|
||||||
|
else if (period === Periods.Yearly) difference = "-1 years";
|
||||||
|
else if (period === Periods.AllTime) difference = null;
|
||||||
|
|
||||||
|
let group = "%Y-%m-%d";
|
||||||
|
if (period === Periods.Yearly) group = "%Y-%m";
|
||||||
|
|
||||||
|
const builder = weightRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select("STRFTIME('%Y-%m-%d', created)", "created")
|
||||||
|
.addSelect("AVG(value) as value")
|
||||||
|
.addSelect("unit")
|
||||||
|
.groupBy(`STRFTIME('${group}', created)`)
|
||||||
|
|
||||||
|
if (difference)
|
||||||
|
builder.where("DATE(created) >= DATE('now', 'weekday 0', :difference)", {
|
||||||
|
difference,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (start)
|
||||||
|
builder.andWhere("DATE(created) >= :start", { start });
|
||||||
|
if (end)
|
||||||
|
builder.andWhere("DATE(created) <= :end", { end });
|
||||||
|
|
||||||
|
builder
|
||||||
|
.getRawMany()
|
||||||
|
.then(setWeights);
|
||||||
|
}, [period, start, end]);
|
||||||
|
|
||||||
|
const pickStart = useCallback(() => {
|
||||||
|
DateTimePickerAndroid.open({
|
||||||
|
value: start || new Date(),
|
||||||
|
onChange: (event, date) => {
|
||||||
|
if (event.type === 'dismissed') return;
|
||||||
|
if (date === start) return;
|
||||||
|
setStart(date);
|
||||||
|
setPeriod(Periods.AllTime);
|
||||||
|
Keyboard.dismiss();
|
||||||
|
},
|
||||||
|
mode: "date",
|
||||||
|
});
|
||||||
|
}, [start]);
|
||||||
|
|
||||||
|
const pickEnd = useCallback(() => {
|
||||||
|
DateTimePickerAndroid.open({
|
||||||
|
value: end || new Date(),
|
||||||
|
onChange: (event, date) => {
|
||||||
|
if (event.type === 'dismissed') return;
|
||||||
|
if (date === end) return;
|
||||||
|
setEnd(date);
|
||||||
|
setPeriod(Periods.AllTime);
|
||||||
|
Keyboard.dismiss();
|
||||||
|
},
|
||||||
|
mode: "date",
|
||||||
|
});
|
||||||
|
}, [end]);
|
||||||
|
|
||||||
|
|
||||||
|
const charts = useMemo(() => {
|
||||||
|
if (!weights) return;
|
||||||
|
if (weights?.length === 0) {
|
||||||
|
return <List.Item title="No data yet." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLineChart
|
||||||
|
data={weights.map((set) => set.value)}
|
||||||
|
labels={weights.map((weight) =>
|
||||||
|
format(new Date(weight.created), "yyyy-MM-d")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [weights]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StackHeader title="Weight graph">
|
||||||
|
<IconButton
|
||||||
|
onPress={() =>
|
||||||
|
captureScreen().then(async (uri) => {
|
||||||
|
const base64 = await FileSystem.readFile(uri, "base64");
|
||||||
|
const url = `data:image/jpeg;base64,${base64}`;
|
||||||
|
Share.open({
|
||||||
|
type: "image/jpeg",
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
icon="share"
|
||||||
|
/>
|
||||||
|
</StackHeader>
|
||||||
|
<ScrollView style={{ padding: PADDING }}>
|
||||||
|
<Select
|
||||||
|
label="Period"
|
||||||
|
items={[
|
||||||
|
{ value: Periods.Weekly, label: Periods.Weekly },
|
||||||
|
{ value: Periods.Monthly, label: Periods.Monthly },
|
||||||
|
{ value: Periods.TwoMonths, label: Periods.TwoMonths },
|
||||||
|
{ value: Periods.ThreeMonths, label: Periods.ThreeMonths },
|
||||||
|
{ value: Periods.SixMonths, label: Periods.SixMonths },
|
||||||
|
{ value: Periods.Yearly, label: Periods.Yearly },
|
||||||
|
{ value: Periods.AllTime, label: Periods.AllTime },
|
||||||
|
]}
|
||||||
|
onChange={(value) => {
|
||||||
|
setPeriod(value as Periods);
|
||||||
|
if (value === Periods.AllTime) return;
|
||||||
|
setStart(null);
|
||||||
|
setEnd(null);
|
||||||
|
}}
|
||||||
|
value={period}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', marginBottom: MARGIN }}>
|
||||||
|
<AppInput
|
||||||
|
label="Start date"
|
||||||
|
value={start ? format(start, settings.date || "Pp") : null}
|
||||||
|
onPressOut={pickStart}
|
||||||
|
style={{ flex: 1, marginRight: MARGIN }}
|
||||||
|
/>
|
||||||
|
<AppInput
|
||||||
|
label="End date"
|
||||||
|
value={end ? format(end, settings.date || "Pp") : null}
|
||||||
|
onPressOut={pickEnd}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{charts}
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { NavigationProp, useNavigation } from "@react-navigation/native";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { List, Text } from "react-native-paper";
|
||||||
|
import { StackParams } from "./AppStack";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import Weight from "./weight";
|
||||||
|
|
||||||
|
export default function WeightItem({
|
||||||
|
item,
|
||||||
|
settings,
|
||||||
|
}: {
|
||||||
|
item: Weight;
|
||||||
|
settings: Settings;
|
||||||
|
}) {
|
||||||
|
const navigation = useNavigation<NavigationProp<StackParams>>();
|
||||||
|
|
||||||
|
const press = useCallback(() => {
|
||||||
|
navigation.navigate("EditWeight", { weight: item });
|
||||||
|
}, [item, navigation]);
|
||||||
|
|
||||||
|
const today = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const created = new Date(item.created);
|
||||||
|
return (
|
||||||
|
now.getFullYear() === created.getFullYear() &&
|
||||||
|
now.getMonth() === created.getMonth() &&
|
||||||
|
now.getDate() === created.getDate()
|
||||||
|
);
|
||||||
|
}, [item.created]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
onPress={press}
|
||||||
|
title={`${item.value}${item.unit || "kg"}`}
|
||||||
|
right={() => (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
alignSelf: "center",
|
||||||
|
textDecorationLine: today ? "underline" : "none",
|
||||||
|
fontWeight: today ? "bold" : "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{format(new Date(item.created), settings.date || "Pp")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,150 @@
|
||||||
|
import {
|
||||||
|
NavigationProp,
|
||||||
|
useFocusEffect,
|
||||||
|
useNavigation,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { FlatList } from "react-native";
|
||||||
|
import { IconButton, List } from "react-native-paper";
|
||||||
|
import { Like } from "typeorm";
|
||||||
|
import { StackParams } from "./AppStack";
|
||||||
|
import { LIMIT } from "./constants";
|
||||||
|
import { getNow, settingsRepo, weightRepo } from "./db";
|
||||||
|
import DrawerHeader from "./DrawerHeader";
|
||||||
|
import Page from "./Page";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import { default as Weight, defaultWeight } from "./weight";
|
||||||
|
import WeightItem from "./WeightItem";
|
||||||
|
|
||||||
|
export default function WeightList() {
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [weights, setWeights] = useState<Weight[]>();
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [end, setEnd] = useState(false);
|
||||||
|
const [settings, setSettings] = useState<Settings>();
|
||||||
|
const { navigate } = useNavigation<NavigationProp<StackParams>>();
|
||||||
|
const [term, setTerm] = useState("");
|
||||||
|
|
||||||
|
const reset = useCallback(
|
||||||
|
async (value: string) => {
|
||||||
|
const newWeights = await weightRepo.find({
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
value: isNaN(Number(term)) ? undefined : Number(term),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
created: Like(`%${term}%`),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
take: LIMIT,
|
||||||
|
skip: 0,
|
||||||
|
order: { created: "DESC" },
|
||||||
|
});
|
||||||
|
console.log(`${WeightList.name}.reset:`, { value, offset });
|
||||||
|
setWeights(newWeights);
|
||||||
|
setEnd(false);
|
||||||
|
},
|
||||||
|
[offset, term]
|
||||||
|
);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
settingsRepo.findOne({ where: {} }).then(setSettings);
|
||||||
|
reset(term);
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, [term])
|
||||||
|
);
|
||||||
|
|
||||||
|
const search = (value: string) => {
|
||||||
|
console.log(`${WeightList.name}.search:`, value);
|
||||||
|
setTerm(value);
|
||||||
|
setOffset(0);
|
||||||
|
reset(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item }: { item: Weight }) => (
|
||||||
|
<WeightItem settings={settings} item={item} key={item.id} />
|
||||||
|
),
|
||||||
|
[settings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const next = async () => {
|
||||||
|
console.log(`${WeightList.name}.next:`, { end, refreshing });
|
||||||
|
if (end || refreshing) return;
|
||||||
|
const newOffset = offset + LIMIT;
|
||||||
|
console.log(`${WeightList.name}.next:`, { offset, newOffset, term });
|
||||||
|
const newWeights = await weightRepo.find({
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
value: Number(term),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
created: Like(`%${term}%`),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
take: LIMIT,
|
||||||
|
skip: newOffset,
|
||||||
|
order: { created: "DESC" },
|
||||||
|
});
|
||||||
|
if (newWeights.length === 0) return setEnd(true);
|
||||||
|
if (!weights) return;
|
||||||
|
const map = new Map<number, Weight>();
|
||||||
|
for (const weight of weights) map.set(weight.id, weight);
|
||||||
|
for (const weight of newWeights) map.set(weight.id, weight);
|
||||||
|
const unique = Array.from(map.values());
|
||||||
|
setWeights(unique);
|
||||||
|
if (newWeights.length < LIMIT) return setEnd(true);
|
||||||
|
setOffset(newOffset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAdd = useCallback(async () => {
|
||||||
|
const now = await getNow();
|
||||||
|
let weight: Partial<Weight> = { ...weights[0] };
|
||||||
|
if (!weight) weight = { ...defaultWeight };
|
||||||
|
weight.created = now;
|
||||||
|
delete weight.id;
|
||||||
|
navigate("EditWeight", { weight });
|
||||||
|
}, [navigate, weights]);
|
||||||
|
|
||||||
|
const getContent = () => {
|
||||||
|
if (!settings) return null;
|
||||||
|
if (weights?.length === 0)
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
title="No sets yet"
|
||||||
|
description="A set is a group of repetitions. E.g. 8 reps of Squats."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={weights ?? []}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
renderItem={renderItem}
|
||||||
|
onEndReached={next}
|
||||||
|
refreshing={refreshing}
|
||||||
|
keyExtractor={(set) => set.id?.toString()}
|
||||||
|
onRefresh={() => {
|
||||||
|
setOffset(0);
|
||||||
|
setRefreshing(true);
|
||||||
|
reset(term).finally(() => setRefreshing(false));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DrawerHeader name="Weight">
|
||||||
|
<IconButton
|
||||||
|
onPress={() => navigate("ViewWeightGraph")}
|
||||||
|
icon="chart-bell-curve-cumulative"
|
||||||
|
/>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<Page onAdd={onAdd} term={term} search={search}>
|
||||||
|
{getContent()}
|
||||||
|
</Page>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,87 +0,0 @@
|
||||||
import {NavigationProp, useNavigation} from '@react-navigation/native'
|
|
||||||
import {useCallback, useMemo, useState} from 'react'
|
|
||||||
import {GestureResponderEvent, Image} from 'react-native'
|
|
||||||
import {List, Menu, Text} from 'react-native-paper'
|
|
||||||
import ConfirmDialog from './ConfirmDialog'
|
|
||||||
import {setRepo} from './db'
|
|
||||||
import GymSet from './gym-set'
|
|
||||||
import {WorkoutsPageParams} from './WorkoutsPage'
|
|
||||||
|
|
||||||
export default function WorkoutItem({
|
|
||||||
item,
|
|
||||||
onRemove,
|
|
||||||
images,
|
|
||||||
}: {
|
|
||||||
item: GymSet
|
|
||||||
onRemove: () => void
|
|
||||||
images: boolean
|
|
||||||
}) {
|
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
|
||||||
const [anchor, setAnchor] = useState({x: 0, y: 0})
|
|
||||||
const [showRemove, setShowRemove] = useState('')
|
|
||||||
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>()
|
|
||||||
|
|
||||||
const remove = useCallback(async () => {
|
|
||||||
await setRepo.delete({name: item.name})
|
|
||||||
setShowMenu(false)
|
|
||||||
onRemove()
|
|
||||||
}, [setShowMenu, onRemove, item.name])
|
|
||||||
|
|
||||||
const longPress = useCallback(
|
|
||||||
(e: GestureResponderEvent) => {
|
|
||||||
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY})
|
|
||||||
setShowMenu(true)
|
|
||||||
},
|
|
||||||
[setShowMenu, setAnchor],
|
|
||||||
)
|
|
||||||
|
|
||||||
const description = useMemo(() => {
|
|
||||||
const seconds = item.seconds?.toString().padStart(2, '0')
|
|
||||||
return `${item.sets} x ${item.minutes || 0}:${seconds}`
|
|
||||||
}, [item])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<List.Item
|
|
||||||
onPress={() => navigation.navigate('EditWorkout', {value: item})}
|
|
||||||
title={item.name}
|
|
||||||
description={description}
|
|
||||||
onLongPress={longPress}
|
|
||||||
left={() =>
|
|
||||||
images &&
|
|
||||||
item.image && (
|
|
||||||
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
right={() => (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
alignSelf: 'center',
|
|
||||||
}}>
|
|
||||||
<Menu
|
|
||||||
anchor={anchor}
|
|
||||||
visible={showMenu}
|
|
||||||
onDismiss={() => setShowMenu(false)}>
|
|
||||||
<Menu.Item
|
|
||||||
icon="delete"
|
|
||||||
onPress={() => {
|
|
||||||
setShowRemove(item.name)
|
|
||||||
setShowMenu(false)
|
|
||||||
}}
|
|
||||||
title="Delete"
|
|
||||||
/>
|
|
||||||
</Menu>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ConfirmDialog
|
|
||||||
title={`Delete ${showRemove}`}
|
|
||||||
show={!!showRemove}
|
|
||||||
setShow={show => (show ? null : setShowRemove(''))}
|
|
||||||
onOk={remove}>
|
|
||||||
This irreversibly deletes ALL sets related to this workout. Are you
|
|
||||||
sure?
|
|
||||||
</ConfirmDialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
122
WorkoutList.tsx
122
WorkoutList.tsx
|
@ -1,122 +0,0 @@
|
||||||
import {
|
|
||||||
NavigationProp,
|
|
||||||
useFocusEffect,
|
|
||||||
useNavigation,
|
|
||||||
} from '@react-navigation/native'
|
|
||||||
import {useCallback, useState} from 'react'
|
|
||||||
import {FlatList} from 'react-native'
|
|
||||||
import {List} from 'react-native-paper'
|
|
||||||
import DrawerHeader from './DrawerHeader'
|
|
||||||
import Page from './Page'
|
|
||||||
import GymSet from './gym-set'
|
|
||||||
import SetList from './SetList'
|
|
||||||
import WorkoutItem from './WorkoutItem'
|
|
||||||
import {WorkoutsPageParams} from './WorkoutsPage'
|
|
||||||
import {setRepo, settingsRepo} from './db'
|
|
||||||
import Settings from './settings'
|
|
||||||
|
|
||||||
const limit = 15
|
|
||||||
|
|
||||||
export default function WorkoutList() {
|
|
||||||
const [workouts, setWorkouts] = useState<GymSet[]>()
|
|
||||||
const [offset, setOffset] = useState(0)
|
|
||||||
const [term, setTerm] = useState('')
|
|
||||||
const [end, setEnd] = useState(false)
|
|
||||||
const [settings, setSettings] = useState<Settings>()
|
|
||||||
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>()
|
|
||||||
|
|
||||||
const refresh = useCallback(async (value: string) => {
|
|
||||||
const newWorkouts = await setRepo
|
|
||||||
.createQueryBuilder()
|
|
||||||
.select()
|
|
||||||
.where('name LIKE :name', {name: `%${value}%`})
|
|
||||||
.groupBy('name')
|
|
||||||
.orderBy('name')
|
|
||||||
.limit(limit)
|
|
||||||
.getMany()
|
|
||||||
console.log(`${WorkoutList.name}`, {newWorkout: newWorkouts[0]})
|
|
||||||
setWorkouts(newWorkouts)
|
|
||||||
setOffset(0)
|
|
||||||
setEnd(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
refresh(term)
|
|
||||||
settingsRepo.findOne({where: {}}).then(setSettings)
|
|
||||||
}, [refresh, term]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
|
||||||
({item}: {item: GymSet}) => (
|
|
||||||
<WorkoutItem
|
|
||||||
images={settings?.images}
|
|
||||||
item={item}
|
|
||||||
key={item.name}
|
|
||||||
onRemove={() => refresh(term)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[refresh, term, settings?.images],
|
|
||||||
)
|
|
||||||
|
|
||||||
const next = useCallback(async () => {
|
|
||||||
if (end) return
|
|
||||||
const newOffset = offset + limit
|
|
||||||
console.log(`${SetList.name}.next:`, {
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
newOffset,
|
|
||||||
term,
|
|
||||||
})
|
|
||||||
const newWorkouts = await setRepo
|
|
||||||
.createQueryBuilder()
|
|
||||||
.select()
|
|
||||||
.where('name LIKE :name', {name: `%${term}%`})
|
|
||||||
.groupBy('name')
|
|
||||||
.orderBy('name')
|
|
||||||
.limit(limit)
|
|
||||||
.offset(newOffset)
|
|
||||||
.getMany()
|
|
||||||
if (newWorkouts.length === 0) return setEnd(true)
|
|
||||||
if (!workouts) return
|
|
||||||
setWorkouts([...workouts, ...newWorkouts])
|
|
||||||
if (newWorkouts.length < limit) return setEnd(true)
|
|
||||||
setOffset(newOffset)
|
|
||||||
}, [term, end, offset, workouts])
|
|
||||||
|
|
||||||
const onAdd = useCallback(async () => {
|
|
||||||
navigation.navigate('EditWorkout', {
|
|
||||||
value: new GymSet(),
|
|
||||||
})
|
|
||||||
}, [navigation])
|
|
||||||
|
|
||||||
const search = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setTerm(value)
|
|
||||||
refresh(value)
|
|
||||||
},
|
|
||||||
[refresh],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DrawerHeader name="Workouts" />
|
|
||||||
<Page onAdd={onAdd} term={term} search={search}>
|
|
||||||
{workouts?.length === 0 ? (
|
|
||||||
<List.Item
|
|
||||||
title="No workouts yet."
|
|
||||||
description="A workout is something you do at the gym. For example Deadlifts are a workout."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FlatList
|
|
||||||
data={workouts}
|
|
||||||
style={{flex: 1}}
|
|
||||||
renderItem={renderItem}
|
|
||||||
keyExtractor={w => w.name}
|
|
||||||
onEndReached={next}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Page>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
import {createStackNavigator} from '@react-navigation/stack'
|
|
||||||
import EditWorkout from './EditWorkout'
|
|
||||||
import GymSet from './gym-set'
|
|
||||||
import WorkoutList from './WorkoutList'
|
|
||||||
|
|
||||||
export type WorkoutsPageParams = {
|
|
||||||
WorkoutList: {}
|
|
||||||
EditWorkout: {
|
|
||||||
value: GymSet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Stack = createStackNavigator<WorkoutsPageParams>()
|
|
||||||
|
|
||||||
export default function WorkoutsPage() {
|
|
||||||
return (
|
|
||||||
<Stack.Navigator
|
|
||||||
screenOptions={{headerShown: false, animationEnabled: false}}>
|
|
||||||
<Stack.Screen name="WorkoutList" component={WorkoutList} />
|
|
||||||
<Stack.Screen name="EditWorkout" component={EditWorkout} />
|
|
||||||
</Stack.Navigator>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,27 +1,27 @@
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.5)
|
CFPropertyList (3.0.6)
|
||||||
rexml
|
rexml
|
||||||
addressable (2.8.1)
|
addressable (2.8.6)
|
||||||
public_suffix (>= 2.0.2, < 6.0)
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
artifactory (3.0.15)
|
artifactory (3.0.15)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.2.0)
|
aws-eventstream (1.3.0)
|
||||||
aws-partitions (1.657.0)
|
aws-partitions (1.888.0)
|
||||||
aws-sdk-core (3.166.0)
|
aws-sdk-core (3.191.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.651.0)
|
aws-partitions (~> 1, >= 1.651.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.8)
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
aws-sdk-kms (1.59.0)
|
aws-sdk-kms (1.77.0)
|
||||||
aws-sdk-core (~> 3, >= 3.165.0)
|
aws-sdk-core (~> 3, >= 3.191.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sdk-s3 (1.117.1)
|
aws-sdk-s3 (1.143.0)
|
||||||
aws-sdk-core (~> 3, >= 3.165.0)
|
aws-sdk-core (~> 3, >= 3.191.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.4)
|
aws-sigv4 (~> 1.8)
|
||||||
aws-sigv4 (1.5.2)
|
aws-sigv4 (1.8.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
|
@ -30,14 +30,13 @@ GEM
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
digest-crc (0.6.4)
|
digest-crc (0.6.5)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
domain_name (0.5.20190701)
|
domain_name (0.6.20240107)
|
||||||
unf (>= 0.0.5, < 1.0.0)
|
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
emoji_regex (3.2.3)
|
||||||
excon (0.93.1)
|
excon (0.109.0)
|
||||||
faraday (1.10.2)
|
faraday (1.10.3)
|
||||||
faraday-em_http (~> 1.0)
|
faraday-em_http (~> 1.0)
|
||||||
faraday-em_synchrony (~> 1.0)
|
faraday-em_synchrony (~> 1.0)
|
||||||
faraday-excon (~> 1.1)
|
faraday-excon (~> 1.1)
|
||||||
|
@ -65,8 +64,8 @@ GEM
|
||||||
faraday-retry (1.0.3)
|
faraday-retry (1.0.3)
|
||||||
faraday_middleware (1.2.0)
|
faraday_middleware (1.2.0)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.2.6)
|
fastimage (2.3.0)
|
||||||
fastlane (2.210.1)
|
fastlane (2.219.0)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.8, < 3.0.0)
|
||||||
artifactory (~> 3.0)
|
artifactory (~> 3.0)
|
||||||
|
@ -85,20 +84,22 @@ GEM
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
|
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||||
google-cloud-storage (~> 1.31)
|
google-cloud-storage (~> 1.31)
|
||||||
highline (~> 2.0)
|
highline (~> 2.0)
|
||||||
|
http-cookie (~> 1.0.5)
|
||||||
json (< 3.0.0)
|
json (< 3.0.0)
|
||||||
jwt (>= 2.1.0, < 3)
|
jwt (>= 2.1.0, < 3)
|
||||||
mini_magick (>= 4.9.4, < 5.0.0)
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
multipart-post (~> 2.0.0)
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
optparse (~> 0.1.1)
|
optparse (>= 0.1.1)
|
||||||
plist (>= 3.1.0, < 4.0.0)
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
rubyzip (>= 2.0.0, < 3.0.0)
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
security (= 0.1.3)
|
security (= 0.1.3)
|
||||||
simctl (~> 1.6.3)
|
simctl (~> 1.6.3)
|
||||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||||
terminal-table (>= 1.4.5, < 2.0.0)
|
terminal-table (~> 3)
|
||||||
tty-screen (>= 0.6.3, < 1.0.0)
|
tty-screen (>= 0.6.3, < 1.0.0)
|
||||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||||
word_wrap (~> 1.0.0)
|
word_wrap (~> 1.0.0)
|
||||||
|
@ -106,9 +107,9 @@ GEM
|
||||||
xcpretty (~> 0.3.0)
|
xcpretty (~> 0.3.0)
|
||||||
xcpretty-travis-formatter (>= 0.0.3)
|
xcpretty-travis-formatter (>= 0.0.3)
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.31.0)
|
google-apis-androidpublisher_v3 (0.54.0)
|
||||||
google-apis-core (>= 0.9.1, < 2.a)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-apis-core (0.9.1)
|
google-apis-core (0.11.3)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
httpclient (>= 2.8.1, < 3.a)
|
httpclient (>= 2.8.1, < 3.a)
|
||||||
|
@ -116,31 +117,29 @@ GEM
|
||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 2.0, < 4.a)
|
||||||
rexml
|
rexml
|
||||||
webrick
|
google-apis-iamcredentials_v1 (0.17.0)
|
||||||
google-apis-iamcredentials_v1 (0.16.0)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-apis-core (>= 0.9.1, < 2.a)
|
google-apis-playcustomapp_v1 (0.13.0)
|
||||||
google-apis-playcustomapp_v1 (0.12.0)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-apis-core (>= 0.9.1, < 2.a)
|
google-apis-storage_v1 (0.31.0)
|
||||||
google-apis-storage_v1 (0.19.0)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-apis-core (>= 0.9.0, < 2.a)
|
google-cloud-core (1.6.1)
|
||||||
google-cloud-core (1.6.0)
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
google-cloud-env (~> 1.0)
|
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (1.6.0)
|
google-cloud-env (1.6.0)
|
||||||
faraday (>= 0.17.3, < 3.0)
|
faraday (>= 0.17.3, < 3.0)
|
||||||
google-cloud-errors (1.3.0)
|
google-cloud-errors (1.3.1)
|
||||||
google-cloud-storage (1.44.0)
|
google-cloud-storage (1.47.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-iamcredentials_v1 (~> 0.1)
|
google-apis-iamcredentials_v1 (~> 0.1)
|
||||||
google-apis-storage_v1 (~> 0.19.0)
|
google-apis-storage_v1 (~> 0.31.0)
|
||||||
google-cloud-core (~> 1.6)
|
google-cloud-core (~> 1.6)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
googleauth (1.3.0)
|
googleauth (1.8.1)
|
||||||
faraday (>= 0.17.3, < 3.a)
|
faraday (>= 0.17.3, < 3.a)
|
||||||
jwt (>= 1.4, < 3.0)
|
jwt (>= 1.4, < 3.0)
|
||||||
memoist (~> 0.16)
|
|
||||||
multi_json (~> 1.11)
|
multi_json (~> 1.11)
|
||||||
os (>= 0.9, < 2.0)
|
os (>= 0.9, < 2.0)
|
||||||
signet (>= 0.16, < 2.a)
|
signet (>= 0.16, < 2.a)
|
||||||
|
@ -148,55 +147,50 @@ GEM
|
||||||
http-cookie (1.0.5)
|
http-cookie (1.0.5)
|
||||||
domain_name (~> 0.5)
|
domain_name (~> 0.5)
|
||||||
httpclient (2.8.3)
|
httpclient (2.8.3)
|
||||||
jmespath (1.6.1)
|
jmespath (1.6.2)
|
||||||
json (2.6.2)
|
json (2.7.1)
|
||||||
jwt (2.5.0)
|
jwt (2.7.1)
|
||||||
memoist (0.16.2)
|
mini_magick (4.12.0)
|
||||||
mini_magick (4.11.0)
|
mini_mime (1.1.5)
|
||||||
mini_mime (1.1.2)
|
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multipart-post (2.0.0)
|
multipart-post (2.4.0)
|
||||||
nanaimo (0.3.0)
|
nanaimo (0.3.0)
|
||||||
naturally (2.2.1)
|
naturally (2.2.1)
|
||||||
optparse (0.1.1)
|
optparse (0.4.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
plist (3.6.0)
|
plist (3.7.1)
|
||||||
public_suffix (5.0.0)
|
public_suffix (5.0.4)
|
||||||
rake (13.0.6)
|
rake (13.1.0)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
retriable (3.1.2)
|
retriable (3.1.2)
|
||||||
rexml (3.2.5)
|
rexml (3.2.6)
|
||||||
rouge (2.0.7)
|
rouge (2.0.7)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (2.3.2)
|
rubyzip (2.3.2)
|
||||||
security (0.1.3)
|
security (0.1.3)
|
||||||
signet (0.17.0)
|
signet (0.18.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
faraday (>= 0.17.5, < 3.a)
|
faraday (>= 0.17.5, < 3.a)
|
||||||
jwt (>= 1.5, < 3.0)
|
jwt (>= 1.5, < 3.0)
|
||||||
multi_json (~> 1.10)
|
multi_json (~> 1.10)
|
||||||
simctl (1.6.8)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
terminal-notifier (2.0.0)
|
terminal-notifier (2.0.0)
|
||||||
terminal-table (1.8.0)
|
terminal-table (3.0.2)
|
||||||
unicode-display_width (~> 1.1, >= 1.1.1)
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
trailblazer-option (0.1.2)
|
trailblazer-option (0.1.2)
|
||||||
tty-cursor (0.7.1)
|
tty-cursor (0.7.1)
|
||||||
tty-screen (0.8.1)
|
tty-screen (0.8.2)
|
||||||
tty-spinner (0.9.3)
|
tty-spinner (0.9.3)
|
||||||
tty-cursor (~> 0.7)
|
tty-cursor (~> 0.7)
|
||||||
uber (0.1.0)
|
uber (0.1.0)
|
||||||
unf (0.1.4)
|
unicode-display_width (2.5.0)
|
||||||
unf_ext
|
|
||||||
unf_ext (0.0.8.2)
|
|
||||||
unicode-display_width (1.8.0)
|
|
||||||
webrick (1.7.0)
|
|
||||||
word_wrap (1.0.0)
|
word_wrap (1.0.0)
|
||||||
xcodeproj (1.22.0)
|
xcodeproj (1.24.0)
|
||||||
CFPropertyList (>= 2.3.3, < 4.0)
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
atomos (~> 0.1.3)
|
atomos (~> 0.1.3)
|
||||||
claide (>= 1.0.2, < 2.0)
|
claide (>= 1.0.2, < 2.0)
|
||||||
|
@ -210,6 +204,7 @@ GEM
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
ruby
|
ruby
|
||||||
|
x64-mingw-ucrt
|
||||||
x86_64-linux
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
|
|
@ -1,115 +1,94 @@
|
||||||
apply plugin: "com.android.application"
|
apply plugin: "com.android.application"
|
||||||
|
apply plugin: "com.facebook.react"
|
||||||
apply plugin: "kotlin-android"
|
apply plugin: "kotlin-android"
|
||||||
|
apply plugin: "org.jetbrains.kotlin.android"
|
||||||
|
|
||||||
project.ext.react = [
|
/**
|
||||||
enableHermes: true, // clean and rebuild if changing
|
* 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.vectoricons = [
|
/* Variants */
|
||||||
iconFontNames: ['MaterialIcons.ttf']
|
// 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"]
|
||||||
|
|
||||||
apply from: "../../node_modules/react-native/react.gradle"
|
/* Bundling */
|
||||||
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
|
// 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 = []
|
||||||
|
|
||||||
def enableSeparateBuildPerCPUArchitecture = true
|
/* Hermes Commands */
|
||||||
def enableProguardInReleaseBuilds = true
|
// The hermes compiler command to run. By default it is 'hermesc'
|
||||||
def jscFlavor = 'org.webkit:android-jsc:+'
|
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||||
def enableHermes = project.ext.react.get("enableHermes", true);
|
//
|
||||||
|
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||||
def reactNativeArchitectures() {
|
// hermesFlags = ["-O", "-output-source-map"]
|
||||||
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
|
|
||||||
|
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||||
|
compileSdk 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 36124
|
versionCode 36250
|
||||||
versionName "1.98"
|
versionName "2.35"
|
||||||
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 {
|
||||||
|
@ -129,15 +108,14 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildFeatures {
|
|
||||||
viewBinding true
|
|
||||||
}
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
@ -145,65 +123,24 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'androidx.appcompat:appcompat:1.4.1'
|
// The version of react-native is set by the React Native Gradle Plugin
|
||||||
implementation 'com.google.android.material:material:1.4.+'
|
implementation("com.facebook.react:react-android")
|
||||||
|
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
implementation 'androidx.databinding:databinding-runtime:7.1.2'
|
implementation 'com.opencsv:opencsv:5.5.2'
|
||||||
def work_version = "2.7.1"
|
|
||||||
|
|
||||||
implementation "androidx.work:work-runtime:$work_version"
|
|
||||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
|
||||||
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')
|
||||||
|
implementation("com.facebook.react:flipper-integration")
|
||||||
|
|
||||||
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
|
if (hermesEnabled.toBoolean()) {
|
||||||
exclude group: 'com.facebook.fbjni'
|
implementation("com.facebook.react:hermes-android")
|
||||||
}
|
|
||||||
|
|
||||||
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
|
|
||||||
exclude group: 'com.facebook.flipper'
|
|
||||||
exclude group: 'com.squareup.okhttp3', module: 'okhttp'
|
|
||||||
}
|
|
||||||
|
|
||||||
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") {
|
|
||||||
exclude group: 'com.facebook.flipper'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enableHermes) {
|
|
||||||
implementation("com.facebook.react:hermes-engine:+") { // From node_modules
|
|
||||||
exclude group: 'com.facebook.fbjni'
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
implementation jscFlavor
|
implementation jscFlavor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNewArchitectureEnabled()) {
|
|
||||||
configurations.all {
|
|
||||||
resolutionStrategy.dependencySubstitution {
|
|
||||||
substitute(module("com.facebook.react:react-native"))
|
|
||||||
.using(project(":ReactAndroid"))
|
|
||||||
.because("On New Architecture we're building React Native from source")
|
|
||||||
substitute(module("com.facebook.react:hermes-engine"))
|
|
||||||
.using(project(":ReactAndroid:hermes-engine"))
|
|
||||||
.because("On New Architecture we're building Hermes from source")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
task copyDownloadableDepsToLibs(type: Copy) {
|
|
||||||
from configurations.implementation
|
|
||||||
into 'libs'
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
|
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
|
||||||
|
project.ext.vectoricons = [
|
||||||
def isNewArchitectureEnabled() {
|
iconFontNames: ['MaterialCommunityIcons.ttf']
|
||||||
return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
|
]
|
||||||
}
|
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
|
||||||
|
|
|
@ -7,7 +7,5 @@
|
||||||
<application
|
<application
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="28"
|
tools:targetApi="28"
|
||||||
tools:ignore="GoogleAppIndexingWarning">
|
tools:ignore="GoogleAppIndexingWarning"/>
|
||||||
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" android:exported="false" />
|
|
||||||
</application>
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
/**
|
|
||||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
*
|
|
||||||
* <p>This source code is licensed under the MIT license found in the LICENSE file in the root
|
|
||||||
* directory of this source tree.
|
|
||||||
*/
|
|
||||||
package com.massive;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import com.facebook.flipper.android.AndroidFlipperClient;
|
|
||||||
import com.facebook.flipper.android.utils.FlipperUtils;
|
|
||||||
import com.facebook.flipper.core.FlipperClient;
|
|
||||||
import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin;
|
|
||||||
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
|
|
||||||
import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin;
|
|
||||||
import com.facebook.flipper.plugins.inspector.DescriptorMapping;
|
|
||||||
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
|
|
||||||
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
|
|
||||||
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
|
|
||||||
import com.facebook.flipper.plugins.react.ReactFlipperPlugin;
|
|
||||||
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
|
|
||||||
import com.facebook.react.ReactInstanceEventListener;
|
|
||||||
import com.facebook.react.ReactInstanceManager;
|
|
||||||
import com.facebook.react.bridge.ReactContext;
|
|
||||||
import com.facebook.react.modules.network.NetworkingModule;
|
|
||||||
import okhttp3.OkHttpClient;
|
|
||||||
|
|
||||||
public class ReactNativeFlipper {
|
|
||||||
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
|
|
||||||
if (FlipperUtils.shouldEnableFlipper(context)) {
|
|
||||||
final FlipperClient client = AndroidFlipperClient.getInstance(context);
|
|
||||||
|
|
||||||
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
|
|
||||||
client.addPlugin(new ReactFlipperPlugin());
|
|
||||||
client.addPlugin(new DatabasesFlipperPlugin(context));
|
|
||||||
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
|
|
||||||
client.addPlugin(CrashReporterPlugin.getInstance());
|
|
||||||
|
|
||||||
NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin();
|
|
||||||
NetworkingModule.setCustomClientBuilder(
|
|
||||||
new NetworkingModule.CustomClientBuilder() {
|
|
||||||
@Override
|
|
||||||
public void apply(OkHttpClient.Builder builder) {
|
|
||||||
builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
client.addPlugin(networkFlipperPlugin);
|
|
||||||
client.start();
|
|
||||||
|
|
||||||
// Fresco Plugin needs to ensure that ImagePipelineFactory is initialized
|
|
||||||
// Hence we run if after all native modules have been initialized
|
|
||||||
ReactContext reactContext = reactInstanceManager.getCurrentReactContext();
|
|
||||||
if (reactContext == null) {
|
|
||||||
reactInstanceManager.addReactInstanceEventListener(
|
|
||||||
new ReactInstanceEventListener() {
|
|
||||||
@Override
|
|
||||||
public void onReactContextInitialized(ReactContext reactContext) {
|
|
||||||
reactInstanceManager.removeReactInstanceEventListener(this);
|
|
||||||
reactContext.runOnNativeModulesQueueThread(
|
|
||||||
new Runnable() {
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
client.addPlugin(new FrescoFlipperPlugin());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
client.addPlugin(new FrescoFlipperPlugin());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +1,28 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
package="com.massive">
|
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.ACCESS_NETWORK_STATE"
|
android:name="android.permission.ACCESS_NETWORK_STATE"
|
||||||
tools:node="remove" />
|
tools:node="remove" />
|
||||||
|
|
||||||
|
<uses-permission
|
||||||
|
android:name="com.google.android.gms.permission.AD_ID"
|
||||||
|
tools:node="remove" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
android:allowBackup="false"
|
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:theme="@style/AppTheme">
|
android:theme="@style/AppTheme">
|
||||||
<activity
|
|
||||||
android:name=".TimerDone"
|
|
||||||
android:exported="false">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.app.lib_name"
|
|
||||||
android:value="" />
|
|
||||||
</activity>
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
||||||
|
@ -35,17 +31,30 @@
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".TimerDone"
|
||||||
|
android:exported="false">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.lib_name"
|
||||||
|
android:value="" />
|
||||||
|
</activity>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".StopAlarm"
|
android:name=".StopAlarm"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:process=":remote" />
|
android:process=":remote" />
|
||||||
<service
|
|
||||||
android:name=".AlarmService"
|
|
||||||
android:exported="false" />
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
<service
|
||||||
|
android:name=".TimerService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="App does not require SCHEDULE_EXACT_ALARM or USE_EXACT_ALARM, but needs foreground service for foreground timer."/>
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
|
@ -1,236 +1,25 @@
|
||||||
package com.massive
|
package com.massive
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.*
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.CountDownTimer
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import com.facebook.react.bridge.*
|
import com.facebook.react.bridge.*
|
||||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
||||||
import kotlin.math.floor
|
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
class AlarmModule constructor(context: ReactApplicationContext?) :
|
class AlarmModule(context: ReactApplicationContext?) :
|
||||||
ReactContextBaseJavaModule(context) {
|
ReactContextBaseJavaModule(context) {
|
||||||
|
|
||||||
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?) {
|
|
||||||
val vibrate = intent?.extras?.getBoolean("vibrate") == true
|
|
||||||
val sound = intent?.extras?.getString("sound")
|
|
||||||
val noSound = intent?.extras?.getBoolean("noSound") == true
|
|
||||||
Log.d("AlarmModule", "vibrate=$vibrate,sound=$sound,noSound=$noSound")
|
|
||||||
add(vibrate, sound, noSound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
reactApplicationContext.registerReceiver(stopReceiver, IntentFilter(STOP_BROADCAST))
|
|
||||||
reactApplicationContext.registerReceiver(addReceiver, IntentFilter(ADD_BROADCAST))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCatalystInstanceDestroy() {
|
|
||||||
reactApplicationContext.unregisterReceiver(stopReceiver)
|
|
||||||
reactApplicationContext.unregisterReceiver(addReceiver)
|
|
||||||
super.onCatalystInstanceDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun add(vibrate: Boolean, sound: String?, noSound: Boolean = false) {
|
fun timer(milliseconds: Int, description: String) {
|
||||||
Log.d("AlarmModule", "Add 1 min to alarm.")
|
|
||||||
countdownTimer?.cancel()
|
|
||||||
val newMs = if (running) currentMs.toInt().plus(60000) else 60000
|
|
||||||
countdownTimer = getTimer(newMs, vibrate, sound, noSound)
|
|
||||||
countdownTimer?.start()
|
|
||||||
running = true
|
|
||||||
val manager = getManager()
|
|
||||||
manager.cancel(NOTIFICATION_ID_DONE)
|
|
||||||
val intent = Intent(reactApplicationContext, AlarmService::class.java)
|
|
||||||
reactApplicationContext.stopService(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
|
||||||
@ReactMethod
|
|
||||||
fun stop() {
|
|
||||||
Log.d("AlarmModule", "Stop alarm.")
|
|
||||||
countdownTimer?.cancel()
|
|
||||||
running = false
|
|
||||||
val intent = Intent(reactApplicationContext, AlarmService::class.java)
|
|
||||||
reactApplicationContext?.stopService(intent)
|
|
||||||
val manager = getManager()
|
|
||||||
manager.cancel(NOTIFICATION_ID_DONE)
|
|
||||||
manager.cancel(NOTIFICATION_ID_PENDING)
|
|
||||||
val params = Arguments.createMap().apply {
|
|
||||||
putString("minutes", "00")
|
|
||||||
putString("seconds", "00")
|
|
||||||
}
|
|
||||||
reactApplicationContext
|
|
||||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
||||||
.emit("tick", params)
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
|
||||||
@ReactMethod
|
|
||||||
fun timer(milliseconds: Int, vibrate: Boolean, sound: String?, noSound: Boolean = false) {
|
|
||||||
Log.d("AlarmModule", "Queue alarm for $milliseconds delay")
|
Log.d("AlarmModule", "Queue alarm for $milliseconds delay")
|
||||||
val manager = getManager()
|
val intent = Intent(reactApplicationContext, TimerService::class.java)
|
||||||
manager.cancel(NOTIFICATION_ID_DONE)
|
intent.putExtra("milliseconds", milliseconds)
|
||||||
val intent = Intent(reactApplicationContext, AlarmService::class.java)
|
intent.putExtra("description", description)
|
||||||
reactApplicationContext.stopService(intent)
|
reactApplicationContext.startForegroundService(intent)
|
||||||
countdownTimer?.cancel()
|
|
||||||
countdownTimer = getTimer(milliseconds, vibrate, sound, noSound)
|
|
||||||
countdownTimer?.start()
|
|
||||||
running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.M)
|
|
||||||
private fun getTimer(
|
|
||||||
endMs: Int,
|
|
||||||
vibrate: Boolean,
|
|
||||||
sound: String?,
|
|
||||||
noSound: Boolean
|
|
||||||
): CountDownTimer {
|
|
||||||
val builder = getBuilder(vibrate, sound, noSound)
|
|
||||||
return object : CountDownTimer(endMs.toLong(), 1000) {
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
override fun onTick(current: Long) {
|
|
||||||
currentMs = current
|
|
||||||
val seconds =
|
|
||||||
floor((current / 1000).toDouble() % 60).toInt().toString().padStart(2, '0')
|
|
||||||
val minutes =
|
|
||||||
floor((current / 1000).toDouble() / 60).toInt().toString().padStart(2, '0')
|
|
||||||
builder.setContentText("$minutes:$seconds").setAutoCancel(false).setDefaults(0)
|
|
||||||
.setProgress(endMs, current.toInt(), false)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS).priority =
|
|
||||||
NotificationCompat.PRIORITY_LOW
|
|
||||||
val manager = getManager()
|
|
||||||
manager.notify(NOTIFICATION_ID_PENDING, builder.build())
|
|
||||||
val params = Arguments.createMap().apply {
|
|
||||||
putString("minutes", minutes)
|
|
||||||
putString("seconds", seconds)
|
|
||||||
}
|
|
||||||
reactApplicationContext
|
|
||||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
||||||
.emit("tick", params)
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
override fun onFinish() {
|
|
||||||
val context = reactApplicationContext
|
|
||||||
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 manager = getManager()
|
|
||||||
manager.notify(NOTIFICATION_ID_DONE, builder.build())
|
|
||||||
manager.cancel(NOTIFICATION_ID_PENDING)
|
|
||||||
Log.d("AlarmModule", "Finished: vibrate=$vibrate,sound=$sound,noSound=$noSound")
|
|
||||||
val alarmIntent = Intent(context, AlarmService::class.java).apply {
|
|
||||||
putExtra("vibrate", vibrate)
|
|
||||||
putExtra("sound", sound)
|
|
||||||
putExtra("noSound", noSound)
|
|
||||||
}
|
|
||||||
context.startService(alarmIntent)
|
|
||||||
reactApplicationContext
|
|
||||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
||||||
.emit("finish", Arguments.createMap().apply {
|
|
||||||
putString("minutes", "00")
|
|
||||||
putString("seconds", "00")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedImmutableFlag")
|
|
||||||
@RequiresApi(Build.VERSION_CODES.M)
|
|
||||||
private fun getBuilder(
|
|
||||||
vibrate: Boolean,
|
|
||||||
sound: String?,
|
|
||||||
noSound: Boolean
|
|
||||||
): NotificationCompat.Builder {
|
|
||||||
val context = reactApplicationContext
|
|
||||||
val contentIntent = Intent(context, MainActivity::class.java)
|
|
||||||
val pendingContent =
|
|
||||||
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
|
|
||||||
val addBroadcast = Intent(ADD_BROADCAST).apply {
|
|
||||||
setPackage(reactApplicationContext.packageName)
|
|
||||||
putExtra("vibrate", vibrate)
|
|
||||||
putExtra("sound", sound)
|
|
||||||
putExtra("noSound", noSound)
|
|
||||||
}
|
|
||||||
val pendingAdd =
|
|
||||||
PendingIntent.getBroadcast(context, 0, addBroadcast, PendingIntent.FLAG_MUTABLE)
|
|
||||||
val stopBroadcast = Intent(STOP_BROADCAST)
|
|
||||||
stopBroadcast.setPackage(reactApplicationContext.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 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 notificationManager = reactApplicationContext.getSystemService(
|
|
||||||
NotificationManager::class.java
|
|
||||||
)
|
|
||||||
notificationManager.createNotificationChannel(alarmsChannel)
|
|
||||||
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 CHANNEL_ID_DONE = "Alarm"
|
|
||||||
const val NOTIFICATION_ID_PENDING = 1
|
|
||||||
const val NOTIFICATION_ID_DONE = 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,78 +0,0 @@
|
||||||
package com.massive
|
|
||||||
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.media.MediaPlayer.OnPreparedListener
|
|
||||||
import android.media.MediaPlayer
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import android.content.Intent
|
|
||||||
import android.media.AudioAttributes
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.*
|
|
||||||
|
|
||||||
class AlarmService : Service(), OnPreparedListener {
|
|
||||||
var mediaPlayer: MediaPlayer? = null
|
|
||||||
private var vibrator: Vibrator? = null
|
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
|
||||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
|
||||||
if (intent.action == "stop") {
|
|
||||||
onDestroy()
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
val sound = intent.extras?.getString("sound")
|
|
||||||
val noSound = intent.extras?.getBoolean("noSound") == true
|
|
||||||
|
|
||||||
if (sound == null && !noSound) {
|
|
||||||
mediaPlayer = MediaPlayer.create(applicationContext, R.raw.argon)
|
|
||||||
mediaPlayer?.start()
|
|
||||||
mediaPlayer?.setOnCompletionListener { vibrator?.cancel() }
|
|
||||||
} else if (sound != null && !noSound) {
|
|
||||||
mediaPlayer = MediaPlayer().apply {
|
|
||||||
setAudioAttributes(
|
|
||||||
AudioAttributes.Builder()
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
setDataSource(applicationContext, Uri.parse(sound))
|
|
||||||
prepare()
|
|
||||||
start()
|
|
||||||
setOnCompletionListener { vibrator?.cancel() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val pattern = longArrayOf(0, 300, 1300, 300, 1300, 300)
|
|
||||||
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
val vibratorManager =
|
|
||||||
getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
|
||||||
vibratorManager.defaultVibrator
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
getSystemService(VIBRATOR_SERVICE) as Vibrator
|
|
||||||
}
|
|
||||||
val audioAttributes = AudioAttributes.Builder()
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
|
||||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
|
||||||
.build()
|
|
||||||
val vibrate = intent.extras!!.getBoolean("vibrate")
|
|
||||||
if (vibrate)
|
|
||||||
vibrator!!.vibrate(VibrationEffect.createWaveform(pattern, 1), audioAttributes)
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepared(player: MediaPlayer) {
|
|
||||||
player.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
mediaPlayer?.stop()
|
|
||||||
mediaPlayer?.release()
|
|
||||||
vibrator?.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,138 @@
|
||||||
|
package com.massive
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.*
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import com.facebook.react.bridge.Promise
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
import com.facebook.react.bridge.ReactMethod
|
||||||
|
import java.io.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
|
class BackupModule(context: ReactApplicationContext?) :
|
||||||
|
ReactContextBaseJavaModule(context) {
|
||||||
|
val context: ReactApplicationContext = reactApplicationContext
|
||||||
|
|
||||||
|
private val copyReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
val targetDir = intent?.getStringExtra("targetDir")
|
||||||
|
Log.d("BackupModule", "onReceive $targetDir")
|
||||||
|
val treeUri: Uri = Uri.parse(targetDir)
|
||||||
|
val documentFile = context?.let { DocumentFile.fromTreeUri(it, treeUri) }
|
||||||
|
val file = documentFile?.createFile("application/octet-stream", "massive.db")
|
||||||
|
val output = context?.contentResolver?.openOutputStream(file!!.uri)
|
||||||
|
val sourceFile = File(context?.getDatabasePath("massive.db")!!.path)
|
||||||
|
val input = FileInputStream(sourceFile)
|
||||||
|
if (output != null) {
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
output?.flush()
|
||||||
|
output?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun once(target: String, promise: Promise) {
|
||||||
|
Log.d("BackupModule", "once $target")
|
||||||
|
try {
|
||||||
|
val treeUri: Uri = Uri.parse(target)
|
||||||
|
val documentFile = context.let { DocumentFile.fromTreeUri(it, treeUri) }
|
||||||
|
val file = documentFile?.createFile("application/octet-stream", "massive.db")
|
||||||
|
val output = context.contentResolver?.openOutputStream(file!!.uri)
|
||||||
|
val sourceFile = File(context.getDatabasePath("massive.db")!!.path)
|
||||||
|
val input = FileInputStream(sourceFile)
|
||||||
|
if (output != null) {
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
output?.flush()
|
||||||
|
output?.close()
|
||||||
|
promise.resolve(0)
|
||||||
|
}
|
||||||
|
catch (error: Exception) {
|
||||||
|
promise.reject("ERROR", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun start(baseUri: String) {
|
||||||
|
Log.d("BackupModule", "start $baseUri")
|
||||||
|
val alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
val intent = Intent(COPY_BROADCAST)
|
||||||
|
intent.putExtra("targetDir", baseUri)
|
||||||
|
val pendingIntent =
|
||||||
|
PendingIntent.getBroadcast(context, baseUri.hashCode(), intent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
pendingIntent.send()
|
||||||
|
|
||||||
|
val calendar = Calendar.getInstance().apply {
|
||||||
|
timeInMillis = System.currentTimeMillis()
|
||||||
|
set(Calendar.HOUR_OF_DAY, 6)
|
||||||
|
set(Calendar.MINUTE, 0)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
alarmMgr.setRepeating(
|
||||||
|
AlarmManager.RTC_WAKEUP,
|
||||||
|
calendar.timeInMillis,
|
||||||
|
AlarmManager.INTERVAL_DAY,
|
||||||
|
pendingIntent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
||||||
|
fun stop() {
|
||||||
|
val alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
val intent = Intent(COPY_BROADCAST)
|
||||||
|
val pendingIntent =
|
||||||
|
PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
alarmMgr.cancel(pendingIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun exportPlans(target: String, promise: Promise) {
|
||||||
|
try {
|
||||||
|
val db = DatabaseHelper(reactApplicationContext)
|
||||||
|
db.exportPlans(target, reactApplicationContext)
|
||||||
|
promise.resolve("Export successful!")
|
||||||
|
}
|
||||||
|
catch (e: Exception) {
|
||||||
|
promise.reject("ERROR", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun exportSets(target: String, promise: Promise) {
|
||||||
|
try {
|
||||||
|
val db = DatabaseHelper(reactApplicationContext)
|
||||||
|
db.exportSets(target, reactApplicationContext)
|
||||||
|
promise.resolve("Export successful!")
|
||||||
|
}
|
||||||
|
catch (e: Exception) {
|
||||||
|
promise.reject("ERROR", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
reactApplicationContext.registerReceiver(copyReceiver, IntentFilter(COPY_BROADCAST),
|
||||||
|
Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
reactApplicationContext.registerReceiver(copyReceiver, IntentFilter(COPY_BROADCAST))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val COPY_BROADCAST = "copy-event"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getName(): String {
|
||||||
|
return "BackupModule"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
package com.massive
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import android.database.sqlite.SQLiteOpenHelper
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import com.opencsv.CSVWriter
|
||||||
|
|
||||||
|
class DatabaseHelper(context: Context) :
|
||||||
|
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||||
|
companion object {
|
||||||
|
private const val DATABASE_NAME = "massive.db"
|
||||||
|
private const val DATABASE_VERSION = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportSets(target: String, context: Context) {
|
||||||
|
Log.d("DatabaseHelper", "exportSets $target")
|
||||||
|
val treeUri: Uri = Uri.parse(target)
|
||||||
|
val documentFile = context.let { DocumentFile.fromTreeUri(it, treeUri) }
|
||||||
|
val file = documentFile?.createFile("application/octet-stream", "sets.csv") ?: return
|
||||||
|
|
||||||
|
context.contentResolver.openOutputStream(file.uri).use { outputStream ->
|
||||||
|
val csvWrite = CSVWriter(outputStream?.writer())
|
||||||
|
val db = this.readableDatabase
|
||||||
|
|
||||||
|
val setCursor = db.rawQuery("SELECT * FROM sets", null)
|
||||||
|
csvWrite.writeNext(setCursor.columnNames)
|
||||||
|
|
||||||
|
var lastId = 0
|
||||||
|
while(setCursor.moveToNext()) {
|
||||||
|
val arrStr = arrayOfNulls<String>(setCursor.columnCount)
|
||||||
|
for(i in 0 until setCursor.columnCount) {
|
||||||
|
arrStr[i] = setCursor.getString(i)
|
||||||
|
}
|
||||||
|
val id = arrStr[0]?.toInt()
|
||||||
|
if (id != null && id > lastId) lastId = id
|
||||||
|
csvWrite.writeNext(arrStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
val weightCursor = db.rawQuery("SELECT * FROM weights", null)
|
||||||
|
while (weightCursor.moveToNext()) {
|
||||||
|
val arrStr = arrayOfNulls<String>(setCursor.columnCount)
|
||||||
|
arrStr[0] = lastId++.toString()
|
||||||
|
arrStr[1] = "Weight"
|
||||||
|
arrStr[2] = "1"
|
||||||
|
arrStr[3] = weightCursor.getString(1)
|
||||||
|
arrStr[4] = weightCursor.getString(2)
|
||||||
|
arrStr[5] = "kg"
|
||||||
|
|
||||||
|
csvWrite.writeNext(arrStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvWrite.close()
|
||||||
|
setCursor.close()
|
||||||
|
weightCursor.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportPlans(target: String, context: Context) {
|
||||||
|
Log.d("DatabaseHelper", "exportPlans $target")
|
||||||
|
val treeUri: Uri = Uri.parse(target)
|
||||||
|
val documentFile = context.let { DocumentFile.fromTreeUri(it, treeUri) }
|
||||||
|
val file = documentFile?.createFile("application/octet-stream", "plans.csv") ?: return
|
||||||
|
|
||||||
|
context.contentResolver.openOutputStream(file.uri).use { outputStream ->
|
||||||
|
val csvWrite = CSVWriter(outputStream?.writer())
|
||||||
|
val db = this.readableDatabase
|
||||||
|
val cursor = db.rawQuery("SELECT * FROM plans", null)
|
||||||
|
csvWrite.writeNext(cursor.columnNames)
|
||||||
|
|
||||||
|
while(cursor.moveToNext()) {
|
||||||
|
val arrStr = arrayOfNulls<String>(cursor.columnCount)
|
||||||
|
for(i in 0 until cursor.columnCount) {
|
||||||
|
arrStr[i] = cursor.getString(i)
|
||||||
|
}
|
||||||
|
csvWrite.writeNext(arrStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvWrite.close()
|
||||||
|
cursor.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(db: SQLiteDatabase) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,33 +1,31 @@
|
||||||
package com.massive
|
package com.massive
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
import com.facebook.react.ReactActivity
|
import com.facebook.react.ReactActivity
|
||||||
import com.facebook.react.ReactActivityDelegate
|
import com.facebook.react.ReactActivityDelegate
|
||||||
import com.facebook.react.ReactRootView
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||||
import android.os.Bundle;
|
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||||
|
|
||||||
class MainActivity : ReactActivity() {
|
class MainActivity : ReactActivity() {
|
||||||
override fun getMainComponentName(): String? {
|
/**
|
||||||
return "massive"
|
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||||
}
|
* rendering of the component.
|
||||||
|
*/
|
||||||
|
override fun getMainComponentName(): String = "massive"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the instance of the [ReactActivityDelegate]. Here we use a util class [ ] which allows you to easily enable Fabric and Concurrent React
|
||||||
|
* (aka React 18) with two boolean flags.
|
||||||
|
*/
|
||||||
override fun createReactActivityDelegate(): ReactActivityDelegate {
|
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.
|
||||||
|
fabricEnabled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(null)
|
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,70 +1,45 @@
|
||||||
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.ReactHost
|
||||||
|
import com.facebook.react.ReactNativeHost
|
||||||
|
import com.facebook.react.ReactPackage
|
||||||
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
|
||||||
|
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
||||||
|
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||||
|
import com.facebook.react.flipper.ReactNativeFlipper
|
||||||
import com.facebook.soloader.SoLoader
|
import com.facebook.soloader.SoLoader
|
||||||
import com.massive.newarchitecture.MainApplicationReactNativeHost
|
|
||||||
import java.lang.reflect.InvocationTargetException
|
|
||||||
|
|
||||||
class MainApplication : Application(), ReactApplication {
|
class MainApplication : Application(), ReactApplication {
|
||||||
private val mReactNativeHost: ReactNativeHost = object : ReactNativeHost(this) {
|
override val reactNativeHost: ReactNativeHost =
|
||||||
override fun getUseDeveloperSupport(): Boolean {
|
object : DefaultReactNativeHost(this) {
|
||||||
return BuildConfig.DEBUG
|
override fun getPackages(): List<ReactPackage> =
|
||||||
|
PackageList(this).packages.apply {
|
||||||
|
add(MassivePackage())
|
||||||
|
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||||
|
// add(MyReactNativePackage())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getJSMainModuleName(): String = "index"
|
||||||
|
|
||||||
|
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
|
||||||
|
|
||||||
|
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
||||||
|
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getPackages(): List<ReactPackage> {
|
override val reactHost: ReactHost
|
||||||
val packages: MutableList<ReactPackage> = PackageList(this).packages
|
get() = getDefaultReactHost(this.applicationContext, reactNativeHost)
|
||||||
packages.add(MassivePackage())
|
|
||||||
return packages
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getJSMainModuleName(): String {
|
|
||||||
return "index"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val mNewArchitectureNativeHost: ReactNativeHost = MainApplicationReactNativeHost(this)
|
|
||||||
override fun getReactNativeHost(): ReactNativeHost {
|
|
||||||
return if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
|
||||||
mNewArchitectureNativeHost
|
|
||||||
} else {
|
|
||||||
mReactNativeHost
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
ReactFeatureFlags.useTurboModules = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
|
||||||
SoLoader.init(this, false)
|
SoLoader.init(this, false)
|
||||||
initializeFlipper(this, reactNativeHost.reactInstanceManager)
|
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
||||||
}
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +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 java.util.ArrayList
|
|
||||||
|
|
||||||
class MassivePackage : ReactPackage {
|
class MassivePackage : ReactPackage {
|
||||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||||
|
@ -16,8 +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(SettingsModule(reactContext))
|
||||||
|
modules.add(BackupModule(reactContext))
|
||||||
return modules
|
return modules
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,13 +21,13 @@ class SettingsModule constructor(context: ReactApplicationContext?) :
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.M)
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun ignoringBattery(callback: Callback) {
|
fun ignoringBattery(promise: Promise) {
|
||||||
val packageName = reactApplicationContext.packageName
|
val packageName = reactApplicationContext.packageName
|
||||||
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
callback.invoke(pm.isIgnoringBatteryOptimizations(packageName))
|
promise.resolve(pm.isIgnoringBatteryOptimizations(packageName))
|
||||||
} else {
|
} else {
|
||||||
callback.invoke(true)
|
promise.resolve(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,17 +1,11 @@
|
||||||
package com.massive
|
package com.massive
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import com.massive.AlarmService
|
|
||||||
import com.massive.MainActivity
|
|
||||||
|
|
||||||
class StopAlarm : Activity() {
|
class StopAlarm : Activity() {
|
||||||
@RequiresApi(Build.VERSION_CODES.O_MR1)
|
@RequiresApi(Build.VERSION_CODES.O_MR1)
|
||||||
|
@ -19,7 +13,7 @@ class StopAlarm : Activity() {
|
||||||
Log.d("AlarmActivity", "Call to AlarmActivity")
|
Log.d("AlarmActivity", "Call to AlarmActivity")
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val context = applicationContext
|
val context = applicationContext
|
||||||
context.stopService(Intent(context, AlarmService::class.java))
|
context.stopService(Intent(context, TimerService::class.java))
|
||||||
savedInstanceState.apply { setShowWhenLocked(true) }
|
savedInstanceState.apply { setShowWhenLocked(true) }
|
||||||
val intent = Intent(context, MainActivity::class.java)
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
|
@ -0,0 +1,171 @@
|
||||||
|
package com.massive
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.AlarmManager.ELAPSED_REALTIME_WAKEUP
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
class Timer(private var msTimerDuration: Long) {
|
||||||
|
|
||||||
|
enum class State {
|
||||||
|
Running,
|
||||||
|
Paused,
|
||||||
|
Expired
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
if (state != State.Paused) return
|
||||||
|
endTime = SystemClock.elapsedRealtime() + msTimerDuration
|
||||||
|
registerPendingIntent(context)
|
||||||
|
state = State.Running
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
if (state != State.Running) return
|
||||||
|
msTimerDuration = endTime - SystemClock.elapsedRealtime()
|
||||||
|
unregisterPendingIntent(context)
|
||||||
|
state = State.Paused
|
||||||
|
}
|
||||||
|
|
||||||
|
fun expire() {
|
||||||
|
state = State.Expired
|
||||||
|
msTimerDuration = 0
|
||||||
|
totalTimerDuration = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRemainingSeconds(): Int {
|
||||||
|
return (getRemainingMillis() / 1000).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun increaseDuration(context: Context, milli: Long) {
|
||||||
|
val wasRunning = isRunning()
|
||||||
|
if (wasRunning) stop(context)
|
||||||
|
msTimerDuration += milli
|
||||||
|
totalTimerDuration += milli
|
||||||
|
if (wasRunning) start(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isExpired(): Boolean {
|
||||||
|
return state == State.Expired
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDurationSeconds(): Int {
|
||||||
|
return (totalTimerDuration / 1000).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRemainingMillis(): Long {
|
||||||
|
return if (state == State.Running) endTime - SystemClock.elapsedRealtime()
|
||||||
|
else
|
||||||
|
msTimerDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isRunning(): Boolean {
|
||||||
|
return state == State.Running
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestPermission(context: Context): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
|
||||||
|
val intent = Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
|
||||||
|
intent.data = Uri.parse("package:" + context.packageName)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
return try {
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context2: Context?, intent: Intent?) {
|
||||||
|
context.unregisterReceiver(this)
|
||||||
|
registerPendingIntent(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
context.registerReceiver(
|
||||||
|
receiver,
|
||||||
|
IntentFilter(AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED),
|
||||||
|
Context.RECEIVER_NOT_EXPORTED
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
context.registerReceiver(
|
||||||
|
receiver,
|
||||||
|
IntentFilter(AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.startActivity(intent)
|
||||||
|
false
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Request for SCHEDULE_EXACT_ALARM rejected on your device",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun incorrectPermissions(context: Context, alarmManager: AlarmManager): Boolean {
|
||||||
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
&& !alarmManager.canScheduleExactAlarms()
|
||||||
|
&& !requestPermission(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAlarmManager(context: Context): AlarmManager {
|
||||||
|
return context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterPendingIntent(context: Context) {
|
||||||
|
val intent = Intent(context, TimerService::class.java)
|
||||||
|
.setAction(TimerService.TIMER_EXPIRED)
|
||||||
|
val pendingIntent = PendingIntent.getService(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val alarmManager = getAlarmManager(context)
|
||||||
|
if (incorrectPermissions(context, alarmManager)) return
|
||||||
|
|
||||||
|
alarmManager.cancel(pendingIntent)
|
||||||
|
pendingIntent.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerPendingIntent(context: Context) {
|
||||||
|
val intent = Intent(context, TimerService::class.java)
|
||||||
|
.setAction(TimerService.TIMER_EXPIRED)
|
||||||
|
val pendingIntent = PendingIntent.getService(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val alarmManager = getAlarmManager(context)
|
||||||
|
if (incorrectPermissions(context, alarmManager)) return
|
||||||
|
|
||||||
|
alarmManager.setExactAndAllowWhileIdle(
|
||||||
|
ELAPSED_REALTIME_WAKEUP,
|
||||||
|
endTime,
|
||||||
|
pendingIntent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var endTime: Long = 0
|
||||||
|
private var totalTimerDuration: Long = msTimerDuration
|
||||||
|
private var state: State = State.Paused
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun emptyTimer(): Timer {
|
||||||
|
return Timer(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const val ONE_MINUTE_MILLI: Long = 60000
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,5 @@
|
||||||
package com.massive
|
package com.massive
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
@ -10,49 +7,24 @@ import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
|
||||||
class TimerDone : AppCompatActivity() {
|
class TimerDone : AppCompatActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_timer_done)
|
setContentView(R.layout.activity_timer_done)
|
||||||
|
Log.d("TimerDone", "Rendered.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
@Suppress("UNUSED_PARAMETER")
|
@Suppress("UNUSED_PARAMETER")
|
||||||
fun stop(view: View) {
|
fun stop(view: View) {
|
||||||
Log.d("TimerDone", "Stopping...")
|
Log.d("TimerDone", "Stopping...")
|
||||||
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
|
applicationContext.stopService(Intent(applicationContext, TimerService::class.java))
|
||||||
val manager = getManager()
|
val manager = NotificationManagerCompat.from(this)
|
||||||
manager.cancel(AlarmModule.NOTIFICATION_ID_DONE)
|
manager.cancel(TimerService.ONGOING_ID)
|
||||||
manager.cancel(AlarmModule.NOTIFICATION_ID_PENDING)
|
|
||||||
val intent = Intent(applicationContext, MainActivity::class.java)
|
val intent = Intent(applicationContext, MainActivity::class.java)
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
applicationContext.startActivity(intent)
|
applicationContext.startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
fun getManager(): NotificationManager {
|
|
||||||
val alarmsChannel = NotificationChannel(
|
|
||||||
AlarmModule.CHANNEL_ID_DONE,
|
|
||||||
AlarmModule.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
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -0,0 +1,358 @@
|
||||||
|
package com.massive
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.*
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.*
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
|
||||||
|
class Settings(val sound: String?, val noSound: Boolean, val vibrate: Boolean, val duration: Long)
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
class TimerService : Service() {
|
||||||
|
|
||||||
|
private lateinit var timerHandler: Handler
|
||||||
|
private var timerRunnable: Runnable? = null
|
||||||
|
private var timer: Timer = Timer.emptyTimer()
|
||||||
|
private var mediaPlayer: MediaPlayer? = null
|
||||||
|
private var vibrator: Vibrator? = null
|
||||||
|
private var currentDescription = ""
|
||||||
|
|
||||||
|
private val stopReceiver =
|
||||||
|
object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
Log.d("TimerService", "Received stop broadcast intent")
|
||||||
|
timer.stop(applicationContext)
|
||||||
|
timer.expire()
|
||||||
|
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val addReceiver =
|
||||||
|
object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
timer.increaseDuration(applicationContext, Timer.ONE_MINUTE_MILLI)
|
||||||
|
updateNotification(timer.getRemainingSeconds())
|
||||||
|
mediaPlayer?.stop()
|
||||||
|
vibrator?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
timerHandler = Handler(Looper.getMainLooper())
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
applicationContext.registerReceiver(
|
||||||
|
stopReceiver, IntentFilter(STOP_BROADCAST),
|
||||||
|
Context.RECEIVER_NOT_EXPORTED
|
||||||
|
)
|
||||||
|
applicationContext.registerReceiver(
|
||||||
|
addReceiver, IntentFilter(ADD_BROADCAST),
|
||||||
|
Context.RECEIVER_NOT_EXPORTED
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
applicationContext.registerReceiver(stopReceiver, IntentFilter(STOP_BROADCAST))
|
||||||
|
applicationContext.registerReceiver(addReceiver, IntentFilter(ADD_BROADCAST))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTimerStart(intent: Intent?) {
|
||||||
|
timerRunnable?.let { timerHandler.removeCallbacks(it) }
|
||||||
|
currentDescription = intent?.getStringExtra("description").toString()
|
||||||
|
|
||||||
|
timer.stop(applicationContext)
|
||||||
|
timer = Timer((intent?.getIntExtra("milliseconds", 0) ?: 0).toLong())
|
||||||
|
timer.start(applicationContext)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
startForeground(
|
||||||
|
ONGOING_ID,
|
||||||
|
getProgress(timer.getRemainingSeconds()).build(),
|
||||||
|
FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(ONGOING_ID, getProgress(timer.getRemainingSeconds()).build())
|
||||||
|
}
|
||||||
|
|
||||||
|
battery()
|
||||||
|
Log.d("TimerService", "onTimerStart seconds=${timer.getDurationSeconds()}")
|
||||||
|
|
||||||
|
timerRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
val startTime = SystemClock.elapsedRealtime()
|
||||||
|
if (timer.isExpired()) return
|
||||||
|
updateNotification(timer.getRemainingSeconds())
|
||||||
|
|
||||||
|
val delay = timer.getRemainingMillis() % 1000
|
||||||
|
timerHandler.postDelayed(this, if (SystemClock.elapsedRealtime() - startTime + delay > 980) 20 else delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timerHandler.postDelayed(timerRunnable!!, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTimerExpired() {
|
||||||
|
Log.d("TimerService", "onTimerExpired duration=${timer.getDurationSeconds()}")
|
||||||
|
timer.expire()
|
||||||
|
|
||||||
|
val settings = getSettings()
|
||||||
|
vibrate(settings)
|
||||||
|
playSound(settings)
|
||||||
|
notifyFinished()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent != null && intent.action == TIMER_EXPIRED) onTimerExpired()
|
||||||
|
else onTimerStart(intent)
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
timerRunnable?.let { timerHandler.removeCallbacks(it) }
|
||||||
|
applicationContext.unregisterReceiver(stopReceiver)
|
||||||
|
applicationContext.unregisterReceiver(addReceiver)
|
||||||
|
mediaPlayer?.stop()
|
||||||
|
mediaPlayer?.release()
|
||||||
|
vibrator?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("BatteryLife")
|
||||||
|
fun battery() {
|
||||||
|
val powerManager =
|
||||||
|
applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
val ignoring =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||||
|
powerManager.isIgnoringBatteryOptimizations(
|
||||||
|
applicationContext.packageName
|
||||||
|
)
|
||||||
|
else true
|
||||||
|
if (ignoring) return
|
||||||
|
val intent = Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||||
|
intent.data = Uri.parse("package:" + applicationContext.packageName)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
try {
|
||||||
|
applicationContext.startActivity(intent)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
"Requests to ignore battery optimizations are disabled on your device.",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("Range")
|
||||||
|
private fun getSettings(): Settings {
|
||||||
|
val db = DatabaseHelper(applicationContext).readableDatabase
|
||||||
|
val cursor = db.rawQuery("SELECT sound, noSound, vibrate, duration FROM settings", null)
|
||||||
|
cursor.moveToFirst()
|
||||||
|
val sound = cursor.getString(cursor.getColumnIndex("sound"))
|
||||||
|
val noSound = cursor.getInt(cursor.getColumnIndex("noSound")) == 1
|
||||||
|
val vibrate = cursor.getInt(cursor.getColumnIndex("vibrate")) == 1
|
||||||
|
var duration = cursor.getLong(cursor.getColumnIndex("duration"))
|
||||||
|
if (duration.toInt() == 0) duration = 300
|
||||||
|
cursor.close()
|
||||||
|
return Settings(sound, noSound, vibrate, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playSound(settings: Settings) {
|
||||||
|
if (settings.noSound) return
|
||||||
|
if (settings.sound == null) {
|
||||||
|
mediaPlayer = MediaPlayer.create(applicationContext, R.raw.argon)
|
||||||
|
mediaPlayer?.start()
|
||||||
|
mediaPlayer?.setOnCompletionListener { vibrator?.cancel() }
|
||||||
|
} else {
|
||||||
|
mediaPlayer = MediaPlayer().apply {
|
||||||
|
setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
setDataSource(applicationContext, Uri.parse(settings.sound))
|
||||||
|
prepare()
|
||||||
|
start()
|
||||||
|
setOnCompletionListener { vibrator?.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getProgress(timeLeftInSeconds: Int): NotificationCompat.Builder {
|
||||||
|
val notificationText = formatTime(timeLeftInSeconds)
|
||||||
|
val notificationChannelId = "timer_channel"
|
||||||
|
val notificationIntent = Intent(this, MainActivity::class.java)
|
||||||
|
val contentPending = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
notificationIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val stopBroadcast = Intent(STOP_BROADCAST)
|
||||||
|
stopBroadcast.setPackage(applicationContext.packageName)
|
||||||
|
val stopPending =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
stopBroadcast,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val addBroadcast =
|
||||||
|
Intent(ADD_BROADCAST).apply { setPackage(applicationContext.packageName) }
|
||||||
|
val addPending =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
addBroadcast,
|
||||||
|
PendingIntent.FLAG_MUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationBuilder = NotificationCompat.Builder(this, notificationChannelId)
|
||||||
|
.setContentTitle(currentDescription)
|
||||||
|
.setContentText(notificationText)
|
||||||
|
.setSmallIcon(R.drawable.ic_baseline_timer_24)
|
||||||
|
.setProgress(timer.getDurationSeconds(), timeLeftInSeconds, false)
|
||||||
|
.setContentIntent(contentPending)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setDeleteIntent(stopPending)
|
||||||
|
.addAction(R.drawable.ic_baseline_stop_24, "Stop", stopPending)
|
||||||
|
.addAction(R.drawable.ic_baseline_stop_24, "Add 1 min", addPending)
|
||||||
|
|
||||||
|
val notificationManager = NotificationManagerCompat.from(this)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
notificationChannelId,
|
||||||
|
"Timer Channel",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
return notificationBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun vibrate(settings: Settings) {
|
||||||
|
if (!settings.vibrate) return
|
||||||
|
val pattern =
|
||||||
|
longArrayOf(0, settings.duration, 1000, settings.duration, 1000, settings.duration)
|
||||||
|
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val vibratorManager =
|
||||||
|
getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||||
|
vibratorManager.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
getSystemService(VIBRATOR_SERVICE) as Vibrator
|
||||||
|
}
|
||||||
|
vibrator!!.vibrate(VibrationEffect.createWaveform(pattern, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyFinished() {
|
||||||
|
val channelId = "finished_channel"
|
||||||
|
val notificationManager = NotificationManagerCompat.from(this)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel =
|
||||||
|
NotificationChannel(
|
||||||
|
channelId,
|
||||||
|
"Timer Finished Channel",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
)
|
||||||
|
channel.setSound(null, null)
|
||||||
|
channel.setBypassDnd(true)
|
||||||
|
channel.enableVibration(false)
|
||||||
|
channel.description = "Plays an alarm when a rest timer completes."
|
||||||
|
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
val fullIntent = Intent(applicationContext, TimerDone::class.java)
|
||||||
|
val fullPending = PendingIntent.getActivity(
|
||||||
|
applicationContext, 0, fullIntent, PendingIntent.FLAG_MUTABLE
|
||||||
|
)
|
||||||
|
val finishIntent = Intent(applicationContext, StopAlarm::class.java)
|
||||||
|
val finishPending = PendingIntent.getActivity(
|
||||||
|
applicationContext, 0, finishIntent, PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val stopBroadcast = Intent(STOP_BROADCAST)
|
||||||
|
stopBroadcast.setPackage(applicationContext.packageName)
|
||||||
|
val pendingStop =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
stopBroadcast,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(this, channelId)
|
||||||
|
.setContentTitle("Timer finished")
|
||||||
|
.setContentText(currentDescription)
|
||||||
|
.setSmallIcon(R.drawable.ic_baseline_timer_24)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setContentIntent(finishPending)
|
||||||
|
.setFullScreenIntent(fullPending, true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setDeleteIntent(pendingStop)
|
||||||
|
|
||||||
|
if (ActivityCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notificationManager.notify(FINISHED_ID, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(seconds: Int) {
|
||||||
|
val notificationManager = NotificationManagerCompat.from(this)
|
||||||
|
val notification = getProgress(seconds)
|
||||||
|
if (ActivityCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notificationManager.notify(ONGOING_ID, notification.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(timeInSeconds: Int): String {
|
||||||
|
val minutes = timeInSeconds / 60
|
||||||
|
val seconds = timeInSeconds % 60
|
||||||
|
return String.format("%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val STOP_BROADCAST = "stop-timer-event"
|
||||||
|
const val ADD_BROADCAST = "add-timer-event"
|
||||||
|
const val TIMER_EXPIRED = "timer-expired-event"
|
||||||
|
const val ONGOING_ID = 1
|
||||||
|
const val FINISHED_ID = 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,70 +5,6 @@
|
||||||
android:viewportHeight="108"
|
android:viewportHeight="108"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<path android:fillColor="#3DDC84"
|
<path android:fillColor="#1d1f21"
|
||||||
android:pathData="M0,0h108v108h-108z"/>
|
android:pathData="M0,0h108v108h-108z"/>
|
||||||
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
|
||||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
|
@ -1,55 +1,25 @@
|
||||||
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 = "34.0.0"
|
||||||
buildToolsVersion = "31.0.0"
|
|
||||||
minSdkVersion = 21
|
minSdkVersion = 21
|
||||||
compileSdkVersion = 31
|
compileSdkVersion = 34
|
||||||
targetSdkVersion = 31
|
targetSdkVersion = 34
|
||||||
|
|
||||||
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 = "25.1.8937393"
|
||||||
ndkVersion = "24.0.8215888"
|
kotlinVersion = "1.8.0"
|
||||||
} 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")
|
||||||
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 {
|
apply plugin: "com.facebook.react.rootproject"
|
||||||
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' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -24,9 +24,6 @@ android.useAndroidX=true
|
||||||
# Automatically convert third-party libraries to use AndroidX
|
# Automatically convert third-party libraries to use AndroidX
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
|
|
||||||
# Version of flipper SDK to use with React Native
|
|
||||||
FLIPPER_VERSION=0.125.0
|
|
||||||
|
|
||||||
# Use this property to specify which architecture you want to build.
|
# Use this property to specify which architecture you want to build.
|
||||||
# You can also override it from the CLI using
|
# You can also override it from the CLI using
|
||||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||||
|
@ -38,3 +35,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
|
||||||
|
|
Binary file not shown.
|
@ -1,5 +1,7 @@
|
||||||
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.3-all.zip
|
||||||
|
validateDistributionUrl=true
|
||||||
|
networkTimeout=10000
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|
|
@ -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,13 +80,11 @@ 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##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
MAX_FD=maximum
|
MAX_FD=maximum
|
||||||
|
@ -133,22 +131,29 @@ location of your Java installation."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
JAVACMD=java
|
JAVACMD=java
|
||||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
location of your Java installation."
|
location of your Java installation."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
# Increase the maximum file descriptors if we can.
|
||||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
case $MAX_FD in #(
|
case $MAX_FD in #(
|
||||||
max*)
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC3045
|
||||||
MAX_FD=$( ulimit -H -n ) ||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
warn "Could not query maximum file descriptor limit"
|
warn "Could not query maximum file descriptor limit"
|
||||||
esac
|
esac
|
||||||
case $MAX_FD in #(
|
case $MAX_FD in #(
|
||||||
'' | soft) :;; #(
|
'' | soft) :;; #(
|
||||||
*)
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC3045
|
||||||
ulimit -n "$MAX_FD" ||
|
ulimit -n "$MAX_FD" ||
|
||||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
esac
|
esac
|
||||||
|
@ -193,6 +198,10 @@ if "$cygwin" || "$msys" ; then
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
# Collect all arguments for the java command;
|
# Collect all arguments for the java command;
|
||||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||||
# shell script including quotes and variable substitutions, so put them in
|
# shell script including quotes and variable substitutions, so put them in
|
||||||
|
@ -205,6 +214,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.
|
||||||
|
|
|
@ -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,11 +1,11 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
presets: ['module:metro-react-native-babel-preset'],
|
presets: ['module:@react-native/babel-preset'],
|
||||||
plugins: [
|
plugins: [
|
||||||
'@babel/plugin-transform-flow-strip-types',
|
'@babel/plugin-transform-flow-strip-types',
|
||||||
['@babel/plugin-proposal-decorators', {legacy: true}],
|
['@babel/plugin-proposal-decorators', { legacy: true }],
|
||||||
['@babel/plugin-proposal-class-properties', {loose: true}],
|
['@babel/plugin-proposal-class-properties', { loose: true }],
|
||||||
'react-native-reanimated/plugin',
|
|
||||||
'react-native-paper/babel',
|
'react-native-paper/babel',
|
||||||
|
'react-native-reanimated/plugin',
|
||||||
],
|
],
|
||||||
env: {
|
env: {
|
||||||
production: {
|
production: {
|
||||||
|
|
|
@ -1,15 +1,42 @@
|
||||||
import {setRepo} from './db'
|
import { LIMIT } from "./constants";
|
||||||
import GymSet from './gym-set'
|
import { setRepo } from "./db";
|
||||||
|
import GymSet from "./gym-set";
|
||||||
|
|
||||||
export const getBestSet = async (name: string): Promise<GymSet> => {
|
export const getBestSet = async (name: string): Promise<GymSet> => {
|
||||||
return setRepo
|
return setRepo
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.select()
|
.select()
|
||||||
.addSelect('MAX(weight)', 'weight')
|
.addSelect("MAX(weight)", "weight")
|
||||||
.where('name = :name', {name})
|
.where("name = :name", { name })
|
||||||
.groupBy('name')
|
.groupBy("name")
|
||||||
.addGroupBy('reps')
|
.addGroupBy("reps")
|
||||||
.orderBy('weight', 'DESC')
|
.orderBy("weight", "DESC")
|
||||||
.addOrderBy('reps', 'DESC')
|
.addOrderBy("reps", "DESC")
|
||||||
.getOne()
|
.getOne();
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export const getBestSets = ({
|
||||||
|
term: term,
|
||||||
|
offset,
|
||||||
|
}: {
|
||||||
|
term: string;
|
||||||
|
offset?: number;
|
||||||
|
}) => {
|
||||||
|
return setRepo
|
||||||
|
.createQueryBuilder("gym_set")
|
||||||
|
.select(["gym_set.name", "gym_set.reps", "gym_set.weight"])
|
||||||
|
.groupBy("gym_set.name")
|
||||||
|
.innerJoin(
|
||||||
|
(qb) =>
|
||||||
|
qb
|
||||||
|
.select(["gym_set2.name", "MAX(gym_set2.weight) AS max_weight"])
|
||||||
|
.from(GymSet, "gym_set2")
|
||||||
|
.where("gym_set2.name LIKE (:name)", { name: `%${term.trim()}%` })
|
||||||
|
.groupBy("gym_set2.name"),
|
||||||
|
"subquery",
|
||||||
|
"gym_set.name = subquery.gym_set2_name AND gym_set.weight = subquery.max_weight"
|
||||||
|
)
|
||||||
|
.limit(LIMIT)
|
||||||
|
.offset(offset || 0)
|
||||||
|
.getMany();
|
||||||
|
};
|
||||||
|
|
59
colors.ts
59
colors.ts
|
@ -1,40 +1,27 @@
|
||||||
import {DarkTheme, DefaultTheme} from 'react-native-paper'
|
import { DefaultTheme, MD3DarkTheme } from "react-native-paper";
|
||||||
|
|
||||||
export const lightColors = [
|
export const LIGHT_COLORS = [
|
||||||
{hex: DarkTheme.colors.primary, name: 'Purple'},
|
{ hex: MD3DarkTheme.colors.primary, name: "Purple" },
|
||||||
{hex: '#B3E5FC', name: 'Blue'},
|
{ hex: "#B3E5FC", name: "Blue" },
|
||||||
{hex: '#FA8072', name: 'Salmon'},
|
{ hex: "#FA8072", name: "Salmon" },
|
||||||
{hex: '#FFC0CB', name: 'Pink'},
|
{ hex: "#FFC0CB", name: "Pink" },
|
||||||
{hex: '#E9DCC9', name: 'Linen'},
|
{ hex: "#E9DCC9", name: "Linen" },
|
||||||
]
|
{ hex: "#9ACD32", name: "Green" },
|
||||||
|
{ hex: "#FFD700", name: "Gold" },
|
||||||
|
{ hex: "#00CED1", name: "Turquoise" },
|
||||||
|
];
|
||||||
|
|
||||||
export const darkColors = [
|
export const DARK_COLORS = [
|
||||||
{hex: DefaultTheme.colors.primary, name: 'Purple'},
|
{ hex: DefaultTheme.colors.primary, name: "Purple" },
|
||||||
{hex: '#0051a9', name: 'Blue'},
|
{ hex: "#0051a9", name: "Blue" },
|
||||||
{hex: '#000000', name: 'Black'},
|
{ hex: "#000000", name: "Black" },
|
||||||
{hex: '#863c3c', name: 'Red'},
|
{ hex: "#863c3c", name: "Brandy" },
|
||||||
{hex: '#1c6000', name: 'Kermit'},
|
{ hex: "#1c6000", name: "Kermit" },
|
||||||
]
|
{ hex: "#990000", name: "Red" },
|
||||||
|
{ hex: "#660066", name: "Magenta" },
|
||||||
|
];
|
||||||
|
|
||||||
export const colorShade = (color: any, amount: number) => {
|
export function darkenRgba(rgba: string, amount: number) {
|
||||||
color = color.replace(/^#/, '')
|
let [r, g, b, a] = rgba.match(/\d+/g).map(Number);
|
||||||
if (color.length === 3)
|
return `rgba(${r}, ${g}, ${b}, ${Math.max(0, a - amount)})`;
|
||||||
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}`
|
|
||||||
}
|
}
|
||||||
|
|
13
constants.ts
13
constants.ts
|
@ -1,5 +1,8 @@
|
||||||
export const MARGIN = 10
|
export const MARGIN = 10;
|
||||||
export const PADDING = 10
|
export const PADDING = 10;
|
||||||
export const ITEM_PADDING = 8
|
export const ITEM_PADDING = 8;
|
||||||
export const DARK_RIPPLE = '#444444'
|
export const DARK_RIPPLE = "#444444";
|
||||||
export const LIGHT_RIPPLE = '#c2c2c2'
|
export const LIGHT_RIPPLE = "#c2c2c2";
|
||||||
|
export const DARK_SUBDUED = "#909090ff";
|
||||||
|
export const LIGHT_SUBDUED = "#717171ff";
|
||||||
|
export const LIMIT = 15;
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
export function convert(weight: number, fromUnit: string, toUnit: string) {
|
||||||
|
let result = Number(weight);
|
||||||
|
if (fromUnit === "lb" && toUnit === "kg") result /= 2.2;
|
||||||
|
else if (fromUnit === "kg" && toUnit === "lb") result *= 2.2;
|
||||||
|
else if (fromUnit === "stone" && toUnit === "kg") result *= 6.35;
|
||||||
|
else if (fromUnit === "kg" && toUnit === "stone") result /= 6.35;
|
||||||
|
else if (fromUnit === "stone" && toUnit === "lb") result *= 14;
|
||||||
|
else if (fromUnit === "lb" && toUnit === "stone") result /= 14;
|
||||||
|
result = Math.round((result + Number.EPSILON) * 100) / 100;
|
||||||
|
return result;
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
export default interface CountMany {
|
export default interface CountMany {
|
||||||
name: string
|
name: string;
|
||||||
total: number
|
total: number;
|
||||||
sets?: number
|
sets?: number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,39 +1,53 @@
|
||||||
import {DataSource} from 'typeorm'
|
import { DataSource } from "typeorm";
|
||||||
import GymSet from './gym-set'
|
import GymSet from "./gym-set";
|
||||||
import {Sets1667185586014 as sets1667185586014} from './migrations/1667185586014-sets'
|
import { Sets1667185586014 as sets1667185586014 } from "./migrations/1667185586014-sets";
|
||||||
import {plans1667186124792} from './migrations/1667186124792-plans'
|
import { plans1667186124792 } from "./migrations/1667186124792-plans";
|
||||||
import {settings1667186130041} from './migrations/1667186130041-settings'
|
import { settings1667186130041 } from "./migrations/1667186130041-settings";
|
||||||
import {addSound1667186139844} from './migrations/1667186139844-add-sound'
|
import { addSound1667186139844 } from "./migrations/1667186139844-add-sound";
|
||||||
import {addHidden1667186159379} from './migrations/1667186159379-add-hidden'
|
import { addHidden1667186159379 } from "./migrations/1667186159379-add-hidden";
|
||||||
import {addNotify1667186166140} from './migrations/1667186166140-add-notify'
|
import { addNotify1667186166140 } from "./migrations/1667186166140-add-notify";
|
||||||
import {addImage1667186171548} from './migrations/1667186171548-add-image'
|
import { addImage1667186171548 } from "./migrations/1667186171548-add-image";
|
||||||
import {addImages1667186179488} from './migrations/1667186179488-add-images'
|
import { addImages1667186179488 } from "./migrations/1667186179488-add-images";
|
||||||
import {insertSettings1667186203827} from './migrations/1667186203827-insert-settings'
|
import { insertSettings1667186203827 } from "./migrations/1667186203827-insert-settings";
|
||||||
import {addSteps1667186211251} from './migrations/1667186211251-add-steps'
|
import { addSteps1667186211251 } from "./migrations/1667186211251-add-steps";
|
||||||
import {addSets1667186250618} from './migrations/1667186250618-add-sets'
|
import { addSets1667186250618 } from "./migrations/1667186250618-add-sets";
|
||||||
import {addMinutes1667186255650} from './migrations/1667186255650-add-minutes'
|
import { addMinutes1667186255650 } from "./migrations/1667186255650-add-minutes";
|
||||||
import {addSeconds1667186259174} from './migrations/1667186259174-add-seconds'
|
import { addSeconds1667186259174 } from "./migrations/1667186259174-add-seconds";
|
||||||
import {addShowUnit1667186265588} from './migrations/1667186265588-add-show-unit'
|
import { addShowUnit1667186265588 } from "./migrations/1667186265588-add-show-unit";
|
||||||
import {addColor1667186320954} from './migrations/1667186320954-add-color'
|
import { addColor1667186320954 } from "./migrations/1667186320954-add-color";
|
||||||
import {addSteps1667186348425} from './migrations/1667186348425-add-steps'
|
import { addSteps1667186348425 } from "./migrations/1667186348425-add-steps";
|
||||||
import {addDate1667186431804} from './migrations/1667186431804-add-date'
|
import { addDate1667186431804 } from "./migrations/1667186431804-add-date";
|
||||||
import {addShowDate1667186435051} from './migrations/1667186435051-add-show-date'
|
import { addShowDate1667186435051 } from "./migrations/1667186435051-add-show-date";
|
||||||
import {addTheme1667186439366} from './migrations/1667186439366-add-theme'
|
import { addTheme1667186439366 } from "./migrations/1667186439366-add-theme";
|
||||||
import {addShowSets1667186443614} from './migrations/1667186443614-add-show-sets'
|
import { addShowSets1667186443614 } from "./migrations/1667186443614-add-show-sets";
|
||||||
import {addSetsCreated1667186451005} from './migrations/1667186451005-add-sets-created'
|
import { addSetsCreated1667186451005 } from "./migrations/1667186451005-add-sets-created";
|
||||||
import {addNoSound1667186456118} from './migrations/1667186456118-add-no-sound'
|
import { addNoSound1667186456118 } from "./migrations/1667186456118-add-no-sound";
|
||||||
import {dropMigrations1667190214743} from './migrations/1667190214743-drop-migrations'
|
import { dropMigrations1667190214743 } from "./migrations/1667190214743-drop-migrations";
|
||||||
import {splitColor1669420187764} from './migrations/1669420187764-split-color'
|
import { splitColor1669420187764 } from "./migrations/1669420187764-split-color";
|
||||||
import {Plan} from './plan'
|
import { addBackup1678334268359 } from "./migrations/1678334268359-add-backup";
|
||||||
import Settings from './settings'
|
import { planTitle1692654882408 } from "./migrations/1692654882408-plan-title";
|
||||||
|
import { weight1697766633971 } from "./migrations/1697766633971-weight";
|
||||||
|
import { exercises1699508495726 } from "./migrations/1699508495726-exercises";
|
||||||
|
import { exercisesFix1699613077628 } from "./migrations/1699613077628-exercises-fix";
|
||||||
|
import { settingsDuration1699743753975 } from "./migrations/1699743753975-settings-duration";
|
||||||
|
import { settingsStartup1699783784680 } from "./migrations/1699783784680-settings-startup";
|
||||||
|
import { settingsBackupDir1699839054226 } from "./migrations/1699839054226-settings-backup-dir";
|
||||||
|
import { homeHistoryStartup1699853245534 } from "./migrations/1699853245534-home-history-startup";
|
||||||
|
import { autoConvert1699948105001 } from "./migrations/1699948105001-auto-convert";
|
||||||
|
import { Plan } from "./plan";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import Weight from "./weight";
|
||||||
|
import { settingsDefaultSets1700009253976 } from "./migrations/1700009253976-settings-default-sets";
|
||||||
|
import { settingsDefaults1700009729468 } from "./migrations/1700009729468-settings-defaults";
|
||||||
|
import { leadingZeros1707094662099 } from "./migrations/1707094662099-leading-zeros";
|
||||||
|
|
||||||
export const AppDataSource = new DataSource({
|
export const AppDataSource = new DataSource({
|
||||||
type: 'react-native',
|
type: "react-native",
|
||||||
database: 'massive.db',
|
database: "massive.db",
|
||||||
location: 'default',
|
location: "default",
|
||||||
entities: [GymSet, Plan, Settings],
|
entities: [GymSet, Plan, Settings, Weight],
|
||||||
migrationsRun: true,
|
migrationsRun: true,
|
||||||
migrationsTableName: 'typeorm_migrations',
|
migrationsTableName: "typeorm_migrations",
|
||||||
migrations: [
|
migrations: [
|
||||||
sets1667185586014,
|
sets1667185586014,
|
||||||
plans1667186124792,
|
plans1667186124792,
|
||||||
|
@ -59,5 +73,18 @@ export const AppDataSource = new DataSource({
|
||||||
addNoSound1667186456118,
|
addNoSound1667186456118,
|
||||||
dropMigrations1667190214743,
|
dropMigrations1667190214743,
|
||||||
splitColor1669420187764,
|
splitColor1669420187764,
|
||||||
|
addBackup1678334268359,
|
||||||
|
planTitle1692654882408,
|
||||||
|
weight1697766633971,
|
||||||
|
exercises1699508495726,
|
||||||
|
exercisesFix1699613077628,
|
||||||
|
settingsDuration1699743753975,
|
||||||
|
settingsStartup1699783784680,
|
||||||
|
settingsBackupDir1699839054226,
|
||||||
|
homeHistoryStartup1699853245534,
|
||||||
|
autoConvert1699948105001,
|
||||||
|
settingsDefaultSets1700009253976,
|
||||||
|
settingsDefaults1700009729468,
|
||||||
|
leadingZeros1707094662099,
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const DAYS = [
|
||||||
|
"Sunday",
|
||||||
|
"Monday",
|
||||||
|
"Tuesday",
|
||||||
|
"Wednesday",
|
||||||
|
"Thursday",
|
||||||
|
"Friday",
|
||||||
|
"Saturday",
|
||||||
|
];
|
27
db.ts
27
db.ts
|
@ -1,14 +1,17 @@
|
||||||
import {AppDataSource} from './data-source'
|
import { AppDataSource } from "./data-source";
|
||||||
import GymSet from './gym-set'
|
import GymSet from "./gym-set";
|
||||||
import {Plan} from './plan'
|
import { Plan } from "./plan";
|
||||||
import Settings from './settings'
|
import Settings from "./settings";
|
||||||
|
import Weight from "./weight";
|
||||||
|
|
||||||
export const setRepo = AppDataSource.manager.getRepository(GymSet)
|
export const setRepo = AppDataSource.manager.getRepository(GymSet);
|
||||||
export const planRepo = AppDataSource.manager.getRepository(Plan)
|
export const planRepo = AppDataSource.manager.getRepository(Plan);
|
||||||
export const settingsRepo = AppDataSource.manager.getRepository(Settings)
|
export const settingsRepo = AppDataSource.manager.getRepository(Settings);
|
||||||
|
export const weightRepo = AppDataSource.manager.getRepository(Weight);
|
||||||
|
|
||||||
export const getNow = (): Promise<{now: string}[]> => {
|
export const getNow = async (): Promise<string> => {
|
||||||
return AppDataSource.manager.query(
|
const query = await AppDataSource.manager.query(
|
||||||
"SELECT STRFTIME('%Y-%m-%dT%H:%M:%S','now','localtime') AS now",
|
"SELECT STRFTIME('%Y-%m-%dT%H:%M:%S','now','localtime') AS now"
|
||||||
)
|
);
|
||||||
}
|
return query[0].now;
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"fmt": {
|
||||||
|
"useTabs": false,
|
||||||
|
"lineWidth": 80,
|
||||||
|
"semiColons": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"include": ["src/"],
|
||||||
|
"exclude": ["src/testdata/", "data/fixtures/**/*.ts"]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
import simpleGit from 'simple-git';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
execSync('npx tsc', { stdio: 'inherit' });
|
||||||
|
|
||||||
|
let build = readFileSync('android/app/build.gradle', 'utf8');
|
||||||
|
|
||||||
|
const codeMatch = build.match(/versionCode (\d+)/);
|
||||||
|
if (!codeMatch) throw new Error('versionCode not found in build.gradle');
|
||||||
|
const versionCode = parseInt(codeMatch[1], 10) + 1;
|
||||||
|
build = build.replace(/versionCode \d+/, `versionCode ${versionCode}`);
|
||||||
|
|
||||||
|
const nameMatch = build.match(/versionName "(\d+\.\d+)"/);
|
||||||
|
if (!nameMatch) throw new Error('versionName not found in build.gradle');
|
||||||
|
const versionParts = nameMatch[1].split('.');
|
||||||
|
versionParts[1] = (parseInt(versionParts[1], 10) + 1).toString();
|
||||||
|
const versionName = versionParts.join('.');
|
||||||
|
build = build.replace(/versionName "\d+\.\d+"/, `versionName "${versionName}"`);
|
||||||
|
|
||||||
|
writeFileSync('android/app/build.gradle', build);
|
||||||
|
|
||||||
|
let packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||||
|
packageJson.version = versionName;
|
||||||
|
writeFileSync('package.json', JSON.stringify(packageJson, null, 2));
|
||||||
|
|
||||||
|
const git = simpleGit();
|
||||||
|
await git.add(['package.json', 'android/app/build.gradle']);
|
||||||
|
await git.log(['-1']).then(log => {
|
||||||
|
const newTitle = `${log.latest.message} - ${versionName} 🚀`;
|
||||||
|
console.log(newTitle);
|
||||||
|
const message = [newTitle, log.latest.body].join('\n');
|
||||||
|
return git.commit(message, [], ['--amend']);
|
||||||
|
}).then(() => {
|
||||||
|
return git.addTag(versionCode.toString());
|
||||||
|
}).then(() => {
|
||||||
|
return git.push('origin', 'HEAD', ['--tags', '--force']);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Error amending commit:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.chdir('android')
|
||||||
|
const isWindows = os.platform() === 'win32';
|
||||||
|
execSync(isWindows ? '.\\gradlew.bat bundleRelease -q' : './gradlew bundleRelease -q', { stdio: 'inherit' });
|
||||||
|
execSync('bundle install --quiet', { stdio: 'inherit' });
|
||||||
|
execSync('bundle exec fastlane supply --aab app/build/outputs/bundle/release/app-release.aab', { stdio: 'inherit' });
|
42
deploy.sh
42
deploy.sh
|
@ -1,42 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -ex
|
|
||||||
|
|
||||||
yarn tsc
|
|
||||||
yarn lint
|
|
||||||
git push origin HEAD
|
|
||||||
|
|
||||||
cd android || exit 1
|
|
||||||
|
|
||||||
build=app/build.gradle
|
|
||||||
versionCode=$(
|
|
||||||
grep '^\s*versionCode [0-9]*$' "$build" | awk '{print $2+1}'
|
|
||||||
)
|
|
||||||
major=$(
|
|
||||||
grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" |
|
|
||||||
sed 's/"//g' | cut -d '.' -f 1 | awk '{print $2}'
|
|
||||||
)
|
|
||||||
minor=$(
|
|
||||||
grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" |
|
|
||||||
sed 's/"//g' | cut -d '.' -f 2
|
|
||||||
)
|
|
||||||
minor=$((minor + 1))
|
|
||||||
|
|
||||||
sed -i "s/\(^\s*\)versionCode [0-9]*$/\1versionCode $versionCode/" \
|
|
||||||
"$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
|
|
||||||
|
|
||||||
[ "$1" != "--nobundle" ] && ./gradlew bundleRelease
|
|
||||||
|
|
||||||
bundle install
|
|
||||||
bundle exec fastlane supply --aab app/build/outputs/bundle/release/app-release.aab
|
|
||||||
|
|
||||||
git add app/build.gradle ../package.json
|
|
||||||
git commit --no-verify --message "Set versionCode=$versionCode"
|
|
||||||
git tag "$versionCode"
|
|
||||||
git push origin HEAD &
|
|
||||||
git push --tags
|
|
||||||
|
|
||||||
cd ..
|
|
||||||
./install.sh
|
|
|
@ -1,8 +0,0 @@
|
||||||
export type DrawerParamList = {
|
|
||||||
Home: {}
|
|
||||||
Settings: {}
|
|
||||||
Best: {}
|
|
||||||
Plans: {}
|
|
||||||
Workouts: {}
|
|
||||||
Timer: {}
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue