Compare commits

...

576 Commits

Author SHA1 Message Date
314b09017b
Add title to Plan 2023-08-21 14:25:29 +02:00
331597e3ee Add increment/decrement buttons to reps/weight - 1.148
Closes brandon.presley/Massive#164
2023-08-14 13:32:10 +12:00
79cde3a219 Use accurate theme color for switch text
Only if no custom color is provided
2023-08-14 10:55:24 +12:00
63e1db7349 Rename variable in SettingsPage 2023-08-14 10:50:44 +12:00
da17f8899c Paginate graphs
Also factor out LIMIT constant
2023-08-14 10:42:15 +12:00
8648cf166e Remove prettier from project deps 2023-08-13 20:58:36 +12:00
af96ec8507 Validate numbers in EditWorkout - 1.147 2023-08-12 15:41:32 +12:00
f51284e4ea Validate and fix numbers in StartPlan 2023-08-12 15:30:47 +12:00
f778426aba Run prettier
Something happened with the deno formatter,
I can't remember what! Hahahahahaahahaha
2023-08-12 15:23:02 +12:00
44283fc990 Validate reps+weight on EditSet
Numbers shouldn't contain dashes, spaces or commas.
2023-08-12 15:22:00 +12:00
c97ba1151e Optimize GraphsList
Instead of getting a list of max weights,
then looping in JS to run SQL queries for max
reps, use a subquery.
2023-08-12 15:03:31 +12:00
95681c0b3d Save plan before starting it
Also after saving it makes more sense to just
navigate to PlanList instead of calling
navigation.goBack(). This way we can make sure
we have up-to-date data.

The old way would typically lead to us seeing
stale data. E.g.
1. Tap on a plan
2. Tap on edit
3. Change details of the plan
4. Press save
5. See old plan

Now when we save we instead see the original list of plans.
2023-07-31 15:54:32 +12:00
158dd61668 Prevent sets dissapearing after updating - 1.146
Previously we would always update the created
field by making a new Date object, even if
the user didn't pick a new date. On some
devices this might slightly change the time
of the day, meaning it jumps somewhere else
on the home page.
2023-07-27 13:31:50 +12:00
e628d345ca Reduce escaping of characters 2023-07-23 14:52:22 +12:00
85915b9aa0 Retrieve last set when running a plan - 1.145
Closes brandon.presley/Massive#162

We should just keep it simple and get the most recent set instead of
trying to figure out what the best kind of maximum would be.
2023-07-23 13:59:11 +12:00
9833752bab Deno fmt 2023-07-20 14:55:19 +12:00
556347e632 Include date picker for new sets - 1.144
Not sure why I had this hidden,
probably made sense in the earlier
versions of this app.
2023-07-17 18:52:38 +12:00
9dc188e6ec Remove duplicated permission in AndroidManifest
Also add something to remove the "advertising ID"
or whatever the hell that means. (Thanks google?)
2023-07-17 18:43:15 +12:00
82da62f699 Remove unused variable from ListMenu 2023-07-17 18:39:09 +12:00
36d3de401b Fix a few instances of react/no-unstable-nested-components 2023-07-17 18:38:28 +12:00
040d588b5a Ignore mock-providers.tsx 2023-07-17 16:46:23 +12:00
47d4532169 Fix .eslintrc.js 2023-07-17 16:45:21 +12:00
3e41c3bbd8 Use outlined buttons instead of contained ones
I like them better! Ahahahahahahahahah
Bwahahahahahahahahahahahahahahahahahah
2023-07-17 16:24:09 +12:00
b776d88327 Fix snackbar color for button 2023-07-17 16:21:56 +12:00
adc2d05b2c Add back in missing titleStyle in Select.tsx 2023-07-15 15:02:29 +12:00
c3a3e33e25 Fix install.sh script for new react-native output
Now they just put it in app-release.apk i guess
2023-07-15 15:02:13 +12:00
89606b9d21 Fix type errors related to upgrade 2023-07-15 14:38:46 +12:00
6dabb7049f Upgrade all packages 2023-07-15 14:19:08 +12:00
4b42ab5f21 Upgrade react-native-paper to v5 2023-07-15 13:21:09 +12:00
a7da93583d Upgrade to react-native 0.72.3 2023-07-15 12:16:42 +12:00
1b2cbab370 Simplify SetItem
It had a pointless react fragment
wrapping it's only element.
2023-07-07 13:41:48 +12:00
09354829a8 Update yarn.lock 2023-07-07 13:21:44 +12:00
514efc6467 Upgrade react-native to 0.72.1
This unfortunately has broken the unit tests.
Will have to worry about fixing them later.
Everything seems to be working other
than that.
2023-07-07 13:17:19 +12:00
1603496424 Rework Best -> Graphs
I was considering adding weight tracking,
so then this graph page would include body
weight graphs. Meaning it's not really
recording the "best" of anything.
It currently only shows the best on the list
page anyway.
2023-07-04 11:35:52 +12:00
0beb1397a6 Hide play button from new plans - 1.143
It doesn't make sense to start a plan
that hasn't been created yet.
2023-07-04 11:20:07 +12:00
a5b6673e9a Don't auto-focus weight when editing a set - 1.142
I find myself opening up a set often to
just read the fields rather than changing
anything.
It makes sense to auto-focus the name for
a brand new set, since it's required and
typically what you first fill out.
2023-06-29 16:31:15 +12:00
6a7bd632e5 Add delete database button - 1.141
Semi-related to brandon.presley/Massive#160
If a user manages to import a database that ultimately
breaks the app elsewhere, deleting the database is a nice
tool to try and fix things.
2023-06-29 15:34:14 +12:00
4303fe2cc4 Use deno fmt instead of prettier 2023-06-27 15:16:59 +12:00
23ed95dcdb Reduce debug logging in ViewBest - 1.140 2023-06-24 13:06:35 +12:00
8f1f9f6e7d Ran bundle update 2023-06-22 10:26:01 +12:00
bdd5e23f32 Change date formats to be day/month instead of month/day - 1.139
Also added the ISO one yyyy-mm-dd, time.
Closes #157.
2023-06-18 11:36:35 +12:00
9c9a5fdd63 Trim search queries - 1.138
Closes #156
2023-06-13 14:18:49 +12:00
90db607190 Easily swap between edit/start for plans - 1.137 2023-03-28 12:20:32 +13:00
457134df6b Only show share button on best view 2023-03-28 12:04:54 +13:00
db5cc566ea Remove double permissions request and fix import - 1.136 2023-03-27 14:45:28 +13:00
76e5aeacfd Choose directory when backing up automatically - 1.135
Related to #146.
2023-03-27 14:34:17 +13:00
2fb46e1dcc Fix audio type on document picker 2023-03-27 14:01:37 +13:00
d1342c0efa Update fastlane 2023-03-24 19:32:26 +13:00
288ae1ae0c Disable timers if rest time is set to zero - 1.134 2023-03-24 19:16:35 +13:00
0e7920bde9 Show a toast when EACCESS from backing up - 1.133
I don't like having a bunch of backups saying
massive (x).db. Also this masks the issue of permissions,
which is easily fixed by deleting/moving the old massive.db.
2023-03-24 17:51:16 +13:00
d2a1c432bb Handle EACCESS in BackupModule 2023-03-24 17:43:28 +13:00
5dd569ef72 Upgrade to react-native 0.70.2 - 1.132 2023-03-21 17:09:24 +13:00
dfc4f73ca4 Upgrade react-native to 0.70.6 2023-03-21 16:59:21 +13:00
79a48b1e47 Run automatic backups after database imports - 1.131 2023-03-09 18:48:32 +13:00
13b340f5be Add setting to automatically backup - 1.130
Every day at 6am (also immediately when toggled)
we will copy the massive.db file to the Download
directory.
2023-03-09 17:16:08 +13:00
4db820f10a Remove DownloadModule
This was no longer in use
2023-03-07 18:23:38 +13:00
7b401388b5 Get last now excludes todays sets - 1.129
Otherwise the minute you enter something it
becomes the last set. Much more useful to be
showing yesterdays working set instead.
2023-03-06 18:32:32 +13:00
a1643c349d Revert "Update timer page screenshot"
This reverts commit 640a25a0f4.
2023-03-02 19:15:26 +13:00
640a25a0f4 Update timer page screenshot 2023-03-02 19:14:45 +13:00
c9b1ab1f9d Change default reps/weight for starting a plan - 1.128
Previously we used the globally best reps+weight
set by default. More commonly we build up to our
last best working set. People can still check out
their best sets on the best page.

Closes #153
2023-03-02 19:11:24 +13:00
00d4edcfc3 Request FOREGROUND_SERVICE permission - 1.127
Related to #142
2023-02-24 19:30:48 +13:00
8dd8f786ef Round graphs to 2dp - 1.126
Closes #152
2023-02-22 19:44:23 +13:00
a84cab6bbf Optimize batteries after importing database - 1.125
Closes #151
2023-02-14 16:50:14 +13:00
f4db61aeec Fix unit tests - 1.124 2023-02-14 16:41:30 +13:00
3af3e1faf2 Order plan workouts alphabetically
Closes #150
2023-02-14 16:37:09 +13:00
7bc9c00a63 Merge branch 'master' of gitea.presley.nz:brandon.presley/Massive 2023-02-13 11:05:32 +13:00
a03731c6ff Prevent empty flicker on best view - 1.122 - 1.123
Closes #148
2023-02-13 10:43:03 +13:00
f1e0911488 Prevent empty flicker on best view - 1.122
Closes #148
2023-02-04 15:15:34 -07:00
1a75d8897d Skip deploy checks for -n flag - 1.119 2023-02-04 14:16:31 -07:00
9f7cbba80a Add -n flag to deploy.sh 2023-02-04 14:14:29 -07:00
de2aa67e6e Version 1.118 2023-01-26 20:04:53 -07:00
28ec021258 Fix copying sets - 1.117
Related to #143
2023-01-17 10:22:21 -07:00
04ef72e48b Fix unit tests - 1.116 2023-01-08 18:10:24 +13:00
467df629b0 Change edit headers to add when adding 2023-01-08 18:05:59 +13:00
e7f85a9954 Add date/time picker to EditSet - 1.115 2023-01-08 18:02:17 +13:00
5e6896eaba Ignore coverage directory for linting 2023-01-08 14:01:43 +13:00
6438a9c48a Use the same colors as switch for timer page - 1.114 2023-01-08 14:00:27 +13:00
8e8961419c Add plan list unit tests 2023-01-05 17:15:16 +13:00
b0696d1d58 Add view best unit tests 2023-01-05 17:07:06 +13:00
73d9b1c617 Remove duplicate git push from deploy.sh - 1.113
We already do a git push at the end.
2023-01-05 16:43:56 +13:00
a6130b3a10 Merge branch 'master' of gitea.presley.nz:brandon.presley/Massive 2023-01-05 16:41:47 +13:00
7bee8ae732 Try amending last commit for deployment script - 1.112
By including the version name in the last commit,
if that commit references an issue users now know
what version number to expect the fix in.
2023-01-05 16:40:10 +13:00
6c8731c17a Try amending last commit for deployment script
By including the version name in the last commit,
if that commit references an issue users now know
what version number to expect the fix in.
2023-01-05 16:35:53 +13:00
6fa2bbb506 Add versionName to commit message for deployment - 1.102
Users are seeing the version name the version code so we should
try to use it as much as possible.
2023-01-05 16:33:48 +13:00
069f770c96 Set versionCode=36137 2023-01-04 13:47:22 +13:00
b41c30d886 Replace it with test for jest files
Test explains what it is we are writing, whereas it
doesn't.
2023-01-04 13:44:28 +13:00
495b89fba3 Add unit to save set test 2023-01-04 13:27:17 +13:00
42912040ff Simplify getNow 2023-01-04 13:24:49 +13:00
c7952738b5 Add selected title for plans + sets
Inspired by the stock Files app in Android.
2023-01-03 17:21:51 +13:00
cffc458338 Start alarms as a foreground service
Related to #142.
Can't be sure this fixed anything because I can't replicate the error
on my emulators running android 13. I need to install android 13
on a real device and try replicate + see if this fixes it.
2023-01-03 17:04:51 +13:00
05237fc293 Set versionCode=36136 2023-01-03 15:15:33 +13:00
5fd7e75908 Fix settings persistence issues 2023-01-03 14:59:19 +13:00
705052f1b4 Set versionCode=36135 2023-01-03 11:33:43 +13:00
efc97bdf47 Fix settings page on shorter devices 2023-01-03 11:31:37 +13:00
d0702b7675 Reduce redundancy of labels for theme setting 2023-01-03 11:31:25 +13:00
24e230e8b9 Use a scroll view for settings page
This way shorter screens dont cut off content
2023-01-02 23:29:46 +13:00
67689f4af8 Set versionCode=36134 2023-01-02 18:56:53 +13:00
a2721e9f12 Use sqlite in Android code for alarm settings
Closes #129
2023-01-02 18:54:35 +13:00
bafdecd3e3 Add unit tests for EditSets
Closes #138
2023-01-01 20:47:07 +13:00
e432c1b711 Add app test 2023-01-01 18:33:03 +13:00
08f91bf531 Test saving a plan 2023-01-01 18:13:45 +13:00
80f2dfdff5 Improve performance of Routes.tsx 2023-01-01 18:05:11 +13:00
3c9b93f0bc Test adding a new set 2023-01-01 18:01:46 +13:00
f221ebb8df Test editing a workout 2023-01-01 15:21:56 +13:00
a68d4d6a69 Include tests directory in organize.sh 2023-01-01 15:21:42 +13:00
5d9df37778 Organize imports 2023-01-01 15:20:56 +13:00
8246155c13 Test start plan + edit plan 2023-01-01 15:06:42 +13:00
58f1c905b2 Test edit set component 2023-01-01 14:47:16 +13:00
805b7bdc34 Simplify tests 2023-01-01 14:35:22 +13:00
a772e36160 Add tests to deploy script 2023-01-01 14:32:23 +13:00
ad95438120 Test all pages 2023-01-01 14:32:10 +13:00
3c953530a4 Add working test for homepage 2023-01-01 14:19:32 +13:00
13e1d4cc21 Move the theme object to be inline for mock-providers
This way we get better auto completion
2023-01-01 14:19:15 +13:00
21773e3b4f Remove fake timers from jestSetup
I believe this was from ages ago when I was using `setInterval` to try
and keep state of the timers from JavaScript. Now we are relying on our
timers in Kotlin so we don't need this any more.
2023-01-01 14:18:36 +13:00
27ff4861d9 Reduce logs in SetList 2023-01-01 14:18:27 +13:00
e43188ccdf Merge branch 'master' into unit-tests 2023-01-01 13:57:01 +13:00
9287c31e70 Update fastlane 2023-01-01 13:47:04 +13:00
5612df5d8c Set versionCode=36133 2023-01-01 13:46:26 +13:00
a78e07dac8 Keep list menu open after selecting all
Typically when you select all records it's because you want
to operate on those records immediately. The only case
this wouldn't be true is if you were scrolling, then selecting all
a bunch of times. I think it's more likely someone just wants to
select all, then delete.
2023-01-01 13:43:55 +13:00
86f01eb002 Memoize the most expensive parts of SettingsPage 2023-01-01 13:39:10 +13:00
5335f4afbc Memoize switches in SettingsPage 2023-01-01 13:32:26 +13:00
d71ad8c170 Set versionCode=36132 2023-01-01 13:18:13 +13:00
53799fdcc4 Change mode of text input from outlined to default
Related to #140
2023-01-01 13:16:08 +13:00
651b130caa Remove pdf I accidentally committed of the readme 2023-01-01 13:15:23 +13:00
73c7486eb3 Update plan start image 2022-12-30 20:46:39 +13:00
ea2ff913db Update screenshots 2022-12-30 20:45:12 +13:00
0be8f03133 Set versionCode=36131 2022-12-30 20:40:15 +13:00
7863b9caa0 Improve speed of setting sound string as well 2022-12-30 20:38:34 +13:00
3fdc5900e3 Improve performance of setttings toggles
I was awaiting the result of the sqlite operations
when really I should have just set the state immediately,
and done the operations in the background.
2022-12-30 20:37:08 +13:00
e51aad21f3 Set versionCode=36130 2022-12-30 20:31:34 +13:00
f48124123c Delete unused variable from Routes.tsx 2022-12-30 20:29:47 +13:00
3603c67133 Add filtering back in to SettingsPage
I accidentally removed it and pushed to production...
Woopsie.
2022-12-30 20:29:16 +13:00
a9000898f3 Replace children with title for Switch
Apparently, the children prop makes React.memo
not work any more. I read about it in
https://stackoverflow.com/questions/53074551/when-should-you-not-use-react-memo
2022-12-30 19:49:54 +13:00
dd7cb0406b Use React.memo for Switch 2022-12-30 19:42:30 +13:00
a5ddf5c94d Use React.memo in Select.tsx 2022-12-30 19:39:35 +13:00
9be10610d2 Use React.memo on AppInput 2022-12-30 19:39:20 +13:00
5e37490c2d Add react-hook-form for settings page
This seems to have improved performance.

Related to #135.
2022-12-30 15:47:12 +13:00
7f1513f0a5 Set versionCode=36129 2022-12-30 13:37:42 +13:00
14edb66e28 Add common date formats
Add yyyy-MM-d and yyyy.MM.d formats

Closes #139
2022-12-30 13:35:11 +13:00
3ed2d4f0cd Never pass falsy value to date format
Closes #141

After adding TypeORM I had to remove the strict checking in TypeScript.
This leads to null errors such as this. Kind of annoying, but I think
the large reduction in code from adding TypeORM is worth it. We
shall see...
2022-12-30 13:30:13 +13:00
46dd50adfb Pause adding unit tests 2022-12-30 13:25:47 +13:00
e430873771 Remove commented code from jestSetup 2022-12-30 12:35:19 +13:00
a9266ba77b Set versionCode=36128 2022-12-29 20:11:34 +13:00
051df31925 Fix inactive track color on mismatched themes
Before if your system theme was dark and your app
theme was set to light, the track colors were wrong.
2022-12-29 19:30:26 +13:00
a3138c48b5 Combine several state operations in SettingsPage 2022-12-29 18:20:40 +13:00
2b302bab73 Move theme line of SettingsPage
Because I am schizophrenic.
2022-12-29 17:28:31 +13:00
7bf802ea45 Set versionCode=36127 2022-12-29 16:43:16 +13:00
5115055280 Reword MassiveX as AppX 2022-12-29 13:57:19 +13:00
41ed9464c9 Use single settings object for state
Since all this state was being set at the same time,
on load, it makes more sense to have it as a single state.
Also they are all connected to the same table so it makes more
sense this way.

Also, I removed the use focus effect in favor of just a
use effect which runs on mount. The things being refreshed here
weren't very important to be updated frequently, and focus effect
has a cost on performance.
2022-12-29 13:52:38 +13:00
596b695c5b Remove unlabelled log from StartPlan 2022-12-28 15:00:00 +13:00
c664a9603c Add missing toasts to some settings
- Date format
- Dark color
- Light color
2022-12-28 14:59:39 +13:00
186b7e0fe9 Remove ios check from SettingsPage
These things are pointless unless I
can get the app deployed to the iOS store.
2022-12-28 14:59:19 +13:00
d6e7d6158c Make sure alarms aren't on with app being optimized
If battery optimizations are on for the app,
alarms will have several unpredictable bugs.
For example, sometimes sounds won't play,
sometimes re focuisng the app won't work.
2022-12-28 14:56:29 +13:00
2c6a773548 Change alarm sound toast
Closes #137
2022-12-28 14:15:02 +13:00
b33a829816 Set versionCode=36126 2022-12-27 00:39:22 +13:00
85ea20640d Fix migrations on android 10
Closes #136
2022-12-27 00:29:45 +13:00
Tiffany Barclay
2176acd924 Clean up timer page styling
Closes #110
2022-12-25 01:05:48 +13:00
Tiffany Barclay
7e81424f60 Merge branch 'master' of gitea.presley.nz:brandon.presley/Massive 2022-12-25 00:44:26 +13:00
Tiffany Barclay
736cee5ccd Remove react-native-picker 2022-12-25 00:41:25 +13:00
a9b86fb555 Set versionCode=36125 2022-12-24 20:10:26 +13:00
60cc619e39 Move button filter to memoized call
This reduces re-renders
2022-12-24 19:58:55 +13:00
48432188c3 Simplify Switch.tsx 2022-12-24 19:55:38 +13:00
27b7e91e91 Factor out buttons in SettingsPage 2022-12-24 13:36:11 +13:00
fc6f5e3b53 Add missing key prop to SettingsPage 2022-12-24 13:18:03 +13:00
8625ca2189 Set versionCode=36124 2022-12-24 13:13:55 +13:00
250335800f Move clear button after select all 2022-12-24 13:10:40 +13:00
d89721c718 Set versionCode=36123 2022-12-23 18:35:11 +13:00
930ebdc9ca Move clear after select all 2022-12-23 18:32:46 +13:00
777eddf943 Remove usage of FlatList in Settings page
Doing so looks like it improved the performance
of the switches.

Related to #135.
2022-12-22 19:18:44 +13:00
e5d9f5fa92 Add select all button
Very useful final addition to the multi edit/delete function.
Typically a user will search for certain criteria, and then select
them all to be removed/edited. For example, if yesterdays sets were all
10x150kg deadlifts, but you review your form video and decide the form
sucked, you would want to mass edit them to lower the weight/reps (or
maybe delete them). This way you won't have these invalid entries
ruining all your progress graphs (showing false progress).
2022-12-22 17:10:14 +13:00
fb19685bb5 Change delete menu title if selected
Closes #133
2022-12-22 16:18:27 +13:00
f9e357ff80 Factor out list menu 2022-12-21 13:02:53 +13:00
75f2a8269a Set versionCode=36122 2022-12-18 18:28:04 +13:00
f714941c88 Fix deleting a second time
Previously we didn't clear the id list.
This meant if you deleted multiple sets
multiple times, it would break.
2022-12-18 18:25:30 +13:00
faeb5ee1e0 Set versionCode=36121 2022-12-18 14:24:20 +13:00
fa19434e77 Refactor DrawerMenu
Closes #132
2022-12-18 13:23:10 +13:00
888ae576b0 Reduce state in SetList
The `set` state here was just a copy of the first element
of `sets`.
2022-12-18 12:48:20 +13:00
f02249e254 Set versionCode=36120 2022-12-17 16:50:00 +13:00
a7099a205c Move edit in drawer menu to be first 2022-12-17 16:45:23 +13:00
1273b6a6d8 Reduce state in StartPlan
Fixes several issues related to old data.
2022-12-17 16:44:11 +13:00
86566fb54d Allow tapping copy with multiple selected
If multiple are selected, copy will duplicate the
last item that was selected.
2022-12-17 14:06:10 +13:00
c1b63815a2 Set versionCode=36119 2022-12-15 16:44:49 +13:00
a1440b680f Fix ripple color for dark theme 2022-12-15 16:43:00 +13:00
fcd1a4146e Set versionCode=36118 2022-12-14 18:57:05 +13:00
7483a504ee Fix typescript errors 2022-12-14 18:54:20 +13:00
8122694c10 Clear selected when editing/copying 2022-12-14 14:02:11 +13:00
71d4ad805c Add button to clear multi selection 2022-12-14 13:02:18 +13:00
9c21ee022d Add display of old values when mass editing sets 2022-12-14 12:55:03 +13:00
cf68b51fef Edit plans after selecting them 2022-12-14 12:47:36 +13:00
af5a7f5abe Move delete in drawer menu to be last 2022-12-14 12:35:42 +13:00
2e347deb53 Add ability to edit/delete multiple sets/plans 2022-12-13 22:54:37 +13:00
c3b14e901d Remove eslint ignore from SetList 2022-12-12 17:26:58 +13:00
1818e39f41 Set versionCode=36117 2022-12-12 13:18:41 +13:00
afee8f0c50 Make light+dark colors same length 2022-12-12 13:15:55 +13:00
9217712a31 Add kermit color and fix contrast ratio of blue+red 2022-12-12 13:14:51 +13:00
6568d224ea Set versionCode=36116 2022-12-10 22:26:33 +13:00
42589fe9ab Fix column reference in settings page 2022-12-10 22:22:51 +13:00
3600003660 Clear set images + alarm when importing a database
Closes #131
2022-12-10 22:19:55 +13:00
9c184c5924 Add log when alarm finishes 2022-12-08 15:56:09 +13:00
6df9bba2ae Set versionCode=36115 2022-12-08 15:42:02 +13:00
f6eb7959e1 Add missing set statement for dark color 2022-12-08 15:40:26 +13:00
216fc43a81 Set versionCode=36114 2022-12-08 14:53:28 +13:00
533b21a907 Remove csv import/export
This is replaced with the backup/restore feature in Settings page.

- Not sure anybody is using this besides me for testing purposes
- Backing up the entire SQLite database is faster than CSV conversion
- This prevents missing data and will work nicely with future plan
  changes

Closes #128
2022-12-08 14:51:34 +13:00
0b2d4d52e1 Add export/import database buttons to search 2022-12-08 13:22:02 +13:00
0b6471a766 Add ability to export/import SQLite database 2022-12-08 13:18:41 +13:00
55e0a9f75e Fix homepage error with default date format 2022-12-08 13:05:09 +13:00
f85074a41f Remove logging from route toast 2022-12-06 12:30:36 +13:00
228fc212bf Reduce logging in DrawerMenu
If a console.log is so long my terminal history can't
scroll backward, then it's probably too much logging...
2022-12-06 12:28:31 +13:00
e46e23c9e1 Set versionCode=36113 2022-12-04 19:38:23 +13:00
e9b02d5eb1 Remove toast from StartPlan 2022-12-04 19:36:27 +13:00
d8eeac66ab Fix starting plan without selecting an item 2022-12-03 22:29:45 +13:00
2aa8073856 Add vscode configuration for debugging
I didn't end up using this but might in the future.
2022-12-03 22:14:01 +13:00
b14d20f1f4 Prevent animation when navigating to plan
Closes #124.
2022-12-03 22:13:35 +13:00
6071957a40 Suppress unused parameter in TimerDone.kt 2022-12-02 16:31:20 +13:00
46262fe6b4 Set versionCode=36112 2022-12-02 14:50:19 +13:00
67d90d4e02 Get settings on TimerPage on focus
Previously it was on mount.
Fixes #122.
2022-12-02 14:48:10 +13:00
c2994da041 Make getManager private on AlarmModule
It was never used publicly.
2022-12-02 14:47:54 +13:00
284983c1cf Set versionCode=36111 2022-12-01 15:54:13 +13:00
96674cd51f Clean unused import from SettingsPage 2022-12-01 15:52:44 +13:00
567e885e76 Make best view select consistent with SettingsPage 2022-12-01 15:51:39 +13:00
c1b6659058 Set versionCode=36110 2022-12-01 15:46:58 +13:00
a284f045d2 Add left padding to settings selects 2022-12-01 15:45:18 +13:00
1016997269 Fix width of alarm sound in SettingsPage 2022-12-01 15:26:41 +13:00
825981460e Set versionCode=36109 2022-12-01 14:20:27 +13:00
53c5a08a14 Merge branch 'master' of gitea.presley.nz:brandon.presley/Massive 2022-12-01 14:18:57 +13:00
76017be226 Remove unused variable from StartPlan 2022-12-01 14:18:37 +13:00
26e0391022 Add missing margin to save set button 2022-12-01 14:11:47 +13:00
tiffrbarclay
4a32f5c85e Merge pull request 'Change width of select label' (#123) from settings-page-select-styling into master
Reviewed-on: brandon.presley/Massive#123
2022-11-30 07:50:36 +00:00
Tiffany Barclay
a3e0ba84cb Change width of select label 2022-11-30 20:47:16 +13:00
521fa0e9d3 Remove margins from buttons
These were for my ios testing, which I might
add back in later.
2022-11-30 18:14:34 +13:00
9db11460fe Set versionCode=36108 2022-11-30 15:21:17 +13:00
c4aad7beb5 Remove colon from alarm sound label 2022-11-30 15:19:31 +13:00
51b2f9396f Set versionCode=36107 2022-11-30 15:17:07 +13:00
de4c8081a6 Add labels to selects 2022-11-30 15:15:19 +13:00
d3c3a09a0f Set versionCode=36106 2022-11-30 14:34:49 +13:00
8e31dc2186 Add labels to colors 2022-11-30 14:32:42 +13:00
4375a9c24e Simplify process of enabling rest timers 2022-11-30 14:23:36 +13:00
6676efe69f Simplify AlarmModule 2022-11-30 14:23:24 +13:00
2d1bed0671 Remove unused import in SettingsModule 2022-11-30 14:23:15 +13:00
1b1bb41ed7 Combine SetForm + EditSet
The abstraction here added more complexity than it saved.
2022-11-30 13:58:56 +13:00
4e9cd59b0b Set versionCode=36105 2022-11-26 14:21:58 +13:00
849bee6e87 Fix mock-providers.tsx 2022-11-26 14:20:18 +13:00
dc27ae9868 Split up dark and light color settings
Previously it was possible to choose a color combination
that was almost impossible to read (due to contrast).
Now we have prevented this from happening, as well as
giving the user more customizability.
2022-11-26 13:15:12 +13:00
0c5a221e0f Set versionCode=36104 2022-11-23 21:53:27 +13:00
ea6137ac52 Remove unused import in ViewBest 2022-11-23 21:51:48 +13:00
5a5253ce82 Add missing margin to plan
Closes #120
2022-11-23 21:51:16 +13:00
30124485c7 Add empty message to best graphs 2022-11-23 21:50:11 +13:00
b504de45a2 Fix x axis cutting off for some charts
Closes #119
2022-11-23 21:49:27 +13:00
62ca3ef1c4 Merge branch 'master' of gitea.presley.nz:brandon.presley/Massive 2022-11-22 22:21:11 +13:00
434f29652f Just use a toast for download notifications
The minor convenience of having that notification
doesn't justify me adding the library.
If I find another reason for me to have notifications
then i'll do it.
2022-11-22 22:18:15 +13:00
c50dc4aacf Fix uploading and downloading sets on ios
Just need to now use a library for notifications
instead of my native DownloadModule
2022-11-22 22:16:14 +13:00
fa180db0fb Remove react-native-device-time-format 2022-11-22 21:44:15 +13:00
93b4861da9 Set versionCode=36103 2022-11-22 21:40:39 +13:00
5a07aee7f4 Ran prettier on react-native.config.js 2022-11-22 21:38:54 +13:00
3930a99cf7 Use document picker types for set form image
This works on both ios and android
2022-11-21 18:52:56 +13:00
e03101f673 Use document picker images type
This hopefully works on ios as well.
2022-11-21 18:44:12 +13:00
ef637d3e56 Add larger button margin to save on EditWorkout 2022-11-21 18:37:19 +13:00
ecece9bbcd Add slight margin to switches for ios
I don't know why the android one has margins
without me specifying to do so...
2022-11-21 18:33:05 +13:00
71a1e69c7b Merge branch 'master' of gitea.presley.nz:brandon.presley/Massive 2022-11-21 18:15:55 +13:00
be4098962e Add settings module for android 2022-11-21 18:15:43 +13:00
1b9d35d71e Reduce logging of SetList 2022-11-21 18:07:02 +13:00
29cbc43534 Use first item for Select.tsx if no value is found 2022-11-21 17:54:45 +13:00
a001760ab0 Just clear users date setting in update-date migration
This switch is probably missing some old ones,
easier to just wipe it.
2022-11-21 17:53:14 +13:00
38332c193c Fix settings page crashing
Select.tsx was crashing if it couldn't
find a label for the selected value.
2022-11-20 21:47:05 +13:00
6fb2022e4d Use react-native-paper menus on ViewBest 2022-11-16 18:48:47 +13:00
87233f34a8 Hide timer on ios 2022-11-16 18:48:37 +13:00
c9adaf59ff Remove confusing add workout button from plan 2022-11-16 18:48:16 +13:00
008667c3a2 Disable download/upload on ios 2022-11-16 18:48:07 +13:00
157a26b843 Remove margin bottom from flatlist on settings 2022-11-16 18:32:59 +13:00
6012747643 Merge branch 'master' of gitea.presley.nz:brandon.presley/Massive 2022-11-16 18:30:21 +13:00
a1b240caae Add margin between settings list and selects 2022-11-16 18:27:58 +13:00
e6488c38c5 Add missing key to menu item 2022-11-16 18:27:49 +13:00
3528ba593f Finish removing react-native-picker-select
Replaced with react-native-paper menus.
2022-11-16 18:17:59 +13:00
e7e2f299da Start moving select dropdowns to use menus 2022-11-16 18:01:40 +13:00
19ec8ac5e9 Fix margins for settings page 2022-11-16 17:10:31 +13:00
58ab135b09 Remove default coloring of selects for SettingsPage 2022-11-16 17:07:43 +13:00
1d8d7b070e Refresh settings for plan on focus
Closes #118
2022-11-16 17:05:49 +13:00
261f1c8bf0 Fix colors of selects in settings 2022-11-16 14:46:45 +13:00
ae842e0ad7 Fix placeholder for Select 2022-11-16 14:32:47 +13:00
16668a80a5 Upgrade react-native-device-time-format to 2.3.0
Downgrading to 2.2.0 fixed ios but broke android.
I'll keep the ios stuff on a separate branch.
2022-11-15 19:56:36 +13:00
162d67c351 Start work on pickers that work on android+ios 2022-11-15 19:51:15 +13:00
f04125efc5 Merge gitea.presley.nz:brandon.presley/Massive 2022-11-15 18:11:45 +13:00
9433aed5a2 Get ios running
Still very broken.
Need to fix:
- Notifications
- Remove timer stuff
- Selects
2022-11-15 18:10:48 +13:00
a4eca33e27 Change ruby version to match what is required in ios 2022-11-15 18:10:30 +13:00
2aa8c690f1 Ensure only MaterialIcon fonts are loaded for ios 2022-11-15 18:09:53 +13:00
6b7849b414 Downgrade react-native-device-time-format
Version 2.3.0 doesn't autolink in ios.
2022-11-15 18:09:07 +13:00
f506aa5af7 Set versionCode=36102 2022-11-15 17:43:28 +13:00
89edc661a4 Replace addColumn with query in add-color
Fixes #106
2022-11-15 17:36:01 +13:00
401ce5d2b8 Disable 24 hour checking and battery for ios 2022-11-14 21:42:37 +13:00
8639b53e7f Set versionCode=36101 2022-11-14 14:29:48 +13:00
3dea1e952c Fix default date in SettingsPage 2022-11-14 14:27:50 +13:00
b74f77e506 Replace progress bar with toast in plan
It doesn't feel obvious enough when a new
set has been added with the progress bar +
number incrementing. I frequently found
myself double-checking I had actually submitted
a new set.
2022-11-14 14:19:41 +13:00
6b74b5114c Move progress bar to bottom of StartPlan 2022-11-14 11:35:09 +13:00
2be1fa8e9f Update timer screenshot
The height wasn't consistent with the other screenshots.
Perhaps I used my Pixel emulator instead of my Nexus 6P
emulator.
2022-11-12 18:23:45 +13:00
f66c180768 Ensure timers don't run when alarms are disabled 2022-11-12 18:22:58 +13:00
3a718142e5 Add progress bar to plan items
Deploy this after using it for a day
(on Sunday NZ time).
2022-11-12 18:19:39 +13:00
c70e9f5c69 Remove blocking toasts from StartPlan
Some of these weren't extra information
and would happen too often. Toasts should
be rare and informational.
2022-11-12 17:26:38 +13:00
79296b6518 Set versionCode=36100 2022-11-12 16:45:39 +13:00
c31cebb6d3 Fix broken best view page 2022-11-12 16:43:56 +13:00
9bfe9737ea Fix lint issues 2022-11-12 16:02:16 +13:00
4cc4679dfd Set versionCode=36099 2022-11-12 16:01:55 +13:00
730a736585 Fix invalid reference to formatMonth in ViewBest 2022-11-12 15:24:34 +13:00
c51bfbd852 Use date-fns and detect 12/24 hour device setting
Related to #116
2022-11-12 14:38:39 +13:00
970cf36c94 Set versionCode=36098 2022-11-10 15:18:05 +13:00
bfa7518e40 Remove unused variables in StartPlan 2022-11-10 15:16:32 +13:00
dc73035607 Add progress bar for rest timer in StartPlan 2022-11-10 15:15:27 +13:00
60fe324e06 Send 00:00 at end of alarm event 2022-11-10 15:15:14 +13:00
e6d5e928a9 Use text for progress color on Timer
The progress color needs to be static
because I can't find a combo of background
color to contrast well with all the custom
primary colors.
2022-11-09 13:26:26 +13:00
60fd0130b3 Set versionCode=36097 2022-11-09 11:29:09 +13:00
0f73fb9d8f Bump react-native to 0.70.5 2022-11-09 11:27:32 +13:00
9db4990202 Set versionCode=36096 2022-11-08 16:47:54 +13:00
427b80cc52 Use bundle exec on fastlane 2022-11-08 16:46:19 +13:00
77f77b0ec4 Update gems 2022-11-08 16:46:13 +13:00
1e213b32f8 Set versionCode=36095 2022-11-08 16:35:21 +13:00
bc4bc44b7d Fix download notifications
I accidentally deleted DownloadModule which
sent a notification of the successful export
of sets/plans
2022-11-08 16:32:57 +13:00
04eb738c73 Set versionCode=36094 2022-11-08 16:10:36 +13:00
13c1f64398 Change backgroundColor on timer 2022-11-08 15:50:14 +13:00
bac2e3498f Add back in primary color for timer
The default progress color had weird
looking jagged edges.
2022-11-08 12:58:47 +13:00
eb23fc2210 Remove primary color from timer progress
Some of these colors provide terrible contrast
so we should just leave it as default. Otherwise
people using certain colors (like light cyan)
can barely see this progress circle at all.
2022-11-08 12:45:40 +13:00
b68f903a1c Remove custom color calculation from Switch 2022-11-08 12:37:24 +13:00
7b403050f3 Set versionCode=36093 2022-11-07 16:38:35 +13:00
91b8b2af13 Comment out fastlane deployments for now
Libssl 3 has broken a bunch of ruby stuff
https://github.com/postmodern/ruby-install/issues/412
2022-11-07 16:36:49 +13:00
442f1a1d67 Add missing bundle install step to deployment 2022-11-07 15:59:40 +13:00
3c17a12f6e Fix settings for adding 1 min to rest timer
Previously when adding one minute to the rest timer from
the notification, settings weren't being used (sound,
vibration).

Closes #113
2022-11-07 15:56:07 +13:00
f87373479a Attempt to optimize SettingsPage
1. Use FlatList instead of ScrollView
2. Wrap `switches` in a `useMemo` call

I didn't measure it but felt like this sped up
performance slightly. Still might come back to this
page again in the future.

Closes #111
2022-11-07 14:30:25 +13:00
bb85935e1d Remove custom circle background color on TimerPage
The default color has better contrast than
what I was using before.

Closes #112
2022-11-07 14:14:43 +13:00
f57de5265d Set versionCode=36092 2022-11-05 17:35:06 +13:00
ae84228913 Remove showSets setting 2022-11-05 17:31:18 +13:00
075d038ccc Set versionCode=36091 2022-11-05 17:24:59 +13:00
97442bc292 Fix adding one minute to a complete alarm 2022-11-05 17:22:51 +13:00
24e7ee58d9 Set versionCode=36090 2022-11-05 14:53:52 +13:00
1e4e66363b Remove unused variables from TimerPage 2022-11-05 14:52:24 +13:00
806480532f Add 1 minute to timer from notification 2022-11-05 14:46:42 +13:00
86ad6b93d6 Prevent overwriting created when updating a set 2022-11-05 14:41:45 +13:00
9c808ce84b Add progress circle to TimerPage 2022-11-05 14:40:06 +13:00
aaca9240a2 Stop flooding logs in deploy.sh when sourcing rvm
This stuff is just because I don't want to source rvm
in my .bashrc which slows it down significantly.
I guess this stuff would not work so well on other
peoples machines, but i'll worry about that if
anyone other than myself wants to do deployments.
2022-11-05 13:01:38 +13:00
568819e85f Set versionCode=36089 2022-11-05 12:59:05 +13:00
1e0daeec90 Remove unused variable in TimerPage 2022-11-05 12:57:32 +13:00
7b4fddfebf Make text bigger on TimerPage 2022-11-05 12:57:08 +13:00
7c9b4bf5f4 Remove unused android code 2022-11-05 12:53:48 +13:00
584a505308 Set versionCode=36088 2022-11-04 23:04:58 +13:00
5fcd0e39af Use bash for deploy.sh
The rvm script requires it
2022-11-04 23:03:17 +13:00
97ade15700 Revert "Add images to fastlane supply in deploy.sh"
This reverts commit f4d70db377.
2022-11-04 23:00:24 +13:00
f4d70db377 Add images to fastlane supply in deploy.sh 2022-11-04 22:52:18 +13:00
03358c203b Copy icon from google play 2022-11-04 22:52:11 +13:00
57c71a39e9 Setup fastlane 2022-11-04 20:52:31 +13:00
2dfff2c851 Add gemfile + lock 2022-11-04 18:50:52 +13:00
1e88a98353 Get color setting when changing system theme 2022-11-04 18:34:41 +13:00
a2d8f4d8ac Set versionCode=36087 2022-11-04 16:03:23 +13:00
f9449a9860 Fix default new sets 2022-11-04 16:02:06 +13:00
ba61e79808 Fix error loading set for adding 2022-11-04 15:51:58 +13:00
7760c94626 Organize deploy.sh a bit 2022-11-03 23:39:05 +13:00
8019df7418 Add some useCallbacks 2022-11-03 23:32:41 +13:00
da4484cf4f Set versionCode=36086 2022-11-03 22:17:21 +13:00
29d14d74ff Fix rest timers for new sets from homepage 2022-11-03 22:16:18 +13:00
0e5de0e519 Add noSound to AlarmModule 2022-11-03 21:59:12 +13:00
facbfe4da5 Add noSound to timer add 2022-11-03 21:59:00 +13:00
b6616a551a Add logging to set item removal 2022-11-03 21:58:49 +13:00
f7c895f608 Fix not remembering settings sound 2022-11-03 21:58:33 +13:00
1110ccb741 Fix deleting first record bug 2022-11-03 21:58:10 +13:00
84b369d54b Merge branch 'alarm-module' 2022-11-03 20:04:50 +13:00
fcce1ad9ef Add native events to communicate the running timer
Closes #99
2022-11-03 20:04:15 +13:00
44b2b26b6d Set versionCode=36085 2022-11-03 19:25:43 +13:00
e8dfd5d427 Set versionCode=36084 2022-11-03 19:22:59 +13:00
98c7fac75d Fix adding new set on fresh installs 2022-11-03 19:21:59 +13:00
4a95ed050c Fix adding new set on fresh installs 2022-11-03 19:21:19 +13:00
cafcb996e3 Set versionCode=36083 2022-11-03 19:12:19 +13:00
90fa309c09 Remove unused variable from SetList 2022-11-03 19:10:56 +13:00
09e178c5ce Fix edit set crashing on fresh installs 2022-11-03 19:10:26 +13:00
f52b1437f2 Fix edit set crashing on fresh installs 2022-11-03 19:09:50 +13:00
6f57b235d6 Merge branch 'master' into alarm-module 2022-11-03 19:01:09 +13:00
aa2d146527 Fix color detection for MassiveFab 2022-11-02 16:38:42 +13:00
a8fac1db69 Simplify adding from SetList 2022-11-02 15:46:45 +13:00
e48e125499 Dont send git push to background in deploy.sh
If it fails we don't want to continue the script.
2022-11-02 15:41:48 +13:00
1d0d7c2fff Set versionCode=36082 2022-11-02 15:41:28 +13:00
2e5edb741e Fix linting issue in StartPlan 2022-11-02 15:40:25 +13:00
4873fcb653 Ran prettier 2022-11-02 15:39:17 +13:00
8835a3efd3 Prevent double initializing typeorm
App is re-mounted when the system theme is changed,
but the connection to the sqlite database stays active.
This means the previous code would fail to initialize
and then be a blank screen.
Closes #107
2022-11-02 15:37:32 +13:00
187a0fbc68 Only initialize typeorm if uninitialized 2022-11-02 15:22:57 +13:00
2aaaac1929 Fix sets added by plan not showing image 2022-11-02 14:41:30 +13:00
7b568d3b04 Format time based on setting when editing a set 2022-11-02 14:41:14 +13:00
156f1fc33f Fix button color on snackbars 2022-11-02 13:39:51 +13:00
1f513f2a03 Update phone screenshots 2022-11-02 13:39:45 +13:00
f91d529f39 Add copy feature for plans
Long tap an existing plan, then press copy.
This will bring up a new plan to add with all
the same workouts/days as the one you have
copied.
2022-11-02 13:02:08 +13:00
ffbefe7a4f Add feature to edit last set from plan
If you are working through a plan, and accidentally
save an incorrect set (e.g. 100 reps instead of 10),
now you can long tap the item, and press edit. This
is slightly easier than swapping back over to the
home page to edit the set. Also since I reused the
same EditSet component this wasn't very much work.
2022-11-02 12:58:57 +13:00
0f6102f433 Make sure undo doesn't delete old items 2022-11-02 12:51:15 +13:00
3bf2193d46 Remove needless double negation in StartPlan
We used to store the numbers as sqlite presented
the booleans, but now TypeOrm automatically converts
it into bools for us so we don't need to.
2022-11-02 12:43:01 +13:00
2a868cc9ee Add --nobuild option to install.sh 2022-11-02 12:42:50 +13:00
2dfbb7224f Remove parallel logic to deploy.sh
Didn't work anyway. Don't mind waiting a bit for deploys.
2022-11-02 12:42:32 +13:00
ffc0662171 Set versionCode=36081 2022-11-02 12:38:00 +13:00
0ed3b9817c Add lighter purple color option 2022-11-02 12:36:48 +13:00
2c029b5f6a Removed tests 2022-11-02 12:35:52 +13:00
18eaa9fc14 Adjust spacing of SettingsPage 2022-11-02 12:28:11 +13:00
202d34d785 Remove hard coded colors in MassiveFab 2022-11-02 12:26:46 +13:00
07a3d240ea Set versionCode=36080 2022-11-01 20:12:48 +13:00
7a97b11e79 Remove React import from SetList 2022-11-01 20:01:04 +13:00
306f13214a Remove --quiet from lint script 2022-11-01 20:00:51 +13:00
58b2990ab2 Ran lint fix on migrations 2022-11-01 20:00:24 +13:00
0a2e0086b3 Add import React to App.tsx
Paper seemed to complain about it for some reason.
I thought one of the parts of using Hermes meant
I didn't need to import React?
2022-11-01 19:59:48 +13:00
83852b3216 Apply eslint rules to js files 2022-11-01 19:59:33 +13:00
6a4d167e08 Fix error editing a workout 2022-11-01 19:25:05 +13:00
e9c2ee743e Make purple the default primary color 2022-11-01 19:22:34 +13:00
949b435853 Split up state for SettingsPage
This improved performance when visually
toggling an option
2022-11-01 18:58:09 +13:00
6ac84d1d32 Fix mock-providers.tsx 2022-11-01 18:30:23 +13:00
6d49cbcc80 Remove redundant code from Routes.tsx 2022-11-01 16:55:36 +13:00
af9dcd0b13 Pass missing settings to SetItem from SetList 2022-11-01 16:54:14 +13:00
31f1528c35 Replace settings context with theme context
The settings context was having a big performance
impact on the app. We only truly need the theme + color
to be a global context.
2022-11-01 16:50:03 +13:00
8d7fe149f5 Remove unused code 2022-11-01 16:11:39 +13:00
139d75493e Memoize action in App.tsx 2022-11-01 16:08:02 +13:00
fadab1f30b Fix colors of pickers in SettingsPage 2022-11-01 16:06:25 +13:00
49b5eb48c6 Refactor MassiveSnack
Instead of using a context for the whole app
use DeviceEventEmitter with root state.
This will probably improve performance,
since I think the react context was
re-rendering the entire DOM tree.
2022-11-01 15:55:37 +13:00
ace327ecad Remove vestiges of react-native-sqlite-storage 2022-11-01 12:30:31 +13:00
f56f0063c4 Turn off some eslint rules 2022-11-01 12:30:06 +13:00
3c4bba3f85 Fix infinite refreshing on first load of StartPlan 2022-11-01 12:29:54 +13:00
1a53fa324b Remove redundant Color context
Settings already stores the color set by the user.
2022-10-31 21:32:33 +13:00
13ca9cef3e Reword "maximum" as "target" for sets
There isn't any restriction involved in the sets
for each workout, it's more like a guide.
2022-10-31 21:00:53 +13:00
bdb27894f7 Optimize root context 2022-10-31 21:00:10 +13:00
b782d66bf2 Fix adding new set from homepage 2022-10-31 20:59:40 +13:00
09ee09f509 Ran prettier on __tests__ 2022-10-31 20:58:51 +13:00
bd6b20fb4e Add migration to drop old migrations table 2022-10-31 18:16:19 +13:00
eafad1f47e Simplify migrations in App.tsx 2022-10-31 18:16:11 +13:00
bc7aca03e8 Remove semicolons from line endings 2022-10-31 17:22:08 +13:00
1bc145f60c Allow failure of migrations
Since we are swapping from the old system to this system
sometimes the columns will be already existing.
These errors failing are OK since we haven't changed
any column types before.
2022-10-31 17:21:28 +13:00
e7321b6d8e Add typeorm migrations 2022-10-31 17:05:31 +13:00
b7f1c2192e Pause converting to typeorm due to odd error
ERROR  TypeError: Cannot read property 'getItem' of undefined

This error is located at:
    in FlatList (created by SetList)
    in RCTView (created by View)
    in View (created by Page)
    in Page (created by SetList)
    in SetList (created by SceneView)
...

I found an open issue on the react-native github which seems
related https://github.com/facebook/react-native/issues/31523
but after following all of their suggestions I still have the
same error. I tried:
- Removing @babel/plugin-proposal-class-properties & @babel/plugin-transform-flow-strip-types
- Adding @babel/plugin-transform-flow-strip-types
2022-10-31 13:20:36 +13:00
111ee4201f Set versionCode=36079 2022-10-30 15:44:19 +13:00
294c6ee639 Send yarn lint & test to background 2022-10-30 15:43:27 +13:00
8ad6189dfc Fix colors on header bar for light theme 2022-10-30 15:42:43 +13:00
9752aa9dd1 Set versionCode=36078 2022-10-30 15:35:31 +13:00
e0da621198 Add undo feature to StartPlan 2022-10-30 15:34:17 +13:00
e33ff1172a Factor out StartPlanItem 2022-10-30 15:23:22 +13:00
992b3d0ba6 Fix unit sometimes exporting as the string 'null' 2022-10-30 15:14:57 +13:00
2ae9d2a4c1 Improve performance of StartPlan 2022-10-30 14:46:32 +13:00
eba33c2599 Revert "Revert "Optimize query in StartPlan""
This reverts commit 129b00dc5d.
2022-10-30 14:27:22 +13:00
a804d9ef05 Set versionCode=36077 2022-10-30 14:09:51 +13:00
5fafc6a63a Fix plan starting 2022-10-30 14:08:41 +13:00
a85bc04c35 Set versionCode=36076 2022-10-30 13:49:10 +13:00
129b00dc5d Revert "Optimize query in StartPlan"
This reverts commit 97827c68b2.
2022-10-30 13:42:20 +13:00
ba2a2259f3 Set versionCode=36075 2022-10-30 13:16:41 +13:00
e1a90a98fb Fix crashing of plans 2022-10-30 13:15:31 +13:00
8a240b78cd Set versionCode=36074 2022-10-30 12:59:02 +13:00
6e75614d10 Add basic working unit tests 2022-10-30 12:56:58 +13:00
cc97c760bb Give up trying to add unit tests
Running tests gives the following error:
TypeError: _reactNative.BackHandler.addEventListener is not a function
2022-10-29 17:16:28 +13:00
4aa62dace8 Set versionCode=36073 2022-10-28 19:05:07 +13:00
3784285695 Fix short day format 2022-10-28 19:04:05 +13:00
5a22c73834 Set versionCode=36072 2022-10-28 19:01:08 +13:00
463852e6a6 Prevent double searching everywhere
Also change variable names. Search should represent the
act of searching, rather than the value being typed by the user.
2022-10-28 18:59:54 +13:00
3d591f4618 Prevent searching twice on homepage first load 2022-10-28 18:41:17 +13:00
e6dcd4a47e Use hermes engine
https://reactnative.dev/docs/hermes
2022-10-28 18:36:47 +13:00
5441aa164b Move registerReceiver to no avail 2022-10-28 17:31:10 +13:00
1c58dc2db1 Local broadcast receiver is not running on stop intent 2022-10-28 17:22:26 +13:00
8504f8b811 Merge branch 'master' into alarm-module 2022-10-28 16:49:39 +13:00
46dcfb96bf Add broadcast receiver to AlarmModule 2022-10-28 16:48:29 +13:00
afbdd2fed5 Upgrade all packages 2022-10-28 16:26:03 +13:00
82888ce530 Set versionCode=36071 2022-10-28 15:50:26 +13:00
ac0af26f77 Add more date format options
Closes #94
2022-10-28 15:49:03 +13:00
6d6a6f7a20 Set versionCode=36070 2022-10-27 17:35:52 +13:00
859fa2a89f Add setting to disable sound on rest timers
Closes #50
2022-10-27 17:28:27 +13:00
ef7342b788 Fix max sets always being 3 for plans 2022-10-27 10:32:40 +13:00
4735b1589b Fix selection for plan starting
Old selection index was based on `workouts`
new one is based on the `counts`.
2022-10-27 10:07:02 +13:00
4e6de66f90 Fix query for start plan
If the WHERE IN query is in the first select,
then we will have no results unless the person
has already worked out today.
2022-10-27 10:01:30 +13:00
cd602cee33 Cast plan description to string
If it's a number, then zero doesn't display
probably because the library is checking for
truthy.
2022-10-27 10:00:47 +13:00
97827c68b2 Optimize query in StartPlan
Closes #98
2022-10-26 18:31:40 +13:00
3be82e0b36 Default unit on volume labels to kg
Closes #100
2022-10-26 18:04:33 +13:00
b19033b814 Add index to sets.created
Most queries will be filtering on created date
(showing todays sets, todays plans) so this
should speed up our queries.
2022-10-26 13:25:13 +13:00
21d9149498 Quit trying to move timer logic into AlarmModule
I just can't figure out how to make the stop button
and delete intents work.
2022-10-24 14:45:21 +13:00
c9125575cc Set versionCode=36069 2022-10-23 19:15:56 +13:00
80b1a1ef56 Fix single views for new custom headers 2022-10-23 19:13:58 +13:00
36e6637ba2 Revert "Revert "Add custom app bar""
This reverts commit e84dd7bdea.
2022-10-23 12:35:58 +13:00
48bb4a34cf Set versionCode=36068 2022-10-23 12:33:54 +13:00
e84dd7bdea Revert "Add custom app bar"
This reverts commit a664b65ce2.
2022-10-23 12:32:44 +13:00
2d9e561908 Set versionCode=36067 2022-10-23 12:28:37 +13:00
a664b65ce2 Add custom app bar
The header bar provided by react-navigation was jumping on first
load, whereas this custom one doesn't.
2022-10-23 12:24:39 +13:00
149872ea7e Set versionCode=36066 2022-10-21 18:41:04 +13:00
b95024abe0 Fix rest timers for newly edited Workouts
Previously if you were to add a new workout, then
add a set for that workout immediately afterwards,
the rest timers would be the default 3:30.
Now, they are the actual value set when creating the
workout.
2022-10-21 18:39:06 +13:00
4ba86be8af Set versionCode=36065 2022-10-19 20:01:16 +13:00
3cb6e8757b Make timer alarm have a stop button 2022-10-19 19:59:22 +13:00
88d751f13b Set versionCode=36064 2022-10-18 21:45:16 +13:00
c73937396e Fix defaults for freshly installed app
Closes #95
2022-10-18 21:43:46 +13:00
d21e7986e3 Add full screen android intent for alarm 2022-10-18 21:38:06 +13:00
dc84fa5f6c Set versionCode=36063 2022-10-17 19:59:17 +13:00
d723bd9745 Make date color grey
Closes #93
2022-10-17 19:57:18 +13:00
db4f6fb482 Update phone screenshots 2022-10-16 17:34:55 +13:00
ae947d5405 Wait for data before displaying workouts on plans
Closes #92
2022-10-16 17:20:13 +13:00
c05a76ed1a Set versionCode=36062 2022-10-16 17:09:46 +13:00
5d5f586f7f Use theme colors for chart axes labels
Closes #89
2022-10-16 17:08:18 +13:00
d23d489ec0 Set versionCode=36061 2022-10-16 16:59:55 +13:00
6238b47e6a Fix plan not activating on radio button press
If you touched the radio button instead of the workout
item itself, it wouldn't toggle.
2022-10-16 16:57:23 +13:00
77db34b310 Add toggle for hiding maximum set count
Closes #90
2022-10-16 16:54:20 +13:00
3714db438e Fix max number of sets for plan
Previously we were trying to get the max # of sets
from our query on the number of sets completed for today.
This meant if we hadn't completed any sets today, we would
get no result for that workout.
2022-10-16 16:46:38 +13:00
f078ede58a Get max sets for each workout in plan
Closes #91
2022-10-16 16:08:38 +13:00
7bd2254719 Set versionCode=36060 2022-10-16 14:39:18 +13:00
2fd9635e40 Add new record notification to plan 2022-10-16 14:38:01 +13:00
4d35d617e8 Change wording on new record notification 2022-10-16 14:37:31 +13:00
b8c98babe6 Reword best weight and volume metrics
Previously we didn't have a period selector,
so the description was "Best weight per day"
and "Best volume per day". Now that the user
can also select the period (daily, monthly, yearly)
it doesn't make sense to label them as "per day".
2022-10-16 14:36:08 +13:00
b89209b852 Update phone screenshots 2022-10-16 14:07:15 +13:00
3012b69e00 Add one rep max calculator for best graphs
I tried out a bunch of formulas as well as
having them as options, and I ended up liking
the Brzycki formula the most.
https://en.wikipedia.org/wiki/One-repetition_maximum#Brzycki
All of them produced similar trends (for me) and the one using
exponents wouldn't work with the SQLite version on android
(can't use POWER function). Also having all the options looked
kind of cluttered. If people ask for it I'll add the other ones
later.
2022-10-16 13:39:59 +13:00
0b041f9643 Stop timer if notification is dismissed
Closes #86
2022-10-16 13:08:37 +13:00
81deba0dbc Set versionCode=36059 2022-10-15 11:16:21 +13:00
6d29ac09be Remove auto focus on reps for starting a plan
Doesn't feel right to open the reps immediately on
a page with so much content. The keyboard hides some of the
stuff on the bottom. Also I find myself opening a plan up just
to see what's on that day rather than editing right away.
2022-10-15 11:14:02 +13:00
d97cdbfe99 Add plan start screenshot 2022-10-14 18:25:30 +13:00
e3758e44e7 Delete accidental empty database massive.db 2022-10-14 18:23:56 +13:00
cd424eba6e Set versionCode=36058 2022-10-14 18:22:08 +13:00
bca65c90e6 Show dates in set edit screen 2022-10-14 18:20:30 +13:00
1d0dd70a49 Set versionCode=36057 2022-10-14 18:13:58 +13:00
9bd8fa9353 Replace height percent setting with flex: 1
This way reacts better to changing the screen sizes.
I think there was one bug where the fab wouldn't be
precisely at the bottom of the page depending on the
flat list length.
2022-10-14 18:12:21 +13:00
46f0875497 Create useSnackbar custom hook 2022-10-14 17:27:19 +13:00
8461f86e88 Wrap color context with useColor custom hook
I find it easier to import hooks by useX instead of
useContext(X). Like how the navigation library is just
useNavigation
2022-10-14 17:24:02 +13:00
d80135d4ed Set versionCode=36056 2022-10-14 17:08:01 +13:00
2782d34a05 Use custom theme color for radio buttons 2022-10-14 17:06:23 +13:00
c4e26e2560 Set versionCode=36055 2022-10-14 17:00:08 +13:00
861b5df9f4 Remove extra padding from workouts rest timers
Typically rest timers don't have double digit minutes.
This doesn't prevent that, but will save space 99% of the time,
and in the odd situation where a rest duration is >9 minutes
will be slightly uneven.
2022-10-14 16:57:08 +13:00
b6665ed4b5 Use radio button for workouts on a plan 2022-10-14 16:55:12 +13:00
90f09e3a31 Reduce redundancy of workouts
Repeating the word sets and rest looks a bit
funny. This way leaves a bit to the imagination,
but looks simpler. If a user doesn't understand what
the description is talking about they will probably
just tap the item to read it.
2022-10-14 16:49:34 +13:00
a65274c2d6 Set versionCode=36054 2022-10-13 16:33:26 +13:00
228383ed23 Bottom align save button on set form 2022-10-13 16:32:12 +13:00
780500ac75 Set versionCode=36053 2022-10-13 16:31:40 +13:00
52f8241054 Prevent flickering of empty lists on slow devices 2022-10-13 16:30:02 +13:00
e6228b3990 Remove non null assertions in TimerService
This might help fix the error we were seeing on the
play store.
2022-10-13 16:12:49 +13:00
f41e7b3ffe Set versionCode=36052 2022-10-13 13:35:15 +13:00
9dd929b177 Handle missing intent extras in TimerService
This might fix an error I was seeing on production in the Play store.
2022-10-13 13:33:01 +13:00
6316e99e6e Set versionCode=36051 2022-10-13 11:27:16 +13:00
1a43f41170 Refresh current day for Plans on focus
Closes #85
2022-10-13 11:25:23 +13:00
2e1e84ac6f Set versionCode=36050 2022-10-12 17:19:40 +13:00
ca09613d9b Include set image when adding a set from plan
If a user has set an image for a workout, then adding
a set should include it's image.
2022-10-12 17:17:47 +13:00
3b0391310b Visually select first workout in plan by default 2022-10-12 17:17:28 +13:00
05a7747f05 Add divider before delete button on plan 2022-10-12 14:53:05 +13:00
da3dd99bc0 Set versionCode=36049 2022-10-12 14:52:49 +13:00
5901722f22 Use flat list instead of chips for workouts 2022-10-12 14:51:30 +13:00
636bfa35a4 Remove dates toggle from set item
This feature exists in the settings page,
so duplicating it here might be confusing to users.
2022-10-12 14:10:59 +13:00
e7438138a6 Set versionCode=36048 2022-10-12 14:09:44 +13:00
e0b84af9e7 Move sessions page functionality onto Plan page
The sessions page mostly copied the exact same features as Plans,
which would confuse the user.
2022-10-12 14:07:48 +13:00
5481e8a20d Fix color for snackbar buttons
The primary color doesn't always work when inverted
from dark to light theme.
2022-10-12 14:07:19 +13:00
17b88f39e4 Make chip icons always fitness center 2022-10-05 23:49:08 +13:00
3cbabb723a Add sessions page
Related to #82
2022-10-05 23:38:52 +13:00
2be2893dc1 Set versionCode=36047 2022-10-05 21:13:13 +13:00
e501276463 Keep alarm notification on screen
If the user picks up their phone late while the alarm
is going, they might not realise the controls are in
the notification not the app.

Closes #75
2022-10-05 21:11:44 +13:00
e2c790870b Hide rest settings in workouts when alarms are off
Closes #83
2022-10-04 14:46:15 +13:00
73fb90961e Set versionCode=36046 2022-10-04 14:37:44 +13:00
eb53d58991 Add ability to set app theme
Closes #79
2022-10-04 14:35:56 +13:00
3164347158 Set versionCode=36045 2022-10-03 00:56:57 +13:00
c1f5f62145 Add format to next page 2022-10-03 00:55:36 +13:00
c65bbf948f Add hh:mm format to date setting 2022-10-02 19:14:10 +13:00
4a924df8fb Set versionCode=36044 2022-10-02 19:09:11 +13:00
93878f14e4 Fix image on new set prediction 2022-10-02 19:07:52 +13:00
ba3ed2a272 Revert "Revert "Add setting for showing date by default""
This reverts commit e1b7e80e2f.
2022-10-02 18:07:43 +13:00
a5c70050a7 Revert "Revert "Add setting for date format""
This reverts commit 3691c729b4.
2022-10-02 18:04:36 +13:00
6f41f87dc1 Improve readability of SetForm 2022-10-02 17:59:08 +13:00
76a3584dbb Hide workout images based on setting
Closes #80
2022-10-01 16:30:29 +13:00
300651f4e9 Set versionCode=36043 2022-10-01 16:03:21 +13:00
b0b804eae1 Use react context for settings
Closes #81
2022-10-01 16:01:07 +13:00
794504dee0 Use latest image for new sets
Previously, it would use the image from the best ranked set.
2022-10-01 15:36:30 +13:00
c866fac9d2 Fix install.sh
It was doing the adb install from the project directory, but had already
changed into android.
2022-10-01 15:36:04 +13:00
df45938bc3 Add image to set edit page 2022-10-01 15:35:52 +13:00
edf823ca8b Prevent title counting sets when we aren't predicting them 2022-10-01 15:35:20 +13:00
0b489cac2a Update edit.png 2022-10-01 13:32:08 +13:00
a02247542e Group created by month when period is yearly
Possible solution to #78

We should keep this issue up for a while longer to see how this
solution feels.
2022-10-01 13:31:05 +13:00
9d42760dff Rename some variables in EditSet
Not sure if I like the look of variables starting with
underscores. Perhaps the only use of this would be to prevent
editor warnings for unused variables.
2022-10-01 13:30:06 +13:00
a530b563b9 Add -d flag to install.sh
Installs it on usb debug device instead of assuming
only one device is plugged in.
2022-09-30 17:13:04 +13:00
6af167268c Remove major.minor tag from deploy.sh 2022-09-30 17:09:19 +13:00
53a5b09f7e Set versionCode=36042 2022-09-30 17:08:39 +13:00
c8cedef8fb Clear old workouts when predicting new set
Closes #73
2022-09-30 17:07:26 +13:00
4d219581d0 Use lambdas for submit editing in EditWorkout
For some reason the next ref wouldn't get focused
if I passed the functions without wrapping in a lambda.
2022-09-30 15:16:56 +13:00
71c358a532 Move cursor to next input for workouts editing 2022-09-30 15:14:53 +13:00
4ba5b204f2 Set versionCode=36041 2022-09-30 13:37:59 +13:00
c6fe5b576a Include versionCode in tag woopsie. 2022-09-30 13:36:52 +13:00
c4930d12ca Set versionCode=36040 2022-09-30 13:30:46 +13:00
4199bf0058 Fix image not being included for new predicted sets 2022-09-30 13:29:33 +13:00
6076e0014f Set versionCode=36039 2022-09-30 13:14:10 +13:00
4eaf2c2134 Give new sets no image instead of last sets image
Closes #72
2022-09-30 13:12:11 +13:00
c41b8438d3 Prevent counting hidden sets for prediction
Closes #71
2022-09-30 13:00:27 +13:00
90a3d473b6 Update settings image 2022-09-29 15:14:03 +13:00
b0b3f7a880 Set versionCode=36038 2022-09-29 14:54:03 +13:00
ee76864a16 Fix margin differences in Settings page 2022-09-29 14:52:38 +13:00
374cbdf45d Add set prediction settings
Closes #51
2022-09-29 14:44:01 +13:00
66c24a96bd Fix missing key in workouts for SetForm 2022-09-29 13:42:19 +13:00
194 changed files with 12013 additions and 6947 deletions

3
.Gemfile Normal file
View File

@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"

View File

@ -1,16 +1,22 @@
module.exports = {
root: true,
extends: '@react-native-community',
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: '@react-native',
overrides: [
{
files: ['*.ts', '*.tsx'],
files: ['*.ts', '*.tsx', '*.js'],
rules: {
'jsx-quotes': 0,
'prettier/prettier': 0,
'@typescript-eslint/no-shadow': ['error'],
'no-shadow': 'off',
'no-undef': 'off',
semi: 'off',
curly: 'off',
'react/react-in-jsx-scope': 'off',
'react-native/no-inline-styles': 'off',
'no-spaced-func': 'off',
},
},
],
};
ignorePatterns: ['coverage/', 'mock-providers.tsx'],
}

1
.gitignore vendored
View File

@ -73,3 +73,4 @@ massive-build
!.yarn/releases
!.yarn/sdks
!.yarn/versions
coverage

View File

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

View File

@ -1 +1 @@
2.7.4
2.7.5

12
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Android Hermes - Experimental",
"request": "launch",
"type": "reactnativedirect",
"cwd": "${workspaceFolder}",
"platform": "android"
}
]
}

136
App.tsx
View File

@ -2,18 +2,21 @@ import {
DarkTheme as NavigationDarkTheme,
DefaultTheme as NavigationDefaultTheme,
NavigationContainer,
} from '@react-navigation/native';
import React, {useState} from 'react';
import {useColorScheme} from 'react-native';
} from "@react-navigation/native";
import React, { useEffect, useMemo, useState } from "react";
import { DeviceEventEmitter, useColorScheme } from "react-native";
import {
DarkTheme as PaperDarkTheme,
DefaultTheme as PaperDefaultTheme,
Provider,
} from 'react-native-paper';
import Ionicon from 'react-native-vector-icons/MaterialIcons';
import {lightColors} from './colors';
import MassiveSnack from './MassiveSnack';
import Routes from './Routes';
MD3DarkTheme as PaperDarkTheme,
MD3LightTheme as PaperDefaultTheme,
Provider as PaperProvider,
Snackbar,
} from "react-native-paper";
import MaterialIcon from "react-native-vector-icons/MaterialIcons";
import { AppDataSource } from "./data-source";
import { settingsRepo } from "./db";
import Routes from "./Routes";
import { TOAST } from "./toast";
import { ThemeContext } from "./use-theme";
export const CombinedDefaultTheme = {
...NavigationDefaultTheme,
@ -23,51 +26,102 @@ export const CombinedDefaultTheme = {
...PaperDefaultTheme.colors,
},
};
export const CombinedDarkTheme = {
...NavigationDarkTheme,
...PaperDarkTheme,
colors: {
...NavigationDarkTheme.colors,
...PaperDarkTheme.colors,
primary: lightColors[0].hex,
background: '#0E0E0E',
},
};
export const CustomTheme = React.createContext({
color: '',
setColor: (_value: string) => {},
});
const App = () => {
const dark = useColorScheme() === 'dark';
const [color, setColor] = useState(
dark
? CombinedDarkTheme.colors.primary.toUpperCase()
: CombinedDefaultTheme.colors.primary.toUpperCase(),
const phoneTheme = useColorScheme();
const [initialized, setInitialized] = useState(false);
const [snackbar, setSnackbar] = useState("");
const [appTheme, setAppTheme] = useState("system");
const [lightColor, setLightColor] = useState<string>(
CombinedDefaultTheme.colors.primary
);
const theme = dark
? {
...CombinedDarkTheme,
colors: {...CombinedDarkTheme.colors, primary: color},
const [darkColor, setDarkColor] = useState<string>(
CombinedDarkTheme.colors.primary
);
useEffect(() => {
(async () => {
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
const settings = await settingsRepo.findOne({ where: {} });
setAppTheme(settings.theme);
if (settings.lightColor) setLightColor(settings.lightColor);
if (settings.darkColor) setDarkColor(settings.darkColor);
setInitialized(true);
})();
const description = DeviceEventEmitter.addListener(
TOAST,
({ value }: { value: string }) => {
setSnackbar(value);
}
: {
...CombinedDefaultTheme,
colors: {...CombinedDefaultTheme.colors, primary: color},
};
);
return description.remove;
}, []);
const paperTheme = useMemo(() => {
const darkTheme = lightColor
? {
...CombinedDarkTheme,
colors: { ...CombinedDarkTheme.colors, primary: darkColor },
}
: CombinedDarkTheme;
const lightTheme = lightColor
? {
...CombinedDefaultTheme,
colors: { ...CombinedDefaultTheme.colors, primary: lightColor },
}
: CombinedDefaultTheme;
let value = phoneTheme === "dark" ? darkTheme : lightTheme;
if (appTheme === "dark") value = darkTheme;
else if (appTheme === "light") value = lightTheme;
return value;
}, [phoneTheme, appTheme, lightColor, darkColor]);
return (
<CustomTheme.Provider value={{color, setColor}}>
<Provider
theme={theme}
settings={{icon: props => <Ionicon {...props} />}}>
<NavigationContainer theme={theme}>
<MassiveSnack>
<PaperProvider
theme={paperTheme}
settings={{ icon: (props) => <MaterialIcon {...props} /> }}
>
<NavigationContainer theme={paperTheme}>
{initialized && (
<ThemeContext.Provider
value={{
theme: appTheme,
setTheme: setAppTheme,
lightColor,
setLightColor,
darkColor,
setDarkColor,
}}
>
<Routes />
</MassiveSnack>
</NavigationContainer>
</Provider>
</CustomTheme.Provider>
</ThemeContext.Provider>
)}
</NavigationContainer>
<Snackbar
duration={3000}
onDismiss={() => setSnackbar("")}
visible={!!snackbar}
action={{
label: "Close",
onPress: () => setSnackbar(""),
textColor: paperTheme.colors.background,
}}
>
{snackbar}
</Snackbar>
</PaperProvider>
);
};

31
AppFab.tsx Normal file
View File

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

26
AppInput.tsx Normal file
View File

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

View File

@ -1,74 +0,0 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {FlatList, Image} from 'react-native';
import {List} from 'react-native-paper';
import {getBestReps, getBestWeights} from './best.service';
import {BestPageParams} from './BestPage';
import Page from './Page';
import Set from './set';
import {settings} from './settings.service';
export default function BestList() {
const [bests, setBests] = useState<Set[]>([]);
const [search, setSearch] = useState('');
const navigation = useNavigation<NavigationProp<BestPageParams>>();
const refresh = useCallback(async () => {
const weights = await getBestWeights(search);
console.log(`${BestList.name}.refresh:`, {length: weights.length});
let newBest: Set[] = [];
for (const set of weights) {
const reps = await getBestReps(set.name, set.weight);
newBest.push(...reps);
}
setBests(newBest);
}, [search]);
useFocusEffect(
useCallback(() => {
refresh();
navigation.getParent()?.setOptions({
headerRight: () => null,
});
}, [refresh, navigation]),
);
useEffect(() => {
refresh();
}, [search, refresh]);
const renderItem = ({item}: {item: Set}) => (
<List.Item
key={item.name}
title={item.name}
description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
onPress={() => navigation.navigate('ViewBest', {best: item})}
left={() =>
(settings.images && item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)) ||
null
}
/>
);
return (
<Page search={search} setSearch={setSearch}>
<FlatList
style={{height: '99%'}}
ListEmptyComponent={
<List.Item
title="No exercises yet"
description="Once sets have been added, this will highlight your personal bests."
/>
}
renderItem={renderItem}
data={bests}
/>
</Page>
);
}

View File

@ -1,42 +0,0 @@
import {DrawerNavigationProp} from '@react-navigation/drawer';
import {useNavigation} from '@react-navigation/native';
import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import {IconButton} from 'react-native-paper';
import BestList from './BestList';
import {DrawerParamList} from './drawer-param-list';
import Set from './set';
import ViewBest from './ViewBest';
const Stack = createStackNavigator<BestPageParams>();
export type BestPageParams = {
BestList: {};
ViewBest: {
best: Set;
};
};
export default function BestPage() {
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
<Stack.Screen name="BestList" component={BestList} />
<Stack.Screen
name="ViewBest"
component={ViewBest}
listeners={{
beforeRemove: () => {
navigation.setOptions({
headerLeft: () => (
<IconButton icon="menu" onPress={navigation.openDrawer} />
),
title: 'Best',
});
},
}}
/>
</Stack.Navigator>
);
}

View File

@ -1,10 +1,11 @@
import * as shape from 'd3-shape';
import React, {useContext} from 'react';
import {View} from 'react-native';
import {Grid, LineChart, XAxis, YAxis} from 'react-native-svg-charts';
import {CustomTheme} from './App';
import {MARGIN, PADDING} from './constants';
import Set from './set';
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,
@ -13,41 +14,53 @@ export default function Chart({
yFormat,
}: {
yData: number[];
xData: Set[];
xData: GymSet[];
xFormat: (value: any, index: number) => string;
yFormat: (value: any) => string;
}) {
const {color} = useContext(CustomTheme);
const axesSvg = {fontSize: 10, fill: 'grey'};
const verticalContentInset = {top: 10, bottom: 10};
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'}}>
<View
style={{
height: 300,
padding: PADDING,
flexDirection: "row",
}}
>
<YAxis
data={yData}
style={{marginBottom: xAxisHeight}}
style={{ marginBottom: xAxisHeight }}
contentInset={verticalContentInset}
svg={axesSvg}
formatLabel={yFormat}
/>
<View style={{flex: 1, marginLeft: MARGIN}}>
<View style={{ flex: 1, marginLeft: MARGIN }}>
<LineChart
style={{flex: 1}}
style={{ flex: 1 }}
data={yData}
contentInset={verticalContentInset}
curve={shape.curveBasis}
svg={{
stroke: color,
}}>
stroke: colors.primary,
}}
>
<Grid />
</LineChart>
<XAxis
style={{marginHorizontal: -10, height: xAxisHeight}}
data={xData}
formatLabel={xFormat}
contentInset={{left: 10, right: 10}}
contentInset={{ left: 15, right: 16 }}
svg={axesSvg}
/>
</View>

View File

@ -1,5 +1,4 @@
import React from 'react';
import {Button, Dialog, Portal, Text} from 'react-native-paper';
import { Button, Dialog, Portal, Text } from "react-native-paper";
export default function ConfirmDialog({
title,
@ -7,13 +6,20 @@ export default function ConfirmDialog({
onOk,
show,
setShow,
onCancel,
}: {
title: string;
children: JSX.Element | JSX.Element[] | string;
onOk: () => void;
show: boolean;
setShow: (show: boolean) => void;
onCancel?: () => void;
}) {
const cancel = () => {
setShow(false);
onCancel && onCancel();
};
return (
<Portal>
<Dialog visible={show} onDismiss={() => setShow(false)}>
@ -23,7 +29,7 @@ export default function ConfirmDialog({
</Dialog.Content>
<Dialog.Actions>
<Button onPress={onOk}>OK</Button>
<Button onPress={() => setShow(false)}>Cancel</Button>
<Button onPress={cancel}>Cancel</Button>
</Dialog.Actions>
</Dialog>
</Portal>

22
DrawerHeader.tsx Normal file
View File

@ -0,0 +1,22 @@
import { DrawerNavigationProp } from "@react-navigation/drawer";
import { useNavigation } from "@react-navigation/native";
import { Appbar, IconButton } from "react-native-paper";
import { DrawerParamList } from "./drawer-param-list";
export default function DrawerHeader({
name,
children,
}: {
name: string;
children?: JSX.Element | JSX.Element[];
}) {
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
return (
<Appbar.Header>
<IconButton icon="menu" onPress={navigation.openDrawer} />
<Appbar.Content title={name} />
{children}
</Appbar.Header>
);
}

View File

@ -1,150 +0,0 @@
import {NavigationProp, useNavigation} from '@react-navigation/native';
import React, {useCallback, useContext, useState} from 'react';
import DocumentPicker from 'react-native-document-picker';
import {FileSystem} from 'react-native-file-access';
import {Divider, IconButton, Menu} from 'react-native-paper';
import ConfirmDialog from './ConfirmDialog';
import {DrawerParamList} from './drawer-param-list';
import {SnackbarContext} from './MassiveSnack';
import {Plan} from './plan';
import {addPlans, deletePlans, getAllPlans} from './plan.service';
import {addSets, deleteSets, getAllSets} from './set.service';
import {write} from './write';
const setFields =
'id,name,reps,weight,created,unit,hidden,sets,minutes,seconds';
const planFields = 'id,days,workouts';
export default function DrawerMenu({name}: {name: keyof DrawerParamList}) {
const [showMenu, setShowMenu] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const {toast} = useContext(SnackbarContext);
const {reset} = useNavigation<NavigationProp<DrawerParamList>>();
const exportSets = useCallback(async () => {
const sets = await getAllSets();
const data = [setFields]
.concat(
sets.map(
set =>
`${set.id},${set.name},${set.reps},${set.weight},${set.created},${set.unit},${set.hidden},${set.sets},${set.minutes},${set.seconds}`,
),
)
.join('\n');
console.log(`${DrawerMenu.name}.exportSets`, {length: sets.length});
await write('sets.csv', data);
}, []);
const exportPlans = useCallback(async () => {
const plans: Plan[] = await getAllPlans();
const data = [planFields]
.concat(plans.map(set => `"${set.id}","${set.days}","${set.workouts}"`))
.join('\n');
console.log(`${DrawerMenu.name}.exportPlans`, {length: plans.length});
await write('plans.csv', data);
}, []);
const download = useCallback(async () => {
setShowMenu(false);
if (name === 'Home') exportSets();
else if (name === 'Plans') exportPlans();
}, [name, exportSets, exportPlans]);
const uploadSets = useCallback(async () => {
const result = await DocumentPicker.pickSingle();
const file = await FileSystem.readFile(result.uri);
console.log(`${DrawerMenu.name}.${uploadSets.name}:`, file.length);
const lines = file.split('\n');
console.log(lines[0]);
if (!setFields.includes(lines[0])) return toast('Invalid csv.', 3000);
const values = lines
.slice(1)
.filter(line => line)
.map(set => {
const [
,
setName,
reps,
weight,
created,
unit,
hidden,
sets,
minutes,
seconds,
] = set.split(',');
return `('${setName}',${reps},${weight},'${created}','${unit}',${hidden},${
sets ?? 3
},${minutes ?? 3},${seconds ?? 30})`;
})
.join(',');
await addSets(setFields.split(',').slice(1).join(','), values);
toast('Data imported.', 3000);
reset({index: 0, routes: [{name}]});
}, [reset, name, toast]);
const uploadPlans = useCallback(async () => {
const result = await DocumentPicker.pickSingle();
const file = await FileSystem.readFile(result.uri);
console.log(`${DrawerMenu.name}.uploadPlans:`, file.length);
const lines = file.split('\n');
if (lines[0] != planFields) return toast('Invalid csv.', 3000);
const values = file
.split('\n')
.slice(1)
.filter(line => line)
.map(set => {
const [, days, workouts] = set
.split('","')
.map(cell => cell.replace(/"/g, ''));
return `('${days}','${workouts}')`;
})
.join(',');
await addPlans(values);
toast('Data imported.', 3000);
}, [toast]);
const upload = useCallback(async () => {
setShowMenu(false);
if (name === 'Home') await uploadSets();
else if (name === 'Plans') await uploadPlans();
reset({index: 0, routes: [{name}]});
}, [name, uploadPlans, uploadSets, reset]);
const remove = useCallback(async () => {
setShowMenu(false);
setShowRemove(false);
if (name === 'Home') await deleteSets();
else if (name === 'Plans') await deletePlans();
toast('All data has been deleted.', 4000);
reset({index: 0, routes: [{name}]});
}, [reset, name, toast]);
if (name === 'Home' || name === 'Plans')
return (
<Menu
visible={showMenu}
onDismiss={() => setShowMenu(false)}
anchor={
<IconButton onPress={() => setShowMenu(true)} icon="more-vert" />
}>
<Menu.Item icon="arrow-downward" onPress={download} title="Download" />
<Menu.Item icon="arrow-upward" onPress={upload} title="Upload" />
<Divider />
<Menu.Item
icon="delete"
onPress={() => setShowRemove(true)}
title="Delete"
/>
<ConfirmDialog
title="Delete all data"
show={showRemove}
setShow={setShowRemove}
onOk={remove}>
This irreversibly deletes all data from the app. Are you sure?
</ConfirmDialog>
</Menu>
);
return null;
}

View File

@ -1,78 +1,69 @@
import {
NavigationProp,
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {ScrollView, StyleSheet, View} from 'react-native';
import {Button, IconButton, Text} from 'react-native-paper';
import {MARGIN, PADDING} from './constants';
import {DrawerParamList} from './drawer-param-list';
import {PlanPageParams} from './plan-page-params';
import {addPlan, updatePlan} from './plan.service';
import {getNames} from './set.service';
import Switch from './Switch';
import {DAYS} from './time';
} from "@react-navigation/native";
import { useCallback, useEffect, useState } from "react";
import { ScrollView, StyleSheet, View } from "react-native";
import { Button, IconButton, Text } from "react-native-paper";
import { MARGIN, PADDING } from "./constants";
import { planRepo, setRepo } from "./db";
import { defaultSet } from "./gym-set";
import { PlanPageParams } from "./plan-page-params";
import StackHeader from "./StackHeader";
import Switch from "./Switch";
import { DAYS } from "./time";
import AppInput from "./AppInput";
export default function EditPlan() {
const {params} = useRoute<RouteProp<PlanPageParams, 'EditPlan'>>();
const {plan} = params;
const { params } = useRoute<RouteProp<PlanPageParams, "EditPlan">>();
const { plan } = params;
const [title, setTitle] = useState<string>(plan?.title);
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(',') : [],
plan.workouts ? plan.workouts.split(",") : []
);
const [names, setNames] = useState<string[]>([]);
const navigation = useNavigation<NavigationProp<DrawerParamList>>();
useFocusEffect(
useCallback(() => {
console.log(`${EditPlan.name}.focus:`, {plan});
navigation.getParent()?.setOptions({
headerLeft: () => (
<IconButton icon="arrow-back" onPress={() => navigation.goBack()} />
),
headerRight: () => null,
title: plan.id ? 'Edit plan' : 'Create plan',
});
}, [navigation, plan]),
);
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
useEffect(() => {
getNames().then(n => {
console.log(EditPlan.name, {n});
setNames(n);
});
setRepo
.createQueryBuilder()
.select("name")
.distinct(true)
.orderBy("name")
.getRawMany()
.then((values) => {
console.log(EditPlan.name, { values });
setNames(values.map((value) => value.name));
});
}, []);
const save = useCallback(async () => {
console.log(`${EditPlan.name}.save`, {days, workouts, plan});
console.log(`${EditPlan.name}.save`, { days, workouts, plan });
if (!days || !workouts) return;
const newWorkouts = workouts.filter(workout => workout).join(',');
const newDays = days.filter(day => day).join(',');
if (typeof plan.id === 'undefined')
await addPlan({days: newDays, workouts: newWorkouts});
else
await updatePlan({
days: newDays,
workouts: newWorkouts,
id: plan.id,
});
navigation.goBack();
}, [days, workouts, plan, navigation]);
const newWorkouts = workouts.filter((workout) => workout).join(",");
const newDays = days.filter((day) => day).join(",");
await planRepo.save({
title: title,
days: newDays,
workouts: newWorkouts,
id: plan.id,
});
}, [title, days, workouts, plan]);
const toggleWorkout = useCallback(
(on: boolean, name: string) => {
if (on) {
setWorkouts([...workouts, name]);
} else {
setWorkouts(workouts.filter(workout => workout !== name));
setWorkouts(workouts.filter((workout) => workout !== name));
}
},
[setWorkouts, workouts],
[setWorkouts, workouts]
);
const toggleDay = useCallback(
@ -80,66 +71,83 @@ export default function EditPlan() {
if (on) {
setDays([...days, day]);
} else {
setDays(days.filter(d => d !== day));
setDays(days.filter((d) => d !== day));
}
},
[setDays, days],
[setDays, days]
);
return (
<View style={{padding: PADDING}}>
<ScrollView style={{height: '90%'}}>
<Text style={styles.title}>Days</Text>
{DAYS.map(day => (
<Switch
key={day}
onValueChange={value => toggleDay(value, day)}
onPress={() => toggleDay(!days.includes(day), day)}
value={days.includes(day)}>
{day}
</Switch>
))}
<Text style={[styles.title, {marginTop: MARGIN}]}>Workouts</Text>
{names.length === 0 ? (
<View>
<Text>No workouts found.</Text>
</View>
) : (
names.map(name => (
<Switch
key={name}
onValueChange={value => toggleWorkout(value, name)}
value={workouts.includes(name)}
onPress={() => toggleWorkout(!workouts.includes(name), name)}>
{name}
</Switch>
))
<>
<StackHeader
title={typeof plan.id === "number" ? "Edit plan" : "Add plan"}
>
{typeof plan.id === "number" && (
<IconButton
onPress={async () => {
await save();
const newPlan = await planRepo.findOne({
where: { id: plan.id },
});
let first = await setRepo.findOne({
where: { name: workouts[0] },
order: { created: "desc" },
});
if (!first) first = { ...defaultSet, name: workouts[0] };
delete first.id;
navigation.navigate("StartPlan", { plan: newPlan, first });
}}
icon="play-arrow"
/>
)}
</ScrollView>
{names.length === 0 ? (
</StackHeader>
<View style={{ padding: PADDING, flex: 1 }}>
<ScrollView style={{ flex: 1 }}>
<AppInput
label="Title"
value={title}
onChangeText={(value) => setTitle(value)}
/>
<Text style={styles.title}>Days</Text>
{DAYS.map((day) => (
<Switch
key={day}
onChange={(value) => toggleDay(value, day)}
value={days.includes(day)}
title={day}
/>
))}
<Text style={[styles.title, { marginTop: MARGIN }]}>Workouts</Text>
{names.length === 0 ? (
<View>
<Text>No workouts found.</Text>
</View>
) : (
names.map((name) => (
<Switch
key={name}
onChange={(value) => toggleWorkout(value, name)}
value={workouts.includes(name)}
title={name}
/>
))
)}
</ScrollView>
<Button
disabled={workouts.length === 0 && days.length === 0}
mode="contained"
onPress={() => {
navigation.goBack();
navigation.navigate('Workouts', {
screen: 'EditWorkout',
params: {value: {name: ''}},
});
}}>
Add workout
</Button>
) : (
<Button
disabled={workouts.length === 0 && days.length === 0}
style={{marginTop: MARGIN}}
mode="contained"
style={styles.button}
mode="outlined"
icon="save"
onPress={save}>
onPress={async () => {
await save();
navigation.navigate("PlanList");
}}
>
Save
</Button>
)}
</View>
</View>
</>
);
}
@ -148,4 +156,5 @@ const styles = StyleSheet.create({
fontSize: 20,
marginBottom: MARGIN,
},
button: {},
});

View File

@ -1,94 +1,277 @@
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import {
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native';
import React, {useCallback, useContext} from 'react';
import {NativeModules, View} from 'react-native';
import {IconButton} from 'react-native-paper';
import {PADDING} from './constants';
import {HomePageParams} from './home-page-params';
import {SnackbarContext} from './MassiveSnack';
import Set from './set';
import {addSet, updateSet} from './set.service';
import SetForm from './SetForm';
import {getSettings, settings, updateSettings} from './settings.service';
} from "@react-navigation/native";
import { format } from "date-fns";
import { useCallback, useRef, useState } from "react";
import { NativeModules, 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 ConfirmDialog from "./ConfirmDialog";
import { MARGIN, PADDING } from "./constants";
import { getNow, setRepo, settingsRepo } from "./db";
import GymSet from "./gym-set";
import { HomePageParams } from "./home-page-params";
import Settings from "./settings";
import StackHeader from "./StackHeader";
import { toast } from "./toast";
import { fixNumeric } from "./fix-numeric";
export default function EditSet() {
const {params} = useRoute<RouteProp<HomePageParams, 'EditSet'>>();
const {set, count, workouts} = params;
const { params } = useRoute<RouteProp<HomePageParams, "EditSet">>();
const { set } = params;
const navigation = useNavigation();
const {toast} = useContext(SnackbarContext);
const [settings, setSettings] = useState<Settings>({} as Settings);
const [name, setName] = useState(set.name);
const [reps, setReps] = useState(set.reps?.toString());
const [weight, setWeight] = useState(set.weight?.toString());
const [newImage, setNewImage] = useState(set.image);
const [unit, setUnit] = useState(set.unit);
const [created, setCreated] = useState<Date>(
set.created ? new Date(set.created) : new Date()
);
const [createdDirty, setCreatedDirty] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const [removeImage, setRemoveImage] = useState(false);
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const unitRef = useRef<TextInput>(null);
const [selection, setSelection] = useState({
start: 0,
end: set.reps?.toString().length,
});
useFocusEffect(
useCallback(() => {
console.log(`${EditSet.name}.focus:`, set);
let title = 'Create set';
if (typeof set.id === 'number') title = 'Edit set';
else if (Number(set.sets) > 0)
title = `${set.name} (${count + 1} / ${set.sets})`;
navigation.getParent()?.setOptions({
headerLeft: () => (
<IconButton icon="arrow-back" onPress={() => navigation.goBack()} />
),
headerRight: null,
title,
});
}, [navigation, set, count]),
settingsRepo.findOne({ where: {} }).then(setSettings);
}, [])
);
const startTimer = useCallback(async (_set: Set) => {
if (!settings.alarm) return;
const milliseconds =
Number(_set.minutes) * 60 * 1000 + Number(_set.seconds) * 1000;
NativeModules.AlarmModule.timer(
milliseconds,
!!settings.vibrate,
settings.sound,
);
const next = new Date();
next.setTime(next.getTime() + milliseconds);
await updateSettings({...settings, nextAlarm: next.toISOString()});
await getSettings();
const startTimer = useCallback(
async (value: string) => {
if (!settings.alarm) return;
const first = await setRepo.findOne({ where: { name: value } });
const milliseconds =
(first?.minutes ?? 3) * 60 * 1000 + (first?.seconds ?? 0) * 1000;
if (milliseconds) NativeModules.AlarmModule.timer(milliseconds);
},
[settings]
);
const added = useCallback(
async (value: GymSet) => {
startTimer(value.name);
console.log(`${EditSet.name}.add`, { set: value });
if (!settings.notify) return;
if (
value.weight > set.weight ||
(value.reps > set.reps && value.weight === set.weight)
) {
toast("Great work King! That's a new record.");
}
},
[startTimer, set, settings]
);
const handleSubmit = async () => {
if (!name) return;
const newSet: Partial<GymSet> = {
id: set.id,
name,
reps: Number(reps),
weight: Number(weight),
unit,
minutes: Number(set.minutes ?? 3),
seconds: Number(set.seconds ?? 30),
sets: set.sets ?? 3,
hidden: false,
};
newSet.image = newImage;
if (!newImage && !removeImage) {
newSet.image = await setRepo
.findOne({ where: { name } })
.then((s) => s?.image);
}
if (createdDirty) newSet.created = created.toISOString();
if (typeof set.id !== "number") newSet.created = await getNow();
const saved = await setRepo.save(newSet);
if (typeof set.id !== "number") added(saved);
navigation.goBack();
};
const changeImage = useCallback(async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: "documentDirectory",
});
if (fileCopyUri) setNewImage(fileCopyUri);
}, []);
const update = useCallback(
async (_set: Set) => {
console.log(`${EditSet.name}.update`, _set);
await updateSet(_set);
navigation.goBack();
},
[navigation],
);
const handleRemove = useCallback(async () => {
setNewImage("");
setRemoveImage(true);
setShowRemove(false);
}, []);
const add = useCallback(
async (_set: Set) => {
console.log(`${EditSet.name}.add`, {set: _set});
startTimer(_set);
await addSet(_set);
if (!settings.notify) return navigation.goBack();
if (
_set.weight > set.weight ||
(_set.reps > set.reps && _set.weight === set.weight)
)
toast("Great work King, that's a new record!", 3000);
navigation.goBack();
},
[navigation, startTimer, set, toast],
);
const save = useCallback(
async (_set: Set) => {
if (typeof set.id === 'number') return update(_set);
return add(_set);
},
[update, add, set.id],
);
const pickDate = useCallback(() => {
DateTimePickerAndroid.open({
value: created,
onChange: (_, date) => {
if (date === created) return;
setCreated(date);
setCreatedDirty(true);
DateTimePickerAndroid.open({
value: date,
onChange: (__, time) => setCreated(time),
mode: "time",
});
},
mode: "date",
});
}, [created]);
return (
<View style={{padding: PADDING}}>
<SetForm save={save} set={set} workouts={workouts} />
</View>
<>
<StackHeader
title={typeof set.id === "number" ? "Edit set" : "Add set"}
/>
<View style={{ padding: PADDING, flex: 1 }}>
<AppInput
label="Name"
value={name}
onChangeText={setName}
autoCorrect={false}
autoFocus={!name}
onSubmitEditing={() => repsRef.current?.focus()}
/>
<View style={{ flexDirection: "row" }}>
<AppInput
style={{
flex: 1,
marginBottom: MARGIN,
}}
label="Reps"
keyboardType="numeric"
value={reps}
onChangeText={(newReps) => {
const fixed = fixNumeric(newReps);
setReps(fixed);
if (fixed.length !== newReps.length)
toast("Reps must be a number");
}}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
innerRef={repsRef}
/>
<IconButton
icon="add"
onPress={() => setReps((Number(reps) + 1).toString())}
/>
<IconButton
icon="remove"
onPress={() => setReps((Number(reps) - 1).toString())}
/>
</View>
<View
style={{
flexDirection: "row",
marginBottom: MARGIN,
}}
>
<AppInput
style={{ flex: 1 }}
label="Weight"
keyboardType="numeric"
value={weight}
onChangeText={(newWeight) => {
const fixed = fixNumeric(newWeight);
setWeight(fixed);
if (fixed.length !== newWeight.length)
toast("Weight must be a number");
}}
onSubmitEditing={handleSubmit}
innerRef={weightRef}
/>
<IconButton
icon="add"
onPress={() => setWeight((Number(weight) + 2.5).toString())}
/>
<IconButton
icon="remove"
onPress={() => setWeight((Number(weight) - 2.5).toString())}
/>
</View>
{settings.showUnit && (
<AppInput
autoCapitalize="none"
label="Unit"
value={unit}
onChangeText={setUnit}
innerRef={unitRef}
/>
)}
{settings.showDate && (
<AppInput
label="Created"
value={format(created, settings.date || "P")}
onPressOut={pickDate}
/>
)}
{settings.images && newImage && (
<TouchableRipple
style={{ marginBottom: MARGIN }}
onPress={changeImage}
onLongPress={() => setShowRemove(true)}
>
<Card.Cover source={{ uri: newImage }} />
</TouchableRipple>
)}
{settings.images && !newImage && (
<Button
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="add-photo-alternate"
>
Image
</Button>
)}
</View>
<Button
disabled={!name}
mode="outlined"
icon="save"
style={{ margin: MARGIN }}
onPress={handleSubmit}
>
Save
</Button>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemove}
setShow={setShowRemove}
>
Are you sure you want to remove the image?
</ConfirmDialog>
</>
);
}

192
EditSets.tsx Normal file
View File

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

View File

@ -3,75 +3,82 @@ import {
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native';
import React, {useCallback, useContext, useState} from 'react';
import {ScrollView, View} from 'react-native';
import DocumentPicker from 'react-native-document-picker';
import {Button, Card, IconButton, TouchableRipple} from 'react-native-paper';
import ConfirmDialog from './ConfirmDialog';
import {MARGIN, PADDING} from './constants';
import MassiveInput from './MassiveInput';
import {SnackbarContext} from './MassiveSnack';
import {updatePlanWorkouts} from './plan.service';
import {addSet, updateManySet, updateSetImage} from './set.service';
import {settings} from './settings.service';
import {WorkoutsPageParams} from './WorkoutsPage';
} from "@react-navigation/native";
import { useCallback, useRef, useState } from "react";
import { ScrollView, TextInput, View } from "react-native";
import DocumentPicker from "react-native-document-picker";
import { Button, Card, TouchableRipple } from "react-native-paper";
import AppInput from "./AppInput";
import ConfirmDialog from "./ConfirmDialog";
import { MARGIN, PADDING } from "./constants";
import { getNow, planRepo, setRepo, settingsRepo } from "./db";
import { fixNumeric } from "./fix-numeric";
import { defaultSet } from "./gym-set";
import Settings from "./settings";
import StackHeader from "./StackHeader";
import { toast } from "./toast";
import { WorkoutsPageParams } from "./WorkoutsPage";
export default function EditWorkout() {
const {params} = useRoute<RouteProp<WorkoutsPageParams, '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',
params.value.minutes?.toString() ?? "3"
);
const [seconds, setSeconds] = useState(
params.value.seconds?.toString() ?? '30',
params.value.seconds?.toString() ?? "30"
);
const [sets, setSets] = useState(params.value.sets?.toString() ?? '3');
const {toast} = useContext(SnackbarContext);
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(() => {
navigation.getParent()?.setOptions({
headerLeft: () => (
<IconButton icon="arrow-back" onPress={() => navigation.goBack()} />
),
headerRight: null,
title: params.value.name || 'New workout',
});
if (!name) return;
}, [navigation, name, params.value.name]),
settingsRepo.findOne({ where: {} }).then(setSettings);
}, [])
);
const update = async () => {
await updateManySet({
oldName: params.value.name,
newName: name || params.value.name,
sets: sets ?? '3',
seconds: seconds?.toString() ?? '30',
minutes: minutes?.toString() ?? '3',
steps,
});
await updatePlanWorkouts(params.value.name, name || params.value.name);
if (uri || removeImage) await updateSetImage(params.value.name, uri || '');
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 () => {
await addSet({
const now = await getNow();
await setRepo.save({
...defaultSet,
name,
reps: 0,
weight: 0,
hidden: true,
image: uri,
minutes: minutes ? +minutes : 3,
seconds: seconds ? +seconds : 30,
sets: sets ? +sets : 3,
steps,
created: now,
});
navigation.goBack();
};
@ -82,93 +89,116 @@ export default function EditWorkout() {
};
const changeImage = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
type: 'image/*',
copyTo: 'documentDirectory',
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: "documentDirectory",
});
if (fileCopyUri) setUri(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setUri('');
setUri("");
setRemoveImage(true);
setShowRemove(false);
}, []);
const handleName = (value: string) => {
setName(value.replace(/,|'/g, ''));
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
const handleSteps = (value: string) => {
setSteps(value.replace(/,|'/g, ''));
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
const submitName = () => {
if (settings.steps) stepsRef.current?.focus();
else setsRef.current?.focus();
};
return (
<View style={{padding: PADDING}}>
<ScrollView style={{height: '90%'}}>
<MassiveInput
autoFocus
label="Name"
value={name}
onChangeText={handleName}
/>
{!!settings.steps && (
<MassiveInput
selectTextOnFocus={false}
value={steps}
onChangeText={handleSteps}
label="Steps"
multiline
<>
<StackHeader title={params.value.name ? "Edit workout" : "Add workout"} />
<View style={{ padding: PADDING, flex: 1 }}>
<ScrollView style={{ flex: 1 }}>
<AppInput
autoFocus
label="Name"
value={name}
onChangeText={setName}
onSubmitEditing={submitName}
/>
)}
<MassiveInput
value={sets}
onChangeText={setSets}
label="Sets per workout"
keyboardType="numeric"
/>
<MassiveInput
value={minutes}
onChangeText={setMinutes}
label="Rest minutes"
keyboardType="numeric"
/>
<MassiveInput
value={seconds}
onChangeText={setSeconds}
label="Rest seconds"
keyboardType="numeric"
/>
{uri ? (
<TouchableRipple
style={{marginBottom: MARGIN}}
onPress={changeImage}
onLongPress={() => setShowRemove(true)}>
<Card.Cover source={{uri}} />
</TouchableRipple>
) : (
<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>
{settings?.steps && (
<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 workout"
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={() => setShowRemove(true)}
>
<Card.Cover source={{ uri }} />
</TouchableRipple>
)}
{settings?.images && !uri && (
<Button
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="add-photo-alternate"
>
Image
</Button>
)}
</ScrollView>
<Button disabled={!name} mode="outlined" icon="save" onPress={save}>
Save
</Button>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemove}
setShow={setShowRemove}
>
Are you sure you want to remove the image?
</ConfirmDialog>
</View>
</>
);
}

View File

@ -1,6 +1,6 @@
source 'https://rubygems.org'
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby '2.7.5'
ruby ">= 2.6.10"
gem 'cocoapods', '~> 1.11', '>= 1.11.2'
gem 'cocoapods', '~> 1.12'

100
Gemfile.lock Normal file
View File

@ -0,0 +1,100 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.5)
rexml
activesupport (6.1.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
atomos (0.1.3)
claide (1.1.0)
cocoapods (1.11.3)
addressable (~> 2.8)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.11.3)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 1.4.0, < 2.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.4.0, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
fourflusher (>= 2.3.0, < 3.0)
gh_inspector (~> 1.0)
molinillo (~> 0.8.0)
nap (~> 1.0)
ruby-macho (>= 1.0, < 3.0)
xcodeproj (>= 1.21.0, < 2.0)
cocoapods-core (1.11.3)
activesupport (>= 5.0, < 7)
addressable (~> 2.8)
algoliasearch (~> 1.0)
concurrent-ruby (~> 1.1)
fuzzy_match (~> 2.0.4)
nap (~> 1.0)
netrc (~> 0.11)
public_suffix (~> 4.0)
typhoeus (~> 1.0)
cocoapods-deintegrate (1.0.5)
cocoapods-downloader (1.6.3)
cocoapods-plugins (1.0.0)
nap
cocoapods-search (1.0.1)
cocoapods-trunk (1.6.0)
nap (>= 0.8, < 2.0)
netrc (~> 0.11)
cocoapods-try (1.2.0)
colored2 (3.1.2)
concurrent-ruby (1.1.10)
escape (0.0.4)
ethon (0.16.0)
ffi (>= 1.15.0)
ffi (1.15.5)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
httpclient (2.8.3)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
json (2.6.2)
minitest (5.16.3)
molinillo (0.8.0)
nanaimo (0.3.0)
nap (1.1.0)
netrc (0.11.0)
public_suffix (4.0.7)
rexml (3.2.5)
ruby-macho (2.5.1)
typhoeus (1.4.0)
ethon (>= 0.9.0)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
zeitwerk (2.6.6)
PLATFORMS
ruby
DEPENDENCIES
cocoapods (~> 1.11, >= 1.11.2)
RUBY VERSION
ruby 2.7.5p203
BUNDLED WITH
2.1.4

102
GraphsList.tsx Normal file
View File

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

23
GraphsPage.tsx Normal file
View File

@ -0,0 +1,23 @@
import { createStackNavigator } from "@react-navigation/stack";
import GraphsList from "./GraphsList";
import GymSet from "./gym-set";
import ViewGraph from "./ViewGraph";
const Stack = createStackNavigator<GraphsPageParams>();
export type GraphsPageParams = {
GraphsList: {};
ViewGraph: {
best: GymSet;
};
};
export default function GraphsPage() {
return (
<Stack.Navigator
screenOptions={{ headerShown: false, animationEnabled: false }}
>
<Stack.Screen name="GraphsList" component={GraphsList} />
<Stack.Screen name="ViewGraph" component={ViewGraph} />
</Stack.Navigator>
);
}

View File

@ -1,36 +1,19 @@
import {DrawerNavigationProp} from '@react-navigation/drawer';
import {useNavigation} from '@react-navigation/native';
import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import {IconButton} from 'react-native-paper';
import {DrawerParamList} from './drawer-param-list';
import EditSet from './EditSet';
import {HomePageParams} from './home-page-params';
import SetList from './SetList';
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() {
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
screenOptions={{ headerShown: false, animationEnabled: false }}
>
<Stack.Screen name="Sets" component={SetList} />
<Stack.Screen
name="EditSet"
component={EditSet}
listeners={{
beforeRemove: () => {
navigation.setOptions({
headerLeft: () => (
<IconButton icon="menu" onPress={navigation.openDrawer} />
),
title: 'Home',
});
},
}}
/>
<Stack.Screen name="EditSet" component={EditSet} />
<Stack.Screen name="EditSets" component={EditSets} />
</Stack.Navigator>
);
}

94
ListMenu.tsx Normal file
View File

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

View File

@ -1,27 +0,0 @@
import React, {useContext} from 'react';
import {FAB} from 'react-native-paper';
import {CustomTheme} from './App';
import {lightColors} from './colors';
export default function MassiveFab(
props: Partial<React.ComponentProps<typeof FAB>>,
) {
const {color} = useContext(CustomTheme);
const fabColor = lightColors.map(lightColor => lightColor.hex).includes(color)
? 'black'
: undefined;
return (
<FAB
icon="add"
color={fabColor}
style={{
position: 'absolute',
right: 10,
bottom: 60,
backgroundColor: color,
}}
{...props}
/>
);
}

View File

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

View File

@ -1,44 +0,0 @@
import React, {useContext, useState} from 'react';
import {useColorScheme} from 'react-native';
import {Snackbar} from 'react-native-paper';
import {CombinedDarkTheme, CustomTheme} from './App';
export const SnackbarContext = React.createContext<{
toast: (value: string, timeout: number) => void;
}>({toast: () => null});
export default function MassiveSnack({
children,
}: {
children: JSX.Element[] | JSX.Element;
}) {
const [snackbar, setSnackbar] = useState('');
const [timeoutId, setTimeoutId] = useState(0);
const dark = useColorScheme() === 'dark';
const {color} = useContext(CustomTheme);
const toast = (value: string, timeout: number) => {
setSnackbar(value);
clearTimeout(timeoutId);
const id = setTimeout(() => setSnackbar(''), timeout);
setTimeoutId(id);
};
return (
<>
<SnackbarContext.Provider value={{toast}}>
{children}
</SnackbarContext.Provider>
<Snackbar
onDismiss={() => setSnackbar('')}
visible={!!snackbar}
action={{
label: 'Close',
onPress: () => setSnackbar(''),
color: dark ? CombinedDarkTheme.colors.background : color,
}}>
{snackbar}
</Snackbar>
</>
);
}

View File

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

View File

@ -1,53 +1,107 @@
import {NavigationProp, useNavigation} from '@react-navigation/native';
import React, {useCallback, useState} from 'react';
import {GestureResponderEvent} from 'react-native';
import {List, Menu} from 'react-native-paper';
import {Plan} from './plan';
import {PlanPageParams} from './plan-page-params';
import {deletePlan} from './plan.service';
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from "@react-navigation/native";
import { useCallback, useMemo, useState } from "react";
import { Text } from "react-native";
import { List } from "react-native-paper";
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants";
import { setRepo } from "./db";
import { defaultSet } from "./gym-set";
import { Plan } from "./plan";
import { PlanPageParams } from "./plan-page-params";
import { DAYS } from "./time";
import useDark from "./use-dark";
export default function PlanItem({
item,
onRemove,
setIds,
ids,
}: {
item: Plan;
onRemove: () => void;
ids: number[];
setIds: (value: number[]) => void;
}) {
const [show, setShow] = useState(false);
const [anchor, setAnchor] = useState({x: 0, y: 0});
const [today, setToday] = useState<string>();
const dark = useDark();
const days = useMemo(() => item.days.split(","), [item.days]);
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
const remove = useCallback(async () => {
if (typeof item.id === 'number') await deletePlan(item.id);
setShow(false);
onRemove();
}, [setShow, item.id, onRemove]);
const longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
setShow(true);
},
[setAnchor, setShow],
useFocusEffect(
useCallback(() => {
const newToday = DAYS[new Date().getDay()];
setToday(newToday);
}, [])
);
const start = useCallback(async () => {
const workout = item.workouts.split(",")[0];
let first = await setRepo.findOne({
where: { name: workout },
order: { created: "desc" },
});
if (!first) first = { ...defaultSet, name: workout };
delete first.id;
if (ids.length === 0) {
return navigation.navigate("StartPlan", { plan: item, first });
}
const removing = ids.find((id) => id === item.id);
if (removing) setIds(ids.filter((id) => id !== item.id));
else setIds([...ids, item.id]);
}, [ids, setIds, item, navigation]);
const longPress = useCallback(() => {
if (ids.length > 0) return;
setIds([item.id]);
}, [ids.length, item.id, setIds]);
const currentDays = days.map((day, index) => (
<Text key={day}>
{day === today ? (
<Text
style={{
fontWeight: "bold",
textDecorationLine: "underline",
}}
>
{day}
</Text>
) : (
day
)}
{index === days.length - 1 ? "" : ", "}
</Text>
));
const title = useMemo(
() =>
item.title ? (
<Text style={{ fontWeight: "bold" }}>{item.title}</Text>
) : (
currentDays
),
[item.title, currentDays]
);
const description = useMemo(
() => (item.title ? currentDays : item.workouts.replace(/,/g, ", ")),
[item.title, currentDays, item.workouts]
);
const backgroundColor = useMemo(() => {
if (!ids.includes(item.id)) return;
if (dark) return DARK_RIPPLE;
return LIGHT_RIPPLE;
}, [dark, ids, item.id]);
return (
<>
<List.Item
onPress={() => navigation.navigate('EditPlan', {plan: item})}
title={
item.days
? item.days.replace(/,/g, ', ')
: item.workouts.replace(/,/g, ', ')
}
description={item.days ? item.workouts.replace(/,/g, ', ') : null}
onLongPress={longPress}
right={() => (
<Menu anchor={anchor} visible={show} onDismiss={() => setShow(false)}>
<Menu.Item icon="delete" onPress={remove} title="Delete" />
</Menu>
)}
/>
</>
<List.Item
onPress={start}
title={title}
description={description}
onLongPress={longPress}
style={{ backgroundColor }}
/>
);
}

View File

@ -2,63 +2,119 @@ import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {FlatList} from 'react-native';
import {List} from 'react-native-paper';
import DrawerMenu from './DrawerMenu';
import Page from './Page';
import {Plan} from './plan';
import {PlanPageParams} from './plan-page-params';
import {getPlans} from './plan.service';
import PlanItem from './PlanItem';
} from "@react-navigation/native";
import { useCallback, useState } from "react";
import { FlatList } from "react-native";
import { List } from "react-native-paper";
import { Like } from "typeorm";
import { planRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
import ListMenu from "./ListMenu";
import Page from "./Page";
import { Plan } from "./plan";
import { PlanPageParams } from "./plan-page-params";
import PlanItem from "./PlanItem";
export default function PlanList() {
const [search, setSearch] = useState('');
const [plans, setPlans] = useState<Plan[]>([]);
const [term, setTerm] = useState("");
const [plans, setPlans] = useState<Plan[]>();
const [ids, setIds] = useState<number[]>([]);
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
const refresh = useCallback(async () => {
getPlans(search).then(setPlans);
}, [search]);
const refresh = useCallback(async (value: string) => {
planRepo
.find({
where: [
{ title: Like(`%${value.trim()}%`) },
{ days: Like(`%${value.trim()}%`) },
{ workouts: Like(`%${value.trim()}%`) },
],
})
.then(setPlans);
}, []);
useFocusEffect(
useCallback(() => {
refresh();
navigation.getParent()?.setOptions({
headerRight: () => <DrawerMenu name="Plans" />,
});
}, [refresh, navigation]),
refresh(term);
}, [refresh, term])
);
useEffect(() => {
refresh();
}, [search, refresh]);
const search = useCallback(
(value: string) => {
setTerm(value);
refresh(value);
},
[refresh]
);
const renderItem = useCallback(
({item}: {item: Plan}) => (
<PlanItem item={item} key={item.id} onRemove={refresh} />
({ item }: { item: Plan }) => (
<PlanItem ids={ids} setIds={setIds} item={item} key={item.id} />
),
[refresh],
[ids]
);
const onAdd = () =>
navigation.navigate('EditPlan', {plan: {days: '', workouts: ''}});
navigation.navigate("EditPlan", {
plan: { title: "", days: "", workouts: "" },
});
const edit = useCallback(async () => {
const plan = await planRepo.findOne({ where: { id: ids.pop() } });
navigation.navigate("EditPlan", { plan });
setIds([]);
}, [ids, navigation]);
const copy = useCallback(async () => {
const plan = await planRepo.findOne({
where: { id: ids.pop() },
});
delete plan.id;
navigation.navigate("EditPlan", { plan });
setIds([]);
}, [ids, navigation]);
const clear = useCallback(() => {
setIds([]);
}, []);
const remove = useCallback(async () => {
await planRepo.delete(ids.length > 0 ? ids : {});
await refresh(term);
setIds([]);
}, [ids, refresh, term]);
const select = useCallback(() => {
setIds(plans.map((plan) => plan.id));
}, [plans]);
return (
<Page onAdd={onAdd} search={search} setSearch={setSearch}>
<FlatList
style={{height: '99%'}}
data={plans}
renderItem={renderItem}
keyExtractor={set => set.id?.toString() || ''}
ListEmptyComponent={
<>
<DrawerHeader name={ids.length > 0 ? `${ids.length} selected` : "Plans"}>
<ListMenu
onClear={clear}
onCopy={copy}
onDelete={remove}
onEdit={edit}
ids={ids}
onSelect={select}
/>
</DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}>
{plans?.length === 0 ? (
<List.Item
title="No plans yet"
description="A plan is a list of workouts for certain days."
/>
}
/>
</Page>
) : (
<FlatList
style={{ flex: 1 }}
data={plans}
renderItem={renderItem}
keyExtractor={(set) => set.id?.toString() || ""}
/>
)}
</Page>
</>
);
}

View File

@ -1,36 +1,21 @@
import {DrawerNavigationProp} from '@react-navigation/drawer';
import {useNavigation} from '@react-navigation/native';
import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import {IconButton} from 'react-native-paper';
import {DrawerParamList} from './drawer-param-list';
import EditPlan from './EditPlan';
import {PlanPageParams} from './plan-page-params';
import PlanList from './PlanList';
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() {
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
screenOptions={{ headerShown: false, animationEnabled: false }}
>
<Stack.Screen name="PlanList" component={PlanList} />
<Stack.Screen
name="EditPlan"
component={EditPlan}
listeners={{
beforeRemove: () => {
navigation.setOptions({
headerLeft: () => (
<IconButton icon="menu" onPress={navigation.openDrawer} />
),
title: 'Plans',
});
},
}}
/>
<Stack.Screen name="EditPlan" component={EditPlan} />
<Stack.Screen name="StartPlan" component={StartPlan} />
<Stack.Screen name="EditSet" component={EditSet} />
</Stack.Navigator>
);
}

View File

@ -26,6 +26,7 @@ Massive tracks your reps and sets at the gym. No internet connectivity or high s
<img src="metadata/en-US/images/phoneScreenshots/timer.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/plans.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/plan-edit.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/plan-start.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/best-view.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/settings.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/drawer.png" width="318"/>

View File

@ -1,60 +1,57 @@
import {createDrawerNavigator} from '@react-navigation/drawer';
import React, {useContext, useEffect, useState} from 'react';
import {useColorScheme} from 'react-native';
import {IconButton} from 'react-native-paper';
import {CustomTheme} from './App';
import BestPage from './BestPage';
import {runMigrations} from './db';
import {DrawerParamList} from './drawer-param-list';
import HomePage from './HomePage';
import PlanPage from './PlanPage';
import Route from './route';
import {getSettings, settings} from './settings.service';
import SettingsPage from './SettingsPage';
import WorkoutsPage from './WorkoutsPage';
import { createDrawerNavigator } from "@react-navigation/drawer";
import { IconButton } from "react-native-paper";
import GraphsPage from "./GraphsPage";
import { DrawerParamList } from "./drawer-param-list";
import HomePage from "./HomePage";
import PlanPage from "./PlanPage";
import SettingsPage from "./SettingsPage";
import TimerPage from "./TimerPage";
import useDark from "./use-dark";
import WorkoutsPage from "./WorkoutsPage";
const Drawer = createDrawerNavigator<DrawerParamList>();
export default function Routes() {
const [migrated, setMigrated] = useState(false);
const dark = useColorScheme() === 'dark';
const {setColor} = useContext(CustomTheme);
useEffect(() => {
runMigrations()
.then(getSettings)
.then(() => {
setMigrated(true);
if (settings.color) setColor(settings.color);
});
}, [setColor]);
if (!migrated) return null;
const routes: Route[] = [
{name: 'Home', component: HomePage, icon: 'home'},
{name: 'Plans', component: PlanPage, icon: 'event'},
{name: 'Best', component: BestPage, icon: 'insights'},
{name: 'Workouts', component: WorkoutsPage, icon: 'fitness-center'},
{name: 'Settings', component: SettingsPage, icon: 'settings'},
];
const dark = useDark();
return (
<Drawer.Navigator
screenOptions={{
headerTintColor: dark ? 'white' : 'black',
headerTintColor: dark ? "white" : "black",
swipeEdgeWidth: 1000,
}}>
{routes.map(route => (
<Drawer.Screen
key={route.name}
name={route.name}
component={route.component}
options={{
drawerIcon: () => <IconButton icon={route.icon} />,
}}
/>
))}
headerShown: false,
}}
>
<Drawer.Screen
name="Home"
component={HomePage}
options={{ drawerIcon: () => <IconButton icon="home" /> }}
/>
<Drawer.Screen
name="Plans"
component={PlanPage}
options={{ drawerIcon: () => <IconButton icon="event" /> }}
/>
<Drawer.Screen
name="Graphs"
component={GraphsPage}
options={{ drawerIcon: () => <IconButton icon="insights" /> }}
/>
<Drawer.Screen
name="Workouts"
component={WorkoutsPage}
options={{ drawerIcon: () => <IconButton icon="fitness-center" /> }}
/>
<Drawer.Screen
name="Timer"
component={TimerPage}
options={{ drawerIcon: () => <IconButton icon="access-time" /> }}
/>
<Drawer.Screen
name="Settings"
component={SettingsPage}
options={{ drawerIcon: () => <IconButton icon="settings" /> }}
/>
</Drawer.Navigator>
);
}

75
Select.tsx Normal file
View File

@ -0,0 +1,75 @@
import React, { useCallback, useMemo, useState } from "react";
import { View } from "react-native";
import { Button, Menu, Subheading, useTheme } from "react-native-paper";
import { ITEM_PADDING } from "./constants";
export interface Item {
value: string;
label: string;
color?: string;
}
function Select({
value,
onChange,
items,
label,
}: {
value: string;
onChange: (value: string) => void;
items: Item[];
label?: string;
}) {
const [show, setShow] = useState(false);
const { colors } = useTheme();
const selected = useMemo(
() => items.find((item) => item.value === value) || items[0],
[items, value]
);
const handlePress = useCallback(
(newValue: string) => {
onChange(newValue);
setShow(false);
},
[onChange]
);
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingLeft: ITEM_PADDING,
}}
>
{label && <Subheading style={{ width: 100 }}>{label}</Subheading>}
<Menu
visible={show}
onDismiss={() => setShow(false)}
anchor={
<Button
onPress={() => setShow(true)}
style={{
alignSelf: "flex-start",
}}
>
{selected?.label}
</Button>
}
>
{items.map((item) => (
<Menu.Item
titleStyle={{ color: item.color || colors.onSurface }}
key={item.value}
title={item.label}
onPress={() => handlePress(item.value)}
/>
))}
</Menu>
</View>
);
}
export default React.memo(Select);

View File

@ -1,136 +0,0 @@
import React, {useContext, useEffect, useRef, useState} from 'react';
import {ScrollView, View} from 'react-native';
import {Button, Text} from 'react-native-paper';
import MassiveInput from './MassiveInput';
import {SnackbarContext} from './MassiveSnack';
import Set from './set';
import {getSets} from './set.service';
import {settings} from './settings.service';
export default function SetForm({
save,
set,
workouts,
}: {
set: Set;
save: (set: Set) => void;
workouts: string[];
}) {
const [name, setName] = useState(set.name);
const [reps, setReps] = useState(set.reps.toString());
const [weight, setWeight] = useState(set.weight.toString());
const [unit, setUnit] = useState(set.unit);
const [uri, setUri] = useState(set.image);
const [selection, setSelection] = useState({
start: 0,
end: set.reps.toString().length,
});
const {toast} = useContext(SnackbarContext);
const weightRef = useRef<any>(null);
const repsRef = useRef<any>(null);
const unitRef = useRef<any>(null);
useEffect(() => {
console.log('SetForm.useEffect:', {uri, name: set.name});
if (!uri)
getSets({search: set.name, limit: 1, offset: 0}).then(([s]) =>
setUri(s?.image),
);
}, [uri, set.name]);
const handleSubmit = () => {
if (!name) return;
save({
name,
reps: Number(reps),
weight: Number(weight),
id: set.id,
unit,
image: uri,
minutes: Number(set.minutes ?? 3),
seconds: Number(set.seconds ?? 30),
sets: set.sets ?? 3,
});
};
const handleName = (value: string) => {
setName(value.replace(/,|'/g, ''));
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
const handleUnit = (value: string) => {
setUnit(value.replace(/,|'/g, ''));
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
return (
<>
<ScrollView style={{height: '90%'}}>
<MassiveInput
label="Name"
value={name}
onChangeText={handleName}
autoCorrect={false}
autoFocus={!name}
blurOnSubmit={false}
onSubmitEditing={() => repsRef.current?.focus()}
/>
<MassiveInput
label="Reps"
keyboardType="numeric"
value={reps}
onChangeText={setReps}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
autoFocus={!!name}
blurOnSubmit={false}
innerRef={repsRef}
/>
<MassiveInput
label="Weight"
keyboardType="numeric"
value={weight}
onChangeText={setWeight}
onSubmitEditing={handleSubmit}
innerRef={weightRef}
/>
{!!settings.showUnit && (
<MassiveInput
autoCapitalize="none"
label="Unit"
value={unit}
onChangeText={handleUnit}
innerRef={unitRef}
/>
)}
{workouts.length > 0 && !!settings.workouts && (
<View style={{flexDirection: 'row'}}>
{workouts.map((workout, index) => (
<Text>
<Text
style={
workout === name
? {textDecorationLine: 'underline', fontWeight: 'bold'}
: null
}>
{workout}
</Text>
{index === workouts.length - 1 ? '' : ', '}
</Text>
))}
</View>
)}
</ScrollView>
<Button
disabled={!name}
mode="contained"
icon="save"
onPress={handleSubmit}>
Save
</Button>
</>
);
}

View File

@ -1,99 +1,77 @@
import {NavigationProp, useNavigation} from '@react-navigation/native';
import React, {useCallback, useState} from 'react';
import {GestureResponderEvent, Image} from 'react-native';
import {Divider, List, Menu, Text} from 'react-native-paper';
import {HomePageParams} from './home-page-params';
import Set from './set';
import {deleteSet} from './set.service';
import { NavigationProp, useNavigation } from "@react-navigation/native";
import { format } from "date-fns";
import { useCallback, useMemo } from "react";
import { Image } from "react-native";
import { List, Text } from "react-native-paper";
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants";
import GymSet from "./gym-set";
import { HomePageParams } from "./home-page-params";
import Settings from "./settings";
import useDark from "./use-dark";
export default function SetItem({
item,
onRemove,
dates,
setDates,
images,
setImages,
settings,
ids,
setIds,
}: {
item: Set;
item: GymSet;
onRemove: () => void;
dates: boolean;
setDates: (value: boolean) => void;
images: boolean;
setImages: (value: boolean) => void;
settings: Settings;
ids: number[];
setIds: (value: number[]) => void;
}) {
const [showMenu, setShowMenu] = useState(false);
const [anchor, setAnchor] = useState({x: 0, y: 0});
const dark = useDark();
const navigation = useNavigation<NavigationProp<HomePageParams>>();
const remove = useCallback(async () => {
if (typeof item.id === 'number') await deleteSet(item.id);
setShowMenu(false);
onRemove();
}, [setShowMenu, onRemove, item.id]);
const longPress = useCallback(() => {
if (ids.length > 0) return;
setIds([item.id]);
}, [ids.length, item.id, setIds]);
const copy = useCallback(() => {
const set: Set = {...item};
delete set.id;
setShowMenu(false);
navigation.navigate('EditSet', {set, workouts: [], count: 0});
}, [navigation, item]);
const press = useCallback(() => {
if (ids.length === 0) return navigation.navigate("EditSet", { set: item });
const removing = ids.find((id) => id === item.id);
if (removing) setIds(ids.filter((id) => id !== item.id));
else setIds([...ids, item.id]);
}, [ids, item, navigation, setIds]);
const longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
setShowMenu(true);
},
[setShowMenu, setAnchor],
);
const backgroundColor = useMemo(() => {
if (!ids.includes(item.id)) return;
if (dark) return DARK_RIPPLE;
return LIGHT_RIPPLE;
}, [dark, ids, item.id]);
const toggleDates = useCallback(() => {
setDates(!dates);
setShowMenu(false);
}, [dates, setDates]);
const left = useCallback(() => {
if (!settings.images || !item.image) return null;
return (
<Image source={{ uri: item.image }} style={{ height: 75, width: 75 }} />
);
}, [item.image, settings.images]);
const toggleImages = useCallback(() => {
setImages(!images);
setShowMenu(false);
}, [images, setImages]);
const right = useCallback(() => {
if (!settings.showDate) return null;
return (
<Text
style={{
alignSelf: "center",
color: dark ? "#909090ff" : "#717171ff",
}}
>
{format(new Date(item.created), settings.date || "P")}
</Text>
);
}, [settings.showDate, item.created, settings.date, dark]);
return (
<>
<List.Item
onPress={() =>
navigation.navigate('EditSet', {set: item, workouts: [], count: 0})
}
title={item.name}
description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
onLongPress={longPress}
left={() =>
images &&
item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)
}
right={() => (
<>
{dates && (
<Text
style={{
alignSelf: 'center',
}}>
{item.created?.replace('T', ' ')}
</Text>
)}
<Menu
anchor={anchor}
visible={showMenu}
onDismiss={() => setShowMenu(false)}>
<Menu.Item icon="content-copy" onPress={copy} title="Copy" />
<Menu.Item icon="image" onPress={toggleImages} title="Images" />
<Menu.Item icon="event" onPress={toggleDates} title="Dates" />
<Divider />
<Menu.Item icon="delete" onPress={remove} title="Delete" />
</Menu>
</>
)}
/>
</>
<List.Item
onPress={press}
title={item.name}
description={`${item.reps} x ${item.weight}${item.unit || "kg"}`}
onLongPress={longPress}
style={{ backgroundColor }}
left={left}
right={right}
/>
);
}

View File

@ -2,139 +2,157 @@ import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {FlatList} from 'react-native';
import {List} from 'react-native-paper';
import {getBestSet} from './best.service';
import DrawerMenu from './DrawerMenu';
import {HomePageParams} from './home-page-params';
import Page from './Page';
import {getTodaysPlan} from './plan.service';
import Set from './set';
import {countToday, defaultSet, getSets, getToday} from './set.service';
import SetItem from './SetItem';
import {settings} from './settings.service';
const limit = 15;
} from "@react-navigation/native";
import { useCallback, useState } from "react";
import { FlatList } from "react-native";
import { List } from "react-native-paper";
import { Like } from "typeorm";
import { LIMIT } from "./constants";
import { getNow, setRepo, settingsRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
import GymSet, { defaultSet } from "./gym-set";
import { HomePageParams } from "./home-page-params";
import ListMenu from "./ListMenu";
import Page from "./Page";
import SetItem from "./SetItem";
import Settings from "./settings";
export default function SetList() {
const [sets, setSets] = useState<Set[]>();
const [set, setSet] = useState<Set>();
const [count, setCount] = useState(0);
const [workouts, setWorkouts] = useState<string[]>([]);
const [sets, setSets] = useState<GymSet[]>([]);
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [term, setTerm] = useState("");
const [end, setEnd] = useState(false);
const [dates, setDates] = useState(false);
const [images, setImages] = useState(true);
const [settings, setSettings] = useState<Settings>();
const [ids, setIds] = useState<number[]>([]);
const navigation = useNavigation<NavigationProp<HomePageParams>>();
const predict = useCallback(async () => {
setCount(0);
setSet({...defaultSet});
if (!settings.predict) return;
const todaysPlan = await getTodaysPlan();
if (todaysPlan.length === 0) return;
const todaysWorkouts = todaysPlan[0].workouts.split(',');
setWorkouts(todaysWorkouts);
let workout = todaysWorkouts[0];
let best = await getBestSet(workout);
const todaysSet = await getToday();
if (!todaysSet || !todaysWorkouts.includes(todaysSet.name))
return setSet(best);
let _count = await countToday(todaysSet.name);
workout = todaysSet.name;
best = await getBestSet(workout);
const index = todaysWorkouts.indexOf(todaysSet.name) + 1;
if (_count >= Number(best.sets)) {
best = await getBestSet(todaysWorkouts[index]);
_count = 0;
}
if (best.name === '') setCount(0);
else setCount(_count);
setSet(best);
}, []);
const refresh = useCallback(async () => {
predict();
const newSets = await getSets({search: `%${search}%`, limit, offset: 0});
console.log(`${SetList.name}.refresh:`, {first: newSets[0]});
if (newSets.length === 0) return setSets([]);
const refresh = useCallback(async (value: string) => {
const newSets = await setRepo.find({
where: { name: Like(`%${value.trim()}%`), hidden: 0 as any },
take: LIMIT,
skip: 0,
order: { created: "DESC" },
});
console.log(`${SetList.name}.refresh:`, { value });
setSets(newSets);
setOffset(0);
setEnd(false);
}, [search, predict]);
}, []);
useFocusEffect(
useCallback(() => {
refresh();
navigation.getParent()?.setOptions({
headerRight: () => <DrawerMenu name="Home" />,
});
setImages(!!settings.images);
}, [refresh, navigation]),
refresh(term);
settingsRepo.findOne({ where: {} }).then(setSettings);
}, [refresh, term])
);
useEffect(() => {
refresh();
}, [search, refresh]);
const renderItem = useCallback(
({item}: {item: Set}) => (
({ item }: { item: GymSet }) => (
<SetItem
dates={dates}
setDates={setDates}
images={images}
setImages={setImages}
settings={settings}
item={item}
key={item.id}
onRemove={refresh}
onRemove={() => refresh(term)}
ids={ids}
setIds={setIds}
/>
),
[refresh, dates, setDates, images, setImages],
[refresh, term, settings, ids]
);
const next = useCallback(async () => {
if (end) return;
const newOffset = offset + limit;
console.log(`${SetList.name}.next:`, {offset, newOffset, search});
const newSets = await getSets({
search: `%${search}%`,
limit,
offset: newOffset,
const newOffset = offset + LIMIT;
console.log(`${SetList.name}.next:`, { offset, newOffset, term });
const newSets = await setRepo.find({
where: { name: Like(`%${term}%`), hidden: 0 as any },
take: LIMIT,
skip: newOffset,
order: { created: "DESC" },
});
if (newSets.length === 0) return setEnd(true);
if (!sets) return;
setSets([...sets, ...newSets]);
if (newSets.length < limit) return setEnd(true);
if (newSets.length < LIMIT) return setEnd(true);
setOffset(newOffset);
}, [search, end, offset, sets]);
}, [term, end, offset, sets]);
const onAdd = useCallback(async () => {
console.log(`${SetList.name}.onAdd`, {set, defaultSet, workouts});
navigation.navigate('EditSet', {
set: set || {...defaultSet},
workouts,
count,
const now = await getNow();
let set = sets[0];
if (!set) set = { ...defaultSet };
set.created = now;
delete set.id;
navigation.navigate("EditSet", { set });
}, [navigation, sets]);
const search = useCallback(
(value: string) => {
setTerm(value);
refresh(value);
},
[refresh]
);
const edit = useCallback(() => {
navigation.navigate("EditSets", { ids });
setIds([]);
}, [ids, navigation]);
const copy = useCallback(async () => {
const set = await setRepo.findOne({
where: { id: ids.pop() },
});
}, [navigation, set, workouts, count]);
delete set.id;
delete set.created;
navigation.navigate("EditSet", { set });
setIds([]);
}, [ids, navigation]);
const clear = useCallback(() => {
setIds([]);
}, []);
const remove = useCallback(async () => {
setIds([]);
await setRepo.delete(ids.length > 0 ? ids : {});
await refresh(term);
}, [ids, refresh, term]);
const select = useCallback(() => {
setIds(sets.map((set) => set.id));
}, [sets]);
return (
<Page onAdd={onAdd} search={search} setSearch={setSearch}>
<FlatList
data={sets}
style={{height: '99%'}}
ListEmptyComponent={
<>
<DrawerHeader name={ids.length > 0 ? `${ids.length} selected` : "Home"}>
<ListMenu
onClear={clear}
onCopy={copy}
onDelete={remove}
onEdit={edit}
ids={ids}
onSelect={select}
/>
</DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}>
{sets?.length === 0 ? (
<List.Item
title="No sets yet"
description="A set is a group of repetitions. E.g. 8 reps of Squats."
/>
}
renderItem={renderItem}
keyExtractor={s => s.id!.toString()}
onEndReached={next}
/>
</Page>
) : (
settings && (
<FlatList
data={sets}
style={{ flex: 1 }}
renderItem={renderItem}
onEndReached={next}
/>
)
)}
</Page>
</>
);
}

33
SettingButton.tsx Normal file
View File

@ -0,0 +1,33 @@
import { View } from "react-native";
import { Button, Subheading } from "react-native-paper";
import { ITEM_PADDING } from "./constants";
export default function SettingButton({
name: text,
label,
onPress,
}: {
name: string;
label?: string;
onPress: () => void;
}) {
if (label) {
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingLeft: ITEM_PADDING,
}}
>
<Subheading style={{ width: 100 }}>{label}</Subheading>
<Button onPress={onPress}>{text}</Button>
</View>
);
}
return (
<Button style={{ alignSelf: "flex-start" }} onPress={onPress}>
{text}
</Button>
);
}

View File

@ -1,213 +1,352 @@
import {Picker} from '@react-native-picker/picker';
import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback, useContext, useEffect, useState} from 'react';
import {NativeModules, ScrollView} from 'react-native';
import DocumentPicker from 'react-native-document-picker';
import {Button} from 'react-native-paper';
import {CustomTheme} from './App';
import {darkColors, lightColors} from './colors';
import ConfirmDialog from './ConfirmDialog';
import {MARGIN} from './constants';
import Input from './input';
import {SnackbarContext} from './MassiveSnack';
import Page from './Page';
import {getSettings, settings, updateSettings} from './settings.service';
import Switch from './Switch';
import { NavigationProp, useNavigation } from "@react-navigation/native";
import { format } from "date-fns";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { NativeModules, ScrollView } from "react-native";
import DocumentPicker from "react-native-document-picker";
import { Dirs, FileSystem } from "react-native-file-access";
import ConfirmDialog from "./ConfirmDialog";
import { MARGIN } from "./constants";
import { AppDataSource } from "./data-source";
import { setRepo, settingsRepo } from "./db";
import { DrawerParamList } from "./drawer-param-list";
import DrawerHeader from "./DrawerHeader";
import Input from "./input";
import { darkOptions, lightOptions, themeOptions } from "./options";
import Page from "./Page";
import Select from "./Select";
import SettingButton from "./SettingButton";
import Settings from "./settings";
import Switch from "./Switch";
import { toast } from "./toast";
import { useTheme } from "./use-theme";
const twelveHours = [
"dd/LL/yyyy",
"dd/LL/yyyy, p",
"ccc p",
"p",
"yyyy-MM-d",
"yyyy-MM-d, p",
"yyyy.MM.d",
];
const twentyFours = [
"dd/LL/yyyy",
"dd/LL/yyyy, k:m",
"ccc k:m",
"k:m",
"yyyy-MM-d",
"yyyy-MM-d, k:m",
"yyyy.MM.d",
];
export default function SettingsPage() {
const [battery, setBattery] = useState(false);
const [ignoring, setIgnoring] = useState(false);
const [search, setSearch] = useState('');
const [vibrate, setVibrate] = useState(!!settings.vibrate);
const [alarm, setAlarm] = useState(!!settings.alarm);
const [predict, setPredict] = useState(!!settings.predict);
const [sound, setSound] = useState(settings.sound);
const [notify, setNotify] = useState(!!settings.notify);
const [images, setImages] = useState(!!settings.images);
const [showUnit, setShowUnit] = useState(!!settings.showUnit);
const [workouts, setWorkouts] = useState(!!settings.workouts);
const [steps, setSteps] = useState(!!settings.steps);
const {color, setColor} = useContext(CustomTheme);
const {toast} = useContext(SnackbarContext);
const [term, setTerm] = useState("");
const [formatOptions, setFormatOptions] = useState<string[]>(twelveHours);
const [importing, setImporting] = useState(false);
const [deleting, setDeleting] = useState(false);
const { reset } = useNavigation<NavigationProp<DrawerParamList>>();
useFocusEffect(
useCallback(() => {
NativeModules.AlarmModule.ignoringBattery(setIgnoring);
}, []),
);
const { watch, setValue } = useForm<Settings>({
defaultValues: () => settingsRepo.findOne({ where: {} }),
});
const settings = watch();
const {
theme,
setTheme,
lightColor,
setLightColor,
darkColor,
setDarkColor,
} = useTheme();
useEffect(() => {
updateSettings({
vibrate: +vibrate,
alarm: +alarm,
predict: +predict,
sound,
notify: +notify,
images: +images,
showUnit: +showUnit,
color,
workouts: +workouts,
steps: +steps,
NativeModules.SettingsModule.ignoringBattery(setIgnoring);
NativeModules.SettingsModule.is24().then((is24: boolean) => {
console.log(`${SettingsPage.name}.focus:`, { is24 });
if (is24) setFormatOptions(twentyFours);
else setFormatOptions(twelveHours);
});
getSettings();
}, [
vibrate,
alarm,
predict,
sound,
notify,
images,
showUnit,
color,
workouts,
steps,
]);
}, []);
const changeAlarmEnabled = useCallback(
(enabled: boolean) => {
setAlarm(enabled);
if (enabled) toast('Timers will now run after each set.', 4000);
else toast('Stopped timers running after each set.', 4000);
if (enabled && !ignoring) setBattery(true);
},
[setBattery, ignoring, toast],
);
const update = useCallback((key: keyof Settings, value: unknown) => {
return settingsRepo
.createQueryBuilder()
.update()
.set({ [key]: value })
.printSql()
.execute();
}, []);
const changePredict = useCallback(
(enabled: boolean) => {
setPredict(enabled);
if (enabled) toast('Predict your next set based on todays plan.', 4000);
else toast('New sets will always be empty.', 4000);
},
[setPredict, toast],
);
const changeVibrate = useCallback(
(enabled: boolean) => {
setVibrate(enabled);
if (enabled) toast('When a timer completes, vibrate your phone.', 4000);
else toast('Stop vibrating at the end of timers.', 4000);
},
[setVibrate, toast],
);
const soundString = useMemo(() => {
if (!settings.sound) return null;
const split = settings.sound.split("/");
return split.pop();
}, [settings.sound]);
const changeSound = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
type: 'audio/*',
copyTo: 'documentDirectory',
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.audio,
copyTo: "documentDirectory",
});
if (!fileCopyUri) return;
setSound(fileCopyUri);
toast('This song will now play after rest timers complete.', 4000);
}, [toast]);
setValue("sound", fileCopyUri);
await update("sound", fileCopyUri);
toast("Sound will play after rest timers.");
}, [setValue, update]);
const changeNotify = useCallback(
(enabled: boolean) => {
setNotify(enabled);
if (enabled) toast('Show when a set is a new record.', 4000);
else toast('Stopped showing notifications for new records.', 4000);
},
[toast],
const switches: Input<boolean>[] = useMemo(
() => [
{ name: "Rest timers", value: settings.alarm, key: "alarm" },
{ name: "Vibrate", value: settings.vibrate, key: "vibrate" },
{ name: "Disable sound", value: settings.noSound, key: "noSound" },
{ name: "Notifications", value: settings.notify, key: "notify" },
{ name: "Show images", value: settings.images, key: "images" },
{ name: "Show unit", value: settings.showUnit, key: "showUnit" },
{ name: "Show steps", value: settings.steps, key: "steps" },
{ name: "Show date", value: settings.showDate, key: "showDate" },
{ name: "Automatic backup", value: settings.backup, key: "backup" },
],
[settings]
);
const changeImages = useCallback(
(enabled: boolean) => {
setImages(enabled);
if (enabled) toast('Show images for sets.', 4000);
else toast('Stopped showing images for sets.', 4000);
},
[toast],
const filter = useCallback(
({ name }) => name.toLowerCase().includes(term.toLowerCase()),
[term]
);
const changeUnit = useCallback(
(enabled: boolean) => {
setShowUnit(enabled);
if (enabled) toast('Show option to select unit for sets.', 4000);
else toast('Hid unit option for sets.', 4000);
const changeBoolean = useCallback(
async (key: keyof Settings, value: boolean) => {
setValue(key, value);
await update(key, value);
switch (key) {
case "alarm":
if (value) toast("Timers will now run after each set.");
else toast("Stopped timers running after each set.");
if (value && !ignoring) NativeModules.SettingsModule.ignoreBattery();
return;
case "vibrate":
if (value) toast("Alarms will now vibrate.");
else toast("Alarms will no longer vibrate.");
return;
case "notify":
if (value) toast("Show notifications for new records.");
else toast("Stopped notifications for new records.");
return;
case "images":
if (value) toast("Show images for sets.");
else toast("Hid images for sets.");
return;
case "showUnit":
if (value) toast("Show option to select unit for sets.");
else toast("Hid unit option for sets.");
return;
case "steps":
if (value) toast("Show steps for a workout.");
else toast("Hid steps for workouts.");
return;
case "showDate":
if (value) toast("Show date for sets.");
else toast("Hid date on sets.");
return;
case "noSound":
if (value) toast("Disable sound on rest timer alarms.");
else toast("Enabled sound for rest timer alarms.");
return;
case "backup":
if (value) {
const result = await DocumentPicker.pickDirectory();
toast("Backup database daily.");
NativeModules.BackupModule.start(result.uri);
} else {
toast("Stopped backing up daily");
NativeModules.BackupModule.stop();
}
return;
}
},
[toast],
[ignoring, setValue, update]
);
const changeWorkouts = useCallback(
(enabled: boolean) => {
setWorkouts(enabled);
if (enabled) toast('Show workout for sets.', 4000);
else toast('Stopped showing workout for sets.', 4000);
},
[toast],
const renderSwitch = useCallback(
(item: Input<boolean>) => (
<Switch
key={item.name}
value={item.value}
onChange={(value) => changeBoolean(item.key, value)}
title={item.name}
/>
),
[changeBoolean]
);
const changeSteps = useCallback(
(enabled: boolean) => {
setSteps(enabled);
if (enabled) toast('Show steps for a workout.', 4000);
else toast('Stopped showing steps for workouts.', 4000);
},
[toast],
const switchesMarkup = useMemo(
() => switches.filter(filter).map((s) => renderSwitch(s)),
[filter, switches, renderSwitch]
);
const switches: Input<boolean>[] = [
{name: 'Rest timers', value: alarm, onChange: changeAlarmEnabled},
{name: 'Vibrate', value: vibrate, onChange: changeVibrate},
{name: 'Predict sets', value: predict, onChange: changePredict},
{name: 'Record notifications', value: notify, onChange: changeNotify},
{name: 'Show images', value: images, onChange: changeImages},
{name: 'Show unit', value: showUnit, onChange: changeUnit},
{name: 'Show workouts', value: workouts, onChange: changeWorkouts},
{name: 'Show steps', value: steps, onChange: changeSteps},
];
const changeString = useCallback(
async (key: keyof Settings, value: string) => {
setValue(key, value);
await update(key, value);
switch (key) {
case "date":
return toast("Changed date format");
case "darkColor":
setDarkColor(value);
return toast("Set primary color for dark mode.");
case "lightColor":
setLightColor(value);
return toast("Set primary color for light mode.");
case "vibrate":
return toast("Set primary color for light mode.");
case "sound":
return toast("Sound will play after rest timers.");
case "theme":
setTheme(value as string);
if (value === "dark") toast("Theme will always be dark.");
else if (value === "light") toast("Theme will always be light.");
else if (value === "system") toast("Theme will follow system.");
return;
}
},
[update, setTheme, setDarkColor, setLightColor, setValue]
);
const selects: Input<string>[] = useMemo(() => {
const today = new Date();
return [
{ name: "Theme", value: theme, items: themeOptions, key: "theme" },
{
name: "Dark color",
value: darkColor,
items: lightOptions,
key: "darkColor",
},
{
name: "Light color",
value: lightColor,
items: darkOptions,
key: "lightColor",
},
{
name: "Date format",
value: settings.date,
items: formatOptions.map((option) => ({
label: format(today, option),
value: option,
})),
key: "date",
},
];
}, [settings, darkColor, formatOptions, theme, lightColor]);
const renderSelect = useCallback(
(input: Input<string>) => (
<Select
key={input.name}
value={input.value}
onChange={(value) => changeString(input.key, value)}
label={input.name}
items={input.items}
/>
),
[changeString]
);
const selectsMarkup = useMemo(
() => selects.filter(filter).map(renderSelect),
[filter, selects, renderSelect]
);
const confirmDelete = useCallback(async () => {
setDeleting(false);
await AppDataSource.dropDatabase();
await AppDataSource.destroy();
await AppDataSource.initialize();
toast("Database deleted.");
}, []);
const confirmImport = useCallback(async () => {
setImporting(false);
await AppDataSource.destroy();
const file = await DocumentPicker.pickSingle();
await FileSystem.cp(file.uri, Dirs.DatabaseDir + "/massive.db");
await AppDataSource.initialize();
await setRepo.createQueryBuilder().update().set({ image: null }).execute();
await update("sound", null);
const { alarm, backup } = await settingsRepo.findOne({ where: {} });
console.log({ backup });
const directory = await DocumentPicker.pickDirectory();
if (backup) NativeModules.BackupModule.start(directory.uri);
else NativeModules.BackupModule.stop();
NativeModules.SettingsModule.ignoringBattery((isIgnoring: boolean) => {
if (alarm && !isIgnoring) NativeModules.SettingsModule.ignoreBattery();
reset({ index: 0, routes: [{ name: "Settings" }] });
});
}, [reset, update]);
const exportDatabase = useCallback(async () => {
const path = Dirs.DatabaseDir + "/massive.db";
await FileSystem.cpExternal(path, "massive.db", "downloads");
toast("Database exported. Check downloads.");
}, []);
const buttons = useMemo(
() => [
{
name: soundString || "Default",
onPress: changeSound,
label: "Alarm sound",
},
{ name: "Export database", onPress: exportDatabase },
{ name: "Import database", onPress: () => setImporting(true) },
{ name: "Delete database", onPress: () => setDeleting(true) },
],
[changeSound, exportDatabase, soundString]
);
const buttonsMarkup = useMemo(
() =>
buttons
.filter(filter)
.map((button) => <SettingButton {...button} key={button.name} />),
[buttons, filter]
);
return (
<Page search={search} setSearch={setSearch}>
<ScrollView style={{marginTop: MARGIN}}>
{switches
.filter(input =>
input.name.toLowerCase().includes(search.toLowerCase()),
)
.map(input => (
<Switch
onPress={() => input.onChange(!input.value)}
key={input.name}
value={input.value}
onValueChange={input.onChange}>
{input.name}
</Switch>
))}
{'theme'.includes(search.toLowerCase()) && (
<Picker
style={{color}}
dropdownIconColor={color}
selectedValue={color}
onValueChange={value => setColor(value)}>
{darkColors.concat(lightColors).map(colorOption => (
<Picker.Item
key={colorOption.hex}
value={colorOption.hex}
label={`${colorOption.name} theme`}
color={colorOption.hex}
/>
))}
</Picker>
)}
{'alarm sound'.includes(search.toLowerCase()) && (
<Button style={{alignSelf: 'flex-start'}} onPress={changeSound}>
Alarm sound
{sound
? ': ' + sound.split('/')[sound.split('/').length - 1]
: null}
</Button>
)}
</ScrollView>
<>
<DrawerHeader name="Settings" />
<Page term={term} search={setTerm} style={{ flexGrow: 1 }}>
<ScrollView style={{ marginTop: MARGIN, flex: 1 }}>
{switchesMarkup}
{selectsMarkup}
{buttonsMarkup}
</ScrollView>
</Page>
<ConfirmDialog
title="Battery optimizations"
show={battery}
setShow={setBattery}
onOk={() => {
NativeModules.AlarmModule.ignoreBattery();
setBattery(false);
}}>
Disable battery optimizations for Massive to use rest timers.
title="Are you sure?"
onOk={confirmImport}
setShow={setImporting}
show={importing}
>
Importing a database overwrites your current data. This action cannot be
reversed!
</ConfirmDialog>
</Page>
<ConfirmDialog
title="Are you sure?"
onOk={confirmDelete}
setShow={setDeleting}
show={deleting}
>
Deleting your database wipes your current data. This action cannot be
reversed!
</ConfirmDialog>
</>
);
}

20
StackHeader.tsx Normal file
View File

@ -0,0 +1,20 @@
import { useNavigation } from "@react-navigation/native";
import { Appbar, IconButton } from "react-native-paper";
export default function StackHeader({
title,
children,
}: {
title: string;
children?: JSX.Element | JSX.Element[];
}) {
const navigation = useNavigation();
return (
<Appbar.Header>
<IconButton icon="arrow-back" onPress={navigation.goBack} />
<Appbar.Content title={title} />
{children}
</Appbar.Header>
);
}

226
StartPlan.tsx Normal file
View File

@ -0,0 +1,226 @@
import {
NavigationProp,
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from "@react-navigation/native";
import { useCallback, useMemo, useRef, useState } from "react";
import { FlatList, NativeModules, TextInput, View } from "react-native";
import { Button, IconButton, ProgressBar } from "react-native-paper";
import AppInput from "./AppInput";
import { getBestSet } from "./best.service";
import { MARGIN, PADDING } from "./constants";
import CountMany from "./count-many";
import { AppDataSource } from "./data-source";
import { getNow, setRepo, settingsRepo } from "./db";
import { fixNumeric } from "./fix-numeric";
import GymSet from "./gym-set";
import { PlanPageParams } from "./plan-page-params";
import Settings from "./settings";
import StackHeader from "./StackHeader";
import StartPlanItem from "./StartPlanItem";
import { toast } from "./toast";
export default function StartPlan() {
const { params } = useRoute<RouteProp<PlanPageParams, "StartPlan">>();
const [reps, setReps] = useState(params.first?.reps.toString() || "0");
const [weight, setWeight] = useState(params.first?.weight.toString() || "0");
const [unit, setUnit] = useState<string>(params.first?.unit || "kg");
const [selected, setSelected] = useState(0);
const [settings, setSettings] = useState<Settings>();
const [counts, setCounts] = useState<CountMany[]>();
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const unitRef = useRef<TextInput>(null);
const workouts = useMemo(() => params.plan.workouts.split(","), [params]);
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
const [selection, setSelection] = useState({
start: 0,
end: 0,
});
const refresh = useCallback(async () => {
const questions = workouts
.map((workout, index) => `('${workout}',${index})`)
.join(",");
const select = `
SELECT workouts.name, COUNT(sets.id) as total, sets.sets
FROM (select 0 as name, 0 as sequence union values ${questions}) as workouts
LEFT JOIN sets ON sets.name = workouts.name
AND sets.created LIKE STRFTIME('%Y-%m-%d%%', 'now', 'localtime')
AND NOT sets.hidden
GROUP BY workouts.name
ORDER BY workouts.sequence
LIMIT -1
OFFSET 1
`;
const newCounts = await AppDataSource.manager.query(select);
console.log(`${StartPlan.name}.focus:`, { newCounts });
setCounts(newCounts);
}, [workouts]);
const select = useCallback(
async (index: number, newCounts?: CountMany[]) => {
setSelected(index);
if (!counts && !newCounts) return;
const workout = counts ? counts[index] : newCounts[index];
console.log(`${StartPlan.name}.next:`, { workout });
const last = await setRepo.findOne({
where: { name: workout.name },
order: { created: "desc" },
});
console.log({ last });
if (!last) return;
delete last.id;
console.log(`${StartPlan.name}.select:`, { last });
setReps(last.reps.toString());
setWeight(last.weight.toString());
setUnit(last.unit);
},
[counts]
);
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
refresh();
}, [refresh])
);
const handleSubmit = async () => {
const now = await getNow();
const workout = counts[selected];
const best = await getBestSet(workout.name);
delete best.id;
const newSet: GymSet = {
...best,
weight: +weight,
reps: +reps,
unit,
created: now,
hidden: false,
};
await setRepo.save(newSet);
await refresh();
if (
settings.notify &&
(+weight > best.weight || (+reps > best.reps && +weight === best.weight))
) {
toast("Great work King! That's a new record.");
}
if (!settings.alarm) return;
const milliseconds =
Number(best.minutes) * 60 * 1000 + Number(best.seconds) * 1000;
NativeModules.AlarmModule.timer(milliseconds);
};
return (
<>
<StackHeader title={params.plan.days.replace(/,/g, ", ")}>
<IconButton
onPress={() => navigation.navigate("EditPlan", { plan: params.plan })}
icon="edit"
/>
</StackHeader>
<View style={{ padding: PADDING, flex: 1, flexDirection: "column" }}>
<View style={{ flex: 1 }}>
<View
style={{
flexDirection: "row",
marginBottom: MARGIN,
}}
>
<AppInput
label="Reps"
style={{ flex: 1 }}
keyboardType="numeric"
value={reps}
onChangeText={(newReps) => {
const fixed = fixNumeric(newReps);
setReps(fixed);
if (fixed.length !== newReps.length)
toast("Reps must be a number");
}}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
innerRef={repsRef}
/>
<IconButton
icon="add"
onPress={() => setReps((Number(reps) + 1).toString())}
/>
<IconButton
icon="remove"
onPress={() => setReps((Number(reps) - 1).toString())}
/>
</View>
<View
style={{
flexDirection: "row",
marginBottom: MARGIN,
}}
>
<AppInput
label="Weight"
style={{ flex: 1 }}
keyboardType="numeric"
value={weight}
onChangeText={(newWeight) => {
const fixed = fixNumeric(newWeight);
setWeight(fixed);
if (fixed.length !== newWeight.length)
toast("Weight must be a number");
}}
onSubmitEditing={handleSubmit}
innerRef={weightRef}
blurOnSubmit
/>
<IconButton
icon="add"
onPress={() => setWeight((Number(weight) + 2.5).toString())}
/>
<IconButton
icon="remove"
onPress={() => setWeight((Number(weight) - 2.5).toString())}
/>
</View>
{settings?.showUnit && (
<AppInput
autoCapitalize="none"
label="Unit"
value={unit}
onChangeText={setUnit}
innerRef={unitRef}
/>
)}
{counts && (
<FlatList
data={counts}
renderItem={(props) => (
<View>
<StartPlanItem
{...props}
onUndo={refresh}
onSelect={select}
selected={selected}
/>
<ProgressBar
progress={(props.item.total || 0) / (props.item.sets || 3)}
/>
</View>
)}
/>
)}
</View>
<Button mode="outlined" icon="save" onPress={handleSubmit}>
Save
</Button>
</View>
</>
);
}

112
StartPlanItem.tsx Normal file
View File

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

View File

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

75
TimerPage.tsx Normal file
View File

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

View File

@ -1,110 +0,0 @@
import {Picker} from '@react-native-picker/picker';
import {
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {useColorScheme, View} from 'react-native';
import {FileSystem} from 'react-native-file-access';
import {IconButton} from 'react-native-paper';
import Share from 'react-native-share';
import {captureScreen} from 'react-native-view-shot';
import {getVolumes, getWeightsBy} from './best.service';
import {BestPageParams} from './BestPage';
import Chart from './Chart';
import {PADDING} from './constants';
import {Metrics} from './metrics';
import {Periods} from './periods';
import Set from './set';
import {formatMonth} from './time';
import Volume from './volume';
export default function ViewBest() {
const {params} = useRoute<RouteProp<BestPageParams, 'ViewBest'>>();
const dark = useColorScheme() === 'dark';
const [weights, setWeights] = useState<Set[]>([]);
const [volumes, setVolumes] = useState<Volume[]>([]);
const [metric, setMetric] = useState(Metrics.Weight);
const [period, setPeriod] = useState(Periods.Monthly);
const navigation = useNavigation();
useFocusEffect(
useCallback(() => {
console.log(`${ViewBest.name}.useFocusEffect`);
navigation.getParent()?.setOptions({
headerLeft: () => (
<IconButton icon="arrow-back" onPress={() => navigation.goBack()} />
),
headerRight: () => (
<IconButton
onPress={() =>
captureScreen().then(async uri => {
const base64 = await FileSystem.readFile(uri, 'base64');
const url = `data:image/jpeg;base64,${base64}`;
Share.open({
type: 'image/jpeg',
url,
});
})
}
icon="share"
/>
),
title: params.best.name,
});
}, [navigation, params.best]),
);
useEffect(() => {
if (metric === Metrics.Weight)
getWeightsBy(params.best.name, period).then(setWeights);
else if (metric === Metrics.Volume)
getVolumes(params.best.name, period).then(setVolumes);
console.log(`${ViewBest.name}.useEffect`, {metric});
console.log(`${ViewBest.name}.useEffect`, {period});
}, [params.best.name, metric, period]);
return (
<View style={{padding: PADDING}}>
<Picker
style={{color: dark ? 'white' : 'black'}}
dropdownIconColor={dark ? 'white' : 'black'}
selectedValue={metric}
onValueChange={value => setMetric(value)}>
<Picker.Item value={Metrics.Volume} label={Metrics.Volume} />
<Picker.Item value={Metrics.Weight} label={Metrics.Weight} />
</Picker>
<Picker
style={{color: dark ? 'white' : 'black'}}
dropdownIconColor={dark ? 'white' : 'black'}
selectedValue={period}
onValueChange={value => setPeriod(value)}>
<Picker.Item value={Periods.Weekly} label={Periods.Weekly} />
<Picker.Item value={Periods.Monthly} label={Periods.Monthly} />
<Picker.Item value={Periods.Yearly} label={Periods.Yearly} />
</Picker>
{metric === Metrics.Volume && (
<Chart
yData={volumes.map(v => v.value)}
yFormat={(value: number) =>
`${value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${
volumes[0].unit
}`
}
xData={weights}
xFormat={(_value, index) => formatMonth(weights[index].created!)}
/>
)}
{metric === Metrics.Weight && (
<Chart
yData={weights.map(set => set.weight)}
yFormat={value => `${value}${weights[0].unit}`}
xData={weights}
xFormat={(_value, index) => formatMonth(weights[index].created!)}
/>
)}
</View>
);
}

154
ViewGraph.tsx Normal file
View File

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

View File

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

View File

@ -2,97 +2,120 @@ import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import React, {useCallback, useEffect, useState} from 'react';
import {FlatList} from 'react-native';
import {List} from 'react-native-paper';
import Page from './Page';
import Set from './set';
import {getDistinctSets} from './set.service';
import SetList from './SetList';
import WorkoutItem from './WorkoutItem';
import {WorkoutsPageParams} from './WorkoutsPage';
const limit = 15;
} from "@react-navigation/native";
import { useCallback, useState } from "react";
import { FlatList } from "react-native";
import { List } from "react-native-paper";
import { LIMIT } from "./constants";
import { setRepo, settingsRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
import GymSet from "./gym-set";
import Page from "./Page";
import SetList from "./SetList";
import Settings from "./settings";
import WorkoutItem from "./WorkoutItem";
import { WorkoutsPageParams } from "./WorkoutsPage";
export default function WorkoutList() {
const [workouts, setWorkouts] = useState<Set[]>();
const [workouts, setWorkouts] = useState<GymSet[]>();
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [term, setTerm] = useState("");
const [end, setEnd] = useState(false);
const [settings, setSettings] = useState<Settings>();
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
const refresh = useCallback(async () => {
const newWorkouts = await getDistinctSets({
search: `%${search}%`,
limit,
offset: 0,
});
console.log(`${WorkoutList.name}`, {newWorkout: newWorkouts[0]});
const refresh = useCallback(async (value: string) => {
const newWorkouts = await setRepo
.createQueryBuilder()
.select()
.where("name LIKE :name", { name: `%${value.trim()}%` })
.groupBy("name")
.orderBy("name")
.limit(LIMIT)
.getMany();
console.log(`${WorkoutList.name}`, { newWorkout: newWorkouts[0] });
setWorkouts(newWorkouts);
setOffset(0);
setEnd(false);
}, [search]);
useEffect(() => {
refresh();
}, [search, refresh]);
}, []);
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh]),
refresh(term);
settingsRepo.findOne({ where: {} }).then(setSettings);
}, [refresh, term])
);
const renderItem = useCallback(
({item}: {item: Set}) => (
<WorkoutItem item={item} key={item.name} onRemoved={refresh} />
({ item }: { item: GymSet }) => (
<WorkoutItem
images={settings?.images}
item={item}
key={item.name}
onRemove={() => refresh(term)}
/>
),
[refresh],
[refresh, term, settings?.images]
);
const next = useCallback(async () => {
if (end) return;
const newOffset = offset + limit;
const newOffset = offset + LIMIT;
console.log(`${SetList.name}.next:`, {
offset,
limit,
limit: LIMIT,
newOffset,
search,
});
const newWorkouts = await getDistinctSets({
search: `%${search}%`,
limit,
offset: newOffset,
term,
});
const newWorkouts = await setRepo
.createQueryBuilder()
.select()
.where("name LIKE :name", { name: `%${term.trim()}%` })
.groupBy("name")
.orderBy("name")
.limit(LIMIT)
.offset(newOffset)
.getMany();
if (newWorkouts.length === 0) return setEnd(true);
if (!workouts) return;
setWorkouts([...workouts, ...newWorkouts]);
if (newWorkouts.length < limit) return setEnd(true);
if (newWorkouts.length < LIMIT) return setEnd(true);
setOffset(newOffset);
}, [search, end, offset, workouts]);
}, [term, end, offset, workouts]);
const onAdd = useCallback(async () => {
navigation.navigate('EditWorkout', {
value: {name: '', sets: 3, image: '', steps: '', reps: 0, weight: 0},
navigation.navigate("EditWorkout", {
value: new GymSet(),
});
}, [navigation]);
const search = useCallback(
(value: string) => {
setTerm(value);
refresh(value);
},
[refresh]
);
return (
<Page onAdd={onAdd} search={search} setSearch={setSearch}>
<FlatList
data={workouts}
style={{height: '99%'}}
ListEmptyComponent={
<>
<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."
/>
}
renderItem={renderItem}
keyExtractor={w => w.name}
onEndReached={next}
/>
</Page>
) : (
<FlatList
data={workouts}
style={{ flex: 1 }}
renderItem={renderItem}
keyExtractor={(w) => w.name}
onEndReached={next}
/>
)}
</Page>
</>
);
}

View File

@ -1,43 +1,24 @@
import {DrawerNavigationProp} from '@react-navigation/drawer';
import {useNavigation} from '@react-navigation/native';
import {createStackNavigator} from '@react-navigation/stack';
import React from 'react';
import {IconButton} from 'react-native-paper';
import {DrawerParamList} from './drawer-param-list';
import EditWorkout from './EditWorkout';
import Set from './set';
import WorkoutList from './WorkoutList';
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: Set;
value: GymSet;
};
};
const Stack = createStackNavigator<WorkoutsPageParams>();
export default function WorkoutsPage() {
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
screenOptions={{ headerShown: false, animationEnabled: false }}
>
<Stack.Screen name="WorkoutList" component={WorkoutList} />
<Stack.Screen
name="EditWorkout"
component={EditWorkout}
listeners={{
beforeRemove: () => {
navigation.setOptions({
headerLeft: () => (
<IconButton icon="menu" onPress={navigation.openDrawer} />
),
title: 'Workouts',
});
},
}}
/>
<Stack.Screen name="EditWorkout" component={EditWorkout} />
</Stack.Navigator>
);
}

3
android/Gemfile Normal file
View File

@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"

219
android/Gemfile.lock Normal file
View File

@ -0,0 +1,219 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
rexml
addressable (2.8.4)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.780.0)
aws-sdk-core (3.175.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.67.0)
aws-sdk-core (~> 3, >= 3.174.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.126.0)
aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.100.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.7)
fastlane (2.213.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (>= 1.4.5, < 2.0.0)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.43.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.19.0)
google-apis-core (>= 0.9.0, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.19.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.5.2)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.5)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.6.3)
jwt (2.7.1)
memoist (0.16.2)
mini_magick (4.12.0)
mini_mime (1.1.2)
multi_json (1.15.0)
multipart-post (2.3.0)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.1.1)
os (1.1.4)
plist (3.7.0)
public_suffix (5.0.1)
rake (13.0.6)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.5)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
signet (0.17.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (1.8.0)
webrick (1.8.1)
word_wrap (1.0.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
x86_64-linux
DEPENDENCIES
fastlane
BUNDLED WITH
2.3.25

View File

@ -1,117 +1,92 @@
apply plugin: "com.android.application"
apply plugin: "com.facebook.react"
apply plugin: "kotlin-android"
import com.android.build.OutputFile
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '..'
// root = file("../")
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
// reactNativeDir = file("../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen
// codegenDir = file("../node_modules/@react-native/codegen")
// The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js
// cliFile = file("../node_modules/react-native/cli.js")
project.ext.react = [
enableHermes: false, // clean and rebuild if changing
]
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
project.ext.vectoricons = [
iconFontNames: ['MaterialIcons.ttf']
]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The command to run when bundling. By default is 'bundle'
// bundleCommand = "ram-bundle"
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
apply from: "../../node_modules/react-native/react.gradle"
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
def enableSeparateBuildPerCPUArchitecture = true
def enableProguardInReleaseBuilds = true
def jscFlavor = 'org.webkit:android-jsc:+'
def enableHermes = project.ext.react.get("enableHermes", false);
def reactNativeArchitectures() {
def value = project.getProperties().get("reactNativeArchitectures")
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
}
android {
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/
def enableProguardInReleaseBuilds = true
packagingOptions {
jniLibs {
pickFirsts += ['**/armeabi-v7a/libfolly_runtime.so', '**/x86/libfolly_runtime.so', '**/arm64-v8a/libfolly_runtime.so', '**/x86_64/libfolly_runtime.so']
}
}
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* 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
compileSdkVersion rootProject.ext.compileSdkVersion
namespace "com.massive"
lintOptions {
checkReleaseBuilds false
abortOnError false
}
defaultConfig {
applicationId "com.massive"
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 36037
versionName "1.11"
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())
}
versionCode 36174
versionName "1.148"
}
signingConfigs {
release {
@ -136,7 +111,9 @@ android {
signingConfig signingConfigs.debug
}
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
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
@ -144,61 +121,28 @@ android {
}
dependencies {
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"
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation project(':react-native-sqlite-storage')
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
exclude group:'com.facebook.fbjni'
}
implementation project(':react-native-vector-icons')
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}")
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'
}
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
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)
def isNewArchitectureEnabled() {
return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
}
project.ext.vectoricons = [
iconFontNames: ['MaterialIcons.ttf']
]
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"

View File

@ -44,3 +44,6 @@
-dontwarn java.nio.file.*
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn okio.**
-keep class com.facebook.hermes.unicode.** { *; }
-keep class com.facebook.jni.** { *; }

View File

@ -17,7 +17,6 @@ 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;
@ -25,13 +24,16 @@ import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.network.NetworkingModule;
import okhttp3.OkHttpClient;
/**
* Class responsible of loading Flipper inside your React Native application. This is the debug
* flavor of it. Here you can add your own plugins and customize the Flipper setup.
*/
public class ReactNativeFlipper {
public 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());

View File

@ -1,35 +1,56 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.massive">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission tools:node="remove" android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE"
tools:node="remove" />
<uses-permission android:name="com.google.android.gms.permission.AD_ID"
tools:node="remove"/>
<application
android:name=".MainApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/AppTheme"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:exported="true" android:process=":remote" android:name=".StopAlarm" />
<service android:name=".StopTimer" android:exported="true" android:process=":remote" />
<service android:name=".AlarmService" android:exported="true" />
<service android:name=".TimerService" android:exported="true" />
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".TimerDone"
android:exported="false">
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<activity
android:name=".StopAlarm"
android:exported="true"
android:process=":remote" />
<service
android:name=".AlarmService"
android:exported="false" />
</application>
</manifest>

View File

@ -1,90 +1,191 @@
package com.massive
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.app.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.content.IntentFilter
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.os.CountDownTimer
import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresApi
import com.facebook.react.bridge.Callback
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import androidx.core.app.NotificationCompat
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule
import kotlin.math.floor
class AlarmModule internal constructor(context: ReactApplicationContext?) :
class AlarmModule constructor(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) {
private var countdownTimer: CountDownTimer? = null
var currentMs: Long = 0
var running = false
override fun getName(): String {
return "AlarmModule"
}
private val stopReceiver = object : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onReceive(context: Context?, intent: Intent?) {
Log.d("AlarmModule", "Received stop broadcast intent")
stop()
}
}
private val addReceiver = object : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onReceive(context: Context?, intent: Intent?) {
add()
}
}
init {
reactApplicationContext.registerReceiver(stopReceiver, IntentFilter(STOP_BROADCAST))
reactApplicationContext.registerReceiver(addReceiver, IntentFilter(ADD_BROADCAST))
}
override fun onCatalystInstanceDestroy() {
reactApplicationContext.unregisterReceiver(stopReceiver)
reactApplicationContext.unregisterReceiver(addReceiver)
super.onCatalystInstanceDestroy()
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
fun add(milliseconds: Int, vibrate: Boolean, sound: String?) {
fun add() {
Log.d("AlarmModule", "Add 1 min to alarm.")
val addIntent = Intent(reactApplicationContext, TimerService::class.java)
addIntent.action = "add"
addIntent.putExtra("vibrate", vibrate)
addIntent.putExtra("sound", sound)
addIntent.data = Uri.parse("$milliseconds")
reactApplicationContext.startService(addIntent)
countdownTimer?.cancel()
val newMs = if (running) currentMs.toInt().plus(60000) else 60000
countdownTimer = getTimer(newMs)
countdownTimer?.start()
running = true
val manager = getManager()
manager.cancel(AlarmService.NOTIFICATION_ID_DONE)
val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.stopService(intent)
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
fun stop() {
Log.d("AlarmModule", "Stop alarm.")
val timerIntent = Intent(reactApplicationContext, TimerService::class.java)
reactApplicationContext.stopService(timerIntent)
val alarmIntent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.stopService(alarmIntent)
countdownTimer?.cancel()
running = false
val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext?.stopService(intent)
val manager = getManager()
manager.cancel(AlarmService.NOTIFICATION_ID_DONE)
manager.cancel(NOTIFICATION_ID_PENDING)
val params = Arguments.createMap().apply {
putString("minutes", "00")
putString("seconds", "00")
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("tick", params)
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
fun timer(milliseconds: Int, vibrate: Boolean, sound: String?) {
fun timer(milliseconds: Int) {
Log.d("AlarmModule", "Queue alarm for $milliseconds delay")
val intent = Intent(reactApplicationContext, TimerService::class.java)
intent.putExtra("milliseconds", milliseconds)
intent.putExtra("vibrate", vibrate)
intent.putExtra("sound", sound)
reactApplicationContext.startService(intent)
val manager = getManager()
manager.cancel(AlarmService.NOTIFICATION_ID_DONE)
val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.stopService(intent)
countdownTimer?.cancel()
countdownTimer = getTimer(milliseconds)
countdownTimer?.start()
running = true
}
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun ignoringBattery(callback: Callback) {
val packageName = reactApplicationContext.packageName
val pm =
reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
callback.invoke(pm.isIgnoringBatteryOptimizations(packageName))
} else {
callback.invoke(true)
private fun getTimer(
endMs: Int,
): CountDownTimer {
val builder = getBuilder()
return object : CountDownTimer(endMs.toLong(), 1000) {
@RequiresApi(Build.VERSION_CODES.O)
override fun onTick(current: Long) {
currentMs = current
val seconds =
floor((current / 1000).toDouble() % 60).toInt().toString().padStart(2, '0')
val minutes =
floor((current / 1000).toDouble() / 60).toInt().toString().padStart(2, '0')
builder.setContentText("$minutes:$seconds").setAutoCancel(false).setDefaults(0)
.setProgress(endMs, current.toInt(), false)
.setCategory(NotificationCompat.CATEGORY_PROGRESS).priority =
NotificationCompat.PRIORITY_LOW
val manager = getManager()
manager.notify(NOTIFICATION_ID_PENDING, builder.build())
val params = Arguments.createMap().apply {
putString("minutes", minutes)
putString("seconds", seconds)
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("tick", params)
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onFinish() {
val context = reactApplicationContext
context.startForegroundService(Intent(context, AlarmService::class.java))
context
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("finish", Arguments.createMap().apply {
putString("minutes", "00")
putString("seconds", "00")
})
}
}
}
@SuppressLint("BatteryLife")
@SuppressLint("UnspecifiedImmutableFlag")
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun ignoreBattery() {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.parse("package:" + reactApplicationContext.packageName)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
try {
reactApplicationContext.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(
reactApplicationContext,
"Requests to ignore battery optimizations are disabled on your device.",
Toast.LENGTH_LONG
).show()
private fun getBuilder(): NotificationCompat.Builder {
val context = reactApplicationContext
val contentIntent = Intent(context, MainActivity::class.java)
val pendingContent =
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
val addBroadcast = Intent(ADD_BROADCAST).apply {
setPackage(context.packageName)
}
val pendingAdd =
PendingIntent.getBroadcast(context, 0, addBroadcast, PendingIntent.FLAG_MUTABLE)
val stopBroadcast = Intent(STOP_BROADCAST)
stopBroadcast.setPackage(context.packageName)
val pendingStop =
PendingIntent.getBroadcast(context, 0, stopBroadcast, PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(context, CHANNEL_ID_PENDING)
.setSmallIcon(R.drawable.ic_baseline_hourglass_bottom_24).setContentTitle("Resting")
.setContentIntent(pendingContent)
.addAction(R.drawable.ic_baseline_stop_24, "Stop", pendingStop)
.addAction(R.drawable.ic_baseline_stop_24, "Add 1 min", pendingAdd)
.setDeleteIntent(pendingStop)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun getManager(): NotificationManager {
val notificationManager = reactApplicationContext.getSystemService(
NotificationManager::class.java
)
val timersChannel = NotificationChannel(
CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, NotificationManager.IMPORTANCE_LOW
)
timersChannel.setSound(null, null)
timersChannel.description = "Progress on rest timers."
notificationManager.createNotificationChannel(timersChannel)
return notificationManager
}
companion object {
const val STOP_BROADCAST = "stop-timer-event"
const val ADD_BROADCAST = "add-timer-event"
const val CHANNEL_ID_PENDING = "Timer"
const val NOTIFICATION_ID_PENDING = 1
}
}

View File

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

View File

@ -0,0 +1,84 @@
package com.massive
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.*
import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.documentfile.provider.DocumentFile
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import java.io.*
import java.util.*
class BackupModule constructor(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) {
val context: ReactApplicationContext = reactApplicationContext
private var targetDir: String? = null
private val copyReceiver = object : BroadcastReceiver() {
@RequiresApi(Build.VERSION_CODES.O)
override fun onReceive(context: Context?, intent: Intent?) {
val treeUri: Uri = Uri.parse(targetDir)
val documentFile = context?.let { DocumentFile.fromTreeUri(it, treeUri) }
val file = documentFile?.createFile("application/octet-stream", "massive.db")
val output = context?.contentResolver?.openOutputStream(file!!.uri)
val sourceFile = File(context?.getDatabasePath("massive.db")!!.path)
val input = FileInputStream(sourceFile)
if (output != null) {
input.copyTo(output)
}
output?.flush()
output?.close()
}
}
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun start(baseUri: String) {
targetDir = baseUri
val alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(COPY_BROADCAST)
val pendingIntent =
PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
pendingIntent.send()
val calendar = Calendar.getInstance().apply {
timeInMillis = System.currentTimeMillis()
set(Calendar.HOUR_OF_DAY, 6)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
}
alarmMgr.setRepeating(
AlarmManager.RTC_WAKEUP,
calendar.timeInMillis,
AlarmManager.INTERVAL_DAY,
pendingIntent
)
}
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun stop() {
val alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(COPY_BROADCAST)
val pendingIntent =
PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
alarmMgr.cancel(pendingIntent)
}
init {
reactApplicationContext.registerReceiver(copyReceiver, IntentFilter(COPY_BROADCAST))
}
companion object {
const val COPY_BROADCAST = "copy-event"
}
override fun getName(): String {
return "BackupModule"
}
}

View File

@ -0,0 +1,22 @@
package com.massive
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
class DatabaseHelper(context: Context) :
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
companion object {
private const val DATABASE_NAME = "massive.db"
private const val DATABASE_VERSION = 1
}
override fun onCreate(db: SQLiteDatabase) {
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
}
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
}
}

View File

@ -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
}
}

View File

@ -2,32 +2,27 @@ package com.massive
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.ReactRootView
import android.os.Bundle;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
class MainActivity : ReactActivity() {
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String? {
return "massive"
}
/**
* Returns the instance of the [ReactActivityDelegate]. Here we use a util class [ ] which allows you to easily enable Fabric and Concurrent React
* (aka React 18) with two boolean flags.
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
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?) {
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
}
}
}
}

View File

@ -1,15 +1,16 @@
package com.massive
import android.app.Application
import android.content.Context
import com.facebook.react.*
import com.facebook.react.config.ReactFeatureFlags
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.soloader.SoLoader
import com.massive.newarchitecture.MainApplicationReactNativeHost
import java.lang.reflect.InvocationTargetException
class MainApplication : Application(), ReactApplication {
private val mReactNativeHost: ReactNativeHost = object : ReactNativeHost(this) {
private val mReactNativeHost: ReactNativeHost = object : DefaultReactNativeHost(this) {
override fun getUseDeveloperSupport(): Boolean {
return BuildConfig.DEBUG
}
@ -23,48 +24,24 @@ class MainApplication : Application(), ReactApplication {
override fun getJSMainModuleName(): String {
return "index"
}
override val isNewArchEnabled: Boolean
protected get() = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean
protected get() = BuildConfig.IS_HERMES_ENABLED
}
private val mNewArchitectureNativeHost: ReactNativeHost = MainApplicationReactNativeHost(this)
override fun getReactNativeHost(): ReactNativeHost {
return if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
mNewArchitectureNativeHost
} else {
mReactNativeHost
}
return mReactNativeHost
}
override fun onCreate() {
super.onCreate()
ReactFeatureFlags.useTurboModules = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
SoLoader.init(this, false)
initializeFlipper(this, reactNativeHost.reactInstanceManager)
}
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()
}
}
SoLoader.init(this, /* native exopackage */false)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
}
ReactNativeFlipper.initializeFlipper(this, reactNativeHost.reactInstanceManager)
}
}
}

View File

@ -1,15 +0,0 @@
package com.massive
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
class MassiveHelper(context: Context) : SQLiteOpenHelper(context, "massive.db", null, 1) {
override fun onCreate(db: SQLiteDatabase) {
return
}
override fun onUpgrade(p0: SQLiteDatabase?, p1: Int, p2: Int) {
return
}
}

View File

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

View File

@ -0,0 +1,58 @@
package com.massive
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresApi
import com.facebook.react.bridge.*
class SettingsModule constructor(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) {
override fun getName(): String {
return "SettingsModule"
}
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun ignoringBattery(callback: Callback) {
val packageName = reactApplicationContext.packageName
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
callback.invoke(pm.isIgnoringBatteryOptimizations(packageName))
} else {
callback.invoke(true)
}
}
@SuppressLint("BatteryLife")
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun ignoreBattery() {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.parse("package:" + reactApplicationContext.packageName)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
try {
reactApplicationContext.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(
reactApplicationContext,
"Requests to ignore battery optimizations are disabled on your device.",
Toast.LENGTH_LONG
).show()
}
}
@ReactMethod
fun is24(promise: Promise) {
val is24 = android.text.format.DateFormat.is24HourFormat(reactApplicationContext)
Log.d("SettingsModule", "is24=$is24")
promise.resolve(is24)
}
}

View File

@ -1,18 +0,0 @@
package com.massive
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.util.Log
class StopTimer : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
applicationContext.stopService(Intent(applicationContext, TimerService::class.java))
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
return super.onStartCommand(intent, flags, startId)
}
override fun onBind(p0: Intent?): IBinder? {
return null
}
}

View File

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

View File

@ -1,167 +0,0 @@
package com.massive
import android.annotation.SuppressLint
import android.app.*
import android.app.NotificationManager.IMPORTANCE_HIGH
import android.app.NotificationManager.IMPORTANCE_LOW
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.CountDownTimer
import android.os.IBinder
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import kotlin.math.floor
class TimerService() : Service() {
private var manager: NotificationManager? = null
private var countdownTimer: CountDownTimer? = null
private var endMs: Int = 0
private var currentMs: Long = 0
private var vibrate: Boolean = true
private var sound: String? = null
@RequiresApi(Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
vibrate = intent!!.extras!!.getBoolean("vibrate")
sound = intent.extras?.getString("sound")
manager?.cancel(NOTIFICATION_ID_DONE)
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
if (intent.action == "add") {
endMs = currentMs.toInt().plus(60000)
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
} else {
endMs = intent.extras!!.getInt("milliseconds")
}
Log.d("TimerService", "endMs=$endMs,currentMs=$currentMs,vibrate=$vibrate,sound=$sound")
manager = getManager(applicationContext)
val builder = getBuilder(applicationContext)
countdownTimer?.cancel()
countdownTimer = getTimer(builder)
countdownTimer?.start()
return super.onStartCommand(intent, flags, startId)
}
private fun getTimer(builder: NotificationCompat.Builder): CountDownTimer {
return object : CountDownTimer(endMs.toLong(), 1000) {
override fun onTick(current: Long) {
currentMs = current
val seconds = floor((current / 1000).toDouble() % 60)
.toInt().toString().padStart(2, '0')
val minutes = floor((current / 1000).toDouble() / 60)
.toInt().toString().padStart(2, '0')
builder.setContentText("$minutes:$seconds")
.setAutoCancel(false)
.setDefaults(0)
.setProgress(endMs, current.toInt(), false)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.priority = NotificationCompat.PRIORITY_LOW
manager!!.notify(NOTIFICATION_ID_PENDING, builder.build())
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onFinish() {
val finishIntent = Intent(applicationContext, StopAlarm::class.java)
val finishPending =
PendingIntent.getActivity(
applicationContext,
0,
finishIntent,
PendingIntent.FLAG_IMMUTABLE
)
val stopIntent = Intent(applicationContext, StopTimer::class.java)
val pendingStop =
PendingIntent.getService(
applicationContext,
0,
stopIntent,
PendingIntent.FLAG_IMMUTABLE
)
builder.setContentText("Timer finished.")
.setAutoCancel(true)
.setProgress(0, 0, false)
.setOngoing(false)
.setContentIntent(finishPending)
.setChannelId(CHANNEL_ID_DONE)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setDeleteIntent(pendingStop)
.priority = NotificationCompat.PRIORITY_HIGH
manager!!.notify(NOTIFICATION_ID_DONE, builder.build())
manager!!.cancel(NOTIFICATION_ID_PENDING)
val alarmIntent = Intent(applicationContext, AlarmService::class.java)
alarmIntent.putExtra("vibrate", vibrate)
alarmIntent.putExtra("sound", sound)
applicationContext.startService(alarmIntent)
}
}
}
override fun onBind(p0: Intent?): IBinder? {
return null
}
override fun onDestroy() {
Log.d("TimerService", "Destroying...")
countdownTimer?.cancel()
manager?.cancel(NOTIFICATION_ID_PENDING)
manager?.cancel(NOTIFICATION_ID_DONE)
super.onDestroy()
}
@SuppressLint("UnspecifiedImmutableFlag")
@RequiresApi(Build.VERSION_CODES.M)
private fun getBuilder(context: Context): NotificationCompat.Builder {
val contentIntent = Intent(context, MainActivity::class.java)
val pendingContent =
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
val stopIntent = Intent(context, StopTimer::class.java)
val pendingStop =
PendingIntent.getService(context, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
val addIntent = Intent(context, TimerService::class.java)
addIntent.action = "add"
addIntent.putExtra("vibrate", vibrate)
addIntent.putExtra("sound", sound)
addIntent.data = Uri.parse("$currentMs")
val pendingAdd = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getService(context, 0, addIntent, PendingIntent.FLAG_MUTABLE)
} else {
PendingIntent.getService(context, 0, addIntent, PendingIntent.FLAG_UPDATE_CURRENT)
}
return NotificationCompat.Builder(context, CHANNEL_ID_PENDING)
.setSmallIcon(R.drawable.ic_baseline_hourglass_bottom_24)
.setContentTitle("Resting")
.setContentIntent(pendingContent)
.addAction(R.drawable.ic_baseline_stop_24, "Stop", pendingStop)
.addAction(R.drawable.ic_baseline_stop_24, "Add 1 min", pendingAdd)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun getManager(context: Context): NotificationManager {
val alarmsChannel = NotificationChannel(
CHANNEL_ID_DONE,
CHANNEL_ID_DONE,
IMPORTANCE_HIGH
)
alarmsChannel.description = "Alarms for rest timers."
alarmsChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
val notificationManager = context.getSystemService(
NotificationManager::class.java
)
notificationManager.createNotificationChannel(alarmsChannel)
val timersChannel =
NotificationChannel(CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, IMPORTANCE_LOW)
timersChannel.setSound(null, null)
timersChannel.description = "Progress on rest timers."
notificationManager.createNotificationChannel(timersChannel)
return notificationManager
}
companion object {
private const val CHANNEL_ID_PENDING = "Timer"
private const val CHANNEL_ID_DONE = "Alarm"
private const val NOTIFICATION_ID_PENDING = 1
private const val NOTIFICATION_ID_DONE = 2
}
}

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".TimerDone">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Timer up"
android:textSize="28sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Stop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
android:onClick="stop" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,7 @@
<resources>
<style name="ThemeOverlay.Massive.FullscreenContainer" parent="">
<item name="fullscreenBackgroundColor">@color/light_blue_900</item>
<item name="fullscreenTextColor">@color/light_blue_A400</item>
</style>
</resources>

View File

@ -0,0 +1,6 @@
<resources>
<declare-styleable name="FullscreenAttrs">
<attr name="fullscreenBackgroundColor" format="color" />
<attr name="fullscreenTextColor" format="color" />
</declare-styleable>
</resources>

View File

@ -0,0 +1,7 @@
<resources>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="light_blue_A200">#FF40C4FF</color>
<color name="light_blue_A400">#FF00B0FF</color>
<color name="black_overlay">#66000000</color>
</resources>

View File

@ -1,3 +1,8 @@
<resources>
<string name="app_name">Massive</string>
<string name="title_activity_fullscreen">FullscreenActivity</string>
<string name="dummy_button">Dummy Button</string>
<string name="dummy_content">DUMMY\nCONTENT</string>
<string name="rest_timer_up">Rest timer up</string>
<string name="stop">STOP</string>
</resources>

View File

@ -6,4 +6,13 @@
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
</style>
<style name="Widget.AppTheme.ActionBar.Fullscreen" parent="Widget.AppCompat.ActionBar">
<item name="android:background">@color/black_overlay</item>
</style>
<style name="Widget.AppTheme.ButtonBar.Fullscreen" parent="">
<item name="android:background">@color/black_overlay</item>
<item name="android:buttonBarStyle">?android:attr/buttonBarStyle</item>
</style>
</resources>

View File

@ -0,0 +1,13 @@
<resources>
<style name="AppTheme.Fullscreen" parent="AppTheme">
<item name="android:actionBarStyle">@style/Widget.AppTheme.ActionBar.Fullscreen</item>
<item name="android:windowActionBarOverlay">true</item>
<item name="android:windowBackground">@null</item>
</style>
<style name="ThemeOverlay.Massive.FullscreenContainer" parent="">
<item name="fullscreenBackgroundColor">@color/light_blue_600</item>
<item name="fullscreenTextColor">@color/light_blue_A200</item>
</style>
</resources>

View File

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

View File

@ -1,55 +1,22 @@
import org.apache.tools.ant.taskdefs.condition.Os
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext {
kotlin_version = '1.6.10'
buildToolsVersion = "31.0.0"
buildToolsVersion = "33.0.0"
minSdkVersion = 21
compileSdkVersion = 31
targetSdkVersion = 31
compileSdkVersion = 33
targetSdkVersion = 33
if (System.properties['os.arch'] == "aarch64") {
// For M1 Users we need to use the NDK 24 which added support for aarch64
ndkVersion = "24.0.8215888"
} else {
// Otherwise we default to the side-by-side NDK version from AGP.
ndkVersion = "21.4.7075529"
}
// We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP.
ndkVersion = "23.1.7779620"
}
repositories {
google()
mavenCentral()
}
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("de.undercouch:gradle-download-task:5.0.1")
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url("$rootDir/../node_modules/react-native/android")
}
maven {
// Android JSC is installed from npm
url("$rootDir/../node_modules/jsc-android/dist")
}
mavenCentral {
// We don't want to fetch react-native from Maven Central as there are
// older versions over there.
content {
excludeGroup "com.facebook.react"
}
}
google()
maven { url 'https://www.jitpack.io' }
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22")
}
}

2
android/fastlane/Appfile Normal file
View File

@ -0,0 +1,2 @@
json_key_file("~/.config/googlePlay.json") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one
package_name("com.massive") # e.g. com.krausefx.app

38
android/fastlane/Fastfile Normal file
View File

@ -0,0 +1,38 @@
# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
# https://docs.fastlane.tools/actions
#
# For a list of all available plugins, check out
#
# https://docs.fastlane.tools/plugins/available-plugins
#
# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane
default_platform(:android)
platform :android do
desc "Runs all the tests"
lane :test do
gradle(task: "test")
end
desc "Submit a new Beta Build to Crashlytics Beta"
lane :beta do
gradle(task: "clean assembleRelease")
crashlytics
# sh "your_script.sh"
# You can also use other beta testing services here
end
desc "Deploy a new version to the Google Play"
lane :deploy do
gradle(task: "clean assembleRelease")
upload_to_play_store
end
end

View File

@ -0,0 +1,48 @@
fastlane documentation
----
# Installation
Make sure you have the latest version of the Xcode command line tools installed:
```sh
xcode-select --install
```
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
# Available Actions
## Android
### android test
```sh
[bundle exec] fastlane android test
```
Runs all the tests
### android beta
```sh
[bundle exec] fastlane android beta
```
Submit a new Beta Build to Crashlytics Beta
### android deploy
```sh
[bundle exec] fastlane android deploy
```
Deploy a new version to the Google Play
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).

View File

@ -25,7 +25,7 @@ android.useAndroidX=true
android.enableJetifier=true
# Version of flipper SDK to use with React Native
FLIPPER_VERSION=0.125.0
FLIPPER_VERSION=0.182.0
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
@ -38,3 +38,7 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
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.

View File

@ -1,5 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-all.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

18
android/gradlew vendored
View File

@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (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.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@ -80,10 +80,10 @@ do
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
@ -143,12 +143,16 @@ fi
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
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 ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | 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" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@ -205,6 +209,12 @@ set -- \
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.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.

15
android/gradlew.bat vendored
View File

@ -14,7 +14,7 @@
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@ -25,7 +25,8 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
if %ERRORLEVEL% equ 0 goto execute
echo.
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
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal

View File

@ -1,13 +1,6 @@
rootProject.name = 'massive'
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
include ':app'
includeBuild('../node_modules/react-native-gradle-plugin')
includeBuild('../node_modules/@react-native/gradle-plugin')
include ':react-native-sqlite-storage'
project(':react-native-sqlite-storage').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-sqlite-storage/platforms/android')
if (settings.hasProperty("newArchEnabled") && settings.newArchEnabled == "true") {
include(":ReactAndroid")
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')
}

View File

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

View File

@ -1,90 +1,42 @@
import {db} from './db';
import {Periods} from './periods';
import Set from './set';
import {defaultSet} from './set.service';
import Volume from './volume';
import { LIMIT } from "./constants";
import { setRepo } from "./db";
import GymSet from "./gym-set";
export const getBestSet = async (name: string): Promise<Set> => {
const bestWeight = `
SELECT name, reps, unit, MAX(weight) AS weight
FROM sets
WHERE name = ? AND NOT hidden
GROUP BY name;
`;
const bestReps = `
SELECT name, MAX(reps) as reps, unit, weight, sets, minutes, seconds
FROM sets
WHERE name = ? AND weight = ? AND NOT hidden
GROUP BY name;
`;
const [weightResult] = await db.executeSql(bestWeight, [name]);
if (!weightResult.rows.length) return {...defaultSet};
const [repsResult] = await db.executeSql(bestReps, [
name,
weightResult.rows.item(0).weight,
]);
return repsResult.rows.item(0);
export const getBestSet = async (name: string): Promise<GymSet> => {
return setRepo
.createQueryBuilder()
.select()
.addSelect("MAX(weight)", "weight")
.where("name = :name", { name })
.groupBy("name")
.addGroupBy("reps")
.orderBy("weight", "DESC")
.addOrderBy("reps", "DESC")
.getOne();
};
export const getWeightsBy = async (
name: string,
period: Periods,
): Promise<Set[]> => {
const select = `
SELECT max(weight) AS weight,
STRFTIME('%Y-%m-%d', created) as created, unit
FROM sets
WHERE name = ? AND NOT hidden
AND DATE(created) >= DATE('now', 'weekday 0', ?)
GROUP BY name, STRFTIME('%Y-%m-%d', created)
`;
let difference = '-7 days';
if (period === Periods.Monthly) difference = '-1 months';
else if (period === Periods.Yearly) difference = '-1 years';
const [result] = await db.executeSql(select, [name, difference]);
return result.rows.raw();
};
export const getVolumes = async (
name: string,
period: Periods,
): Promise<Volume[]> => {
const select = `
SELECT sum(weight * reps) AS value,
STRFTIME('%Y-%m-%d', created) as created, unit
FROM sets
WHERE name = ? AND NOT hidden
AND DATE(created) >= DATE('now', 'weekday 0', ?)
GROUP BY name, STRFTIME('%Y-%m-%d', created)
`;
let difference = '-7 days';
if (period === Periods.Monthly) difference = '-1 months';
else if (period === Periods.Yearly) difference = '-1 years';
const [result] = await db.executeSql(select, [name, difference]);
return result.rows.raw();
};
export const getBestWeights = async (search: string): Promise<Set[]> => {
const select = `
SELECT name, reps, unit, MAX(weight) AS weight
FROM sets
WHERE name LIKE ? AND NOT hidden
GROUP BY name;
`;
const [result] = await db.executeSql(select, [`%${search}%`]);
return result.rows.raw();
};
export const getBestReps = async (
name: string,
weight: number,
): Promise<Set[]> => {
const select = `
SELECT name, MAX(reps) as reps, unit, weight, image
FROM sets
WHERE name = ? AND weight = ? AND NOT hidden
GROUP BY name;
`;
const [result] = await db.executeSql(select, [name, weight]);
return result.rows.raw();
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();
};

View File

@ -1,13 +1,41 @@
import { DefaultTheme, MD3DarkTheme } from "react-native-paper";
export const lightColors = [
{hex: '#FA8072', name: 'Salmon'},
{hex: '#B3E5FC', name: 'Cyan'},
{hex: '#FFC0CB', name: 'Pink'},
{hex: '#E9DCC9', name: 'Linen'},
{ hex: MD3DarkTheme.colors.primary, name: "Purple" },
{ hex: "#B3E5FC", name: "Blue" },
{ hex: "#FA8072", name: "Salmon" },
{ hex: "#FFC0CB", name: "Pink" },
{ hex: "#E9DCC9", name: "Linen" },
];
export const darkColors = [
{hex: '#8156A7', name: 'Purple'},
{hex: '#007AFF', name: 'Blue'},
{hex: '#000000', name: 'Black'},
{hex: '#CD5C5C', name: 'Red'},
{ hex: DefaultTheme.colors.primary, name: "Purple" },
{ hex: "#0051a9", name: "Blue" },
{ hex: "#000000", name: "Black" },
{ hex: "#863c3c", name: "Red" },
{ hex: "#1c6000", name: "Kermit" },
];
export const colorShade = (color: any, amount: number) => {
color = color.replace(/^#/, "");
if (color.length === 3) {
color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2];
}
let [r, g, b] = color.match(/.{2}/g);
[r, g, b] = [
parseInt(r, 16) + amount,
parseInt(g, 16) + amount,
parseInt(b, 16) + amount,
];
r = Math.max(Math.min(255, r), 0).toString(16);
g = Math.max(Math.min(255, g), 0).toString(16);
b = Math.max(Math.min(255, b), 0).toString(16);
const rr = (r.length < 2 ? "0" : "") + r;
const gg = (g.length < 2 ? "0" : "") + g;
const bb = (b.length < 2 ? "0" : "") + b;
return `#${rr}${gg}${bb}`;
};

View File

@ -1,2 +1,6 @@
export const MARGIN = 10;
export const PADDING = 10;
export const ITEM_PADDING = 8;
export const DARK_RIPPLE = "#444444";
export const LIGHT_RIPPLE = "#c2c2c2";
export const LIMIT = 15;

5
count-many.ts Normal file
View File

@ -0,0 +1,5 @@
export default interface CountMany {
name: string;
total: number;
sets?: number;
}

65
data-source.ts Normal file
View File

@ -0,0 +1,65 @@
import { DataSource } from "typeorm";
import GymSet from "./gym-set";
import { Sets1667185586014 as sets1667185586014 } from "./migrations/1667185586014-sets";
import { plans1667186124792 } from "./migrations/1667186124792-plans";
import { settings1667186130041 } from "./migrations/1667186130041-settings";
import { addSound1667186139844 } from "./migrations/1667186139844-add-sound";
import { addHidden1667186159379 } from "./migrations/1667186159379-add-hidden";
import { addNotify1667186166140 } from "./migrations/1667186166140-add-notify";
import { addImage1667186171548 } from "./migrations/1667186171548-add-image";
import { addImages1667186179488 } from "./migrations/1667186179488-add-images";
import { insertSettings1667186203827 } from "./migrations/1667186203827-insert-settings";
import { addSteps1667186211251 } from "./migrations/1667186211251-add-steps";
import { addSets1667186250618 } from "./migrations/1667186250618-add-sets";
import { addMinutes1667186255650 } from "./migrations/1667186255650-add-minutes";
import { addSeconds1667186259174 } from "./migrations/1667186259174-add-seconds";
import { addShowUnit1667186265588 } from "./migrations/1667186265588-add-show-unit";
import { addColor1667186320954 } from "./migrations/1667186320954-add-color";
import { addSteps1667186348425 } from "./migrations/1667186348425-add-steps";
import { addDate1667186431804 } from "./migrations/1667186431804-add-date";
import { addShowDate1667186435051 } from "./migrations/1667186435051-add-show-date";
import { addTheme1667186439366 } from "./migrations/1667186439366-add-theme";
import { addShowSets1667186443614 } from "./migrations/1667186443614-add-show-sets";
import { addSetsCreated1667186451005 } from "./migrations/1667186451005-add-sets-created";
import { addNoSound1667186456118 } from "./migrations/1667186456118-add-no-sound";
import { dropMigrations1667190214743 } from "./migrations/1667190214743-drop-migrations";
import { splitColor1669420187764 } from "./migrations/1669420187764-split-color";
import { addBackup1678334268359 } from "./migrations/1678334268359-add-backup";
import { Plan } from "./plan";
import Settings from "./settings";
export const AppDataSource = new DataSource({
type: "react-native",
database: "massive.db",
location: "default",
entities: [GymSet, Plan, Settings],
migrationsRun: true,
migrationsTableName: "typeorm_migrations",
migrations: [
sets1667185586014,
plans1667186124792,
settings1667186130041,
addSound1667186139844,
addHidden1667186159379,
addNotify1667186166140,
addImage1667186171548,
addImages1667186179488,
insertSettings1667186203827,
addSteps1667186211251,
addSets1667186250618,
addMinutes1667186255650,
addSeconds1667186259174,
addShowUnit1667186265588,
addColor1667186320954,
addSteps1667186348425,
addDate1667186431804,
addShowDate1667186435051,
addTheme1667186439366,
addShowSets1667186443614,
addSetsCreated1667186451005,
addNoSound1667186456118,
dropMigrations1667190214743,
splitColor1669420187764,
addBackup1678334268359,
],
});

137
db.ts
View File

@ -1,128 +1,15 @@
import {
enablePromise,
openDatabase,
SQLiteDatabase,
} from 'react-native-sqlite-storage';
import { AppDataSource } from "./data-source";
import GymSet from "./gym-set";
import { Plan } from "./plan";
import Settings from "./settings";
enablePromise(true);
export const setRepo = AppDataSource.manager.getRepository(GymSet);
export const planRepo = AppDataSource.manager.getRepository(Plan);
export const settingsRepo = AppDataSource.manager.getRepository(Settings);
const migrations = [
`
CREATE TABLE IF NOT EXISTS sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
reps INTEGER NOT NULL,
weight INTEGER NOT NULL,
created TEXT NOT NULL,
unit TEXT DEFAULT 'kg'
)
`,
`
CREATE TABLE IF NOT EXISTS plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
days TEXT NOT NULL,
workouts TEXT NOT NULL
)
`,
`
CREATE TABLE IF NOT EXISTS settings (
minutes INTEGER NOT NULL DEFAULT 3,
seconds INTEGER NOT NULL DEFAULT 30,
alarm BOOLEAN NOT NULL DEFAULT false,
vibrate BOOLEAN NOT NULL DEFAULT true,
predict BOOLEAN NOT NULL DEFAULT true,
sets INTEGER NOT NULL DEFAULT 3
)
`,
`ALTER TABLE settings ADD COLUMN sound TEXT NULL`,
`
CREATE TABLE IF NOT EXISTS workouts(
name TEXT PRIMARY KEY,
sets INTEGER DEFAULT 3
)
`,
`
ALTER TABLE sets ADD COLUMN hidden DEFAULT false
`,
`
ALTER TABLE settings ADD COLUMN notify DEFAULT false
`,
`
ALTER TABLE sets ADD COLUMN image TEXT NULL
`,
`
ALTER TABLE settings ADD COLUMN images BOOLEAN DEFAULT false
`,
`
SELECT * FROM settings LIMIT 1
`,
`
INSERT INTO settings(minutes) VALUES(3)
`,
`
ALTER TABLE workouts ADD COLUMN steps TEXT NULL
`,
`
INSERT OR IGNORE INTO workouts (name) SELECT DISTINCT name FROM sets
`,
`
ALTER TABLE sets ADD COLUMN sets INTEGER NOT NULL DEFAULT 3
`,
`
ALTER TABLE sets ADD COLUMN minutes INTEGER NOT NULL DEFAULT 3
`,
`
ALTER TABLE sets ADD COLUMN seconds INTEGER NOT NULL DEFAULT 30
`,
`
ALTER TABLE settings ADD COLUMN showUnit BOOLEAN DEFAULT true
`,
`
ALTER TABLE sets ADD COLUMN steps TEXT NULL
`,
`
UPDATE sets SET steps = (
SELECT workouts.steps FROM workouts WHERE workouts.name = sets.name
)
`,
`
DROP TABLE workouts
`,
`
ALTER TABLE settings ADD COLUMN color TEXT NULL
`,
`
UPDATE settings SET showUnit = 1
`,
`
ALTER TABLE settings ADD COLUMN workouts BOOLEAN DEFAULT 1
`,
`
ALTER TABLE settings ADD COLUMN steps BOOLEAN DEFAULT 1
`,
`
ALTER TABLE settings ADD COLUMN nextAlarm TEXT NULL
`,
];
export let db: SQLiteDatabase;
export const runMigrations = async () => {
db = await openDatabase({name: 'massive.db'});
await db.executeSql(`
CREATE TABLE IF NOT EXISTS migrations(
id INTEGER PRIMARY KEY AUTOINCREMENT,
command TEXT NOT NULL
)
`);
const [result] = await db.executeSql(`SELECT * FROM migrations`);
const missing = migrations.slice(result.rows.length);
for (const command of missing) {
await db.executeSql(command).catch(console.error);
const insert = `
INSERT INTO migrations (command)
VALUES (?)
`;
await db.executeSql(insert, [command]);
}
export const getNow = async (): Promise<string> => {
const query = await AppDataSource.manager.query(
"SELECT STRFTIME('%Y-%m-%dT%H:%M:%S','now','localtime') AS now"
);
return query[0].now;
};

11
deno.json Normal file
View File

@ -0,0 +1,11 @@
{
"fmt": {
"useTabs": false,
"lineWidth": 80,
"semiColons": false,
"singleQuote": true,
"proseWrap": "preserve",
"include": ["src/"],
"exclude": ["src/testdata/", "data/fixtures/**/*.ts"]
}
}

View File

@ -1,28 +1,39 @@
#!/bin/sh
#!/bin/bash
set -ex
git push origin HEAD > /dev/null &
cd android || exit 1
build=app/build.gradle
build=app/build.gradle
versionCode=$(
grep '^\s*versionCode [0-9]*$' "$build" | awk '{print $2+1}'
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}'
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
grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" |
sed 's/"//g' | cut -d '.' -f 2
)
minor=$((minor+1))
minor=$((minor + 1))
sed -i "s/\(^\s*\)versionCode [0-9]*$/\1versionCode $versionCode/" \
"$build"
"$build"
sed -i "s/\(^\s*\)versionName \"[0-9]*.[0-9]*\"$/\1versionName \"$major.$minor\"/" "$build"
sed -i "s/\"version\": \"[0-9]*.[0-9]*\"/\"version\": \"$major.$minor\"/" ../package.json
[ "$1" != "--nobundle" ] && ./gradlew bundleRelease
if [ "$1" != "-n" ]; then
yarn tsc
yarn lint
./gradlew bundleRelease
bundle install
bundle exec fastlane supply --aab app/build/outputs/bundle/release/app-release.aab
fi
git add app/build.gradle ../package.json
git commit --no-verify --message "Set versionCode=$versionCode"
git tag "$major.$minor"
git push origin HEAD & git push --tags
cd ..
git commit --amend --message \
"$(git log -1 --pretty=%B | sed " 1 s/.*/& - $major.$minor/")"
git tag "$versionCode"
git push origin HEAD
git push --tags

View File

@ -1,7 +1,8 @@
export type DrawerParamList = {
Home: {};
Settings: {};
Best: {};
Graphs: {};
Plans: {};
Workouts: {};
Timer: {};
};

11
fix-numeric.ts Normal file
View File

@ -0,0 +1,11 @@
export const fixNumeric = (text: string) => {
let newText = text.replace(/[^0-9.-]/g, "");
let parts = newText.split(".");
if (parts.length > 2) {
newText = parts[0] + "." + parts.slice(1).join("");
}
if (newText.startsWith("-")) {
newText = "-" + newText.slice(1).replace(/-/g, "");
}
return newText;
};

53
gym-set.ts Normal file
View File

@ -0,0 +1,53 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity("sets")
export default class GymSet {
@PrimaryGeneratedColumn()
id?: number;
@Column("text")
name: string;
@Column("int")
reps: number;
@Column("int")
weight: number;
@Column("int")
sets = 3;
@Column("int")
minutes = 3;
@Column("int")
seconds = 30;
@Column("boolean")
hidden = false;
@Column("text")
created: string;
@Column("text")
unit: string;
@Column("text")
image: string;
@Column("text")
steps?: string;
}
export const defaultSet: GymSet = {
created: "",
name: "",
image: "",
hidden: false,
minutes: 3,
seconds: 30,
reps: 0,
sets: 0,
unit: "kg",
weight: 0,
};

View File

@ -1,10 +1,11 @@
import Set from './set';
import GymSet from "./gym-set";
export type HomePageParams = {
Sets: {};
EditSet: {
set: Set;
workouts: string[];
count: number;
set: GymSet;
};
EditSets: {
ids: number[];
};
};

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