Compare commits

...

445 Commits

Author SHA1 Message Date
Brandon Presley 4aa2c662e5 Try to fix graph errors - 2.34 🚀 2024-04-29 02:14:03 +12:00
Brandon Presley d0e76f574b Enable exporting plans as CSV - 2.33 🚀
This is to help migrations across to Flexify.
Also is generally useful if someone wants
to do some Excel magic on their plans.
2024-03-23 14:46:04 +13:00
Brandon Presley 52f642c2af Add privacy policy 2024-03-13 11:26:20 +13:00
Brandon Presley b7b974fb02 Update screenshots 2024-03-13 11:21:47 +13:00
Brandon Presley 6d22bee440 Merge branch 'use_alarm_manager' of https://gitea.presley.nz/joseph/Massive into joseph-use_alarm_manager - 2.32 🚀 2024-03-12 11:57:36 +13:00
Joseph 58b8488a27 Use AlarmManager to manage the ending of timers 2024-03-11 19:30:33 +00:00
brandon.presley 745f9fb046 Merge pull request 'Improve timer accuracy' (#235) from joseph/Massive:improve_timer_accuracy into master
Reviewed-on: #235
2024-03-07 23:12:46 +00:00
Joseph b979d0943f Improve timer accuracy 2024-03-07 15:48:20 +00:00
Joseph 7c35da3f5b Add themed icons - 2.31 🚀 2024-03-05 18:36:02 +13:00
Brandon Presley 817ac089d3 Fix insights page crashing with no data - 2.30 🚀
Closes #230
Closes #206
2024-03-04 13:19:25 +13:00
Brandon Presley c77d1dbcfb Add video of timers in use because of google play
The play store is having a big whinge about how
we need special foreground permissions.
I hate them. It's unreal.
2024-03-01 20:03:38 +13:00
Brandon Presley 2d5e0620af Merge branch 'fix-timer-service' - 2.29 🚀 2024-03-01 19:58:09 +13:00
Joseph 12f906bfc3 Fix MissingForegroundServiceTypeException in TimerService 2024-02-29 13:29:52 +00:00
Brandon Presley eb30d81003 Update deploy.mjs
- Reduce length of variable names
- Increase redundancy for increased
readability
2024-02-21 18:22:00 +13:00
Brandon Presley fd15d10028 Add contextual plural to delete confirmation - 2.28 🚀 2024-02-21 18:13:29 +13:00
Brandon Presley 7abcea5710 Add button to deselect plans 2024-02-21 17:59:05 +13:00
Brandon Presley e7c0460166 Add back button to deselect exercises 2024-02-21 17:57:59 +13:00
Brandon Presley c2accf7202 Show delete button when sets are selected 2024-02-21 17:56:37 +13:00
Brandon Presley 164d946b90 Fix add 1 minute not adding immediately 2024-02-21 14:51:35 +13:00
Brandon Presley 6ad7091503 Vibrate more at the end of timers 2024-02-21 14:48:55 +13:00
Brandon Presley b681aa82d6 Update package-lock.json 2024-02-21 14:37:40 +13:00
Brandon Presley d7599ff39b Remove redundant !! from MainActivity - 2.27 🚀 2024-02-19 22:10:54 +13:00
Brandon Presley 9f20954ff5 Stop resetting daily page on focus
Closes #221
2024-02-19 22:10:30 +13:00
Brandon Presley be601ac7e4 Hide list menu when selecting all 2024-02-19 19:15:34 +13:00
Brandon Presley 6deb772f99 Update package-lock.json version 2024-02-19 18:29:13 +13:00
Brandon Presley 66d9894c19 Add log for passing TypeScript checks 2024-02-18 01:53:42 +13:00
Brandon Presley 5b066bccaf Add daily page - 2.26 🚀
The daily page is used to flip through your exercises
by day. This is in contrast to the History page, which is
an infinitely scrolling list of all sets.

Closes #216, #207
2024-02-18 01:52:30 +13:00
Brandon Presley d5fab6d6d2 Add toast after successfully importing database 2024-02-18 00:54:51 +13:00
Brandon Presley c6ac2cae86 Prevent multiple alarm timers running at once 2024-02-18 00:50:08 +13:00
Brandon Presley 8162724328 Dont run timers once a plan is finished 2024-02-18 00:49:56 +13:00
Brandon Presley d89e307950 Fix tooltip on settings page vibration 2024-02-18 00:46:21 +13:00
Brandon Presley a9367cd53b Select first item in plan 2024-02-18 00:36:00 +13:00
Brandon Presley f5f96035a0 Use outlined inputs
They look WAY cooler
2024-02-18 00:34:27 +13:00
Brandon Presley 974d2207db Fix broken undo on plans 2024-02-18 00:21:27 +13:00
Brandon Presley cc5089d4b4 Rename GraphsList -> GraphList 2024-02-18 00:08:57 +13:00
Brandon Presley 2d9c69a3dd Rename yellow-green to Green 2024-02-17 20:57:44 +13:00
Brandon Presley 495d6b35b7 Disable sound -> Sound & Remove show steps setting
1. Negating is more complicated than just saying Sound
2. The exercise edit screen is already pretty small,
so this feature of hiding the steps is probably
not useful.
2024-02-17 20:57:29 +13:00
Brandon Presley 617fca0094 Fix tsconfig.json - 2.25 🚀 2024-02-17 19:11:02 +13:00
Brandon Presley eea6c96e8e Make separate channel for finished notifications
Still aren't showing on lock screen, or waking the
device sadly. Ive got to finish reading https://github.com/giorgosneokleous93/fullscreenintentexample/tree/main/app/src/main/java/com/giorgosneokleous/fullscreenintentexample
2024-02-17 19:01:43 +13:00
Brandon Presley 9e3f2fea78 Auto request battery optimizations are off
If the user reinstalls the app, and then imports their
database, they might end up with timers on but
battery optimizations on as well.
2024-02-17 17:42:19 +13:00
Brandon Presley a0dc62e761 Remove all JS side Timers
This is the result of me fixing the background timers.
Previously our code just used a CountdownTimer
not even in a service, just immediately in the
@ReactMethod. This would in certain scenarios stop
running. Even with battery optimizations turned off.

The reason why all the JS side timers had to be removed
is because we were relying on RCTDeviceEventEmitter
which I don't know how to use from within a Service.
See my stackoverflow ticket here: https://stackoverflow.com/questions/74204339/sending-react-native-android-events-to-javascript-from-a-service

Closes #212, #196
2024-02-17 17:27:42 +13:00
Brandon Presley 47cfaa4b67 Fix dismissing alarm and add +1 minute button 2024-02-16 17:47:26 +13:00
Brandon Presley 5355b0eb6a Move timer logic from AlarmModule -> TimerService
Missing a few of the old features here but ultimately
this will fix #210, #212, #196.
2024-02-16 13:15:42 +13:00
Brandon Presley 1e7c994209 Remove startForeground from AlarmService
Starting a foreground service from the background
causes errors in android 12+
2024-02-16 13:14:00 +13:00
Brandon Presley 6e604d7618 Upgrade all minor+patch versions in package.json 2024-02-16 11:49:50 +13:00
Brandon Presley e3d3aad153 Move debug banner to AppStack from AppDrawer
If we have it on the drawer instead of the stack it will
dissapear when navigating to certain screens.
2024-02-15 15:26:14 +13:00
Brandon Presley 3c0f4ce8ad Upgrade react-native from 0.72.3 to 0.73.0 2024-02-15 15:07:11 +13:00
Brandon Presley 183d609bea Add indicator for being in debug mode 2024-02-15 14:10:40 +13:00
Brandon Presley a9acc6f216 Undo NaN filtering for graphs - 2.24 🚀
We published these changes, yet the error was still
occurring. Leaving this in just lowers performance
for no value.
2024-02-15 13:12:45 +13:00
Brandon Presley fd09758ccf Make SettingsModule.ignoringBattery async 2024-02-14 11:14:43 +13:00
Brandon Presley 5f2327de31 Upgrade react-native-svg 2024-02-13 11:25:37 +13:00
Brandon Presley f9fb190f80 Validate database file imported 2024-02-12 18:48:02 +13:00
Brandon Presley b24cb85a70 Always pick directory when exporting csv/database - 2.23 🚀
I'm not sure why but Filesystem.exists seems to return
true, even when the app lacks permission to write to
the destination
2024-02-12 18:27:27 +13:00
Brandon Presley 05b4aa75bb Add icons to dropdown options in Settings - 2.22 🚀 2024-02-12 17:32:02 +13:00
Brandon Presley c822acb544 Rename Dark red color and change Slate blue
I didn't like the look of slate blue on a light theme
so i replaced it with this dark magenta instead.
2024-02-12 17:07:17 +13:00
Brandon Presley 9471d7ce18 Update dark colors
The "Dark orange" was either too light for the contrast
ratio, or too close to dark red so I collapsed the options.
2024-02-12 17:05:24 +13:00
Brandon Presley 000f53a9fb Remove INTERNET permission from AndroidManifest.xml - 2.21 🚀
Commit 514efc6467
added the INTERNET permission without justification.

In fact this was already addressed in a f-droid metadata
issue:
https://gitlab.com/fdroid/fdroiddata/-/merge_requests/11623#note_1080190039
2024-02-12 16:58:39 +13:00
Brandon Presley 0a19d33b50 Add package-lock.json - 2.20 🚀
Somehow i ended up missing a package
2024-02-12 15:26:24 +13:00
Brandon Presley 9ddd2e963c Change some of the colors - 2.19 🚀 2024-02-12 15:25:47 +13:00
Brandon Presley 5e34bd4570 Remove reliance on WRITE_EXTERNAL_STORAGE
https://developer.android.com/about/versions/11/privacy/storage#permissions-target-11
2024-02-12 15:15:34 +13:00
Brandon Presley bfc1b3d546 Remove needless logging from SettingsPage backupString 2024-02-12 14:00:03 +13:00
Brandon Presley 5e420ec9c4 Delete unused file write.ts 2024-02-11 22:10:05 +13:00
Brandon Presley 47bff2d07c Remove READ_EXTERNAL_STORAGE permission
We never actually used this permission.
2024-02-11 18:32:24 +13:00
Brandon Presley 71d425ca03 Remove redundant code in BackupModule.kt 2024-02-11 18:21:46 +13:00
Brandon Presley 20781ddafe Fix registering receiver warnings in BackupModule.kt 2024-02-11 18:21:10 +13:00
Brandon Presley 655fe8ad53 Remove allowBackup=false from AndroidManifest
No clue why this was specified to begin with.
2024-02-11 18:18:05 +13:00
Brandon Presley 6b60c41ac8 Make bundle and gradle builds quiet in deploy.mjs - 2.18 🚀 2024-02-11 18:11:16 +13:00
Brandon Presley 57bc6caffb Auto stop vibrations after 10 seconds - 2.17 🚀
Closes #198
2024-02-11 18:05:38 +13:00
Brandon Presley ff365c791b Add icon to application tag of AndroidManifest.xml 2024-02-11 17:49:53 +13:00
Brandon Presley 4d23cf6106 Upgrade constraintlayout 2024-02-10 16:45:42 +13:00
Brandon Presley 60eec2c482 Fix deprecated warning on vibrate 2024-02-10 16:45:19 +13:00
Brandon Presley 00ae63c9ba Don't select a date if date picker is cancelled 2024-02-09 15:20:25 +13:00
Brandon Presley 9a4f2599a6 Add date range selectors to graphs 2024-02-09 15:20:17 +13:00
Brandon Presley 6ea65bcd16 Auto select last worked on exercise in plans 2024-02-09 14:24:59 +13:00
Brandon Presley cf90b798ab Log the commit title in deploy.mjs 2024-02-09 13:20:02 +13:00
Brandon Presley c9d297e769 Keep body of last commit message in deploy.mjs - 2.16 🚀 2024-02-09 13:18:38 +13:00
Brandon Presley 89b62d69aa Guarantee data passed to react-native-chart-kit is not NaN
Related to #206.
2024-02-09 13:02:02 +13:00
Brandon Presley 4c065a027b Rename Chart -> AppLineChart
Chart is too generic since several types of charts exist
in the react-native-chart-kit.
2024-02-09 12:56:43 +13:00
Brandon Presley 9c3d6772a9 Ensure reps are always positive - 2.15 🚀 2024-02-09 12:48:12 +13:00
Brandon Presley 1921ecb9f4 Guarantee ViewGraph only passes numbers to charts
This error occurs on some devices in the Play store
(android 12). I can't reproduce it unless I forcefully
pass NaN to my charts, so this might just fix the error.
Related to #206.

Exception java.lang.Error:
  at com.horcrux.svg.PathParser.parse_number (PathParser.java:627)
  at com.horcrux.svg.PathParser.parse_list_number (PathParser.java:594)
  at com.horcrux.svg.PathParser.parse (PathParser.java:118)
  at com.horcrux.svg.PathView.setD (PathView.java:28)
  at com.horcrux.svg.RenderableViewManager$PathViewManager.setD (RenderableViewManager.java:751)
  at com.horcrux.svg.RenderableViewManager$PathViewManager.setD (RenderableViewManager.java:740)
  at com.facebook.react.viewmanagers.RNSVGPathManagerDelegate.setProperty (RNSVGPathManagerDelegate.java:112)
  at com.facebook.react.uimanager.ViewManagerPropertyUpdater.updateProps (ViewManagerPropertyUpdater.java:46)
  at com.facebook.react.uimanager.ViewManager.updateProperties (ViewManager.java:84)
  at com.facebook.react.uimanager.ViewManager.createViewInstance (ViewManager.java:188)
  at com.facebook.react.uimanager.ViewManager.createView (ViewManager.java:115)
  at com.facebook.react.uimanager.NativeViewHierarchyManager.createView (NativeViewHierarchyManager.java:281)
  at com.facebook.react.uimanager.UIViewOperationQueue$CreateViewOperation.execute (UIViewOperationQueue.java:194)
  at com.facebook.react.uimanager.UIViewOperationQueue$1.run (UIViewOperationQueue.java:909)
  at com.facebook.react.uimanager.UIViewOperationQueue.flushPendingBatches (UIViewOperationQueue.java:1026)
  at com.facebook.react.uimanager.UIViewOperationQueue$DispatchUIFrameCallback.doFrameGuarded (UIViewOperationQueue.java:1086)
  at com.facebook.react.uimanager.GuardedFrameCallback.doFrame (GuardedFrameCallback.java:29)
  at com.facebook.react.modules.core.ReactChoreographer$ReactChoreographerDispatcher.doFrame (ReactChoreographer.java:175)
  at com.facebook.react.modules.core.ChoreographerCompat$FrameCallback$1.doFrame (ChoreographerCompat.java:85)
  at android.view.Choreographer$CallbackRecord.run (Choreographer.java:1106)
  at android.view.Choreographer.doCallbacks (Choreographer.java:866)
  at android.view.Choreographer.doFrame (Choreographer.java:792)
  at android.view.Choreographer$FrameDisplayEventReceiver.run (Choreographer.java:1092)
  at android.os.Handler.handleCallback (Handler.java:938)
  at android.os.Handler.dispatchMessage (Handler.java:99)
  at android.os.Looper.loopOnce (Looper.java:226)
  at android.os.Looper.loop (Looper.java:313)
  at android.app.ActivityThread.main (ActivityThread.java:8751)
  at java.lang.reflect.Method.invoke
  at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run (RuntimeInit.java:571)
  at com.android.internal.os.ZygoteInit.main (ZygoteInit.java:1135)
2024-02-09 11:27:12 +13:00
Brandon Presley f8a4157c33 Force push amended changes in deployment - 2.14 🚀 2024-02-08 21:28:11 +13:00
Brandon Presley 9650a17b61 Merge branch 'master' of gitea.presley.nz:brandon.presley/Massive - 2.13 🚀 2024-02-08 21:25:33 +13:00
Brandon Presley 15557d1a02 Push to play store last in deploy.mjs - 2.12 🚀 2024-02-08 21:22:41 +13:00
Brandon Presley 5991a6de39 Push to play store last in deploy.mjs
If the play store is acting up then it should still
at least push the tags so f-droid can update.
2024-02-08 21:22:14 +13:00
Brandon Presley 07c704841d Use foreground service for alarm completion
Many of our errors in production are caused
by the alarm module finishing. In devices after
android version 7 we are "required" to use
startForegroundService or else the following
error supposedly occurs:

Exception java.lang.IllegalStateException:
  at android.app.ContextImpl.startServiceCommon (ContextImpl.java:1725)
  at android.app.ContextImpl.startService (ContextImpl.java:1680)
  at android.content.ContextWrapper.startService (ContextWrapper.java:731)
  at android.content.ContextWrapper.startService (ContextWrapper.java:731)
  at com.massive.AlarmModule$getTimer$1.onFinish (AlarmModule.kt:144)
  at android.os.CountDownTimer$1.handleMessage (CountDownTimer.java:127)
  at android.os.Handler.dispatchMessage (Handler.java:106)
  at android.os.Looper.loop (Looper.java:236)
  at com.facebook.react.bridge.queue.MessageQueueThreadImpl$4.run (MessageQueueThreadImpl.java:228)
  at java.lang.Thread.run (Thread.java:923)

I say supposedly because on all of my testing
devices (which are android 7+) this error
doesn't occur.
2024-02-08 20:58:08 +13:00
Brandon Presley 1f6100607d Add platform independent deploy script
Deploy now by running:

node deploy.mjs
2024-02-08 18:54:06 +13:00
Brandon Presley d648850892 Update gemfile 2024-02-08 18:12:19 +13:00
Brandon Presley ab91bbe88f Merge gitea.presley.nz:brandon.presley/Massive 2024-02-07 17:41:54 +13:00
Brandon Presley 936a47b8b2 Add more information to rest timer notifications - 2.11 🚀
From homepage - Name of exercise
From plans - Name (count/total)
2024-02-07 17:41:38 +13:00
Brandon Presley 6a9e2224ec Add more information to rest timer notifications
From homepage - Name of exercise
From plans - Name (count/total)
2024-02-07 17:31:41 +13:00
Brandon Presley 9b881c3d58 Change logging
1. Remove a few needless logs
2. Label where the logs are coming from
2024-02-07 17:31:13 +13:00
Brandon Presley 5ce3b9e69c Move dark/light color selects next to buttons
Now we can easily preview what changing the
primary colors will cause.
2024-02-07 15:52:08 +13:00
Brandon Presley 9dd4e70d33 Add selection for unit to convert to in graphs 2024-02-07 12:02:36 +13:00
Brandon Presley 8fbc92920d Add period selector for "All time" in graphs 2024-02-07 11:42:24 +13:00
Brandon Presley 31f9ddede3 Merge branch 'master' of gitea.presley.nz:brandon.presley/Massive 2024-02-05 14:29:29 +13:00
brandon.presley b42fb6b2b6 Merge pull request 'Add leading zeros to minute and day of month fields' (#219) from svenf/Massive:fix-date-format into master - 2.10 🚀
Reviewed-on: #219
2024-02-05 14:29:02 +13:00
brandon.presley 9b52aec554 Merge pull request 'Add leading zeros to minute and day of month fields' (#219) from svenf/Massive:fix-date-format into master
Reviewed-on: #219
2024-02-05 01:26:48 +00:00
Brandon Presley 426c557019 Add migrations to leading zeros for date settings
Also included the 12-hour formats for leading zeros.
2024-02-05 14:24:53 +13:00
Sven Frotscher fe695de487 Add leading zeros to minute and day of month fields 2024-01-16 12:13:23 +01:00
Brandon Presley 5a3b926dcf Handle fresh user experience better for Plans - 2.9 🚀
1. Add a button to insert some Exercises if none exist
2. Show a toast for the first plan, explaining you should
  tap it to begin the plan.
2023-11-29 12:55:18 +13:00
Brandon Presley 81421e5be7 Make timer bar slightly taller 2023-11-29 09:27:56 +13:00
Brandon Presley 19307a2a3c Remove progress circle from timer page
- This is partly to figure out what is happening in #206.
- It's also because we have the global timer bar
- Could never quite make it look like the text was centered
2023-11-29 09:25:22 +13:00
Brandon Presley 8e9a6be85d Start alarm service as non-foreground - 2.8 🚀
Closes #209
2023-11-28 11:16:41 +13:00
Brandon Presley 3ed87114d2 Fix view weight graph for small screens 2023-11-27 11:53:54 +13:00
Brandon Presley 5a9030dae7 Fix view of graphs on smaller screens 2023-11-27 11:51:15 +13:00
Brandon Presley fe68ddfae8 Show full date for graphs 2023-11-23 15:04:18 +13:00
Brandon Presley 9ab07c0114 Put selected exercises first when editing a plan - 2.7 🚀
Closes #190
2023-11-22 11:29:12 +13:00
Brandon Presley c18072bdc0 Default showing of steps to false 2023-11-21 19:25:18 +13:00
Brandon Presley 610d55c932 Change color name of Red to Brandy 2023-11-21 19:24:47 +13:00
Brandon Presley 0163788175 Set placeholder for plan title 2023-11-21 19:24:40 +13:00
Brandon Presley 8a1e1b982a Show both date and time by default 2023-11-21 19:14:53 +13:00
Brandon Presley 8cfdc354dc Make fresh installs show the date by default 2023-11-21 19:11:12 +13:00
Brandon Presley c98706bd56 Delete gitea issue template
Not sure how to make different kinds since there can be
bugs or feature requests.
2023-11-21 17:57:08 +13:00
Brandon Presley 303d8fa819 Make version a text input for issue template 2023-11-21 17:56:00 +13:00
Brandon Presley e20d07bcf8 Add issue template to gitea 2023-11-21 17:54:42 +13:00
Brandon Presley cb2fa2fb0c Fix error when saving a set with no weight
Just default it to zero if it isn't entered.
2023-11-21 17:44:53 +13:00
Brandon Presley abbe702f24 Update package-lock.json 2023-11-21 17:30:00 +13:00
Brandon Presley 2a78d2e556 Add button to export sets as CSV - 2.6 🚀
Closes #203
2023-11-21 10:26:34 +13:00
Brandon Presley 3882a95b65 Add whitespace to SettingsPage 2023-11-21 10:16:31 +13:00
Brandon Presley 7e764062f4 Remove redundant exports from gym-set.ts 2023-11-21 10:16:17 +13:00
Brandon Presley 857af61eec Refactor SettingsPage
1. If we rely on query builders too much we lose type safety
2. The update method wasn't really worth factoring out in the
  first place.
2023-11-20 20:06:45 +13:00
Brandon Presley 15a28a0a81 Replace dragging with buttons in edit plan - 2.5 🚀
Every draggable library I tried was buggy.
2023-11-20 12:42:23 +13:00
Brandon Presley 2f24104d13 Set finished notification sound to null - 2.4 🚀
Closes #195
2023-11-20 12:24:43 +13:00
Brandon Presley cc6b37e16a Fix timer not always ending - 2.3 🚀
This bug is because I mis-named the event for the alarm finishing.
It would only be possible to create this bug if you do the following:
1. Create a timer
2. Swap to another app for it's duration and keep your phone screen
   active
3. On timer completion, without tapping the notification, focus the app
4. See the timer has not been set to zero
2023-11-15 22:44:02 +13:00
Brandon Presley b485175082 Stop timer when you undo a set from an open plan 2023-11-15 16:05:28 +13:00
Brandon Presley 086e3ea2df Simplify the peek view of exercises from Plan 2023-11-15 16:03:58 +13:00
Brandon Presley 1b164aaaf1 Remove loading indicator where load times are fast
If the load time is too fast (on a mid-tier device),
then for most people they will see a flicker of a
loading spinner (which looks like a bug). These indicators
would only marginally improve the experience of people with
the slowest devices, but for most people this will just look
like a bug.

I left the indicators in the InsightsPage since those queries
actually do take >=300ms on a mid-tier device.
2023-11-15 15:53:32 +13:00
Brandon Presley b44cbae131 Fix progress bar not showing on some pages - 2.2 🚀
Moving the bar  from App.tsx -> AppDrawer.tsx was a mistake,
because then the bar only shows on drawer routes instead of
app-wide.
2023-11-15 14:32:29 +13:00
Brandon Presley 608bb3e97a Add settings for default fields on Exercise - 2.1 🚀
Closes #188
2023-11-15 14:05:25 +13:00
Brandon Presley f6a75d89cd Write an empty mp3 when disabling sound - 1.188 🚀
Also revert to the default sound if you enable it again.

Closes #178
2023-11-15 13:28:30 +13:00
Brandon Presley 0592a9d695 Fix wrong text color on primary buttons - 1.187 🚀 2023-11-15 13:07:58 +13:00
Brandon Presley 3544392002 Handle database importing breaking
1. Backup the current database first
2. Restore the database if something goes wrong
3. Show the user a dialog explaining the error
2023-11-15 12:14:47 +13:00
Brandon Presley bdc648d811 Factor out FatalError component from App.tsx 2023-11-15 12:14:34 +13:00
Brandon Presley 50b3a2ef3d Fix text color in App.tsx fatal error message 2023-11-15 11:52:31 +13:00
Brandon Presley 7a24d844c5 Add fatal error message if database fails to initialize
This will help with debugging if the user can directly
provide what error occurred.
2023-11-15 11:46:33 +13:00
Brandon Presley ec17ad5805 Wrap SetItem in React.memo
Apparently large lists in React Native should be wrapped in this,
as long as they don't have any internal state.

So this should theoretically improve performance when you scroll down
a lot.
2023-11-15 11:30:09 +13:00
Brandon Presley ba24649a52 Organize imports 2023-11-15 11:21:49 +13:00
Brandon Presley 70a318a0a4 Add activity indicator for SetList 2023-11-15 11:19:22 +13:00
Brandon Presley 6b168cccfe Rename drawer-param-list -> drawer-params 2023-11-15 11:08:00 +13:00
Brandon Presley b68587f514 Add auto converter - 1.186 🚀
Closes #193
2023-11-15 11:03:23 +13:00
Brandon Presley 1b217f1905 Fix bug of default EditSets unit being wrong
By default, the unit is empty (no change).
Previously it was defaulting to kilograms.
2023-11-15 11:00:09 +13:00
Brandon Presley c3a8034dd4 Make add/subtract buttons float right in EditSets 2023-11-15 10:59:55 +13:00
Brandon Presley 9cc0eae66a Fix logging in EditSets 2023-11-15 10:59:40 +13:00
Brandon Presley 3ac1e74575 Make save button containde in EditExercise 2023-11-15 10:53:16 +13:00
Brandon Presley c507370398 Change style of primary buttons
According to React Native Paper docs, the purpose
of the "contained" mode of button is for the primary
action.

https://callstack.github.io/react-native-paper/docs/components/Button/#mode
2023-11-15 10:51:54 +13:00
Brandon Presley 35a3ef75b6 Add 3 more colors to dark/light mode 2023-11-15 10:43:47 +13:00
Brandon Presley 859818e5b6 Change layout of SetItem
Moved the date to the description,
and the reps x weight to the right.

Related to #192.
2023-11-14 20:44:54 +13:00
Brandon Presley ddceb91211 Add activity indicators to ViewGraph 2023-11-14 15:03:21 +13:00
Brandon Presley 976bb7c189 Add activity indicator for StartPlan 2023-11-14 14:56:58 +13:00
Brandon Presley 8ee07823aa Replace useDark with useTheme - 1.185 🚀 2023-11-14 14:52:35 +13:00
Brandon Presley 456af73e91 Rename useTheme to useAppTheme
React Native and React Native Paper already
have a useTheme so let's not add to the clutter.
2023-11-14 14:45:15 +13:00
Brandon Presley a7db87c61a Fix colors on fabs 2023-11-14 14:44:10 +13:00
Brandon Presley b9473a8b01 Fix error inserting records on fresh install - 1.184 🚀 2023-11-14 14:36:47 +13:00
Brandon Presley 5a06b7ee2c Fix default colors on fresh install 2023-11-14 14:34:10 +13:00
Brandon Presley bec564e18b Handle loading of exercises in EditPlan 2023-11-14 14:03:40 +13:00
Brandon Presley 4dd8a2950f Organize state in EditPlan 2023-11-14 13:56:50 +13:00
Brandon Presley d0f6550f29 Move TimerProgress from App.tsx -> AppDrawer.tsx
1. The lower this is in the stack the less re-renders we might cause
2. Now we can hook into useFocusEffect and prevent updates when out of
  focus
2023-11-14 13:55:59 +13:00
Brandon Presley be3af4db22 Refactor some code in App.tsx 2023-11-13 19:40:05 +13:00
Brandon Presley 4b5e7011d6 Fix logic for dark color theme 2023-11-13 19:16:13 +13:00
Brandon Presley 315279e28d Replace all unit text boxes with dropdowns - 1.183 🚀 2023-11-13 18:35:30 +13:00
Brandon Presley d8eba22914 Rename Home -> History
Although it is very common to have a Home page,
I would rather have every pages name describe
generally what it is.
2023-11-13 18:29:07 +13:00
Brandon Presley 1ac78de724 Fix some database drift 2023-11-13 18:13:23 +13:00
Brandon Presley 6950cd04f4 Improve performance of app - 1.182 🚀
The App.tsx had a bunch of separate useState calls which would
cause unneccesary re-renders of the entire app. This became
apparent after adding the global progress bar, since it caused
even more re-renders to the point of being unusable.
2023-11-13 17:37:53 +13:00
Brandon Presley 49646c3107 Reduce default limit back to 15
The feeling of scrolling was nice but a page size of 50
makes the main page a bit slower which isn't worth it.
2023-11-13 17:11:00 +13:00
Brandon Presley 7f4c0a5f10 Replace unit text input with a drop down 2023-11-13 16:07:45 +13:00
Brandon Presley ec0fdbcec7 Remove logging from AppDrawer & AppStack 2023-11-13 16:07:23 +13:00
Brandon Presley 155eaddbdd Add margins to app-wide progress bar 2023-11-13 16:07:12 +13:00
Brandon Presley b6afbfcc17 Fix automatic backups - 1.181 🚀
- The broadcast intent wasn't receiving the target directory
- Add separate button for storing the backup location
2023-11-13 15:19:35 +13:00
Brandon Presley d0c0a52ab4 Simplify logic of AlarmService.playSound
This probably won't fix the disabling sound bug.
2023-11-13 14:12:25 +13:00
Brandon Presley 79e462efc2 Fix app crashing when you change system theme
More specifically, this happens when you restore an
activity (not sure exactly when this happens).
2023-11-13 14:07:45 +13:00
Brandon Presley 84ff8a110b Improve responsiveness of timer page - 1.180 🚀 2023-11-12 23:37:58 +13:00
Brandon Presley bf6863000f Stick progress bar to the bottom
Previously it jolted around all the other content
which looks gross so now it's absolute positioned bottom.
2023-11-12 23:35:43 +13:00
Brandon Presley e65c053a62 Add setting to choose the startup page
Closes #186
2023-11-12 23:27:28 +13:00
Brandon Presley 054ae4557d Make page size bigger (15->50) and fetch next page earlier
This project is a little bit slow to get the pages,
so it makes sense to fetch things earlier and to
get them in bigger chunks.
2023-11-12 22:51:42 +13:00
Brandon Presley 92dd65ffee Fix scrolling being broken on some list pages - 1.179 🚀 2023-11-12 22:37:43 +13:00
Brandon Presley afed5f1d54 Reduce nesting of code within SettingsPage - 1.178 🚀
The code before needlessly factored out functions in functions
and made it impossible to read and change. Also it wasn't using a
FlatList which is a performance problem.
2023-11-12 19:11:20 +13:00
Brandon Presley 52f04ad11c Remove old remnants of multiple stack navigators 2023-11-12 17:10:15 +13:00
Brandon Presley ef63fcf470 Organize all imports 2023-11-12 17:05:37 +13:00
Brandon Presley 901cc72fbd Revert my work on surgically updating lists - 1.177 🚀
I tried to maintain the current scroll position
within a list however this caused many issues
to do with outdated data, as well as performance issues.
Now we are going back to just refreshing any list on focus.

Closes #184
2023-11-12 17:03:22 +13:00
Brandon Presley 706d4d1bbd Add duration setting to alarm vibrations - 1.176 🚀
Closes #179
2023-11-12 12:25:09 +13:00
Brandon Presley 75263af8b3 Add global progress bar for ongoing timer - 1.175 🚀
Closes #182
2023-11-12 11:54:19 +13:00
Brandon Presley 70fec83ec3 Replace long press with vertical drag icon button
Closes #181
2023-11-12 11:45:23 +13:00
Brandon Presley 1ff6a87155 Update names list after re-ordering - 1.174 🚀
Closes #183
2023-11-12 11:28:30 +13:00
Brandon Presley 9cbe261938 Fix error creating new plans - 1.173 🚀 2023-11-11 00:15:30 +13:00
Brandon Presley 813928bdd3 Add permissions checks to SettingsPage - 1.172 🚀
Related to #177
2023-11-10 12:54:35 +13:00
Brandon Presley 8988e584ae Rename workout -> exercise
A workout would typically refer to a list of
exercises.
2023-11-09 18:52:50 +13:00
Brandon Presley 6754e2a8ae Add two, three and six months to graphs - 1.171 🚀 2023-11-09 14:31:24 +13:00
Brandon Presley 3e1ea50914 Format code 2023-11-09 13:28:47 +13:00
Brandon Presley e5db6fe34b Add 2,3,6 months to weight graph 2023-11-09 13:27:34 +13:00
Brandon Presley 9fee26f7c8 Add activity indicator for insights
Also update the help descriptions.
2023-11-09 13:20:08 +13:00
Brandon Presley 307ad4c9dd Re organize imports of Select.tsx 2023-11-09 13:15:45 +13:00
Brandon Presley 2fdb220659 Change style of Settings page
1. Move selects above switches
    Selects are larger now so it looks nicer to flow from large
    down to small.
2. Remove label from alarm sound button
    Because the other buttons don't have labels this makes the
    alarm sound stand out and seemed inconsistent.
2023-11-09 13:14:11 +13:00
Brandon Presley 2e96398b38 Enable re-ordering of workouts in EditPlan - 1.170 🚀
Closes #176
2023-11-09 13:01:05 +13:00
Brandon Presley 1a289f1b7b Delete unused code from App.tsx 2023-11-09 12:55:52 +13:00
Brandon c70d6541b2 Add muscle groups 2023-11-09 12:23:19 +13:00
Brandon Presley d41bafdecb Format InsightsPage 2023-11-08 15:37:34 +13:00
Brandon Presley 31b11aefd6 Prevent lag on insights page
- Queries were being run in parallel, now they run sequentially
- Add 400ms delay before starting the queries, to allow for
  drawer navigation animation.
2023-11-08 15:34:48 +13:00
Brandon Presley 8a88c8e7af Make charts smooth and curvy - 1.169 🚀 2023-11-06 23:08:36 +13:00
Brandon Presley ec162911de Refactor code in ViewSetList 2023-11-06 22:35:52 +13:00
Brandon Presley f1075c3b28 Rename "View" to "Peek" in StartPlanItem 2023-11-06 22:35:37 +13:00
Brandon Presley 43ab666540 Make it easier to read old sets on ViewSetList - 1.168 🚀
Now sets alternate background color based on the day
they were entered.
2023-11-06 15:29:23 +13:00
Brandon Presley 1a2d7a27a0 Fix navigation type in WorkoutList 2023-11-06 15:02:29 +13:00
Brandon Presley 2f4574f231 Fix icons in EditWorkouts 2023-11-06 15:02:20 +13:00
Brandon Presley add8b01e4c Remove unused code from TimerPage 2023-11-06 15:00:52 +13:00
Brandon Presley 5d45d33572 Fix dev bug for navigation after adding a set
The previous code worked fine in production,
but gave a development error in the logs.
2023-11-06 14:29:00 +13:00
Brandon Presley 3d54f61a2c Move time.ts -> days.ts 2023-11-06 14:27:27 +13:00
Brandon Presley 744ed928f0 Fix timer page flashing 00:00 on first navigate 2023-11-04 13:53:19 +13:00
Brandon Presley 9cd205686f Remove redundant code from SetList 2023-10-28 16:12:37 +13:00
Brandon Presley 54596a5fc3 Add special screen for viewing sets from plan 2023-10-28 16:10:52 +13:00
Brandon Presley e8ee4a253e Migrate from Drawer -> Stacks to Stack -> Drawer
This simplifies our codebase greatly by
only having a single stack navigator and
a single drawer navigator. Previously we had
a stack navigator for every main page on the drawer.
2023-10-28 15:59:25 +13:00
Brandon Presley b4154b336f Change pie chart colors 2023-10-28 15:18:47 +13:00
Brandon Presley 589efb56bd Rename AppBarChart -> AppPieChart
Because it is a pie chart not a bar chart...
2023-10-28 15:11:41 +13:00
Brandon Presley 347423698d Fix navigating to view graph from start plan - 1.167 🚀 2023-10-27 17:23:27 +13:00
Brandon Presley c3f44fba03 Change style of insights page 2023-10-27 12:36:56 +13:00
Brandon Presley 915f09848b Auto focus new weight value 2023-10-27 12:36:41 +13:00
Brandon Presley 7ea91eeca9 Add most active hours of the day to insights - 1.166 🚀 2023-10-26 21:36:32 +13:00
Brandon Presley 32da68e905 Handle entering multiple weights in a single day
The graph would otherwise use I believe the first entry?
Now we use the average.
2023-10-26 21:11:56 +13:00
Brandon Presley 39b87ba932 Add explanation dialog to most active days 2023-10-26 21:02:25 +13:00
Brandon Presley 2428a51a02 Fix ViewWeightGraph - 1.165 🚀 2023-10-26 20:45:50 +13:00
Brandon Presley 541e8741e8 Replace/Remove all references to yarn -> npm 2023-10-26 20:32:22 +13:00
Brandon Presley 1d13cb9c5d Change select style 2023-10-26 20:30:41 +13:00
Brandon Presley 717c07d512 Fix font color of app bar chart 2023-10-26 18:53:01 +13:00
Brandon Presley e0b2adbb66 Use outlined icons for routes 2023-10-26 18:49:56 +13:00
Brandon Presley 1c10e0f632 Use new chart library in ViewGraph 2023-10-26 12:20:15 +13:00
Brandon Presley f28406b4c4 Move add 1 min button to left side fab 2023-10-26 12:20:04 +13:00
Brandon Presley e106d2475b Add period selectors for insights page 2023-10-25 10:21:21 +13:00
Brandon Presley a176036c33 Warn when weight loss is faster than 1% a week 2023-10-25 09:51:55 +13:00
Brandon Presley f61109cea3 Add insights page 2023-10-24 21:32:31 +13:00
Brandon Presley b1d77cbdce Swap yarn for npm
Got pissed that yarn keeps timing out
2023-10-24 21:07:21 +13:00
Brandon Presley 805f982ccf Add graph button to start plan - 1.164 🚀 2023-10-24 16:24:56 +13:00
Brandon Presley ab107793e4 Change style of weight items
- Make them smaller
- Bold+underline current day weight
2023-10-24 16:16:59 +13:00
Brandon Presley cb5aa72552 Submit weight after entering value 2023-10-24 16:16:42 +13:00
Brandon Presley 28250f1862 Only get set options once for EditSet menu 2023-10-21 14:45:20 +13:00
Brandon Presley 80dc5d2b63 Make start plan increment buttons hover - 1.163 🚀 2023-10-21 14:43:38 +13:00
Brandon Presley a35aba7b97 Add select button to EditSet - 1.162 🚀
Closes #173
2023-10-21 13:24:14 +13:00
Brandon Presley ff7cd2fe54 Add weight page 2023-10-21 11:57:31 +13:00
Brandon Presley 7928cab4c1 Swap to using MaterialCommunityIcons 2023-10-19 18:28:56 +13:00
Brandon Presley 12dfa923e5 Add delete button to edit set - 1.161 🚀 2023-10-19 17:58:34 +13:00
Brandon Presley 38167a47b9 Add log to plan list 2023-10-19 17:47:22 +13:00
Brandon Presley b508df0680 Emit event after saving many sets 2023-10-19 17:40:55 +13:00
Brandon Presley a3b376badb Use events for gym set CRUD 2023-10-18 19:06:13 +13:00
Brandon Presley cfcc15600c Prevent workouts jitter 2023-10-18 13:18:32 +13:00
Brandon Presley 44184516f7 Change ViewGraph defaults to ORM > Best > Volume 2023-10-18 12:46:34 +13:00
Brandon Presley c88642b2ef Fix home page adding sets - 1.160 🚀 2023-10-18 10:45:40 +13:00
Brandon Presley 4cca538d74 Fix listing workouts from plan page 2023-10-18 10:17:50 +13:00
Brandon Presley 90006d3b82 Only show loading on GraphsList on pull-down 2023-10-18 09:59:46 +13:00
Brandon Presley 22f5f3c9ee Only show loading on pull-down for SetList 2023-10-18 09:56:09 +13:00
Brandon Presley b4f6f12b1a Update bundle 2023-10-14 14:31:59 +13:00
Brandon Presley edf54d047e Fix production error with undefined settings - 1.159 🚀 2023-10-14 10:56:46 +13:00
Brandon Presley 9867dee514 Simplify explanation toast for sound disabling - 1.158 🚀 2023-10-14 10:50:58 +13:00
Brandon Presley 57883266b8 Hide rest timers on Workouts when alarms are off
Closes #172.
2023-10-14 10:46:22 +13:00
Brandon Presley adbc87f462 Prevent list jitter on graphs - 1.157 🚀
If we always refresh on focus then we will
be making many redundant requests, as well as
causing the list to jump around whenever we
navigate back/forward.
The only downside to this is occasionally the
data may be stale, however the user can just
pull down on the list to refresh.

Closes #165
2023-09-07 16:52:50 +12:00
Brandon Presley 07cb634883 Use plan title if one is set - 1.156 🚀
Closes #170
2023-09-07 14:51:38 +12:00
Brandon Presley c480d3e382 Replace usage of deprecated DeviceEventEmitter - 1.155 🚀 2023-09-04 14:32:40 +12:00
Brandon Presley c9773af92d Add profiles dir to gitignore 2023-09-04 14:15:53 +12:00
Brandon Presley 9ae311b94a Toggle selecting all for Plans and Workouts 2023-09-04 13:59:35 +12:00
Brandon Presley ec72824e3c Check for existence of `plans` and `workouts` - 1.154 🚀 2023-08-29 11:58:15 +12:00
Brandon Presley 0ba7616ea2 Prevent empty graphs flickering
If `bests` is always an empty array then our logic
for detecting zero graphs is wrong. Now when we haven't
loaded any data `bests` is undefined instead of []
2023-08-29 11:55:21 +12:00
Brandon Presley 4b1bbf2395 Refresh set list on focus if offset is zero - 1.153 🚀 2023-08-29 11:43:10 +12:00
Brandon Presley 7eabe63198 Fix offset in GraphsList 2023-08-29 11:30:25 +12:00
Brandon Presley 386a9a7bb2 Pass whole updated set instead of just its id
Since we already have the whole updated set,
might as well pass it around instead of
re-fetching it.
2023-08-29 11:25:05 +12:00
Brandon Presley 103ae5587d Add missing keyExtractors 2023-08-29 11:22:15 +12:00
Brandon Presley da72692616 Add missing keyExtractor to SetList 2023-08-29 11:17:14 +12:00
Brandon Presley f1e8988e56 Fix multi-edit sets navigation 2023-08-28 18:14:52 +12:00
Brandon Presley 6b524dce34 Revert "Change way we detect set updates"
This reverts commit 185ebd1824.
2023-08-28 18:12:15 +12:00
Brandon Presley 82234a30a8 Reload specific set when edited 2023-08-28 18:11:03 +12:00
Brandon Presley 185ebd1824 Change way we detect set updates
Every time we create/update/delete fire a DeviceEventEmitter
event for gym sets. Then we subscribe to the changes in relevant
components.

Also fixed flickering of "No data yet" on graphs page.
2023-08-24 17:30:57 +12:00
Brandon Presley f0d5fc4fa6 Fix infinite scrolling in homepage - 1.152 🚀
Also prevent flickering of "No sets found"
message on first load.

The infinite scrolling issue was a side-effect
of me messing with our memoization. Some places
didn't specify their deps properly.
2023-08-22 12:27:27 +12:00
Brandon Presley de25cead60 Toggle selecting all - 1.151 🚀 2023-08-22 12:09:04 +12:00
Brandon Presley a9b69638a6 Fix color of progress bar in Timer page
React native paper update made the dark theme color into RGBA
instead of hex, so adding the string '80' no longer works.
2023-08-22 12:04:47 +12:00
Brandon Presley 94a5fa4ac7 Remove track color setting from Switch
This must have been considered a bug in the
React Native Paper codebase, but this was me fixing
the track color being wrong when system theme and app
theme weren't the same.
2023-08-22 11:53:12 +12:00
Brandon Presley 1367f74280 Fix README reference to old build outputs
Also removed the relevant docs section.
2023-08-22 11:47:08 +12:00
Brandon Presley a294b76a4e Add rocket ship to release commits 🚀 2023-08-22 11:44:24 +12:00
Brandon Presley 24fd687856 Add custom event for settings being updated - 1.150
When we used navigation params to decide whether
or not to update the SetList, we broke reacting to
settings changes. This is because we used to update settings
whenever the user navigated to the page.
2023-08-22 11:43:35 +12:00
Brandon Presley bd9746bddb Remove yarn lint from deploy.sh - 1.149 2023-08-22 10:03:09 +12:00
Brandon Presley dd609a20e5 Fix navigation after adding a set 2023-08-22 10:00:13 +12:00
Brandon Presley d2cad451fe Add migration for title 2023-08-22 09:58:48 +12:00
Brandon Presley 2a6ba3b36a Undo change to plans migration
Correct migration files should never be updated.
For more information on how database migrations
work in TypeORM: https://orkhan.gitbook.io/typeorm/docs/migrations
2023-08-22 09:58:00 +12:00
Brandon Presley 0c5562a2f1 Merge branch 'master' of https://gitea.presley.nz/Nuice/Massive into Nuice-master 2023-08-22 09:51:46 +12:00
Brandon Presley 672931746b Only reset SetList in certain situations
This reduces the jitter in the homepage
when you have scrolled down a significant
amount.

Related to #165. Still need to do other
list pages.
2023-08-22 09:49:56 +12:00
Leon Babic 314b09017b
Add title to Plan 2023-08-21 14:25:29 +02:00
Brandon Presley 8e42e9c3e4 Allow editing of multiple workouts 2023-08-14 16:03:07 +12:00
Brandon Presley 9fbae74a01 Merge branch 'master' into feature/multi-edit-workouts 2023-08-14 15:15:00 +12:00
Brandon Presley 331597e3ee Add increment/decrement buttons to reps/weight - 1.148
Closes #164
2023-08-14 13:32:10 +12:00
Brandon Presley dc5434991a Pause adding multi-edit to workouts
Got up to the point where i'm find/replacing the
old names with new names, and I got confused
about the purpose of this feature.
2023-08-14 13:14:34 +12:00
Brandon Presley 79cde3a219 Use accurate theme color for switch text
Only if no custom color is provided
2023-08-14 10:55:24 +12:00
Brandon Presley 63e1db7349 Rename variable in SettingsPage 2023-08-14 10:50:44 +12:00
Brandon Presley da17f8899c Paginate graphs
Also factor out LIMIT constant
2023-08-14 10:42:15 +12:00
Brandon Presley 8648cf166e Remove prettier from project deps 2023-08-13 20:58:36 +12:00
Brandon Presley af96ec8507 Validate numbers in EditWorkout - 1.147 2023-08-12 15:41:32 +12:00
Brandon Presley f51284e4ea Validate and fix numbers in StartPlan 2023-08-12 15:30:47 +12:00
Brandon Presley f778426aba Run prettier
Something happened with the deno formatter,
I can't remember what! Hahahahahaahahaha
2023-08-12 15:23:02 +12:00
Brandon Presley 44283fc990 Validate reps+weight on EditSet
Numbers shouldn't contain dashes, spaces or commas.
2023-08-12 15:22:00 +12:00
Brandon Presley 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
Brandon Presley 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
Brandon Presley 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
Brandon Presley e628d345ca Reduce escaping of characters 2023-07-23 14:52:22 +12:00
Brandon Presley 85915b9aa0 Retrieve last set when running a plan - 1.145
Closes #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
Brandon Presley 9833752bab Deno fmt 2023-07-20 14:55:19 +12:00
Brandon Presley 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
Brandon Presley 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
Brandon Presley 82da62f699 Remove unused variable from ListMenu 2023-07-17 18:39:09 +12:00
Brandon Presley 36d3de401b Fix a few instances of react/no-unstable-nested-components 2023-07-17 18:38:28 +12:00
Brandon Presley 040d588b5a Ignore mock-providers.tsx 2023-07-17 16:46:23 +12:00
Brandon Presley 47d4532169 Fix .eslintrc.js 2023-07-17 16:45:21 +12:00
Brandon Presley 3e41c3bbd8 Use outlined buttons instead of contained ones
I like them better! Ahahahahahahahahah
Bwahahahahahahahahahahahahahahahahahah
2023-07-17 16:24:09 +12:00
Brandon Presley b776d88327 Fix snackbar color for button 2023-07-17 16:21:56 +12:00
Brandon Presley adc2d05b2c Add back in missing titleStyle in Select.tsx 2023-07-15 15:02:29 +12:00
Brandon Presley 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
Brandon Presley 89606b9d21 Fix type errors related to upgrade 2023-07-15 14:38:46 +12:00
Brandon Presley 6dabb7049f Upgrade all packages 2023-07-15 14:19:08 +12:00
Brandon Presley 4b42ab5f21 Upgrade react-native-paper to v5 2023-07-15 13:21:09 +12:00
Brandon Presley a7da93583d Upgrade to react-native 0.72.3 2023-07-15 12:16:42 +12:00
Brandon Presley 1b2cbab370 Simplify SetItem
It had a pointless react fragment
wrapping it's only element.
2023-07-07 13:41:48 +12:00
Brandon Presley 09354829a8 Update yarn.lock 2023-07-07 13:21:44 +12:00
Brandon Presley 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
Brandon Presley 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
Brandon Presley 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
Brandon Presley 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
Brandon Presley 6a7bd632e5 Add delete database button - 1.141
Semi-related to #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
Brandon Presley 4303fe2cc4 Use deno fmt instead of prettier 2023-06-27 15:16:59 +12:00
Brandon Presley 23ed95dcdb Reduce debug logging in ViewBest - 1.140 2023-06-24 13:06:35 +12:00
Brandon Presley 8f1f9f6e7d Ran bundle update 2023-06-22 10:26:01 +12:00
Brandon Presley 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
Brandon Presley 9c9a5fdd63 Trim search queries - 1.138
Closes #156
2023-06-13 14:18:49 +12:00
Brandon Presley 90db607190 Easily swap between edit/start for plans - 1.137 2023-03-28 12:20:32 +13:00
Brandon Presley 457134df6b Only show share button on best view 2023-03-28 12:04:54 +13:00
Brandon Presley db5cc566ea Remove double permissions request and fix import - 1.136 2023-03-27 14:45:28 +13:00
Brandon Presley 76e5aeacfd Choose directory when backing up automatically - 1.135
Related to #146.
2023-03-27 14:34:17 +13:00
Brandon Presley 2fb46e1dcc Fix audio type on document picker 2023-03-27 14:01:37 +13:00
Brandon Presley d1342c0efa Update fastlane 2023-03-24 19:32:26 +13:00
Brandon Presley 288ae1ae0c Disable timers if rest time is set to zero - 1.134 2023-03-24 19:16:35 +13:00
Brandon Presley 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
Brandon Presley d2a1c432bb Handle EACCESS in BackupModule 2023-03-24 17:43:28 +13:00
Brandon Presley 5dd569ef72 Upgrade to react-native 0.70.2 - 1.132 2023-03-21 17:09:24 +13:00
Brandon Presley dfc4f73ca4 Upgrade react-native to 0.70.6 2023-03-21 16:59:21 +13:00
Brandon Presley 79a48b1e47 Run automatic backups after database imports - 1.131 2023-03-09 18:48:32 +13:00
Brandon Presley 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
Brandon Presley 4db820f10a Remove DownloadModule
This was no longer in use
2023-03-07 18:23:38 +13:00
Brandon Presley 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
Brandon Presley a1643c349d Revert "Update timer page screenshot"
This reverts commit 640a25a0f4.
2023-03-02 19:15:26 +13:00
Brandon Presley 640a25a0f4 Update timer page screenshot 2023-03-02 19:14:45 +13:00
Brandon Presley 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
Brandon Presley 00d4edcfc3 Request FOREGROUND_SERVICE permission - 1.127
Related to #142
2023-02-24 19:30:48 +13:00
Brandon Presley 8dd8f786ef Round graphs to 2dp - 1.126
Closes #152
2023-02-22 19:44:23 +13:00
Brandon Presley a84cab6bbf Optimize batteries after importing database - 1.125
Closes #151
2023-02-14 16:50:14 +13:00
Brandon Presley f4db61aeec Fix unit tests - 1.124 2023-02-14 16:41:30 +13:00
Brandon Presley 3af3e1faf2 Order plan workouts alphabetically
Closes #150
2023-02-14 16:37:09 +13:00
Brandon Presley 7bc9c00a63 Merge branch 'master' of gitea.presley.nz:brandon.presley/Massive 2023-02-13 11:05:32 +13:00
Brandon a03731c6ff Prevent empty flicker on best view - 1.122 - 1.123
Closes #148
2023-02-13 10:43:03 +13:00
Brandon f1e0911488 Prevent empty flicker on best view - 1.122
Closes #148
2023-02-04 15:15:34 -07:00
Brandon 1a75d8897d Skip deploy checks for -n flag - 1.119 2023-02-04 14:16:31 -07:00
Brandon 9f7cbba80a Add -n flag to deploy.sh 2023-02-04 14:14:29 -07:00
Brandon de2aa67e6e Version 1.118 2023-01-26 20:04:53 -07:00
Brandon 28ec021258 Fix copying sets - 1.117
Related to #143
2023-01-17 10:22:21 -07:00
Brandon Presley 04ef72e48b Fix unit tests - 1.116 2023-01-08 18:10:24 +13:00
Brandon Presley 467df629b0 Change edit headers to add when adding 2023-01-08 18:05:59 +13:00
Brandon Presley e7f85a9954 Add date/time picker to EditSet - 1.115 2023-01-08 18:02:17 +13:00
Brandon Presley 5e6896eaba Ignore coverage directory for linting 2023-01-08 14:01:43 +13:00
Brandon Presley 6438a9c48a Use the same colors as switch for timer page - 1.114 2023-01-08 14:00:27 +13:00
Brandon Presley 8e8961419c Add plan list unit tests 2023-01-05 17:15:16 +13:00
Brandon Presley b0696d1d58 Add view best unit tests 2023-01-05 17:07:06 +13:00
Brandon Presley 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
Brandon Presley a6130b3a10 Merge branch 'master' of gitea.presley.nz:brandon.presley/Massive 2023-01-05 16:41:47 +13:00
Brandon Presley 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
Brandon Presley 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
Brandon Presley 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
Brandon Presley 069f770c96 Set versionCode=36137 2023-01-04 13:47:22 +13:00
Brandon Presley 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
Brandon Presley 495b89fba3 Add unit to save set test 2023-01-04 13:27:17 +13:00
Brandon Presley 42912040ff Simplify getNow 2023-01-04 13:24:49 +13:00
Brandon Presley c7952738b5 Add selected title for plans + sets
Inspired by the stock Files app in Android.
2023-01-03 17:21:51 +13:00
Brandon Presley 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
Brandon Presley 05237fc293 Set versionCode=36136 2023-01-03 15:15:33 +13:00
Brandon Presley 5fd7e75908 Fix settings persistence issues 2023-01-03 14:59:19 +13:00
Brandon Presley 705052f1b4 Set versionCode=36135 2023-01-03 11:33:43 +13:00
Brandon Presley efc97bdf47 Fix settings page on shorter devices 2023-01-03 11:31:37 +13:00
Brandon Presley d0702b7675 Reduce redundancy of labels for theme setting 2023-01-03 11:31:25 +13:00
Brandon Presley 24e230e8b9 Use a scroll view for settings page
This way shorter screens dont cut off content
2023-01-02 23:29:46 +13:00
Brandon Presley 67689f4af8 Set versionCode=36134 2023-01-02 18:56:53 +13:00
Brandon Presley a2721e9f12 Use sqlite in Android code for alarm settings
Closes #129
2023-01-02 18:54:35 +13:00
Brandon Presley bafdecd3e3 Add unit tests for EditSets
Closes #138
2023-01-01 20:47:07 +13:00
Brandon Presley e432c1b711 Add app test 2023-01-01 18:33:03 +13:00
Brandon Presley 08f91bf531 Test saving a plan 2023-01-01 18:13:45 +13:00
Brandon Presley 80f2dfdff5 Improve performance of Routes.tsx 2023-01-01 18:05:11 +13:00
Brandon Presley 3c9b93f0bc Test adding a new set 2023-01-01 18:01:46 +13:00
Brandon Presley f221ebb8df Test editing a workout 2023-01-01 15:21:56 +13:00
Brandon Presley a68d4d6a69 Include tests directory in organize.sh 2023-01-01 15:21:42 +13:00
Brandon Presley 5d9df37778 Organize imports 2023-01-01 15:20:56 +13:00
Brandon Presley 8246155c13 Test start plan + edit plan 2023-01-01 15:06:42 +13:00
Brandon Presley 58f1c905b2 Test edit set component 2023-01-01 14:47:16 +13:00
Brandon Presley 805b7bdc34 Simplify tests 2023-01-01 14:35:22 +13:00
Brandon Presley a772e36160 Add tests to deploy script 2023-01-01 14:32:23 +13:00
Brandon Presley ad95438120 Test all pages 2023-01-01 14:32:10 +13:00
Brandon Presley 3c953530a4 Add working test for homepage 2023-01-01 14:19:32 +13:00
Brandon Presley 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
Brandon Presley 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
Brandon Presley 27ff4861d9 Reduce logs in SetList 2023-01-01 14:18:27 +13:00
Brandon Presley e43188ccdf Merge branch 'master' into unit-tests 2023-01-01 13:57:01 +13:00
Brandon Presley 9287c31e70 Update fastlane 2023-01-01 13:47:04 +13:00
Brandon Presley 5612df5d8c Set versionCode=36133 2023-01-01 13:46:26 +13:00
Brandon Presley 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
Brandon Presley 86f01eb002 Memoize the most expensive parts of SettingsPage 2023-01-01 13:39:10 +13:00
Brandon Presley 5335f4afbc Memoize switches in SettingsPage 2023-01-01 13:32:26 +13:00
Brandon Presley d71ad8c170 Set versionCode=36132 2023-01-01 13:18:13 +13:00
Brandon Presley 53799fdcc4 Change mode of text input from outlined to default
Related to #140
2023-01-01 13:16:08 +13:00
Brandon Presley 651b130caa Remove pdf I accidentally committed of the readme 2023-01-01 13:15:23 +13:00
Brandon Presley 73c7486eb3 Update plan start image 2022-12-30 20:46:39 +13:00
Brandon Presley ea2ff913db Update screenshots 2022-12-30 20:45:12 +13:00
Brandon Presley 0be8f03133 Set versionCode=36131 2022-12-30 20:40:15 +13:00
Brandon Presley 7863b9caa0 Improve speed of setting sound string as well 2022-12-30 20:38:34 +13:00
Brandon Presley 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
Brandon Presley e51aad21f3 Set versionCode=36130 2022-12-30 20:31:34 +13:00
Brandon Presley f48124123c Delete unused variable from Routes.tsx 2022-12-30 20:29:47 +13:00
Brandon Presley 3603c67133 Add filtering back in to SettingsPage
I accidentally removed it and pushed to production...
Woopsie.
2022-12-30 20:29:16 +13:00
Brandon Presley 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
Brandon Presley dd7cb0406b Use React.memo for Switch 2022-12-30 19:42:30 +13:00
Brandon Presley a5ddf5c94d Use React.memo in Select.tsx 2022-12-30 19:39:35 +13:00
Brandon Presley 9be10610d2 Use React.memo on AppInput 2022-12-30 19:39:20 +13:00
Brandon Presley 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
Brandon Presley 7f1513f0a5 Set versionCode=36129 2022-12-30 13:37:42 +13:00
Brandon Presley 14edb66e28 Add common date formats
Add yyyy-MM-d and yyyy.MM.d formats

Closes #139
2022-12-30 13:35:11 +13:00
Brandon Presley 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
Brandon Presley 46dd50adfb Pause adding unit tests 2022-12-30 13:25:47 +13:00
Brandon Presley e430873771 Remove commented code from jestSetup 2022-12-30 12:35:19 +13:00
Brandon Presley a9266ba77b Set versionCode=36128 2022-12-29 20:11:34 +13:00
Brandon Presley 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
Brandon Presley a3138c48b5 Combine several state operations in SettingsPage 2022-12-29 18:20:40 +13:00
Brandon Presley 2b302bab73 Move theme line of SettingsPage
Because I am schizophrenic.
2022-12-29 17:28:31 +13:00
Brandon Presley 7bf802ea45 Set versionCode=36127 2022-12-29 16:43:16 +13:00
Brandon Presley 5115055280 Reword MassiveX as AppX 2022-12-29 13:57:19 +13:00
Brandon Presley 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
Brandon Presley 596b695c5b Remove unlabelled log from StartPlan 2022-12-28 15:00:00 +13:00
Brandon Presley c664a9603c Add missing toasts to some settings
- Date format
- Dark color
- Light color
2022-12-28 14:59:39 +13:00
Brandon Presley 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
Brandon Presley 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
Brandon Presley 2c6a773548 Change alarm sound toast
Closes #137
2022-12-28 14:15:02 +13:00
Brandon Presley b33a829816 Set versionCode=36126 2022-12-27 00:39:22 +13:00
Brandon Presley 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
Brandon Presley a9b86fb555 Set versionCode=36125 2022-12-24 20:10:26 +13:00
Brandon Presley 60cc619e39 Move button filter to memoized call
This reduces re-renders
2022-12-24 19:58:55 +13:00
Brandon Presley 48432188c3 Simplify Switch.tsx 2022-12-24 19:55:38 +13:00
173 changed files with 18332 additions and 15131 deletions

View File

@ -1,12 +1,12 @@
module.exports = {
root: true,
extends: '@react-native-community',
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: '@react-native',
overrides: [
{
files: ['*.ts', '*.tsx', '*.js'],
rules: {
'jsx-quotes': 0,
'prettier/prettier': 0,
'@typescript-eslint/no-shadow': ['error'],
'no-shadow': 'off',
'no-undef': 'off',
@ -18,4 +18,5 @@ module.exports = {
},
},
],
ignorePatterns: ['coverage/', 'mock-providers.tsx'],
}

2
.gitignore vendored
View File

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

View File

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

176
App.tsx
View File

@ -1,23 +1,22 @@
import {
NavigationContainer,
DarkTheme as NavigationDarkTheme,
DefaultTheme as NavigationDefaultTheme,
NavigationContainer,
} from '@react-navigation/native'
import {useEffect, useMemo, useState} from 'react'
import {DeviceEventEmitter, useColorScheme} from 'react-native'
import React from 'react'
} from "@react-navigation/native";
import React, { useEffect, useMemo, useState } from "react";
import { useColorScheme } from "react-native";
import {
DarkTheme as PaperDarkTheme,
DefaultTheme as PaperDefaultTheme,
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'
} from "react-native-paper";
import MaterialIcon from "react-native-vector-icons/MaterialCommunityIcons";
import AppSnack from "./AppSnack";
import AppStack from "./AppStack";
import FatalError from "./FatalError";
import { AppDataSource } from "./data-source";
import { settingsRepo } from "./db";
import { ThemeContext } from "./use-theme";
export const CombinedDefaultTheme = {
...NavigationDefaultTheme,
@ -26,7 +25,7 @@ export const CombinedDefaultTheme = {
...NavigationDefaultTheme.colors,
...PaperDefaultTheme.colors,
},
}
};
export const CombinedDarkTheme = {
...NavigationDarkTheme,
@ -35,99 +34,92 @@ export const CombinedDarkTheme = {
...NavigationDarkTheme.colors,
...PaperDarkTheme.colors,
},
}
};
const App = () => {
const isDark = useColorScheme() === 'dark'
const [initialized, setInitialized] = useState(false)
const [snackbar, setSnackbar] = useState('')
const [theme, setTheme] = useState('system')
console.log("Re rendered app");
const systemTheme = useColorScheme();
const [lightColor, setLightColor] = useState<string>(
CombinedDefaultTheme.colors.primary,
)
const [darkColor, setDarkColor] = useState<string>(
CombinedDarkTheme.colors.primary,
)
const [appSettings, setAppSettings] = useState({
startup: undefined,
theme: "system",
lightColor: CombinedDefaultTheme.colors.primary,
darkColor: CombinedDarkTheme.colors.primary,
});
const [error, setError] = useState("");
useEffect(() => {
const init = async () => {
if (!AppDataSource.isInitialized) await AppDataSource.initialize()
const settings = await settingsRepo.findOne({where: {}})
console.log(`${App.name}.useEffect:`, {gotSettings: settings})
setTheme(settings.theme)
if (settings.lightColor) setLightColor(settings.lightColor)
if (settings.darkColor) setDarkColor(settings.darkColor)
setInitialized(true)
}
init()
const description = DeviceEventEmitter.addListener(
TOAST,
({value}: {value: string}) => {
setSnackbar(value)
},
)
return description.remove
}, [])
(async () => {
if (!AppDataSource.isInitialized)
await AppDataSource.initialize().catch((e) => setError(e.toString()));
const gotSettings = await settingsRepo.findOne({ where: {} });
console.log(`${App.name}.mount`, { gotSettings });
setAppSettings({
startup: gotSettings.startup,
theme: gotSettings.theme,
lightColor:
gotSettings.lightColor || CombinedDefaultTheme.colors.primary,
darkColor: gotSettings.darkColor || CombinedDarkTheme.colors.primary,
});
})();
}, []);
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 = isDark ? darkTheme : lightTheme
if (theme === 'dark') value = darkTheme
else if (theme === 'light') value = lightTheme
return value
}, [isDark, theme, lightColor, darkColor])
const action = useMemo(
() => ({
label: 'Close',
onPress: () => setSnackbar(''),
color: paperTheme.colors.background,
}),
[paperTheme.colors.background],
)
const darkTheme = {
...CombinedDarkTheme,
colors: {
...CombinedDarkTheme.colors,
primary: appSettings.darkColor,
},
};
const lightTheme = {
...CombinedDefaultTheme,
colors: {
...CombinedDefaultTheme.colors,
primary: appSettings.lightColor,
},
};
let theme = systemTheme === "dark" ? darkTheme : lightTheme;
if (appSettings.theme === "dark") theme = darkTheme;
else if (appSettings.theme === "light") theme = lightTheme;
return theme;
}, [systemTheme, appSettings]);
return (
<PaperProvider
theme={paperTheme}
settings={{icon: props => <MaterialIcon {...props} />}}>
settings={{ icon: (props) => <MaterialIcon {...props} /> }}
>
<NavigationContainer theme={paperTheme}>
{initialized && (
{error && (
<FatalError
message={error}
setAppSettings={setAppSettings}
setError={setError}
/>
)}
{appSettings.startup !== undefined && (
<ThemeContext.Provider
value={{
theme,
setTheme,
lightColor,
setLightColor,
darkColor,
setDarkColor,
}}>
<Routes />
theme: appSettings.theme,
setTheme: (theme) => setAppSettings({ ...appSettings, theme }),
lightColor: appSettings.lightColor,
setLightColor: (color) =>
setAppSettings({ ...appSettings, lightColor: color }),
darkColor: appSettings.darkColor,
setDarkColor: (color) =>
setAppSettings({ ...appSettings, darkColor: color }),
}}
>
<AppStack startup={appSettings.startup} />
</ThemeContext.Provider>
)}
</NavigationContainer>
<Snackbar
duration={3000}
onDismiss={() => setSnackbar('')}
visible={!!snackbar}
action={action}>
{snackbar}
</Snackbar>
<AppSnack textColor={paperTheme.colors.background} />
</PaperProvider>
)
}
);
};
export default App
export default App;

82
AppDrawer.tsx Normal file
View File

@ -0,0 +1,82 @@
import { createDrawerNavigator } from "@react-navigation/drawer";
import { StackScreenProps } from "@react-navigation/stack";
import { IconButton, useTheme, Banner } from "react-native-paper";
import { DrawerParams } from "./drawer-params";
import ExerciseList from "./ExerciseList";
import GraphsList from "./GraphList";
import InsightsPage from "./InsightsPage";
import PlanList from "./PlanList";
import SetList from "./SetList";
import SettingsPage from "./SettingsPage";
import WeightList from "./WeightList";
import Daily from "./Daily";
const Drawer = createDrawerNavigator<DrawerParams>();
interface AppDrawerParams {
startup: string;
}
export default function AppDrawer({
route,
}: StackScreenProps<{ startup: AppDrawerParams }>) {
const { dark } = useTheme();
return (
<Drawer.Navigator
screenOptions={{
headerTintColor: dark ? "white" : "black",
swipeEdgeWidth: 1000,
headerShown: false,
}}
initialRouteName={
(route.params.startup as keyof DrawerParams) || "History"
}
>
<Drawer.Screen
name="History"
component={SetList}
options={{ drawerIcon: () => <IconButton icon="history" /> }}
/>
<Drawer.Screen
name="Exercises"
component={ExerciseList}
options={{ drawerIcon: () => <IconButton icon="dumbbell" /> }}
/>
<Drawer.Screen
name="Daily"
component={Daily}
options={{ drawerIcon: () => <IconButton icon="calendar-outline" /> }}
/>
<Drawer.Screen
name="Plans"
component={PlanList}
options={{ drawerIcon: () => <IconButton icon="checkbox-multiple-marked-outline" /> }}
/>
<Drawer.Screen
name="Graphs"
component={GraphsList}
options={{
drawerIcon: () => <IconButton icon="chart-bell-curve-cumulative" />,
}}
/>
<Drawer.Screen
name="Weight"
component={WeightList}
options={{ drawerIcon: () => <IconButton icon="scale-bathroom" /> }}
/>
<Drawer.Screen
name="Insights"
component={InsightsPage}
options={{
drawerIcon: () => <IconButton icon="lightbulb-on-outline" />,
}}
/>
<Drawer.Screen
name="Settings"
component={SettingsPage}
options={{ drawerIcon: () => <IconButton icon="cog-outline" /> }}
/>
</Drawer.Navigator>
);
}

21
AppFab.tsx Normal file
View File

@ -0,0 +1,21 @@
import { ComponentProps } from "react";
import { FAB, useTheme } from "react-native-paper";
export default function AppFab(props: Partial<ComponentProps<typeof FAB>>) {
const { colors } = useTheme();
return (
<FAB
icon="plus"
testID="add"
color={colors.background}
style={{
position: "absolute",
right: 20,
bottom: 20,
backgroundColor: colors.primary,
}}
{...props}
/>
);
}

26
AppInput.tsx Normal file
View File

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

50
AppLineChart.tsx Normal file
View File

@ -0,0 +1,50 @@
import { useMemo } from "react";
import { useWindowDimensions } from "react-native";
import { LineChart } from "react-native-chart-kit";
import { AbstractChartConfig } from "react-native-chart-kit/dist/AbstractChart";
import { useTheme } from "react-native-paper";
interface ChartProps {
labels: string[];
data: number[];
}
export default function AppLineChart({ labels, data }: ChartProps) {
const { width } = useWindowDimensions();
const { colors } = useTheme();
const config: AbstractChartConfig = {
backgroundGradientFrom: colors.background,
backgroundGradientTo: colors.elevation.level1,
color: () => colors.primary,
};
const pruned = useMemo(() => {
if (labels.length < 3) return labels;
const newPruned = [labels[0]];
const centerIndex = Math.floor(labels.length / 2);
for (let i = 1; i < labels.length - 1; i++) {
if (i === centerIndex) newPruned[i] = labels[i];
else newPruned[i] = "";
}
newPruned.push(labels[labels.length - 1]);
return newPruned;
}, [labels]);
return (
<LineChart
height={400}
width={width - 20}
data={{
labels: pruned,
datasets: [
{
data,
},
],
}}
bezier
chartConfig={config}
/>
);
}

57
AppPieChart.tsx Normal file
View File

@ -0,0 +1,57 @@
import { useWindowDimensions } from "react-native";
import { PieChart } from "react-native-chart-kit";
import { useTheme } from "react-native-paper";
export interface Option {
value: number;
label: string;
}
export default function AppPieChart({ options }: { options: Option[] }) {
const { width } = useWindowDimensions();
const { colors } = useTheme();
const pieChartColors = [
"#FF7F50", // Coral
"#1E90FF", // Dodger Blue
"#32CD32", // Lime Green
"#BA55D3", // Medium Orchid
"#FFD700", // Gold
"#48D1CC", // Medium Turquoise
"#FF69B4", // Hot Pink
];
const data = options.map((option, index) => ({
name: option.label,
value: option.value,
color: pieChartColors[index],
legendFontColor: colors.onSurface,
legendFontSize: 15,
}));
return (
<PieChart
data={data}
paddingLeft="0"
width={width}
height={220}
chartConfig={{
backgroundColor: "#e26a00",
backgroundGradientFrom: "#fb8c00",
backgroundGradientTo: "#ffa726",
color: (opacity = 1) => `rgba(255, 255, 255, ${opacity})`,
labelColor: (opacity = 1) => `rgba(255, 255, 255, ${opacity})`,
style: {
borderRadius: 16,
},
propsForDots: {
r: "6",
strokeWidth: "2",
stroke: "#ffa726",
},
}}
accessor={"value"}
backgroundColor={"transparent"}
/>
);
}

33
AppSnack.tsx Normal file
View File

@ -0,0 +1,33 @@
import React, { useEffect, useState } from "react";
import { Snackbar } from "react-native-paper";
import { emitter } from "./emitter";
import { TOAST } from "./toast";
export default function AppSnack({ textColor }: { textColor: string }) {
const [snackbar, setSnackbar] = useState("");
useEffect(() => {
const description = emitter.addListener(
TOAST,
({ value }: { value: string }) => {
setSnackbar(value);
}
);
return description.remove;
}, []);
return (
<Snackbar
duration={3000}
onDismiss={() => setSnackbar("")}
visible={!!snackbar}
action={{
label: "Close",
onPress: () => setSnackbar(""),
textColor,
}}
>
{snackbar}
</Snackbar>
);
}

101
AppStack.tsx Normal file
View File

@ -0,0 +1,101 @@
import { createStackNavigator } from "@react-navigation/stack";
import AppDrawer from "./AppDrawer";
import EditExercise from "./EditExercise";
import EditExercises from "./EditExercises";
import EditPlan from "./EditPlan";
import EditSet from "./EditSet";
import EditSets from "./EditSets";
import EditWeight from "./EditWeight";
import GymSet from "./gym-set";
import { Plan } from "./plan";
import StartPlan from "./StartPlan";
import ViewGraph from "./ViewGraph";
import ViewSetList from "./ViewSetList";
import ViewWeightGraph from "./ViewWeightGraph";
import Weight from "./weight";
import { View, Text, StyleSheet } from "react-native";
export type StackParams = {
Drawer: {};
EditSet: {
set: Partial<GymSet>;
};
EditSets: {
ids: number[];
};
EditPlan: {
plan: Partial<Plan>;
};
StartPlan: {
plan: Plan;
first: Partial<GymSet>;
};
ViewGraph: {
name: string;
};
EditWeight: {
weight: Partial<Weight>;
};
ViewWeightGraph: {};
EditExercise: {
gymSet: Partial<GymSet>;
};
EditExercises: {
names: string[];
};
ViewSetList: {
name: string;
};
};
const Stack = createStackNavigator<StackParams>();
export default function AppStack({ startup }: { startup: string }) {
return (
<>
{__DEV__ && (
<View style={styles.debugBanner}>
<Text style={styles.debugText}>DEBUG</Text>
</View>
)}
<Stack.Navigator
screenOptions={{ headerShown: false, animationEnabled: false }}
>
<Stack.Screen
name="Drawer"
component={AppDrawer}
initialParams={{ startup }}
/>
<Stack.Screen name="EditSet" component={EditSet} />
<Stack.Screen name="EditSets" component={EditSets} />
<Stack.Screen name="EditPlan" component={EditPlan} />
<Stack.Screen name="StartPlan" component={StartPlan} />
<Stack.Screen name="ViewGraph" component={ViewGraph} />
<Stack.Screen name="EditWeight" component={EditWeight} />
<Stack.Screen name="ViewWeightGraph" component={ViewWeightGraph} />
<Stack.Screen name="EditExercise" component={EditExercise} />
<Stack.Screen name="EditExercises" component={EditExercises} />
<Stack.Screen name="ViewSetList" component={ViewSetList} />
</Stack.Navigator>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
debugBanner: {
position: 'absolute',
top: 20,
right: 100,
backgroundColor: 'red',
zIndex: 1000,
borderRadius: 5,
},
debugText: {
color: 'white',
padding: 5,
fontSize: 10,
},
});

View File

@ -1,98 +0,0 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native'
import {useCallback, useState} from 'react'
import {FlatList, Image} from 'react-native'
import {List} from 'react-native-paper'
import {BestPageParams} from './BestPage'
import {setRepo, settingsRepo} from './db'
import DrawerHeader from './DrawerHeader'
import GymSet from './gym-set'
import Page from './Page'
import Settings from './settings'
export default function BestList() {
const [bests, setBests] = useState<GymSet[]>()
const [term, setTerm] = useState('')
const navigation = useNavigation<NavigationProp<BestPageParams>>()
const [settings, setSettings] = useState<Settings>()
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({where: {}}).then(setSettings)
}, []),
)
const refresh = useCallback(async (value: string) => {
const weights = await setRepo
.createQueryBuilder()
.select()
.addSelect('MAX(weight)', 'weight')
.where('name LIKE :name', {name: `%${value}%`})
.andWhere('NOT hidden')
.groupBy('name')
.getMany()
console.log(`${BestList.name}.refresh:`, {length: weights.length})
let newBest: GymSet[] = []
for (const set of weights) {
const reps = await setRepo
.createQueryBuilder()
.select()
.addSelect('MAX(reps)', 'reps')
.where('name = :name', {name: set.name})
.andWhere('weight = :weight', {weight: set.weight})
.andWhere('NOT hidden')
.groupBy('name')
.getMany()
newBest.push(...reps)
}
setBests(newBest)
}, [])
useFocusEffect(
useCallback(() => {
refresh(term)
}, [refresh, term]),
)
const search = useCallback(
(value: string) => {
setTerm(value)
refresh(value)
},
[refresh],
)
const renderItem = ({item}: {item: GymSet}) => (
<List.Item
key={item.name}
title={item.name}
description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
onPress={() => navigation.navigate('ViewBest', {best: item})}
left={() =>
(settings.images && item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)) ||
null
}
/>
)
return (
<>
<DrawerHeader name="Best" />
<Page term={term} search={search}>
{bests?.length === 0 ? (
<List.Item
title="No exercises yet"
description="Once sets have been added, this will highlight your personal bests."
/>
) : (
<FlatList style={{flex: 1}} renderItem={renderItem} data={bests} />
)}
</Page>
</>
)
}

View File

@ -1,22 +0,0 @@
import {createStackNavigator} from '@react-navigation/stack'
import BestList from './BestList'
import GymSet from './gym-set'
import ViewBest from './ViewBest'
const Stack = createStackNavigator<BestPageParams>()
export type BestPageParams = {
BestList: {}
ViewBest: {
best: GymSet
}
}
export default function BestPage() {
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
<Stack.Screen name="BestList" component={BestList} />
<Stack.Screen name="ViewBest" component={ViewBest} />
</Stack.Navigator>
)
}

View File

@ -1,68 +0,0 @@
import {useTheme} from '@react-navigation/native'
import * as shape from 'd3-shape'
import {View} from 'react-native'
import {Grid, LineChart, XAxis, YAxis} from 'react-native-svg-charts'
import {CombinedDarkTheme, CombinedDefaultTheme} from './App'
import {MARGIN, PADDING} from './constants'
import GymSet from './gym-set'
import useDark from './use-dark'
export default function Chart({
yData,
xFormat,
xData,
yFormat,
}: {
yData: number[]
xData: GymSet[]
xFormat: (value: any, index: number) => string
yFormat: (value: any) => string
}) {
const {colors} = useTheme()
const dark = useDark()
const axesSvg = {
fontSize: 10,
fill: dark
? CombinedDarkTheme.colors.text
: CombinedDefaultTheme.colors.text,
}
const verticalContentInset = {top: 10, bottom: 10}
const xAxisHeight = 30
return (
<>
<View
style={{
height: 300,
padding: PADDING,
flexDirection: 'row',
}}>
<YAxis
data={yData}
style={{marginBottom: xAxisHeight}}
contentInset={verticalContentInset}
svg={axesSvg}
formatLabel={yFormat}
/>
<View style={{flex: 1, marginLeft: MARGIN}}>
<LineChart
style={{flex: 1}}
data={yData}
contentInset={verticalContentInset}
curve={shape.curveBasis}
svg={{
stroke: colors.primary,
}}>
<Grid />
</LineChart>
<XAxis
data={xData}
formatLabel={xFormat}
contentInset={{left: 15, right: 16}}
svg={axesSvg}
/>
</View>
</View>
</>
)
}

View File

@ -1,4 +1,4 @@
import {Button, Dialog, Portal, Text} from 'react-native-paper'
import { Button, Dialog, Portal, Text } from "react-native-paper";
export default function ConfirmDialog({
title,
@ -8,17 +8,17 @@ export default function ConfirmDialog({
setShow,
onCancel,
}: {
title: string
children: JSX.Element | JSX.Element[] | string
onOk: () => void
show: boolean
setShow: (show: boolean) => void
onCancel?: () => void
title: string;
children: JSX.Element | JSX.Element[] | string;
onOk: () => void;
show: boolean;
setShow: (show: boolean) => void;
onCancel?: () => void;
}) {
const cancel = () => {
setShow(false)
onCancel && onCancel()
}
setShow(false);
onCancel && onCancel();
};
return (
<Portal>
@ -33,5 +33,5 @@ export default function ConfirmDialog({
</Dialog.Actions>
</Dialog>
</Portal>
)
);
}

109
Daily.tsx Normal file
View File

@ -0,0 +1,109 @@
import { useCallback, useEffect, useState } from "react";
import { FlatList, View } from "react-native";
import { Button, IconButton, List } from "react-native-paper";
import AppFab from "./AppFab";
import DrawerHeader from "./DrawerHeader";
import { LIMIT, PADDING } from "./constants";
import GymSet, { defaultSet } from "./gym-set";
import { getNow, setRepo, settingsRepo } from "./db";
import { NavigationProp, useFocusEffect, useNavigation } from "@react-navigation/native";
import { Like } from "typeorm";
import Settings from "./settings";
import { format } from "date-fns";
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import SetItem from "./SetItem";
import { StackParams } from "./AppStack";
export default function Daily() {
const [sets, setSets] = useState<GymSet[]>();
const [day, setDay] = useState<Date>()
const [settings, setSettings] = useState<Settings>();
const navigation = useNavigation<NavigationProp<StackParams>>();
const mounted = async () => {
const now = await getNow();
let created = now.split('T')[0];
setDay(new Date(created));
}
useEffect(() => {
mounted();
}, [])
const refresh = async () => {
if (!day) return;
const created = day.toISOString().split('T')[0]
const newSets = await setRepo.find({
where: { hidden: 0 as any, created: Like(`${created}%`) },
take: LIMIT,
skip: 0,
order: { created: "DESC" },
});
setSets(newSets);
settingsRepo.findOne({ where: {} }).then(setSettings)
}
useEffect(() => {
refresh();
}, [day])
useFocusEffect(useCallback(() => {
refresh();
}, [day]))
const onAdd = async () => {
const now = await getNow();
let set: Partial<GymSet> = { ...sets[0] };
if (!set) set = { ...defaultSet };
set.created = now;
delete set.id;
navigation.navigate("EditSet", { set });
}
const onRight = () => {
const newDay = new Date(day)
newDay.setDate(newDay.getDate() + 1)
setDay(newDay)
}
const onLeft = () => {
const newDay = new Date(day)
newDay.setDate(newDay.getDate() - 1)
setDay(newDay)
}
const onDate = () => {
DateTimePickerAndroid.open({
value: new Date(day),
onChange: (event, date) => {
if (event.type === 'dismissed') return;
setDay(date)
},
mode: 'date',
})
}
return (
<>
<DrawerHeader name="Daily" />
<View style={{ padding: PADDING, flexGrow: 1 }}>
<View style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center' }}>
<IconButton style={{ marginRight: 'auto' }} icon="chevron-double-left" onPress={onLeft} />
<Button onPress={onDate}>{format(day ? new Date(day) : new Date(), "PPPP")}</Button>
<IconButton style={{ marginLeft: 'auto' }} icon="chevron-double-right" onPress={onRight} />
</View>
{settings && (
<FlatList ListEmptyComponent={<List.Item title="No sets yet" />} style={{ flex: 1 }} data={sets} renderItem={({ item }) => <SetItem ids={[]} setIds={() => { }} item={item} settings={settings} />} />
)}
<AppFab onPress={onAdd} />
</View>
</>
)
}

View File

@ -1,28 +1,28 @@
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'
import useDark from './use-dark'
import { DrawerNavigationProp } from "@react-navigation/drawer";
import { useNavigation } from "@react-navigation/native";
import { Appbar, IconButton } from "react-native-paper";
import { DrawerParams } from "./drawer-params";
export default function DrawerHeader({
name,
children,
ids,
unSelect,
}: {
name: keyof DrawerParamList
children?: JSX.Element | JSX.Element[]
name: string;
children?: JSX.Element | JSX.Element[];
ids?: unknown[],
unSelect?: () => void,
}) {
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>()
const dark = useDark()
const navigation = useNavigation<DrawerNavigationProp<DrawerParams>>();
return (
<Appbar.Header>
<IconButton
color={dark ? 'white' : 'white'}
icon="menu"
onPress={navigation.openDrawer}
/>
{ids && ids.length > 0 ? (<IconButton icon="arrow-left" onPress={unSelect} />) : (
<IconButton icon="menu" onPress={navigation.openDrawer} />
)}
<Appbar.Content title={name} />
{children}
</Appbar.Header>
)
);
}

231
EditExercise.tsx Normal file
View File

@ -0,0 +1,231 @@
import {
NavigationProp,
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from "@react-navigation/native";
import { useCallback, useRef, useState } from "react";
import { ScrollView, TextInput, View } from "react-native";
import DocumentPicker from "react-native-document-picker";
import { Button, Card, IconButton, TouchableRipple } from "react-native-paper";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import ConfirmDialog from "./ConfirmDialog";
import PrimaryButton from "./PrimaryButton";
import StackHeader from "./StackHeader";
import { MARGIN, PADDING } from "./constants";
import { getNow, planRepo, setRepo, settingsRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import { fixNumeric } from "./fix-numeric";
import GymSet, { defaultSet } from "./gym-set";
import Settings from "./settings";
import { toast } from "./toast";
export default function EditExercise() {
const { params } = useRoute<RouteProp<StackParams, "EditExercise">>();
const [removeImage, setRemoveImage] = useState(false);
const [showRemoveImage, setShowRemoveImage] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [name, setName] = useState(params.gymSet.name);
const [steps, setSteps] = useState(params.gymSet.steps);
const [uri, setUri] = useState(params.gymSet.image);
const [minutes, setMinutes] = useState(
params.gymSet.minutes?.toString() ?? "3"
);
const [seconds, setSeconds] = useState(
params.gymSet.seconds?.toString() ?? "30"
);
const [sets, setSets] = useState(params.gymSet.sets?.toString() ?? "3");
const { navigate } = useNavigation<NavigationProp<DrawerParams>>();
const setsRef = useRef<TextInput>(null);
const stepsRef = useRef<TextInput>(null);
const minutesRef = useRef<TextInput>(null);
const secondsRef = useRef<TextInput>(null);
const [settings, setSettings] = useState<Settings>();
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then((gotSettings) => {
setSettings(gotSettings);
if (params.gymSet.id) return;
setSets(gotSettings.defaultSets?.toString() ?? "3");
setMinutes(gotSettings.defaultMinutes?.toString() ?? "3");
setSeconds(gotSettings.defaultSeconds?.toString() ?? "30");
});
}, [params.gymSet.id])
);
const update = async () => {
const newExercise = {
name: name || params.gymSet.name,
sets: Number(sets),
minutes: Number(minutes),
seconds: Number(seconds),
steps,
image: removeImage ? "" : uri,
} as GymSet;
await setRepo.update({ name: params.gymSet.name }, newExercise);
await planRepo.query(
`UPDATE plans
SET exercises = REPLACE(exercises, $1, $2)
WHERE exercises LIKE $3`,
[params.gymSet.name, name, `%${params.gymSet.name}%`]
);
navigate("Exercises", { update: newExercise });
};
const add = async () => {
const now = await getNow();
await setRepo.save({
...defaultSet,
name,
hidden: true,
image: uri,
minutes: minutes ? Number(minutes) : 3,
seconds: seconds ? Number(seconds) : 30,
sets: sets ? Number(sets) : 3,
steps,
created: now,
});
navigate("Exercises");
};
const remove = async () => {
await setRepo.delete({ name: params.gymSet.name });
navigate("Exercises");
};
const save = async () => {
if (params.gymSet.name) return update();
return add();
};
const changeImage = useCallback(async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: "documentDirectory",
});
if (fileCopyUri) setUri(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setUri("");
setRemoveImage(true);
setShowRemoveImage(false);
}, []);
const submitName = () => {
if (settings.steps) stepsRef.current?.focus();
else setsRef.current?.focus();
};
return (
<>
<StackHeader
title={params.gymSet.name ? "Edit exercise" : "Add exercise"}
>
{typeof params.gymSet.id === "number" ? (
<IconButton onPress={() => setShowDelete(true)} icon="delete" />
) : null}
</StackHeader>
<View style={{ padding: PADDING, flex: 1 }}>
<ScrollView style={{ flex: 1 }}>
<AppInput
autoFocus
label="Name"
value={name}
onChangeText={setName}
onSubmitEditing={submitName}
/>
<AppInput
innerRef={stepsRef}
selectTextOnFocus={false}
value={steps}
onChangeText={setSteps}
label="Steps"
multiline
onSubmitEditing={() => setsRef.current?.focus()}
/>
<AppInput
innerRef={setsRef}
value={sets}
onChangeText={(newSets) => {
const fixed = fixNumeric(newSets);
setSets(fixed);
if (fixed.length !== newSets.length)
toast("Sets must be a number");
}}
label="Sets per exercise"
keyboardType="numeric"
onSubmitEditing={() => minutesRef.current?.focus()}
/>
{settings?.alarm && (
<>
<AppInput
innerRef={minutesRef}
onSubmitEditing={() => secondsRef.current?.focus()}
value={minutes}
onChangeText={(newMinutes) => {
const fixed = fixNumeric(newMinutes);
setMinutes(fixed);
if (fixed.length !== newMinutes.length)
toast("Reps must be a number");
}}
label="Rest minutes"
keyboardType="numeric"
/>
<AppInput
innerRef={secondsRef}
value={seconds}
onChangeText={setSeconds}
label="Rest seconds"
keyboardType="numeric"
blurOnSubmit
/>
</>
)}
{settings?.images && uri && (
<TouchableRipple
style={{ marginBottom: MARGIN }}
onPress={changeImage}
onLongPress={() => setShowRemoveImage(true)}
>
<Card.Cover source={{ uri }} />
</TouchableRipple>
)}
{settings?.images && !uri && (
<Button
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="image-plus"
>
Image
</Button>
)}
</ScrollView>
<PrimaryButton disabled={!name} icon="content-save" onPress={save}>
Save
</PrimaryButton>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemoveImage}
setShow={setShowRemoveImage}
>
Are you sure you want to remove the image?
</ConfirmDialog>
<ConfirmDialog
title="Delete set"
show={showDelete}
onOk={remove}
setShow={setShowDelete}
>
<>Are you sure you want to delete {name}</>
</ConfirmDialog>
</View>
</>
);
}

203
EditExercises.tsx Normal file
View File

@ -0,0 +1,203 @@
import {
NavigationProp,
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from "@react-navigation/native";
import { useCallback, useRef, useState } from "react";
import { ScrollView, TextInput, View } from "react-native";
import DocumentPicker from "react-native-document-picker";
import { Button, Card, TouchableRipple } from "react-native-paper";
import { In } from "typeorm";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import ConfirmDialog from "./ConfirmDialog";
import { MARGIN, PADDING } from "./constants";
import { planRepo, setRepo, settingsRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import { fixNumeric } from "./fix-numeric";
import Settings from "./settings";
import StackHeader from "./StackHeader";
import { toast } from "./toast";
import PrimaryButton from "./PrimaryButton";
export default function EditExercises() {
const { params } = useRoute<RouteProp<StackParams, "EditExercises">>();
const [removeImage, setRemoveImage] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const [name, setName] = useState("");
const [oldNames, setOldNames] = useState(params.names.join(", "));
const [steps, setSteps] = useState("");
const [oldSteps, setOldSteps] = useState("");
const [uri, setUri] = useState("");
const [minutes, setMinutes] = useState("");
const [oldMinutes, setOldMinutes] = useState("");
const [seconds, setSeconds] = useState("");
const [oldSeconds, setOldSeconds] = useState("");
const [sets, setSets] = useState("");
const [oldSets, setOldSets] = useState("");
const navigation = useNavigation<NavigationProp<DrawerParams>>();
const setsRef = useRef<TextInput>(null);
const stepsRef = useRef<TextInput>(null);
const minutesRef = useRef<TextInput>(null);
const secondsRef = useRef<TextInput>(null);
const [settings, setSettings] = useState<Settings>();
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
setRepo
.createQueryBuilder()
.select()
.where("name IN (:...names)", { names: params.names })
.groupBy("name")
.getMany()
.then((gymSets) => {
console.log(`${EditExercises.name}.focus:`, { gymSets });
setOldNames(gymSets.map((set) => set.name).join(", "));
setOldSteps(gymSets.map((set) => set.steps).join(", "));
setOldMinutes(gymSets.map((set) => set.minutes).join(", "));
setOldSeconds(gymSets.map((set) => set.seconds).join(", "));
setOldSets(gymSets.map((set) => set.sets).join(", "));
});
}, [params.names])
);
const update = async () => {
await setRepo.update(
{ name: In(params.names) },
{
name: name || undefined,
sets: sets ? Number(sets) : undefined,
minutes: minutes ? Number(minutes) : undefined,
seconds: seconds ? Number(seconds) : undefined,
steps: steps || undefined,
image: removeImage ? "" : uri,
}
);
for (const oldName of params.names) {
await planRepo
.createQueryBuilder()
.update()
.set({
exercises: () => `REPLACE(exercises, '${oldName}', '${name}')`,
})
.where("exercises LIKE :name", { name: `%${oldName}%` })
.execute();
}
navigation.navigate("Exercises");
};
const changeImage = useCallback(async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: "documentDirectory",
});
if (fileCopyUri) setUri(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setUri("");
setRemoveImage(true);
setShowRemove(false);
}, []);
const submitName = () => {
if (settings.steps) stepsRef.current?.focus();
else setsRef.current?.focus();
};
return (
<>
<StackHeader title={`Edit ${params.names.length} exercises`} />
<View style={{ padding: PADDING, flex: 1 }}>
<ScrollView style={{ flex: 1 }}>
<AppInput
autoFocus
label={`Names: ${oldNames}`}
value={name}
onChangeText={setName}
onSubmitEditing={submitName}
/>
<AppInput
innerRef={stepsRef}
selectTextOnFocus={false}
value={steps}
onChangeText={setSteps}
label={`Steps: ${oldSteps}`}
multiline
onSubmitEditing={() => setsRef.current?.focus()}
/>
<AppInput
innerRef={setsRef}
value={sets}
onChangeText={(newSets) => {
const fixed = fixNumeric(newSets);
setSets(fixed);
if (fixed.length !== newSets.length)
toast("Sets must be a number");
}}
label={`Sets: ${oldSets}`}
keyboardType="numeric"
onSubmitEditing={() => minutesRef.current?.focus()}
/>
{settings?.alarm && (
<>
<AppInput
innerRef={minutesRef}
onSubmitEditing={() => secondsRef.current?.focus()}
value={minutes}
onChangeText={(newMinutes) => {
const fixed = fixNumeric(newMinutes);
setMinutes(fixed);
if (fixed.length !== newMinutes.length)
toast("Reps must be a number");
}}
label={`Rest minutes: ${oldMinutes}`}
keyboardType="numeric"
/>
<AppInput
innerRef={secondsRef}
value={seconds}
onChangeText={setSeconds}
label={`Rest seconds: ${oldSeconds}`}
keyboardType="numeric"
blurOnSubmit
/>
</>
)}
{settings?.images && uri && (
<TouchableRipple
style={{ marginBottom: MARGIN }}
onPress={changeImage}
onLongPress={() => setShowRemove(true)}
>
<Card.Cover source={{ uri }} />
</TouchableRipple>
)}
{settings?.images && !uri && (
<Button
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="image-plus"
>
Image
</Button>
)}
</ScrollView>
<PrimaryButton disabled={!name} icon="content-save" onPress={update}>
Save
</PrimaryButton>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemove}
setShow={setShowRemove}
>
Are you sure you want to remove the image?
</ConfirmDialog>
</View>
</>
);
}

View File

@ -3,117 +3,228 @@ import {
RouteProp,
useNavigation,
useRoute,
} from '@react-navigation/native'
import {useCallback, useEffect, useState} from 'react'
import {ScrollView, StyleSheet, View} from 'react-native'
import {Button, Text} from 'react-native-paper'
import {MARGIN, PADDING} from './constants'
import {planRepo, setRepo} from './db'
import {DrawerParamList} from './drawer-param-list'
import {PlanPageParams} from './plan-page-params'
import StackHeader from './StackHeader'
import Switch from './Switch'
import {DAYS} from './time'
} from "@react-navigation/native";
import React, { useCallback, useEffect, useState } from "react";
import {
FlatList,
Pressable,
ScrollView,
StyleSheet,
View,
} from "react-native";
import {
Button,
IconButton,
Switch as PaperSwitch,
Text,
} from "react-native-paper";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import PrimaryButton from "./PrimaryButton";
import StackHeader from "./StackHeader";
import Switch from "./Switch";
import { MARGIN, PADDING } from "./constants";
import { DAYS } from "./days";
import { planRepo, setRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import GymSet, { defaultSet } from "./gym-set";
import { toast } from "./toast";
export default function EditPlan() {
const {params} = useRoute<RouteProp<PlanPageParams, 'EditPlan'>>()
const {plan} = params
const { params } = useRoute<RouteProp<StackParams, "EditPlan">>();
const { plan } = params;
const [title, setTitle] = useState<string>(plan?.title);
const [names, setNames] = useState<string[]>();
const [days, setDays] = useState<string[]>(
plan.days ? plan.days.split(',') : [],
)
const [workouts, setWorkouts] = useState<string[]>(
plan.workouts ? plan.workouts.split(',') : [],
)
const [names, setNames] = useState<string[]>([])
const navigation = useNavigation<NavigationProp<DrawerParamList>>()
plan.days ? plan.days.split(",") : []
);
const [exercises, setExercises] = useState<string[]>(
plan.exercises ? plan.exercises.split(",") : []
);
const { navigate: drawerNavigate } =
useNavigation<NavigationProp<DrawerParams>>();
const { navigate: stackNavigate } =
useNavigation<NavigationProp<StackParams>>();
useEffect(() => {
setRepo
.createQueryBuilder()
.select('name')
.select("name")
.distinct(true)
.orderBy("name")
.getRawMany()
.then(values => {
console.log(EditPlan.name, {values})
setNames(values.map(value => value.name))
})
}, [])
.then((values) => {
const newNames = values.map((value) => value.name);
console.log(EditPlan.name, { newNames });
setNames(newNames);
});
}, []);
const save = useCallback(async () => {
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(',')
await planRepo.save({days: newDays, workouts: newWorkouts, id: plan.id})
navigation.goBack()
}, [days, workouts, plan, navigation])
console.log(`${EditPlan.name}.save`, { days, exercises, plan });
if (!days || !exercises) return;
const newExercises = exercises.filter((exercise) => exercise).join(",");
const newDays = days.filter((day) => day).join(",");
const saved = await planRepo.save({
title: title,
days: newDays,
exercises: newExercises,
id: plan.id,
});
if (saved.id === 1) toast("Tap your plan again to begin using it");
}, [title, days, exercises, plan]);
const toggleWorkout = useCallback(
const toggleExercise = useCallback(
(on: boolean, name: string) => {
if (on) {
setWorkouts([...workouts, name])
setExercises([...exercises, name]);
} else {
setWorkouts(workouts.filter(workout => workout !== name))
setExercises(exercises.filter((exercise) => exercise !== name));
}
},
[setWorkouts, workouts],
)
[setExercises, exercises]
);
const toggleDay = useCallback(
(on: boolean, day: string) => {
if (on) {
setDays([...days, day])
setDays([...days, day]);
} else {
setDays(days.filter(d => d !== day))
setDays(days.filter((d) => d !== day));
}
},
[setDays, days],
)
[setDays, days]
);
const renderDay = (day: string) => (
<Switch
key={day}
onChange={(value) => toggleDay(value, day)}
value={days.includes(day)}
title={day}
/>
);
const renderExercise = (name: string, index: number, movable: boolean) => (
<Pressable
onPress={() => toggleExercise(!exercises.includes(name), name)}
style={{ flexDirection: "row", alignItems: "center" }}
key={name}
>
<PaperSwitch
value={exercises.includes(name)}
style={{ marginRight: MARGIN }}
onValueChange={(value) => toggleExercise(value, name)}
/>
<Text>{name}</Text>
{movable && (
<>
<IconButton
icon="arrow-up"
style={{ marginLeft: "auto" }}
onPressIn={() => moveUp(index)}
/>
<IconButton icon="arrow-down" onPressIn={() => moveDown(index)} />
</>
)}
</Pressable>
);
const moveDown = (from: number) => {
if (from === exercises.length - 1) return;
const to = from + 1;
const newExercises = [...exercises];
const copy = newExercises[from];
newExercises[from] = newExercises[to];
newExercises[to] = copy;
setExercises(newExercises);
};
const moveUp = (from: number) => {
if (from === 0) return;
const to = from - 1;
const newExercises = [...exercises];
const copy = newExercises[from];
newExercises[from] = newExercises[to];
newExercises[to] = copy;
setExercises(newExercises);
};
return (
<>
<StackHeader title="Edit plan" />
<View style={{padding: PADDING, flex: 1}}>
<ScrollView style={{flex: 1}}>
<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>
))
)}
</ScrollView>
<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: Partial<GymSet> = await setRepo.findOne({
where: { name: exercises[0] },
order: { created: "desc" },
});
if (!first) first = { ...defaultSet, name: exercises[0] };
delete first.id;
stackNavigate("StartPlan", { plan: newPlan, first });
}}
icon="play"
/>
)}
</StackHeader>
<ScrollView style={{ padding: PADDING, flex: 1 }}>
<AppInput
label="Title"
value={title}
placeholder={days.join(", ")}
onChangeText={(value) => setTitle(value)}
/>
<Button
disabled={workouts.length === 0 && days.length === 0}
style={styles.button}
mode="contained"
icon="save"
onPress={save}>
Save
</Button>
</View>
<Text style={styles.title}>Days</Text>
{DAYS.map((day) => renderDay(day))}
<Text style={[styles.title, { marginTop: MARGIN }]}>Exercises</Text>
{exercises.map((exercise, index) =>
renderExercise(exercise, index, true)
)}
{names?.length === 0 && (
<>
<Text>No exercises yet.</Text>
<Button
onPress={() =>
stackNavigate("EditExercise", { gymSet: defaultSet })
}
style={{ alignSelf: "flex-start" }}
>
Add some?
</Button>
</>
)}
{names !== undefined &&
names
.filter((name) => !exercises.includes(name))
.map((name, index) => renderExercise(name, index, false))}
<View style={{ marginBottom: MARGIN }}></View>
</ScrollView>
<PrimaryButton
disabled={exercises.length === 0 && days.length === 0}
icon="content-save"
onPress={async () => {
await save();
drawerNavigate("Plans");
}}
style={{ margin: MARGIN }}
>
Save
</PrimaryButton>
</>
)
);
}
const styles = StyleSheet.create({
@ -121,5 +232,4 @@ const styles = StyleSheet.create({
fontSize: 20,
marginBottom: MARGIN,
},
button: {},
})
});

View File

@ -1,206 +1,370 @@
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import {
NavigationProp,
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native'
import {format} from 'date-fns'
import {useCallback, useRef, useState} from 'react'
import {NativeModules, TextInput, View} from 'react-native'
import DocumentPicker from 'react-native-document-picker'
import {Button, Card, TouchableRipple} from 'react-native-paper'
import ConfirmDialog from './ConfirmDialog'
import {MARGIN, PADDING} from './constants'
import {getNow, setRepo, settingsRepo} from './db'
import GymSet from './gym-set'
import {HomePageParams} from './home-page-params'
import MassiveInput from './MassiveInput'
import Settings from './settings'
import StackHeader from './StackHeader'
import {toast} from './toast'
} 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,
Menu,
TouchableRipple,
} from "react-native-paper";
import { check, PERMISSIONS, request, RESULTS } from "react-native-permissions";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import ConfirmDialog from "./ConfirmDialog";
import { MARGIN, PADDING } from "./constants";
import { convert } from "./conversions";
import { getNow, setRepo, settingsRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import { fixNumeric } from "./fix-numeric";
import GymSet from "./gym-set";
import Select from "./Select";
import Settings from "./settings";
import StackHeader from "./StackHeader";
import { toast } from "./toast";
import PrimaryButton from "./PrimaryButton";
export default function EditSet() {
const {params} = useRoute<RouteProp<HomePageParams, 'EditSet'>>()
const {set} = params
const navigation = useNavigation()
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 [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 { params } = useRoute<RouteProp<StackParams, "EditSet">>();
const { set } = params;
const { navigate } = useNavigation<NavigationProp<DrawerParams>>();
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 [showDelete, setShowDelete] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [created, setCreated] = useState<Date>(
set.created ? new Date(set.created) : new Date()
);
const [createdDirty, setCreatedDirty] = useState(false);
const [showRemoveImage, setShowRemoveImage] = useState(false);
const [removeImage, setRemoveImage] = useState(false);
const [setOptions, setSets] = useState<GymSet[]>([]);
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const [selection, setSelection] = useState({
start: 0,
end: set.reps?.toString().length,
})
});
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({where: {}}).then(setSettings)
}, []),
)
settingsRepo.findOne({ where: {} }).then(gotSettings => {
setSettings(gotSettings);
console.log(`${EditSet.name}.focus:`, { gotSettings })
});
}, [])
);
const startTimer = useCallback(
async (value: string) => {
if (!settings.alarm) return
const first = await setRepo.findOne({where: {name: value}})
if (!settings.alarm) return;
const first = await setRepo.findOne({ where: { name: value } });
const milliseconds =
(first?.minutes ?? 3) * 60 * 1000 + (first?.seconds ?? 0) * 1000
const {vibrate, sound, noSound} = settings
const args = [milliseconds, vibrate, sound, noSound]
NativeModules.AlarmModule.timer(...args)
(first?.minutes ?? 3) * 60 * 1000 + (first?.seconds ?? 0) * 1000;
console.log(`${EditSet.name}.timer:`, { milliseconds });
const canNotify = await check(PERMISSIONS.ANDROID.POST_NOTIFICATIONS);
if (canNotify === RESULTS.DENIED)
await request(PERMISSIONS.ANDROID.POST_NOTIFICATIONS);
if (milliseconds) NativeModules.AlarmModule.timer(milliseconds, `${first.name}`);
},
[settings],
)
[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 notify = (value: Partial<GymSet>) => {
if (!settings.notify) return navigate("History");
if (
value.weight > set.weight ||
(value.reps > set.reps && value.weight === set.weight)
) {
toast("Great work King! That's a new record.");
}
};
const added = async (value: GymSet) => {
console.log(`${EditSet.name}.added:`, value);
startTimer(value.name);
};
const handleSubmit = async () => {
console.log(`${EditSet.name}.handleSubmit:`, {set, uri: newImage, name})
if (!name) return
let image = newImage
if (!newImage && !removeImage)
image = await setRepo.findOne({where: {name}}).then(s => s?.image)
if (!name) return;
console.log(`${EditSet.name}.handleSubmit:`, {image})
const [{now}] = await getNow()
const saved = await setRepo.save({
let newWeight = Number(weight || 0);
let newUnit = unit;
if (settings.autoConvert && unit !== settings.autoConvert) {
newUnit = settings.autoConvert;
newWeight = convert(newWeight, unit, settings.autoConvert);
}
const newSet: Partial<GymSet> = {
id: set.id,
name,
created: set.created || now,
reps: Number(reps),
weight: Number(weight),
unit,
image,
reps: Number(reps || 0),
weight: newWeight,
unit: newUnit,
minutes: Number(set.minutes ?? 3),
seconds: Number(set.seconds ?? 30),
sets: set.sets ?? 3,
hidden: false,
})
if (typeof set.id !== 'number') added(saved)
navigation.goBack()
}
};
newSet.image = newImage;
if (!newImage && !removeImage) {
newSet.image = await setRepo
.findOne({ where: { name } })
.then((s) => s?.image);
}
if (createdDirty) newSet.created = created.toISOString();
if (typeof set.id !== "number") newSet.created = await getNow();
const saved = await setRepo.save(newSet);
notify(newSet);
if (typeof set.id !== "number") added(saved);
navigate("History");
};
const changeImage = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: 'documentDirectory',
})
if (fileCopyUri) setNewImage(fileCopyUri)
}, [])
copyTo: "documentDirectory",
});
if (fileCopyUri) setNewImage(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setNewImage('')
setRemoveImage(true)
setShowRemove(false)
}, [])
setNewImage("");
setRemoveImage(true);
setShowRemoveImage(false);
}, []);
const pickDate = useCallback(() => {
DateTimePickerAndroid.open({
value: created,
onChange: (event, date) => {
if (event.type === 'dismissed') return;
if (date === created) return;
setCreated(date);
setCreatedDirty(true);
DateTimePickerAndroid.open({
value: date,
onChange: (__, time) => setCreated(time),
mode: "time",
});
},
mode: "date",
});
}, [created]);
const remove = async () => {
await setRepo.delete(set.id);
navigate("History");
};
const openMenu = async () => {
if (setOptions.length > 0) return setShowMenu(true);
const latestSets = await setRepo
.createQueryBuilder()
.select()
.addSelect("MAX(created) as created")
.groupBy("name")
.getMany();
setSets(latestSets);
setShowMenu(true);
};
const select = (setOption: GymSet) => {
setName(setOption.name);
setReps(setOption.reps.toString());
setWeight(setOption.weight.toString());
setNewImage(setOption.image);
setUnit(setOption.unit);
setSelection({
start: 0,
end: setOption.reps.toString().length,
});
setShowMenu(false);
};
return (
<>
<StackHeader title="Edit set" />
<StackHeader title={typeof set.id === "number" ? "Edit set" : "Add set"}>
{typeof set.id === "number" ? (
<IconButton onPress={() => setShowDelete(true)} icon="delete" />
) : null}
</StackHeader>
<View style={{padding: PADDING, flex: 1}}>
<MassiveInput
label="Name"
value={name}
onChangeText={setName}
autoCorrect={false}
autoFocus={!name}
onSubmitEditing={() => repsRef.current?.focus()}
/>
<View style={{ padding: PADDING, flex: 1 }}>
<View>
<AppInput
label="Name"
value={name}
onChangeText={setName}
autoCorrect={false}
autoFocus={!name}
onSubmitEditing={() => repsRef.current?.focus()}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<Menu
visible={showMenu}
onDismiss={() => setShowMenu(false)}
anchor={<IconButton icon="menu-down" onPress={openMenu} />}
>
{setOptions.map((setOption) => (
<Menu.Item
title={setOption.name}
key={setOption.id}
onPress={() => select(setOption)}
/>
))}
</Menu>
</View>
</View>
<MassiveInput
label="Reps"
keyboardType="numeric"
value={reps}
onChangeText={setReps}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
autoFocus={!!name}
innerRef={repsRef}
/>
<View>
<AppInput
label="Reps"
keyboardType="numeric"
value={reps}
onChangeText={(newReps) => {
const fixed = fixNumeric(newReps);
setReps(fixed.replace(/-/g, ''))
if (fixed.length !== newReps.length)
toast("Reps must be a number");
else if (fixed.includes('-'))
toast("Reps must be a positive value")
}}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
innerRef={repsRef}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setReps((Number(reps) + 1).toString())}
/>
<IconButton
icon="minus"
onPress={() => setReps((Number(reps) - 1).toString())}
/>
</View>
</View>
<MassiveInput
label="Weight"
keyboardType="numeric"
value={weight}
onChangeText={setWeight}
onSubmitEditing={handleSubmit}
innerRef={weightRef}
/>
<View>
<AppInput
label="Weight"
keyboardType="numeric"
value={weight}
onChangeText={(newWeight) => {
const fixed = fixNumeric(newWeight);
setWeight(fixed);
if (fixed.length !== newWeight.length)
toast("Weight must be a number");
}}
onSubmitEditing={handleSubmit}
innerRef={weightRef}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setWeight((Number(weight) + 2.5).toString())}
/>
<IconButton
icon="minus"
onPress={() => setWeight((Number(weight) - 2.5).toString())}
/>
</View>
</View>
{settings.showUnit && (
<MassiveInput
autoCapitalize="none"
label="Unit"
<Select
value={unit}
onChangeText={setUnit}
innerRef={unitRef}
onChange={setUnit}
items={[
{ label: "kg", value: "kg" },
{ label: "lb", value: "lb" },
{ label: "stone", value: "stone" },
]}
label="Unit"
/>
)}
{typeof set.id === 'number' && settings.showDate && (
<MassiveInput
{settings.showDate && (
<AppInput
label="Created"
disabled
value={format(new Date(set.created), settings.date)}
value={format(created, settings.date || "Pp")}
onPressOut={pickDate}
/>
)}
{settings.images && newImage && (
<TouchableRipple
style={{marginBottom: MARGIN}}
style={{ marginBottom: MARGIN }}
onPress={changeImage}
onLongPress={() => setShowRemove(true)}>
<Card.Cover source={{uri: newImage}} />
onLongPress={() => setShowRemoveImage(true)}
>
<Card.Cover source={{ uri: newImage }} />
</TouchableRipple>
)}
{settings.images && !newImage && (
<Button
style={{marginBottom: MARGIN}}
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="add-photo-alternate">
icon="image-plus"
>
Image
</Button>
)}
</View>
<Button
<PrimaryButton
disabled={!name}
mode="contained"
icon="save"
style={{margin: MARGIN}}
onPress={handleSubmit}>
icon="content-save"
style={{ margin: MARGIN }}
onPress={handleSubmit}
>
Save
</Button>
</PrimaryButton>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemove}
setShow={setShowRemove}>
show={showRemoveImage}
setShow={setShowRemoveImage}
>
Are you sure you want to remove the image?
</ConfirmDialog>
<ConfirmDialog
title="Delete set"
show={showDelete}
onOk={remove}
setShow={setShowDelete}
>
<>Are you sure you want to delete {name}</>
</ConfirmDialog>
</>
)
);
}

View File

@ -1,87 +1,93 @@
import {
NavigationProp,
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, TouchableRipple} from 'react-native-paper'
import {In} from 'typeorm'
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 MassiveInput from './MassiveInput'
import Settings from './settings'
import StackHeader from './StackHeader'
} 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 { StackParams } from "./AppStack";
import ConfirmDialog from "./ConfirmDialog";
import Select from "./Select";
import StackHeader from "./StackHeader";
import { MARGIN, PADDING } from "./constants";
import { setRepo, settingsRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import GymSet from "./gym-set";
import Settings from "./settings";
import PrimaryButton from "./PrimaryButton";
import { fixNumeric } from "./fix-numeric";
import { toast } from "./toast";
export default function EditSets() {
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 { params } = useRoute<RouteProp<StackParams, "EditSets">>();
const { ids } = params;
const { navigate } = useNavigation<NavigationProp<DrawerParams>>();
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]),
)
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 save = async () => {
console.log(`${EditSets.name}.save:`, { 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);
navigate("History");
};
const changeImage = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: 'documentDirectory',
})
if (fileCopyUri) setNewImage(fileCopyUri)
}, [])
copyTo: "documentDirectory",
});
if (fileCopyUri) setNewImage(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setNewImage('')
setShowRemove(false)
}, [])
setNewImage("");
setShowRemove(false);
}, []);
return (
<>
<StackHeader title={`Edit ${ids.length} sets`} />
<View style={{padding: PADDING, flex: 1}}>
<MassiveInput
<View style={{ padding: PADDING, flex: 1 }}>
<AppInput
label={`Names: ${names}`}
value={name}
onChangeText={setName}
@ -89,66 +95,109 @@ export default function EditSets() {
autoFocus={!name}
/>
<MassiveInput
label={`Reps: ${oldReps}`}
keyboardType="numeric"
value={reps}
onChangeText={setReps}
selection={selection}
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
autoFocus={!!name}
/>
<View>
<AppInput
label={`Reps: ${oldReps}`}
keyboardType="numeric"
value={reps}
onChangeText={(newReps) => {
const fixed = fixNumeric(newReps);
setReps(fixed.replace(/-/g, ''))
if (fixed.length !== newReps.length)
toast("Reps must be a number");
else if (fixed.includes('-'))
toast("Reps must be a positive value")
}}
selection={selection}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
autoFocus={!!name}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setReps((Number(reps) + 1).toString())}
/>
<IconButton
icon="minus"
onPress={() => setReps((Number(reps) - 1).toString())}
/>
</View>
</View>
<MassiveInput
label={`Weights: ${weights}`}
keyboardType="numeric"
value={weight}
onChangeText={setWeight}
onSubmitEditing={handleSubmit}
/>
<View>
<AppInput
label={`Weights: ${weights}`}
keyboardType="numeric"
value={weight}
onChangeText={setWeight}
onSubmitEditing={save}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setWeight((Number(weight) + 2.5).toString())}
/>
<IconButton
icon="minus"
onPress={() => setWeight((Number(weight) - 2.5).toString())}
/>
</View>
</View>
{settings.showUnit && (
<MassiveInput
autoCapitalize="none"
label={`Units: ${units}`}
<Select
value={unit}
onChangeText={setUnit}
onChange={setUnit}
items={[
{ label: "", value: "" },
{ label: "kg", value: "kg" },
{ label: "lb", value: "lb" },
{ label: "stone", value: "stone" },
]}
label={`Units: ${units}`}
/>
)}
{settings.images && newImage && (
<TouchableRipple
style={{marginBottom: MARGIN}}
style={{ marginBottom: MARGIN }}
onPress={changeImage}
onLongPress={() => setShowRemove(true)}>
<Card.Cover source={{uri: newImage}} />
onLongPress={() => setShowRemove(true)}
>
<Card.Cover source={{ uri: newImage }} />
</TouchableRipple>
)}
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemove}
setShow={setShowRemove}>
setShow={setShowRemove}
>
Are you sure you want to remove the image?
</ConfirmDialog>
{settings.images && !newImage && (
<Button
style={{marginBottom: MARGIN}}
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="add-photo-alternate">
icon="image-plus"
>
Image
</Button>
)}
</View>
<Button
mode="contained"
icon="save"
style={{margin: MARGIN}}
onPress={handleSubmit}>
<PrimaryButton
icon="content-save"
style={{ margin: MARGIN }}
onPress={save}
>
Save
</Button>
</PrimaryButton>
</>
)
);
}

165
EditWeight.tsx Normal file
View File

@ -0,0 +1,165 @@
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import {
NavigationProp,
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from "@react-navigation/native";
import { format } from "date-fns";
import { useCallback, useRef, useState } from "react";
import { TextInput, View } from "react-native";
import { IconButton } from "react-native-paper";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import ConfirmDialog from "./ConfirmDialog";
import PrimaryButton from "./PrimaryButton";
import Select from "./Select";
import StackHeader from "./StackHeader";
import { MARGIN, PADDING } from "./constants";
import { AppDataSource } from "./data-source";
import { getNow, settingsRepo, weightRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import Settings from "./settings";
import { toast } from "./toast";
import Weight from "./weight";
export default function EditWeight() {
const { params } = useRoute<RouteProp<StackParams, "EditWeight">>();
const { weight } = params;
const { navigate } = useNavigation<NavigationProp<DrawerParams>>();
const { navigate: stackNavigate, goBack } = useNavigation<NavigationProp<StackParams>>();
const [settings, setSettings] = useState<Settings>({} as Settings);
const [value, setValue] = useState(weight.value?.toString());
const [unit, setUnit] = useState(weight.unit);
const [created, setCreated] = useState<Date>(
weight.created ? new Date(weight.created) : new Date()
);
const [showDelete, setShowDelete] = useState(false);
const [createdDirty, setCreatedDirty] = useState(false);
const unitRef = useRef<TextInput>(null);
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
}, [])
);
const submit = async () => {
if (!value) return;
const newWeight: Partial<Weight> = {
id: weight.id,
value: Number(value),
unit,
};
if (createdDirty) newWeight.created = created.toISOString();
else if (typeof weight.id !== "number") newWeight.created = await getNow();
await weightRepo.save(newWeight);
if (settings.notify) await checkWeekly();
goBack();
stackNavigate("ViewWeightGraph");
};
const checkWeekly = async () => {
const select = `
WITH weekly_weights AS (
SELECT
strftime('%W', created) AS week_number,
AVG(value) AS weekly_average
FROM weights
WHERE strftime('%W', created) = strftime('%W', 'now')
GROUP BY week_number
)
SELECT
((SELECT value FROM weights WHERE strftime('%W', created) = strftime('%W', 'now') ORDER BY created LIMIT 1) - weekly_weights.weekly_average) / (SELECT value FROM weights WHERE strftime('%W', created) = strftime('%W', 'now') ORDER BY created LIMIT 1) * 100 AS loss
FROM weekly_weights
WHERE week_number = strftime('%W', 'now')
`;
const result = await AppDataSource.manager.query(select);
console.log(`${EditWeight.name}.checkWeekly:`, result);
if (result.length && result[0].loss > 1)
toast("Weight loss should be <= 1% per week.");
};
const pickDate = useCallback(() => {
DateTimePickerAndroid.open({
value: created,
onChange: (_, date) => {
if (date === created) return;
setCreated(date);
setCreatedDirty(true);
},
mode: "date",
});
}, [created]);
const remove = async () => {
if (!weight.id) return;
await weightRepo.delete(weight.id);
navigate("Weight");
};
return (
<>
<StackHeader
title={typeof weight.id === "number" ? "Edit weight" : "Add weight"}
>
{typeof weight.id === "number" ? (
<IconButton onPress={() => setShowDelete(true)} icon="delete" />
) : null}
</StackHeader>
<ConfirmDialog
title="Delete weight"
show={showDelete}
onOk={remove}
setShow={setShowDelete}
>
<>Are you sure you want to delete {value}</>
</ConfirmDialog>
<View style={{ padding: PADDING, flex: 1 }}>
<AppInput
label="Weight"
value={value}
onChangeText={setValue}
keyboardType="numeric"
onSubmitEditing={submit}
autoFocus
/>
{settings.showUnit && (
<Select
value={unit}
onChange={setUnit}
items={[
{ label: "kg", value: "kg" },
{ label: "lb", value: "lb" },
{ label: "stone", value: "stone" },
]}
label="Unit"
/>
)}
{settings.showDate && (
<AppInput
label="Created"
value={format(created, settings.date || "Pp")}
onPressOut={pickDate}
/>
)}
</View>
<PrimaryButton
disabled={!value}
icon="content-save"
style={{ margin: MARGIN }}
onPress={submit}
>
Save
</PrimaryButton>
</>
);
}

View File

@ -1,189 +0,0 @@
import {
RouteProp,
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native'
import {useCallback, useRef, useState} from 'react'
import {ScrollView, TextInput, View} from 'react-native'
import DocumentPicker from 'react-native-document-picker'
import {Button, Card, TouchableRipple} from 'react-native-paper'
import ConfirmDialog from './ConfirmDialog'
import {MARGIN, PADDING} from './constants'
import {getNow, planRepo, setRepo, settingsRepo} from './db'
import {defaultSet} from './gym-set'
import MassiveInput from './MassiveInput'
import Settings from './settings'
import StackHeader from './StackHeader'
import {WorkoutsPageParams} from './WorkoutsPage'
export default function EditWorkout() {
const {params} = useRoute<RouteProp<WorkoutsPageParams, 'EditWorkout'>>()
const [removeImage, setRemoveImage] = useState(false)
const [showRemove, setShowRemove] = useState(false)
const [name, setName] = useState(params.value.name)
const [steps, setSteps] = useState(params.value.steps)
const [uri, setUri] = useState(params.value.image)
const [minutes, setMinutes] = useState(
params.value.minutes?.toString() ?? '3',
)
const [seconds, setSeconds] = useState(
params.value.seconds?.toString() ?? '30',
)
const [sets, setSets] = useState(params.value.sets?.toString() ?? '3')
const navigation = useNavigation()
const setsRef = useRef<TextInput>(null)
const stepsRef = useRef<TextInput>(null)
const minutesRef = useRef<TextInput>(null)
const secondsRef = useRef<TextInput>(null)
const [settings, setSettings] = useState<Settings>()
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({where: {}}).then(setSettings)
}, []),
)
const update = async () => {
await setRepo.update(
{name: params.value.name},
{
name: name || params.value.name,
sets: Number(sets),
minutes: +minutes,
seconds: +seconds,
steps,
image: removeImage ? '' : uri,
},
)
await planRepo.query(
`UPDATE plans
SET workouts = REPLACE(workouts, $1, $2)
WHERE workouts LIKE $3`,
[params.value.name, name, `%${params.value.name}%`],
)
navigation.goBack()
}
const add = async () => {
const [{now}] = await getNow()
await setRepo.save({
...defaultSet,
name,
hidden: true,
image: uri,
minutes: minutes ? +minutes : 3,
seconds: seconds ? +seconds : 30,
sets: sets ? +sets : 3,
steps,
created: now,
})
navigation.goBack()
}
const save = async () => {
if (params.value.name) return update()
return add()
}
const changeImage = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: 'documentDirectory',
})
if (fileCopyUri) setUri(fileCopyUri)
}, [])
const handleRemove = useCallback(async () => {
setUri('')
setRemoveImage(true)
setShowRemove(false)
}, [])
const submitName = () => {
if (settings.steps) stepsRef.current?.focus()
else setsRef.current?.focus()
}
return (
<>
<StackHeader title="Edit workout" />
<View style={{padding: PADDING, flex: 1}}>
<ScrollView style={{flex: 1}}>
<MassiveInput
autoFocus
label="Name"
value={name}
onChangeText={setName}
onSubmitEditing={submitName}
/>
{settings?.steps && (
<MassiveInput
innerRef={stepsRef}
selectTextOnFocus={false}
value={steps}
onChangeText={setSteps}
label="Steps"
multiline
onSubmitEditing={() => setsRef.current?.focus()}
/>
)}
<MassiveInput
innerRef={setsRef}
value={sets}
onChangeText={setSets}
label="Sets per workout"
keyboardType="numeric"
onSubmitEditing={() => minutesRef.current?.focus()}
/>
{settings?.alarm && (
<>
<MassiveInput
innerRef={minutesRef}
onSubmitEditing={() => secondsRef.current?.focus()}
value={minutes}
onChangeText={setMinutes}
label="Rest minutes"
keyboardType="numeric"
/>
<MassiveInput
innerRef={secondsRef}
value={seconds}
onChangeText={setSeconds}
label="Rest seconds"
keyboardType="numeric"
blurOnSubmit
/>
</>
)}
{settings?.images && uri && (
<TouchableRipple
style={{marginBottom: MARGIN}}
onPress={changeImage}
onLongPress={() => setShowRemove(true)}>
<Card.Cover source={{uri}} />
</TouchableRipple>
)}
{settings?.images && !uri && (
<Button
style={{marginBottom: MARGIN}}
onPress={changeImage}
icon="add-photo-alternate">
Image
</Button>
)}
</ScrollView>
<Button disabled={!name} mode="contained" icon="save" onPress={save}>
Save
</Button>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemove}
setShow={setShowRemove}>
Are you sure you want to remove the image?
</ConfirmDialog>
</View>
</>
)
}

69
ExerciseItem.tsx Normal file
View File

@ -0,0 +1,69 @@
import { NavigationProp, useNavigation } from "@react-navigation/native";
import { useCallback, useMemo } from "react";
import { Image } from "react-native";
import { List, useTheme } from "react-native-paper";
import { StackParams } from "./AppStack";
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants";
import GymSet from "./gym-set";
export default function ExerciseItem({
item,
setNames,
names,
images,
alarm,
}: {
item: GymSet;
images: boolean;
setNames: (value: string[]) => void;
names: string[];
alarm: boolean;
}) {
const navigation = useNavigation<NavigationProp<StackParams>>();
const { dark } = useTheme();
const description = useMemo(() => {
const seconds = item.seconds?.toString().padStart(2, "0");
const time = ` x ${item.minutes || 0}:${seconds}`;
if (alarm) return item.sets.toString() + time;
return item.sets.toString();
}, [item.sets, item.minutes, item.seconds, alarm]);
const left = useCallback(() => {
if (!images || !item.image) return null;
return (
<Image source={{ uri: item.image }} style={{ height: 75, width: 75 }} />
);
}, [item.image, images]);
const long = () => {
if (names.length > 0) return;
setNames([item.name]);
};
const backgroundColor = useMemo(() => {
if (!names.includes(item.name)) return;
if (dark) return DARK_RIPPLE;
return LIGHT_RIPPLE;
}, [dark, names, item.name]);
const press = () => {
console.log(`${ExerciseItem.name}.press:`, { names });
if (names.length === 0)
return navigation.navigate("EditExercise", { gymSet: item });
const removing = names.find((name) => name === item.name);
if (removing) setNames(names.filter((name) => name !== item.name));
else setNames([...names, item.name]);
};
return (
<List.Item
onPress={press}
title={item.name}
description={description}
onLongPress={long}
left={left}
style={{ backgroundColor }}
/>
);
}

162
ExerciseList.tsx Normal file
View File

@ -0,0 +1,162 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from "@react-navigation/native";
import { useCallback, useState } from "react";
import { FlatList } from "react-native";
import { List } from "react-native-paper";
import { In } from "typeorm";
import { StackParams } from "./AppStack";
import { LIMIT } from "./constants";
import { setRepo, settingsRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
import ExerciseItem from "./ExerciseItem";
import GymSet, { defaultSet } from "./gym-set";
import ListMenu from "./ListMenu";
import Page from "./Page";
import SetList from "./SetList";
import Settings from "./settings";
export default function ExerciseList() {
const [exercises, setExercises] = useState<GymSet[]>();
const [offset, setOffset] = useState(0);
const [term, setTerm] = useState("");
const [end, setEnd] = useState(false);
const [settings, setSettings] = useState<Settings>();
const [names, setNames] = useState<string[]>([]);
const [refreshing, setRefreshing] = useState(false);
const navigation = useNavigation<NavigationProp<StackParams>>();
const reset = async (value: string) => {
console.log(`${ExerciseList.name}.reset`, value);
const newExercises = await setRepo
.createQueryBuilder()
.select()
.where("name LIKE :name", { name: `%${value.trim()}%` })
.groupBy("name")
.orderBy("name")
.limit(LIMIT)
.getMany();
setOffset(0);
console.log(`${ExerciseList.name}.reset`, { length: newExercises.length });
setEnd(newExercises.length < LIMIT);
setExercises(newExercises);
};
useFocusEffect(
useCallback(() => {
reset(term);
settingsRepo.findOne({ where: {} }).then(setSettings);
}, [term])
);
const renderItem = useCallback(
({ item }: { item: GymSet }) => (
<ExerciseItem
images={settings?.images}
alarm={settings?.alarm}
item={item}
key={item.name}
names={names}
setNames={setNames}
/>
),
[settings?.images, names, settings?.alarm]
);
const next = async () => {
console.log(`${SetList.name}.next:`, {
offset,
limit: LIMIT,
term,
end,
});
if (end) return;
const newOffset = offset + LIMIT;
const newExercises = await setRepo
.createQueryBuilder()
.select()
.where("name LIKE :name", { name: `%${term.trim()}%` })
.groupBy("name")
.orderBy("name")
.limit(LIMIT)
.offset(newOffset)
.getMany();
if (newExercises.length === 0) return setEnd(true);
if (!exercises) return;
setExercises([...exercises, ...newExercises]);
if (newExercises.length < LIMIT) return setEnd(true);
setOffset(newOffset);
};
const onAdd = useCallback(async () => {
navigation.navigate("EditExercise", {
gymSet: defaultSet,
});
}, [navigation]);
const search = (value: string) => {
setTerm(value);
reset(value);
};
const clear = useCallback(() => {
setNames([]);
}, []);
const remove = async () => {
setNames([]);
if (names.length > 0) await setRepo.delete({ name: In(names) });
await reset(term);
};
const select = () => {
if (!exercises) return;
if (names.length === exercises.length) return setNames([]);
setNames(exercises.map((exercise) => exercise.name));
};
const edit = () => {
navigation.navigate("EditExercises", { names });
};
return (
<>
<DrawerHeader
name={names.length > 0 ? `${names.length} selected` : "Exercises"}
ids={names}
unSelect={() => setNames([])}
>
<ListMenu
onClear={clear}
onDelete={remove}
onEdit={edit}
ids={names}
onSelect={select}
/>
</DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}>
{exercises?.length === 0 ? (
<List.Item
title="No exercises yet."
description="An exercise is something you do at the gym. E.g. Deadlifts"
/>
) : (
<FlatList
data={exercises}
style={{ flex: 1 }}
renderItem={renderItem}
keyExtractor={(w) => w.name}
onEndReached={next}
refreshing={refreshing}
onRefresh={() => {
setRefreshing(true);
reset("").finally(() => setRefreshing(false));
}}
/>
)}
</Page>
</>
);
}

55
FatalError.tsx Normal file
View File

@ -0,0 +1,55 @@
import { View, useColorScheme } from "react-native";
import { useCallback } from "react";
import { Dirs, FileSystem } from "react-native-file-access";
import { Button, Text } from "react-native-paper";
import { CombinedDarkTheme, CombinedDefaultTheme } from "./App";
import { MARGIN } from "./constants";
import { AppDataSource } from "./data-source";
import { settingsRepo } from "./db";
export default function FatalError({
message,
setAppSettings,
setError,
}: {
message: string;
setAppSettings: (settings: {
startup: any;
theme: string;
lightColor: string;
darkColor: string;
}) => void;
setError: (message: string) => void;
}) {
const systemTheme = useColorScheme();
const resetDatabase = useCallback(async () => {
await FileSystem.cp("/dev/null", Dirs.DatabaseDir + "/massive.db");
await AppDataSource.initialize();
const gotSettings = await settingsRepo.findOne({ where: {} });
setAppSettings({
startup: gotSettings.startup,
theme: gotSettings.theme,
lightColor: gotSettings.lightColor || CombinedDefaultTheme.colors.primary,
darkColor: gotSettings.darkColor || CombinedDarkTheme.colors.primary,
});
setError("");
}, [setAppSettings, setError]);
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text
style={{
color: systemTheme === "dark" ? "white" : "black",
margin: MARGIN,
}}
>
Database failed to initialize: {message}
</Text>
<Button mode="contained" onPress={resetDatabase}>
Reset database
</Button>
</View>
);
}

View File

@ -1,6 +1,9 @@
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'
# Cocoapods 1.15 introduced a bug which break the build. We will remove the upper
# bound in the template on Cocoapods with next React Native release.
gem 'cocoapods', '>= 1.13', '< 1.15'
gem 'activesupport', '>= 6.1.7.5', '< 7.1.0'

109
GraphList.tsx Normal file
View File

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

View File

@ -1,18 +0,0 @@
import {createStackNavigator} from '@react-navigation/stack'
import EditSet from './EditSet'
import EditSets from './EditSets'
import {HomePageParams} from './home-page-params'
import SetList from './SetList'
const Stack = createStackNavigator<HomePageParams>()
export default function HomePage() {
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
<Stack.Screen name="Sets" component={SetList} />
<Stack.Screen name="EditSet" component={EditSet} />
<Stack.Screen name="EditSets" component={EditSets} />
</Stack.Navigator>
)
}

216
InsightsPage.tsx Normal file
View File

@ -0,0 +1,216 @@
import { useFocusEffect } from "@react-navigation/native";
import { useCallback, useMemo, useState } from "react";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { IconButton, Text } from "react-native-paper";
import AppPieChart from "./AppPieChart";
import AppLineChart from "./AppLineChart";
import ConfirmDialog from "./ConfirmDialog";
import { MARGIN, PADDING } from "./constants";
import { AppDataSource } from "./data-source";
import { DAYS } from "./days";
import DrawerHeader from "./DrawerHeader";
import { Periods } from "./periods";
import Select from "./Select";
interface WeekCount {
week: string;
count: number;
}
interface HourCount {
hour: string;
count: number;
}
export default function InsightsPage() {
const [weekCounts, setWeekCounts] = useState<WeekCount[]>();
const [hourCounts, setHourCounts] = useState<HourCount[]>();
const [loadingWeeks, setLoadingWeeks] = useState(true);
const [loadingHours, setLoadingHours] = useState(true);
const [period, setPeriod] = useState(Periods.Monthly);
const [showWeek, setShowWeek] = useState(false);
const [showHour, setShowHour] = useState(false);
useFocusEffect(
useCallback(() => {
let difference = "-1 months";
if (period === Periods.TwoMonths) difference = "-2 months";
if (period === Periods.ThreeMonths) difference = "-3 months";
if (period === Periods.SixMonths) difference = "-6 months";
const selectWeeks = `
SELECT strftime('%w', created) as week, COUNT(*) as count
FROM sets
WHERE DATE(created) >= DATE('now', 'weekday 0', '${difference}')
GROUP BY week
HAVING week IS NOT NULL
ORDER BY count DESC;
`;
const selectHours = `
SELECT strftime('%H', created) AS hour, COUNT(*) AS count
FROM sets
WHERE DATE(created) >= DATE('now', 'weekday 0', '${difference}')
GROUP BY hour
having hour is not null
ORDER BY hour
`;
setLoadingWeeks(true);
setLoadingHours(true);
setTimeout(
() =>
AppDataSource.manager
.query(selectWeeks)
.then(setWeekCounts)
.then(() => setLoadingWeeks(false))
.then(() =>
AppDataSource.manager.query(selectHours).then(setHourCounts)
)
.finally(() => {
setLoadingWeeks(false);
setLoadingHours(false);
}),
400
);
}, [period])
);
const hourLabel = (hour: string) => {
let twelveHour = Number(hour);
if (twelveHour === 0) return "12AM";
let amPm = "AM";
if (twelveHour >= 12) amPm = "PM";
if (twelveHour > 12) twelveHour -= 12;
return `${twelveHour} ${amPm}`;
};
const hourCharts = useMemo(() => {
if (loadingHours) return <ActivityIndicator />
if (hourCounts?.length === 0) return (<Text style={{ marginBottom: MARGIN }}>
No entries yet! Start recording sets to see your most active days of
the week.
</Text>)
return <AppLineChart
data={hourCounts.map((hc) => hc.count)}
labels={hourCounts.map((hc) => hourLabel(hc.hour))}
/>
}, [hourCounts, loadingHours])
const weekCharts = useMemo(() => {
if (loadingWeeks) return <ActivityIndicator />
if (weekCounts?.length === 0) return (<Text style={{ marginBottom: MARGIN }}>
No entries yet! Start recording sets to see your most active days of
the week.
</Text>)
return <AppPieChart
options={weekCounts.map((weekCount) => ({
label: DAYS[weekCount.week],
value: weekCount.count,
}))}
/>
}, [weekCounts, loadingWeeks])
return (
<>
<DrawerHeader name="Insights" />
<View
style={{
paddingLeft: PADDING,
paddingTop: PADDING,
paddingRight: PADDING,
}}
>
<Select
label="Period"
items={[
{ value: Periods.Monthly, label: Periods.Monthly },
{ value: Periods.TwoMonths, label: Periods.TwoMonths },
{ value: Periods.ThreeMonths, label: Periods.ThreeMonths },
{ value: Periods.SixMonths, label: Periods.SixMonths },
]}
value={period}
onChange={(value) => setPeriod(value as Periods)}
/>
</View>
<ScrollView
style={{
padding: PADDING,
flexGrow: 1,
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
alignContent: "center",
}}
>
<Text
variant="titleLarge"
style={{
marginBottom: MARGIN,
}}
>
Most active days of the week
</Text>
<IconButton
icon="help-circle-outline"
size={25}
style={{ padding: 0, margin: 0, paddingBottom: 10 }}
onPress={() => setShowWeek(true)}
/>
</View>
{weekCharts}
<View
style={{
flexDirection: "row",
alignItems: "center",
alignContent: "center",
}}
>
<Text
variant="titleLarge"
style={{
marginBottom: MARGIN,
}}
>
Most active hours of the day
</Text>
<IconButton
icon="help-circle-outline"
size={25}
style={{ padding: 0, margin: 0, paddingBottom: 10 }}
onPress={() => setShowHour(true)}
/>
</View>
{hourCharts}
<View style={{ marginBottom: MARGIN }} />
</ScrollView>
<ConfirmDialog
title="Most active days of the week"
show={showWeek}
setShow={setShowWeek}
onOk={() => setShowWeek(false)}
>
Are mondays your weak-spot? Find out here. This counts the # of sets you
tend to do based on the day of the week.
</ConfirmDialog>
<ConfirmDialog
title="Most active hours of the day"
show={showHour}
setShow={setShowHour}
onOk={() => setShowHour(false)}
>
If you find yourself giving up on the gym after 5pm, consider starting
earlier! Or vice-versa. This counts the # of sets you tend to do, based
on what time of day you began your workout.
</ConfirmDialog>
</>
);
}

View File

@ -1,7 +1,6 @@
import {useState} from 'react'
import {Divider, IconButton, Menu} from 'react-native-paper'
import ConfirmDialog from './ConfirmDialog'
import useDark from './use-dark'
import { useState } from "react";
import { Divider, IconButton, Menu } from "react-native-paper";
import ConfirmDialog from "./ConfirmDialog";
export default function ListMenu({
onEdit,
@ -11,91 +10,95 @@ export default function ListMenu({
onSelect,
ids,
}: {
onEdit: () => void
onCopy: () => void
onClear: () => void
onDelete: () => void
onSelect: () => void
ids?: number[]
onEdit: () => void;
onCopy?: () => void;
onClear: () => void;
onDelete: () => void;
onSelect: () => void;
ids?: unknown[];
}) {
const [showMenu, setShowMenu] = useState(false)
const [showRemove, setShowRemove] = useState(false)
const dark = useDark()
const [showMenu, setShowMenu] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const edit = () => {
setShowMenu(false)
onEdit()
}
setShowMenu(false);
onEdit();
};
const copy = () => {
setShowMenu(false)
onCopy()
}
setShowMenu(false);
onCopy();
};
const clear = () => {
setShowMenu(false)
onClear()
}
setShowMenu(false);
onClear();
};
const remove = () => {
setShowMenu(false)
setShowRemove(false)
onDelete()
}
setShowMenu(false);
setShowRemove(false);
onDelete();
};
const select = () => {
setShowMenu(false)
onSelect()
}
setShowMenu(false);
onSelect();
};
return (
<Menu
visible={showMenu}
onDismiss={() => setShowMenu(false)}
anchor={
<IconButton
color={dark ? 'white' : 'white'}
onPress={() => setShowMenu(true)}
icon="more-vert"
<>
{ids.length > 0 && (
<IconButton icon="delete" onPress={() => setShowRemove(true)} />
)}
<Menu
visible={showMenu}
onDismiss={() => setShowMenu(false)}
anchor={
<IconButton onPress={() => setShowMenu(true)} icon="dots-vertical" />
}
>
<Menu.Item leadingIcon="check-all" title="Select all" onPress={select} />
<Menu.Item
leadingIcon="close"
title="Clear"
onPress={clear}
disabled={ids?.length === 0}
/>
}>
<Menu.Item icon="done-all" title="Select all" onPress={select} />
<Menu.Item
icon="clear"
title="Clear"
onPress={clear}
disabled={ids?.length === 0}
/>
<Menu.Item
icon="edit"
title="Edit"
onPress={edit}
disabled={ids?.length === 0}
/>
<Menu.Item
icon="content-copy"
title="Copy"
onPress={copy}
disabled={ids?.length === 0}
/>
<Divider />
<Menu.Item
icon="delete"
onPress={() => setShowRemove(true)}
title="Delete"
/>
<Menu.Item
leadingIcon="pencil"
title="Edit"
onPress={edit}
disabled={ids?.length === 0}
/>
{onCopy && (
<Menu.Item
leadingIcon="content-copy"
title="Copy"
onPress={copy}
disabled={ids?.length === 0}
/>
)}
<Divider />
<Menu.Item
leadingIcon="delete"
onPress={() => setShowRemove(true)}
title="Delete"
/>
</Menu>
<ConfirmDialog
title={ids?.length === 0 ? 'Delete all' : 'Delete selected'}
title={ids?.length === 0 ? "Delete all" : "Delete selected"}
show={showRemove}
setShow={setShowRemove}
onOk={remove}
onCancel={() => setShowMenu(false)}>
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?</>
<>This will delete {ids.length} {ids?.length > 1 ? "records" : "record"}. Are you sure?</>
)}
</ConfirmDialog>
</Menu>
)
</>
);
}

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import {StyleProp, StyleSheet, View, ViewStyle} 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,
@ -10,11 +10,11 @@ export default function Page({
search,
style,
}: {
children: JSX.Element | JSX.Element[]
onAdd?: () => void
term: string
search: (value: string) => void
style?: StyleProp<ViewStyle>
children: JSX.Element | JSX.Element[];
onAdd?: () => void;
term: string;
search: (value: string) => void;
style?: StyleProp<ViewStyle>;
}) {
return (
<View style={[styles.view, style]}>
@ -22,13 +22,13 @@ export default function Page({
placeholder="Search"
value={term}
onChangeText={search}
icon="search"
clearIcon="clear"
icon="magnify"
clearIcon="close"
/>
{children}
{onAdd && <MassiveFab onPress={onAdd} />}
{onAdd && <AppFab onPress={onAdd} />}
</View>
)
);
}
const styles = StyleSheet.create({
@ -36,4 +36,4 @@ const styles = StyleSheet.create({
padding: PADDING,
flexGrow: 1,
},
})
});

View File

@ -2,83 +2,97 @@ 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 {getBestSet} from './best.service'
import {DARK_RIPPLE, LIGHT_RIPPLE} from './constants'
import {defaultSet} from './gym-set'
import {Plan} from './plan'
import {PlanPageParams} from './plan-page-params'
import {DAYS} from './time'
import useDark from './use-dark'
} from "@react-navigation/native";
import { useCallback, useMemo, useState } from "react";
import { Text } from "react-native";
import { List, useTheme } from "react-native-paper";
import { StackParams } from "./AppStack";
import { DARK_RIPPLE, LIGHT_RIPPLE } from "./constants";
import { DAYS } from "./days";
import { setRepo } from "./db";
import GymSet, { defaultSet } from "./gym-set";
import { Plan } from "./plan";
export default function PlanItem({
item,
setIds,
ids,
}: {
item: Plan
ids: number[]
setIds: (value: number[]) => void
item: Plan;
ids: number[];
setIds: (value: number[]) => void;
}) {
const [today, setToday] = useState<string>()
const dark = useDark()
const days = useMemo(() => item.days.split(','), [item.days])
const navigation = useNavigation<NavigationProp<PlanPageParams>>()
const [today, setToday] = useState<string>();
const { dark } = useTheme();
const days = useMemo(() => item.days.split(","), [item.days]);
const navigation = useNavigation<NavigationProp<StackParams>>();
useFocusEffect(
useCallback(() => {
const newToday = DAYS[new Date().getDay()]
setToday(newToday)
}, []),
)
const newToday = DAYS[new Date().getDay()];
setToday(newToday);
}, [])
);
const start = useCallback(async () => {
const workout = item.workouts.split(',')[0]
let first = await getBestSet(workout)
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 exercise = item.exercises.split(",")[0];
let first: Partial<GymSet> = await setRepo.findOne({
where: { name: exercise },
order: { created: "desc" },
});
if (!first) first = { ...defaultSet, name: exercise };
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])
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(
() =>
days.map((day, index) => (
<Text key={day}>
{day === today ? (
<Text style={{fontWeight: 'bold', textDecorationLine: 'underline'}}>
{day}
</Text>
) : (
day
)}
{index === days.length - 1 ? '' : ', '}
</Text>
)),
[days, today],
)
item.title ? (
<Text style={{ fontWeight: "bold" }}>{item.title}</Text>
) : (
currentDays
),
[item.title, currentDays]
);
const description = useMemo(
() => item.workouts.replace(/,/g, ', '),
[item.workouts],
)
() => (item.title ? currentDays : item.exercises.replace(/,/g, ", ")),
[item.title, currentDays, item.exercises]
);
const backgroundColor = useMemo(() => {
if (!ids.includes(item.id)) return
if (dark) return DARK_RIPPLE
return LIGHT_RIPPLE
}, [dark, ids, item.id])
if (!ids.includes(item.id)) return;
if (dark) return DARK_RIPPLE;
return LIGHT_RIPPLE;
}, [dark, ids, item.id]);
return (
<List.Item
@ -86,7 +100,7 @@ export default function PlanItem({
title={title}
description={description}
onLongPress={longPress}
style={{backgroundColor}}
style={{ backgroundColor }}
/>
)
);
}

View File

@ -2,89 +2,102 @@ import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native'
import {useCallback, useState} from 'react'
import {FlatList} from 'react-native'
import {List} from 'react-native-paper'
import {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'
} 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 { StackParams } from "./AppStack";
import { planRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
import ListMenu from "./ListMenu";
import Page from "./Page";
import { defaultPlan, Plan } from "./plan";
import PlanItem from "./PlanItem";
export default function PlanList() {
const [term, setTerm] = useState('')
const [plans, setPlans] = useState<Plan[]>()
const [ids, setIds] = useState<number[]>([])
const navigation = useNavigation<NavigationProp<PlanPageParams>>()
const [term, setTerm] = useState("");
const [plans, setPlans] = useState<Plan[]>();
const [ids, setIds] = useState<number[]>([]);
const navigation = useNavigation<NavigationProp<StackParams>>();
const refresh = useCallback(async (value: string) => {
console.log(`${PlanList.name}.refresh:`, value);
planRepo
.find({
where: [{days: Like(`%${value}%`)}, {workouts: Like(`%${value}%`)}],
where: [
{ title: Like(`%${value.trim()}%`) },
{ days: Like(`%${value.trim()}%`) },
{ exercises: Like(`%${value.trim()}%`) },
],
})
.then(setPlans)
}, [])
.then(setPlans);
}, []);
useFocusEffect(
useCallback(() => {
refresh(term)
}, [refresh, term]),
)
refresh(term);
// eslint-disable-next-line
}, [term])
);
const search = useCallback(
(value: string) => {
setTerm(value)
refresh(value)
setTerm(value);
refresh(value);
},
[refresh],
)
[refresh]
);
const renderItem = useCallback(
({item}: {item: Plan}) => (
({ item }: { item: Plan }) => (
<PlanItem ids={ids} setIds={setIds} item={item} key={item.id} />
),
[ids],
)
[ids]
);
const onAdd = () =>
navigation.navigate('EditPlan', {plan: {days: '', workouts: ''}})
navigation.navigate("EditPlan", {
plan: defaultPlan,
});
const edit = useCallback(async () => {
const plan = await planRepo.findOne({where: {id: ids.pop()}})
navigation.navigate('EditPlan', {plan})
setIds([])
}, [ids, navigation])
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])
where: { id: ids.pop() },
});
delete plan.id;
navigation.navigate("EditPlan", { plan });
setIds([]);
}, [ids, navigation]);
const clear = useCallback(() => {
setIds([])
}, [])
setIds([]);
}, []);
const remove = useCallback(async () => {
await planRepo.delete(ids.length > 0 ? ids : {})
await refresh(term)
setIds([])
}, [ids, refresh, term])
await planRepo.delete(ids.length > 0 ? ids : {});
await refresh(term);
setIds([]);
}, [ids, refresh, term]);
const select = useCallback(() => {
setIds(plans.map(plan => plan.id))
}, [plans])
if (!plans) return;
if (ids.length === plans.length) return setIds([]);
setIds(plans.map((plan) => plan.id));
}, [plans, ids.length]);
return (
<>
<DrawerHeader name="Plans">
<DrawerHeader name={ids.length > 0 ? `${ids.length} selected` : "Plans"}
ids={ids}
unSelect={() => setIds([])}
>
<ListMenu
onClear={clear}
onCopy={copy}
@ -98,17 +111,17 @@ export default function PlanList() {
{plans?.length === 0 ? (
<List.Item
title="No plans yet"
description="A plan is a list of workouts for certain days."
description="A plan is a list of exercises for certain days."
/>
) : (
<FlatList
style={{flex: 1}}
style={{ flex: 1 }}
data={plans}
renderItem={renderItem}
keyExtractor={set => set.id?.toString() || ''}
keyExtractor={(set) => set.id?.toString() || ""}
/>
)}
</Page>
</>
)
);
}

View File

@ -1,20 +0,0 @@
import {createStackNavigator} from '@react-navigation/stack'
import EditPlan from './EditPlan'
import EditSet from './EditSet'
import {PlanPageParams} from './plan-page-params'
import PlanList from './PlanList'
import StartPlan from './StartPlan'
const Stack = createStackNavigator<PlanPageParams>()
export default function PlanPage() {
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
<Stack.Screen name="PlanList" component={PlanList} />
<Stack.Screen name="EditPlan" component={EditPlan} />
<Stack.Screen name="StartPlan" component={StartPlan} />
<Stack.Screen name="EditSet" component={EditSet} />
</Stack.Navigator>
)
}

14
PrimaryButton.tsx Normal file
View File

@ -0,0 +1,14 @@
import { ComponentProps } from "react";
import { Button, useTheme } from "react-native-paper";
type PrimaryButtonProps = Omit<Partial<ComponentProps<typeof Button>>, "mode">;
export default function PrimaryButton(props: PrimaryButtonProps) {
const { colors } = useTheme();
return (
<Button mode="contained" textColor={colors.background} {...props}>
{props.children}
</Button>
);
}

View File

@ -23,13 +23,14 @@ Massive tracks your reps and sets at the gym. No internet connectivity or high s
<img src="metadata/en-US/images/phoneScreenshots/home.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/edit.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/timer.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/plans.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/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"/>
<img src="metadata/en-US/images/phoneScreenshots/exercises.png" width="318"/>
<img src="metadata/en-US/images/phoneScreenshots/exercise-edit.png" width="318"/>
# Building from Source
@ -40,43 +41,29 @@ cd android
./gradlew assembleRelease
```
The apk file can be found at `android/app/build/outputs/apk/release/app-*-release.apk`
The APKs are separated by architecture, for example we have:
- `app-arm64-v8a-release.apk`
- `app-armeabi-v7a-release.apk`
- `app-x86_64-release.apk`
- `app-x86-release.apk`
Your phone is probably `app-arm64-v8a-release.apk`.
The apk file can be found at `android/app/build/outputs/apk/release/app-release.apk`
# Running in Development
First ensure Node.js dependencies are installed:
```
yarn install
npm install
```
Then start the metro server:
```
yarn start
npm start
```
Then (in a separate terminal) run the `android` script:
```
yarn android
npm run android
```
# Fdroid Metadata
You can find the metadata yaml file in the fdroiddata repository:
You can find the metadata yaml file in the fdroiddata repository:
https://gitlab.com/fdroid/fdroiddata/-/blob/master/metadata/com.massive.yml
# Relevant Documentation
- Android https://developer.android.com/docs
- TypeScript https://www.typescriptlang.org/docs/
- JavaScript https://developer.mozilla.org/en-US/docs/Web/JavaScript
- SQLite https://sqlite.org/docs.html

Binary file not shown.

View File

@ -1,57 +0,0 @@
import {createDrawerNavigator} from '@react-navigation/drawer'
import {useMemo} from 'react'
import {Platform} from 'react-native'
import {IconButton} from 'react-native-paper'
import BestPage from './BestPage'
import {DrawerParamList} from './drawer-param-list'
import HomePage from './HomePage'
import PlanPage from './PlanPage'
import Route from './route'
import SettingsPage from './SettingsPage'
import TimerPage from './TimerPage'
import useDark from './use-dark'
import WorkoutsPage from './WorkoutsPage'
const Drawer = createDrawerNavigator<DrawerParamList>()
export default function Routes() {
const dark = useDark()
const routes: Route[] = useMemo(
() => [
{name: 'Home', component: HomePage, icon: 'home'},
{name: 'Plans', component: PlanPage, icon: 'event'},
{name: 'Best', component: BestPage, icon: 'insights'},
{name: 'Workouts', component: WorkoutsPage, icon: 'fitness-center'},
{name: 'Timer', component: TimerPage, icon: 'access-time'},
{name: 'Settings', component: SettingsPage, icon: 'settings'},
],
[],
)
return (
<Drawer.Navigator
screenOptions={{
headerTintColor: dark ? 'white' : 'black',
swipeEdgeWidth: 1000,
headerShown: false,
}}>
{}
{routes
.filter(route => {
if (Platform.OS === 'ios' && route.name === 'Timer') return false
return true
})
.map(route => (
<Drawer.Screen
key={route.name}
name={route.name}
component={route.component}
options={{
drawerIcon: () => <IconButton icon={route.icon} />,
}}
/>
))}
</Drawer.Navigator>
)
}

View File

@ -1,70 +1,75 @@
import {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {Button, Menu, Subheading, useTheme} from 'react-native-paper'
import {ITEM_PADDING} from './constants'
import React, { useCallback, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
import { IconButton, Menu, useTheme } from "react-native-paper";
import AppInput from "./AppInput";
export interface Item {
value: string
label: string
color?: string
value: string;
label: string;
color?: string;
icon?: string;
}
export default function Select({
function Select({
value,
onChange,
items,
label,
}: {
value: string
onChange: (value: string) => void
items: Item[]
label?: string
value: string;
onChange: (value: string) => void;
items: Item[];
label?: string;
}) {
const [show, setShow] = useState(false)
const {colors} = useTheme()
const [show, setShow] = useState(false);
const { colors } = useTheme();
let menuButton: React.Ref<View> = null;
const selected = useMemo(
() => items.find(item => item.value === value) || items[0],
[items, value],
)
() => items.find((item) => item.value === value) || items[0],
[items, value]
);
const handlePress = useCallback(
const press = useCallback(
(newValue: string) => {
onChange(newValue)
setShow(false)
onChange(newValue);
setShow(false);
},
[onChange],
)
[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
key={item.value}
titleStyle={{color: item.color || colors.text}}
title={item.label}
onPress={() => handlePress(item.value)}
/>
))}
</Menu>
</View>
)
<Menu
visible={show}
onDismiss={() => setShow(false)}
anchor={
<View>
<Pressable onPress={() => setShow(true)}>
<AppInput label={label} value={selected.label} editable={false} />
</Pressable>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
ref={menuButton}
icon="menu-down"
onPress={() => setShow(true)}
/>
</View>
</View>
}
>
{items.map((item) => (
<Menu.Item
title={item.label}
key={item.value}
onPress={() => press(item.value)}
titleStyle={{ color: item.color || colors.onSurface }}
leadingIcon={item.icon}
/>
))}
</Menu>
);
}
export default React.memo(Select);

View File

@ -1,75 +1,91 @@
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'
import { NavigationProp, useNavigation } from "@react-navigation/native";
import { format } from "date-fns";
import React, { useCallback, useMemo } from "react";
import { Image } from "react-native";
import { List, Text, useTheme } from "react-native-paper";
import { StackParams } from "./AppStack";
import {
DARK_RIPPLE,
DARK_SUBDUED,
LIGHT_RIPPLE,
LIGHT_SUBDUED,
} from "./constants";
import GymSet from "./gym-set";
import Settings from "./settings";
export default function SetItem({
item,
settings,
ids,
setIds,
}: {
item: GymSet
onRemove: () => void
settings: Settings
ids: number[]
setIds: (value: number[]) => void
}) {
const dark = useDark()
const navigation = useNavigation<NavigationProp<HomePageParams>>()
const SetItem = React.memo(
({
item,
settings,
ids,
setIds,
disablePress,
customBg,
}: {
item: GymSet;
settings: Settings;
ids: number[];
setIds: (value: number[]) => void;
disablePress?: boolean;
customBg?: string;
}) => {
const { dark } = useTheme();
const navigation = useNavigation<NavigationProp<StackParams>>();
const longPress = useCallback(() => {
if (ids.length > 0) return
setIds([item.id])
}, [ids.length, item.id, setIds])
const longPress = useCallback(() => {
if (ids.length > 0) return;
setIds([item.id]);
}, [ids.length, item.id, setIds]);
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 press = useCallback(() => {
if (disablePress) return;
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, disablePress]);
const backgroundColor = useMemo(() => {
if (!ids.includes(item.id)) return
if (dark) return DARK_RIPPLE
return LIGHT_RIPPLE
}, [dark, ids, item.id])
const backgroundColor = useMemo(() => {
if (!ids.includes(item.id)) return;
if (dark) return DARK_RIPPLE;
return LIGHT_RIPPLE;
}, [dark, ids, item.id]);
return (
<>
const image = useCallback(() => {
if (!settings.images || !item.image) return null;
return (
<Image source={{ uri: item.image }} style={{ height: 75, width: 75 }} />
);
}, [item.image, settings.images]);
return (
<List.Item
onPress={press}
title={item.name}
description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
onLongPress={longPress}
style={{backgroundColor}}
left={() =>
settings.images &&
item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)
description={
settings.showDate ? (
<Text style={{ color: dark ? DARK_SUBDUED : LIGHT_SUBDUED }}>
{format(new Date(item.created), settings.date || "Pp")}
</Text>
) : null
}
onLongPress={longPress}
style={{ backgroundColor: customBg || backgroundColor }}
left={image}
right={() => (
<>
{settings.showDate && (
<Text
style={{
alignSelf: 'center',
color: dark ? '#909090ff' : '#717171ff',
}}>
{format(new Date(item.created), settings.date || 'P')}
</Text>
)}
</>
<Text
style={{
alignSelf: "center",
color: dark ? DARK_SUBDUED : LIGHT_SUBDUED,
}}
>
{`${item.reps} x ${item.weight}${item.unit || "kg"}`}
</Text>
)}
/>
</>
)
}
);
}
);
export default SetItem;

View File

@ -2,135 +2,171 @@ import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native'
import {useCallback, useState} from 'react'
import {FlatList} from 'react-native'
import {List} from 'react-native-paper'
import {Like} from 'typeorm'
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'
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 { StackParams } from "./AppStack";
import DrawerHeader from "./DrawerHeader";
import ListMenu from "./ListMenu";
import Page from "./Page";
import SetItem from "./SetItem";
import { LIMIT } from "./constants";
import { getNow, setRepo, settingsRepo } from "./db";
import GymSet, { defaultSet } from "./gym-set";
import Settings from "./settings";
export default function SetList() {
const [sets, setSets] = useState<GymSet[]>([])
const [offset, setOffset] = useState(0)
const [term, setTerm] = useState('')
const [end, setEnd] = useState(false)
const [settings, setSettings] = useState<Settings>()
const [ids, setIds] = useState<number[]>([])
const navigation = useNavigation<NavigationProp<HomePageParams>>()
const [refreshing, setRefreshing] = useState(false);
const [sets, setSets] = useState<GymSet[]>();
const [offset, setOffset] = useState(0);
const [end, setEnd] = useState(false);
const [settings, setSettings] = useState<Settings>();
const [ids, setIds] = useState<number[]>([]);
const navigation = useNavigation<NavigationProp<StackParams>>();
const [term, setTerm] = useState("");
const refresh = useCallback(async (value: string) => {
const newSets = await setRepo.find({
where: {name: Like(`%${value}%`), hidden: 0 as any},
take: limit,
skip: 0,
order: {created: 'DESC'},
})
console.log(`${SetList.name}.refresh:`, {
value,
limit,
length: newSets.length,
})
setSets(newSets)
setOffset(0)
setEnd(false)
}, [])
const reset = 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" },
});
setSets(newSets);
console.log(`${SetList.name}.reset:`, { value, offset });
setEnd(false);
},
[offset]
);
useFocusEffect(
useCallback(() => {
refresh(term)
settingsRepo.findOne({where: {}}).then(setSettings)
}, [refresh, term]),
)
console.log(`${SetList.name}.focus:`, { term });
settingsRepo.findOne({ where: {} }).then(setSettings);
reset(term);
// eslint-disable-next-line
}, [term])
);
const search = (value: string) => {
console.log(`${SetList.name}.search:`, value);
setTerm(value);
setOffset(0);
reset(value);
};
const renderItem = useCallback(
({item}: {item: GymSet}) => (
({ item }: { item: GymSet }) => (
<SetItem
settings={settings}
item={item}
key={item.id}
onRemove={() => refresh(term)}
ids={ids}
setIds={setIds}
/>
),
[refresh, term, settings, ids],
)
[settings, ids]
);
const next = useCallback(async () => {
if (end) return
const newOffset = offset + limit
console.log(`${SetList.name}.next:`, {offset, newOffset, term})
const next = async () => {
console.log(`${SetList.name}.next:`, { end, refreshing });
if (end || refreshing) return;
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,
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)
setOffset(newOffset)
}, [term, end, offset, sets])
order: { created: "DESC" },
});
if (newSets.length === 0) return setEnd(true);
if (!sets) return;
const map = new Map<number, GymSet>();
for (const set of sets) map.set(set.id, set);
for (const set of newSets) map.set(set.id, set);
const unique = Array.from(map.values());
setSets(unique);
if (newSets.length < LIMIT) return setEnd(true);
setOffset(newOffset);
};
const onAdd = useCallback(async () => {
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 now = await getNow();
let set: Partial<GymSet> = { ...sets[0] };
if (!set) set = { ...defaultSet };
set.created = now;
delete set.id;
navigation.navigate("EditSet", { set });
}, [navigation, sets]);
const edit = useCallback(() => {
navigation.navigate('EditSets', {ids})
setIds([])
}, [ids, navigation])
navigation.navigate("EditSets", { ids });
setIds([]);
}, [ids, navigation]);
const copy = useCallback(async () => {
const set = await setRepo.findOne({
where: {id: ids.pop()},
})
delete set.id
delete set.created
navigation.navigate('EditSet', {set})
setIds([])
}, [ids, navigation])
where: { id: ids.pop() },
});
delete set.id;
delete set.created;
navigation.navigate("EditSet", { set });
setIds([]);
}, [ids, navigation]);
const clear = useCallback(() => {
setIds([])
}, [])
setIds([]);
}, []);
const remove = useCallback(async () => {
setIds([])
await setRepo.delete(ids.length > 0 ? ids : {})
await refresh(term)
}, [ids, refresh, term])
const remove = async () => {
setIds([]);
await setRepo.delete(ids.length > 0 ? ids : {});
return reset(term);
};
const select = useCallback(() => {
setIds(sets.map(set => set.id))
}, [sets])
if (!sets) return;
if (ids.length === sets.length) return setIds([]);
setIds(sets.map((set) => set.id));
}, [sets, ids]);
const getContent = () => {
if (!settings || sets === undefined) return null;
if (sets.length === 0)
return (
<List.Item
title="No sets yet"
description="A set is a group of repetitions. E.g. 8 reps of Squats."
/>
);
return (
<FlatList
data={sets ?? []}
style={{ flex: 1 }}
renderItem={renderItem}
onEndReached={next}
onEndReachedThreshold={0.5}
refreshing={refreshing}
keyExtractor={(set) => set.id.toString()}
onRefresh={() => {
setOffset(0);
setRefreshing(true);
reset(term).finally(() => setRefreshing(false));
}}
/>
);
};
return (
<>
<DrawerHeader name="Home">
<DrawerHeader
name={ids.length > 0 ? `${ids.length} selected` : "History"}
ids={ids}
unSelect={() => setIds([])}
>
<ListMenu
onClear={clear}
onCopy={copy}
@ -142,22 +178,8 @@ export default function SetList() {
</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."
/>
) : (
settings && (
<FlatList
data={sets}
style={{flex: 1}}
renderItem={renderItem}
onEndReached={next}
/>
)
)}
{getContent()}
</Page>
</>
)
);
}

View File

@ -1,332 +1,605 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native'
import {format} from 'date-fns'
import {useCallback, useMemo, useState} from 'react'
import {DeviceEventEmitter, NativeModules, Platform, View} from 'react-native'
import DocumentPicker from 'react-native-document-picker'
import {Dirs, FileSystem} from 'react-native-file-access'
import {Button, Subheading} from 'react-native-paper'
import ConfirmDialog from './ConfirmDialog'
import {ITEM_PADDING, 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 Switch from './Switch'
import {toast} from './toast'
import {useTheme} from './use-theme'
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 { FlatList, NativeModules } from "react-native";
import DocumentPicker from "react-native-document-picker";
import { Dirs, FileSystem } from "react-native-file-access";
import { Button } from "react-native-paper";
import AppInput from "./AppInput";
import ConfirmDialog from "./ConfirmDialog";
import { PADDING } from "./constants";
import { AppDataSource } from "./data-source";
import { setRepo, settingsRepo } from "./db";
import { DrawerParams } from "./drawer-params";
import DrawerHeader from "./DrawerHeader";
import { darkOptions, lightOptions, themeOptions } from "./options";
import Page from "./Page";
import Select from "./Select";
import Settings from "./settings";
import Switch from "./Switch";
import { toast } from "./toast";
import { useAppTheme } from "./use-theme";
const defaultFormats = ['P', 'Pp', 'ccc p', 'p']
const twelveHours = [
"dd/LL/yyyy",
"dd/LL/yyyy, p",
"ccc p",
"p",
"yyyy-MM-dd",
"yyyy-MM-dd, p",
"yyyy.MM.dd",
];
const twentyFours = [
"dd/LL/yyyy",
"dd/LL/yyyy, k:mm",
"ccc k:mm",
"k:mm",
"yyyy-MM-dd",
"yyyy-MM-dd, k:mm",
"yyyy.MM.dd",
];
interface Item {
name: string;
renderItem: (name: string) => React.JSX.Element;
}
export default function SettingsPage() {
const [ignoring, setIgnoring] = useState(false)
const [term, setTerm] = useState('')
const [vibrate, setVibrate] = useState(false)
const [alarm, setAlarm] = useState(false)
const [sound, setSound] = useState('')
const [notify, setNotify] = useState(false)
const [images, setImages] = useState(false)
const [showUnit, setShowUnit] = useState(false)
const [steps, setSteps] = useState(false)
const [date, setDate] = useState('P')
const {theme, setTheme, lightColor, setLightColor, darkColor, setDarkColor} =
useTheme()
const [showDate, setShowDate] = useState(false)
const [noSound, setNoSound] = useState(false)
const [formatOptions, setFormatOptions] = useState<string[]>(defaultFormats)
const [importing, setImporting] = useState(false)
const {reset} = useNavigation<NavigationProp<DrawerParamList>>()
const today = new Date()
const [ignoring, setIgnoring] = useState(false);
const [term, setTerm] = useState("");
const [formatOptions, setFormatOptions] = useState<string[]>(twelveHours);
const [importing, setImporting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState("");
const { reset } = useNavigation<NavigationProp<DrawerParams>>();
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({where: {}}).then(settings => {
setAlarm(settings.alarm)
setVibrate(settings.vibrate)
setSound(settings.sound)
setNotify(settings.notify)
setImages(settings.images)
setShowUnit(settings.showUnit)
setSteps(settings.steps)
setDate(settings.date)
setShowDate(settings.showDate)
setNoSound(settings.noSound)
})
if (Platform.OS !== 'android') return
NativeModules.SettingsModule.ignoringBattery(setIgnoring)
NativeModules.SettingsModule.is24().then((is24: boolean) => {
console.log(`${SettingsPage.name}.focus:`, {is24})
if (is24) setFormatOptions(['P', 'P, k:m', 'ccc k:m', 'k:m'])
else setFormatOptions(defaultFormats)
})
}, []),
)
const { watch, setValue } = useForm<Settings>({
defaultValues: () => settingsRepo.findOne({ where: {} }),
});
const settings = watch();
const changeAlarmEnabled = useCallback(
(enabled: boolean) => {
if (enabled)
DeviceEventEmitter.emit('toast', {
value: 'Timers will now run after each set',
timeout: 4000,
})
else toast('Stopped timers running after each set.')
if (enabled && !ignoring) NativeModules.SettingsModule.ignoreBattery()
setAlarm(enabled)
settingsRepo.update({}, {alarm: enabled})
},
[ignoring],
)
const {
theme,
setTheme,
lightColor,
setLightColor,
darkColor,
setDarkColor,
} = useAppTheme();
const changeVibrate = useCallback((enabled: boolean) => {
if (enabled) toast('When a timer completes, vibrate your phone.')
else toast('Stop vibrating at the end of timers.')
setVibrate(enabled)
settingsRepo.update({}, {vibrate: enabled})
}, [])
useEffect(() => {
NativeModules.SettingsModule.ignoringBattery().then(setIgnoring);
NativeModules.SettingsModule.is24().then((is24: boolean) => {
console.log(`${SettingsPage.name}.focus:`, { is24 });
if (is24) setFormatOptions(twentyFours);
else setFormatOptions(twelveHours);
});
}, []);
const changeSound = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
type: 'audio/*',
copyTo: 'documentDirectory',
})
if (!fileCopyUri) return
settingsRepo.update({}, {sound: fileCopyUri})
setSound(fileCopyUri)
toast('This song will now play after rest timers complete.')
}, [])
const changeNotify = useCallback((enabled: boolean) => {
setNotify(enabled)
settingsRepo.update({}, {notify: enabled})
if (enabled) toast('Show when a set is a new record.')
else toast('Stopped showing notifications for new records.')
}, [])
const changeImages = useCallback((enabled: boolean) => {
setImages(enabled)
settingsRepo.update({}, {images: enabled})
if (enabled) toast('Show images for sets.')
else toast('Stopped showing images for sets.')
}, [])
const changeUnit = useCallback((enabled: boolean) => {
setShowUnit(enabled)
settingsRepo.update({}, {showUnit: enabled})
if (enabled) toast('Show option to select unit for sets.')
else toast('Hid unit option for sets.')
}, [])
const changeSteps = useCallback((enabled: boolean) => {
setSteps(enabled)
settingsRepo.update({}, {steps: enabled})
if (enabled) toast('Show steps for a workout.')
else toast('Stopped showing steps for workouts.')
}, [])
const changeShowDate = useCallback((enabled: boolean) => {
setShowDate(enabled)
settingsRepo.update({}, {showDate: enabled})
if (enabled) toast('Show date for sets by default.')
else toast('Stopped showing date for sets by default.')
}, [])
const changeNoSound = useCallback((enabled: boolean) => {
setNoSound(enabled)
settingsRepo.update({}, {noSound: enabled})
if (enabled) toast('Disable sound on rest timer alarms.')
else toast('Enabled sound for rest timer alarms.')
}, [])
const switches: Input<boolean>[] = [
{name: 'Rest timers', value: alarm, onChange: changeAlarmEnabled},
{name: 'Vibrate', value: vibrate, onChange: changeVibrate},
{name: 'Disable sound', value: noSound, onChange: changeNoSound},
{name: 'Notifications', value: notify, onChange: changeNotify},
{name: 'Show images', value: images, onChange: changeImages},
{name: 'Show unit', value: showUnit, onChange: changeUnit},
{name: 'Show steps', value: steps, onChange: changeSteps},
{name: 'Show date', value: showDate, onChange: changeShowDate},
].filter(({name}) => name.toLowerCase().includes(term.toLowerCase()))
const changeTheme = useCallback(
(value: string) => {
settingsRepo.update({}, {theme: value})
setTheme(value)
},
[setTheme],
)
const changeDate = useCallback((value: string) => {
settingsRepo.update({}, {date: value})
setDate(value)
}, [])
const backupString = useMemo(() => {
if (!settings.backupDir) return null;
const split = decodeURIComponent(settings.backupDir).split(":");
return split.pop();
}, [settings.backupDir]);
const soundString = useMemo(() => {
if (!sound) return null
const split = sound.split('/')
return split.pop()
}, [sound])
if (!settings.sound) return null;
const split = settings.sound.split("/");
return split.pop();
}, [settings.sound]);
const changeDarkColor = useCallback(
(value: string) => {
setDarkColor(value)
settingsRepo.update({}, {darkColor: value})
},
[setDarkColor],
)
const changeLightColor = useCallback(
(value: string) => {
setLightColor(value)
settingsRepo.update({}, {lightColor: value})
},
[setLightColor],
)
const renderSwitch = useCallback(
(item: Input<boolean>) => (
<Switch
onPress={() => item.onChange(!item.value)}
key={item.name}
value={item.value}
onValueChange={item.onChange}>
{item.name}
</Switch>
),
[],
)
const selects: Input<string>[] = [
{name: 'Theme', value: theme, onChange: changeTheme, items: themeOptions},
{
name: 'Dark color',
value: darkColor,
onChange: changeDarkColor,
items: lightOptions,
},
{
name: 'Light color',
value: lightColor,
onChange: changeLightColor,
items: darkOptions,
},
{
name: 'Date format',
value: date,
onChange: changeDate,
items: formatOptions.map(option => ({
label: format(today, option),
value: option,
})),
},
].filter(({name}) => name.toLowerCase().includes(term.toLowerCase()))
const renderSelect = useCallback(
(item: Input<string>) => (
<Select
key={item.name}
value={item.value}
onChange={item.onChange}
label={item.name}
items={item.items}
/>
),
[],
)
const 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 result = await DocumentPicker.pickSingle()
await FileSystem.cp(result.uri, Dirs.DatabaseDir + '/massive.db')
await AppDataSource.initialize()
await setRepo.createQueryBuilder().update().set({image: null}).execute()
await settingsRepo
.createQueryBuilder()
.update()
.set({sound: null})
.execute()
reset({index: 0, routes: [{name: 'Settings'}]})
}, [reset])
setImporting(false);
await FileSystem.cp(
Dirs.DatabaseDir + "/massive.db",
Dirs.DatabaseDir + "/massive-backup.db"
);
await AppDataSource.destroy();
const file = await DocumentPicker.pickSingle();
if (!file.uri.endsWith('.db'))
return toast("File name must end with .db")
await FileSystem.cp(file.uri, Dirs.DatabaseDir + "/massive.db");
const exportDatabase = useCallback(async () => {
const path = Dirs.DatabaseDir + '/massive.db'
await FileSystem.cpExternal(path, 'massive.db', 'downloads')
toast('Database exported. Check downloads.')
}, [])
try {
await AppDataSource.initialize();
} catch (e) {
setError(e.toString());
await FileSystem.cp(
Dirs.DatabaseDir + "/massive-backup.db",
Dirs.DatabaseDir + "/massive.db"
);
await AppDataSource.initialize();
return;
}
const buttons = useMemo(
() => [
{
name: 'Alarm sound',
element: (
<View
key="alarm-sound"
style={{
flexDirection: 'row',
alignItems: 'center',
paddingLeft: ITEM_PADDING,
}}>
<Subheading style={{width: 100}}>Alarm sound</Subheading>
<Button onPress={changeSound}>{soundString || 'Default'}</Button>
</View>
),
},
{
name: 'Export database',
element: (
<Button
key="export-db"
style={{alignSelf: 'flex-start'}}
onPress={exportDatabase}>
Export database
</Button>
),
},
{
name: 'Import database',
element: (
<Button
key="import-db"
style={{alignSelf: 'flex-start'}}
onPress={() => setImporting(true)}>
Import database
</Button>
),
},
],
[changeSound, exportDatabase, soundString],
)
await setRepo.update({}, { image: null });
await settingsRepo.update({}, { sound: null, backup: false });
reset({ index: 0, routes: [{ name: "Settings" }] });
toast("Imported database successfully.")
}, [reset]);
const today = new Date();
const data: Item[] = [
{
name: "Start up page",
renderItem: (name: string) => (
<Select
label={name}
items={[
{ label: "History", value: "History", icon: 'history' },
{ label: "Exercises", value: "Exercises", icon: 'dumbbell' },
{ label: "Daily", value: "Daily", icon: 'calendar-outline' },
{ label: "Plans", value: "Plans", icon: 'checkbox-multiple-marked-outline' },
{ label: "Graphs", value: "Graphs", icon: 'chart-bell-curve-cumulative' },
{ label: "Timer", value: "Timer", icon: 'timer-outline' },
{ label: "Weight", value: "Weight", icon: 'scale-bathroom' },
{ label: "Insights", value: "Insights", icon: 'lightbulb-on-outline' },
{ label: "Settings", value: "Settings", icon: 'cog-outline' },
]}
value={settings.startup}
onChange={async (value) => {
setValue("startup", value);
await settingsRepo.update({}, { startup: value });
toast(`App will always start on ${value}`);
}}
/>
),
},
{
name: "Theme",
renderItem: (name: string) => (
<Select
label={name}
items={themeOptions}
value={theme}
onChange={async (value) => {
setValue("theme", value);
setTheme(value);
await settingsRepo.update({}, { theme: value });
if (value === "dark") toast("Theme will always be dark.");
else if (value === "light") toast("Theme will always be light.");
else if (value === "system") toast("Theme will follow system.");
}}
/>
),
},
{
name: "Date format",
renderItem: (name: string) => (
<Select
label={name}
items={formatOptions.map((option) => ({
label: format(today, option),
value: option,
}))}
value={settings.date}
onChange={async (value) => {
setValue("date", value);
await settingsRepo.update({}, { date: value });
toast("Changed date format.");
}}
/>
),
},
{
name: "Auto convert",
renderItem: (name: string) => (
<Select
label={name}
items={[
{ label: "Off", value: "", icon: 'scale-off' },
{ label: "Kilograms", value: "kg", icon: 'weight-kilogram' },
{ label: "Pounds", value: "lb", icon: 'weight-pound' },
{ label: "Stone", value: "stone", icon: 'weight' },
]}
value={settings.autoConvert}
onChange={async (value) => {
setValue("autoConvert", value);
await settingsRepo.update({}, { autoConvert: value });
if (value) toast(`Sets now automatically convert to ${value}`);
else toast("Stopped automatically converting sets.");
}}
/>
),
},
{
name: "Vibration duration (ms)",
renderItem: (name: string) => (
<AppInput
value={settings.duration?.toString() ?? "300"}
label={name}
onChangeText={(value) => setValue("duration", Number(value))}
onSubmitEditing={async (e) => {
const value = Number(e.nativeEvent.text);
setValue("duration", value);
await settingsRepo.update({}, { duration: value });
toast("Changed duration of alarm vibrations.");
}}
keyboardType="numeric"
blurOnSubmit
/>
),
},
{
name: "Default sets",
renderItem: (name: string) => (
<AppInput
value={settings.defaultSets?.toString() ?? "3"}
label={name}
onChangeText={(value) => setValue("defaultSets", Number(value))}
onSubmitEditing={async (e) => {
const value = Number(e.nativeEvent.text);
setValue("defaultSets", value);
await settingsRepo.update({}, { defaultSets: value });
toast(`New exercises now have ${value} sets by default.`);
}}
keyboardType="numeric"
blurOnSubmit
/>
),
},
{
name: "Default minutes",
renderItem: (name: string) => (
<AppInput
value={settings.defaultMinutes?.toString() ?? "3"}
label={name}
onChangeText={(value) => setValue("defaultMinutes", Number(value))}
onSubmitEditing={async (e) => {
const value = Number(e.nativeEvent.text);
setValue("defaultMinutes", value);
await settingsRepo.update({}, { defaultMinutes: value });
toast(`New exercises now wait ${value} minutes by default.`);
}}
keyboardType="numeric"
blurOnSubmit
/>
),
},
{
name: "Default seconds",
renderItem: (name: string) => (
<AppInput
value={settings.defaultSeconds?.toString() ?? "30"}
label={name}
onChangeText={(value) => setValue("defaultSeconds", Number(value))}
onSubmitEditing={async (e) => {
const value = Number(e.nativeEvent.text);
setValue("defaultSeconds", value);
await settingsRepo.update({}, { defaultSeconds: value });
toast(`New exercises now wait ${value} seconds by default.`);
}}
keyboardType="numeric"
blurOnSubmit
/>
),
},
{
name: "Dark color",
renderItem: (name: string) => (
<Select
label={name}
items={lightOptions}
value={darkColor}
onChange={async (value) => {
setValue("darkColor", value);
setDarkColor(value);
await settingsRepo.update({}, { darkColor: value });
toast("Set primary color for dark mode.");
}}
/>
),
},
{
name: "Light color",
renderItem: (name: string) => (
<Select
label={name}
items={darkOptions}
value={lightColor}
onChange={async (value) => {
setValue("lightColor", value);
setLightColor(value);
await settingsRepo.update({}, { lightColor: value });
toast("Set primary color for light mode.");
}}
/>
),
},
{
name: "Rest timers",
renderItem: (name: string) => (
<Switch
value={settings.alarm}
onChange={async (value) => {
setValue("alarm", value);
if (value && !ignoring) {
NativeModules.SettingsModule.ignoreBattery();
}
await settingsRepo.update({}, { alarm: value });
if (value) toast("Timers will now run after each set.");
else toast("Stopped timers running after each set.");
}}
title={name}
/>
),
},
{
name: "Vibrate",
renderItem: (name: string) => (
<Switch
value={settings.vibrate}
onChange={async (value) => {
setValue("vibrate", value);
await settingsRepo.update({}, { vibrate: value });
if (value) toast("Alarms will vibrate.");
else toast("Stopped alarms from vibrating.");
}}
title={name}
/>
),
},
{
name: "Sound",
renderItem: (name: string) => (
<Switch
value={!settings.noSound}
onChange={async (value) => {
setValue("noSound", !value);
await settingsRepo.update({}, { noSound: !value });
if (!value) toast("Alarms will no longer make a sound.");
else toast("Enabled sound for alarms.");
}}
title={name}
/>
),
},
{
name: "Notifications",
renderItem: (name: string) => (
<Switch
value={settings.notify}
onChange={async (value) => {
setValue("notify", value);
await settingsRepo.update({}, { notify: value });
if (value) toast("Show notifications for new records.");
else toast("Stopped notifications for new records.");
}}
title={name}
/>
),
},
{
name: "Show images",
renderItem: (name: string) => (
<Switch
value={settings.images}
onChange={async (value) => {
setValue("images", value);
await settingsRepo.update({}, { images: value });
if (value) toast("Show images for sets.");
else toast("Hid images for sets.");
}}
title={name}
/>
),
},
{
name: "Show unit",
renderItem: (name: string) => (
<Switch
value={settings.showUnit}
onChange={async (value) => {
setValue("showUnit", value);
await settingsRepo.update({}, { showUnit: value });
if (value) toast("Show option to select unit for sets.");
else toast("Hid unit option for sets.");
}}
title={name}
/>
),
},
{
name: "Show date",
renderItem: (name: string) => (
<Switch
value={settings.showDate}
onChange={async (value) => {
setValue("showDate", value);
await settingsRepo.update({}, { showDate: value });
if (value) toast("Show date for sets.");
else toast("Hid date on sets.");
}}
title={name}
/>
),
},
{
name: "Automatic backup",
renderItem: (name: string) => (
<Switch
value={settings.backup}
onChange={async (value) => {
setValue("backup", value);
await settingsRepo.update({}, { backup: value });
if (value) {
const result = await DocumentPicker.pickDirectory();
setValue("backupDir", result.uri);
await settingsRepo.update({}, { backupDir: result.uri });
console.log(`${SettingsPage.name}.backup:`, { result });
toast("Backup database daily.");
NativeModules.BackupModule.start(result.uri);
} else {
toast("Stopped backing up daily");
NativeModules.BackupModule.stop();
}
}}
title={name}
/>
),
},
{
name: `Backup directory: ${backupString || "Not set yet!"}`,
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const result = await DocumentPicker.pickDirectory();
setValue("backupDir", result.uri);
await settingsRepo.update({}, { backupDir: result.uri });
toast("Changed backup directory.");
if (!settings.backup) return;
NativeModules.BackupModule.stop();
NativeModules.BackupModule.start(result.uri);
}}
>
{name}
</Button>
),
},
{
name: `Alarm sound: ${soundString || "Default"}`,
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.audio,
copyTo: "documentDirectory",
});
if (!fileCopyUri) return;
setValue("sound", fileCopyUri);
await settingsRepo.update({}, { sound: fileCopyUri });
toast("Sound will play after rest timers.");
}}
>
{name}
</Button>
),
},
{
name: "Export database",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const result = await DocumentPicker.pickDirectory();
const error = await NativeModules.BackupModule.once(result.uri);
if (error) toast(error);
else toast("Database exported.");
}}
>
{name}
</Button>
),
},
{
name: "Export sets as CSV",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const result = await DocumentPicker.pickDirectory();
await NativeModules.BackupModule.exportSets(result.uri);
toast("Exported sets as CSV.");
}}
>
{name}
</Button>
),
},
{
name: "Export plans as CSV",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={async () => {
const result = await DocumentPicker.pickDirectory();
await NativeModules.BackupModule.exportPlans(result.uri);
toast("Exported plans as CSV.");
}}
>
{name}
</Button>
),
},
{
name: "Import database",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={() => setImporting(true)}
>
{name}
</Button>
),
},
{
name: "Delete database",
renderItem: (name: string) => (
<Button
style={{ alignSelf: "flex-start" }}
onPress={() => setDeleting(true)}
>
{name}
</Button>
),
},
];
return (
<>
<DrawerHeader name="Settings" />
<Page term={term} search={setTerm} style={{flexGrow: 0}}>
<View style={{marginTop: MARGIN}}>
{switches.map(s => renderSwitch(s))}
{selects.map(s => renderSelect(s))}
{buttons
.filter(b => b.name.includes(term.toLowerCase()))
.map(b => b.element)}
</View>
<Page term={term} search={setTerm}>
<FlatList
data={data.filter((item) =>
item.name.toLowerCase().includes(term.toLowerCase())
)}
renderItem={({ item }) => item.renderItem(item.name)}
style={{ flex: 1, paddingTop: PADDING }}
/>
</Page>
<ConfirmDialog
title="Failed to import database"
onOk={() => setError("")}
setShow={() => setError("")}
show={!!error}
>
{error}
</ConfirmDialog>
<ConfirmDialog
title="Are you sure?"
onOk={confirmImport}
setShow={setImporting}
show={importing}>
show={importing}
>
Importing a database overwrites your current data. This action cannot be
reversed!
</ConfirmDialog>
<ConfirmDialog
title="Are you sure?"
onOk={confirmDelete}
setShow={setDeleting}
show={deleting}
>
Deleting your database wipes your current data. This action cannot be
reversed!
</ConfirmDialog>
</>
)
);
}

View File

@ -1,36 +1,20 @@
import {useNavigation} from '@react-navigation/native'
import Share from 'react-native-share'
import {FileSystem} from 'react-native-file-access'
import {Appbar, IconButton} from 'react-native-paper'
import {captureScreen} from 'react-native-view-shot'
import useDark from './use-dark'
import { useNavigation } from "@react-navigation/native";
import { Appbar, IconButton } from "react-native-paper";
export default function StackHeader({title}: {title: string}) {
const navigation = useNavigation()
const dark = useDark()
export default function StackHeader({
title,
children,
}: {
title: string;
children?: JSX.Element | JSX.Element[];
}) {
const navigation = useNavigation();
return (
<Appbar.Header>
<IconButton
color={dark ? 'white' : 'white'}
icon="arrow-back"
onPress={navigation.goBack}
/>
<IconButton icon="arrow-left" onPress={navigation.goBack} />
<Appbar.Content title={title} />
<IconButton
color={dark ? 'white' : 'white'}
onPress={() =>
captureScreen().then(async uri => {
const base64 = await FileSystem.readFile(uri, 'base64')
const url = `data:image/jpeg;base64,${base64}`
Share.open({
type: 'image/jpeg',
url,
})
})
}
icon="share"
/>
{children}
</Appbar.Header>
)
);
}

View File

@ -1,149 +1,228 @@
import {RouteProp, useFocusEffect, useRoute} from '@react-navigation/native'
import {useCallback, useMemo, useRef, useState} from 'react'
import {NativeModules, TextInput, View} from 'react-native'
import {FlatList} from 'react-native-gesture-handler'
import {Button, ProgressBar} from 'react-native-paper'
import {getBestSet} from './best.service'
import {PADDING} from './constants'
import CountMany from './count-many'
import {AppDataSource} from './data-source'
import {getNow, setRepo, settingsRepo} from './db'
import GymSet from './gym-set'
import MassiveInput from './MassiveInput'
import {PlanPageParams} from './plan-page-params'
import Settings from './settings'
import StackHeader from './StackHeader'
import StartPlanItem from './StartPlanItem'
import {toast} from './toast'
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 { IconButton, ProgressBar } from "react-native-paper";
import { PERMISSIONS, RESULTS, check, request } from "react-native-permissions";
import AppInput from "./AppInput";
import { StackParams } from "./AppStack";
import PrimaryButton from "./PrimaryButton";
import Select from "./Select";
import StackHeader from "./StackHeader";
import StartPlanItem from "./StartPlanItem";
import { getBestSet } from "./best.service";
import { PADDING } from "./constants";
import { convert } from "./conversions";
import CountMany from "./count-many";
import { AppDataSource } from "./data-source";
import { getNow, setRepo, settingsRepo } from "./db";
import { fixNumeric } from "./fix-numeric";
import GymSet from "./gym-set";
import Settings from "./settings";
import { toast } from "./toast";
export default function StartPlan() {
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 { params } = useRoute<RouteProp<StackParams, "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<number>(0);
const [settings, setSettings] = useState<Settings>();
const [counts, setCounts] = useState<CountMany[]>();
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const exercises = useMemo(() => params.plan.exercises.split(","), [params]);
const navigation = useNavigation<NavigationProp<StackParams>>();
const [selection, setSelection] = useState({
start: 0,
end: 0,
})
});
const refresh = useCallback(async () => {
const questions = workouts
.map((workout, index) => `('${workout}',${index})`)
.join(',')
console.log({questions, workouts})
const questions = exercises
.map((exercise, index) => `('${exercise}',${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
SELECT exercises.name, COUNT(sets.id) as total, sets.sets
FROM (select 0 as name, 0 as sequence union values ${questions}) as exercises
LEFT JOIN sets ON sets.name = exercises.name
AND sets.created LIKE STRFTIME('%Y-%m-%d%%', 'now', 'localtime')
AND NOT sets.hidden
GROUP BY workouts.name
ORDER BY workouts.sequence
GROUP BY exercises.name
ORDER BY exercises.sequence
LIMIT -1
OFFSET 1
`
const newCounts = await AppDataSource.manager.query(select)
console.log(`${StartPlan.name}.focus:`, {newCounts})
setCounts(newCounts)
}, [workouts])
`;
const newCounts = await AppDataSource.manager.query(select);
console.log(`${StartPlan.name}.focus:`, { newCounts });
setCounts(newCounts);
}, [exercises]);
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 newBest = await getBestSet(workout.name)
if (!newBest) return
delete newBest.id
console.log(`${StartPlan.name}.next:`, {newBest})
setReps(newBest.reps.toString())
setWeight(newBest.weight.toString())
setUnit(newBest.unit)
setSelected(index);
if (!counts && !newCounts) return;
const exercise = counts ? counts[index] : newCounts[index];
console.log(`${StartPlan.name}.next:`, { exercise });
const last = await setRepo.findOne({
where: { name: exercise.name },
order: { created: "desc" },
});
if (!last) return;
delete last.id;
console.log(`${StartPlan.name}.select:`, { last });
setReps(last.reps.toString());
setWeight(last.weight.toString());
setUnit(last.unit);
},
[counts],
)
[counts]
);
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({where: {}}).then(setSettings)
refresh()
}, [refresh]),
)
settingsRepo.findOne({ where: {} }).then(setSettings);
refresh();
// eslint-disable-next-line
}, [])
);
const handleSubmit = async () => {
const [{now}] = await getNow()
const workout = counts[selected]
const best = await getBestSet(workout.name)
delete best.id
const now = await getNow();
const exercise = counts[selected];
const best = await getBestSet(exercise.name);
delete best.id;
let newWeight = Number(weight);
let newUnit = unit;
if (settings.autoConvert && unit !== settings.autoConvert) {
newUnit = settings.autoConvert;
newWeight = convert(newWeight, unit, settings.autoConvert);
}
const newSet: GymSet = {
...best,
weight: +weight,
reps: +reps,
unit,
weight: newWeight,
reps: Number(reps),
unit: newUnit,
created: now,
hidden: false,
}
await setRepo.save(newSet)
await refresh()
};
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
(Number(weight) > best.weight ||
(Number(reps) > best.reps && Number(weight) === best.weight))
) {
toast("Great work King! That's a new record.");
}
if (!settings.alarm) return;
const milliseconds =
Number(best.minutes) * 60 * 1000 + Number(best.seconds) * 1000
const {vibrate, sound, noSound} = settings
const args = [milliseconds, vibrate, sound, noSound]
NativeModules.AlarmModule.timer(...args)
}
Number(best.minutes) * 60 * 1000 + Number(best.seconds) * 1000;
const canNotify = await check(PERMISSIONS.ANDROID.POST_NOTIFICATIONS);
if (canNotify === RESULTS.DENIED)
await request(PERMISSIONS.ANDROID.POST_NOTIFICATIONS);
if (isNaN(exercise.total) ? 0 : exercise.total === best.sets - 1 && selected === exercises.length - 1)
return
NativeModules.AlarmModule.timer(milliseconds, `${exercise.name} (${exercise.total + 1}/${best.sets})`);
};
return (
<>
<StackHeader title={params.plan.days.replace(/,/g, ', ')} />
<View style={{padding: PADDING, flex: 1, flexDirection: 'column'}}>
<View style={{flex: 1}}>
<MassiveInput
label="Reps"
keyboardType="numeric"
value={reps}
onChangeText={setReps}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
innerRef={repsRef}
/>
<MassiveInput
label="Weight"
keyboardType="numeric"
value={weight}
onChangeText={setWeight}
onSubmitEditing={handleSubmit}
innerRef={weightRef}
blurOnSubmit
/>
<StackHeader
title={params.plan.title || params.plan.days.replace(/,/g, ", ")}
>
<IconButton
onPress={() => navigation.navigate("EditPlan", { plan: params.plan })}
icon="pencil"
/>
</StackHeader>
<View style={{ padding: PADDING, flex: 1, flexDirection: "column" }}>
<View style={{ flex: 1 }}>
<View>
<AppInput
label="Reps"
keyboardType="numeric"
value={reps}
onChangeText={(newReps) => {
const fixed = fixNumeric(newReps);
setReps(fixed.replace(/-/g, ''))
if (fixed.length !== newReps.length)
toast("Reps must be a number");
else if (fixed.includes('-'))
toast("Reps must be a positive value")
}}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
innerRef={repsRef}
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setReps((Number(reps) + 1).toString())}
/>
<IconButton
icon="minus"
onPress={() => setReps((Number(reps) - 1).toString())}
/>
</View>
</View>
<View>
<AppInput
label="Weight"
keyboardType="numeric"
value={weight}
onChangeText={(newWeight) => {
const fixed = fixNumeric(newWeight);
setWeight(fixed);
if (fixed.length !== newWeight.length)
toast("Weight must be a number");
}}
onSubmitEditing={handleSubmit}
innerRef={weightRef}
blurOnSubmit
/>
<View
style={{ position: "absolute", right: 0, flexDirection: "row" }}
>
<IconButton
icon="plus"
onPress={() => setWeight((Number(weight) + 2.5).toString())}
/>
<IconButton
icon="minus"
onPress={() => setWeight((Number(weight) - 2.5).toString())}
/>
</View>
</View>
{settings?.showUnit && (
<MassiveInput
autoCapitalize="none"
label="Unit"
<Select
value={unit}
onChangeText={setUnit}
innerRef={unitRef}
onChange={setUnit}
items={[
{ label: "kg", value: "kg" },
{ label: "lb", value: "lb" },
{ label: "stone", value: "stone" },
]}
label="Unit"
/>
)}
{counts && (
{counts !== undefined && (
<FlatList
data={counts}
renderItem={props => (
keyExtractor={(count) => count.name}
renderItem={(props) => (
<View>
<StartPlanItem
{...props}
@ -159,10 +238,10 @@ export default function StartPlan() {
/>
)}
</View>
<Button mode="contained" icon="save" onPress={handleSubmit}>
<PrimaryButton icon="content-save" onPress={handleSubmit}>
Save
</Button>
</PrimaryButton>
</View>
</>
)
);
}

View File

@ -1,66 +1,123 @@
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'
import { NavigationProp, useNavigation } from "@react-navigation/native";
import React, { useCallback, useState } from "react";
import {
GestureResponderEvent,
ListRenderItemInfo,
NativeModules,
View,
} from "react-native";
import { List, Menu, RadioButton, useTheme } from "react-native-paper";
import { Like } from "typeorm";
import { StackParams } from "./AppStack";
import CountMany from "./count-many";
import { getNow, setRepo } from "./db";
import { toast } from "./toast";
interface Props extends ListRenderItemInfo<CountMany> {
onSelect: (index: number) => void
selected: number
onUndo: () => void
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 { index, item, onSelect, selected, onUndo } = props;
const { colors } = useTheme();
const [anchor, setAnchor] = useState({ x: 0, y: 0 });
const [showMenu, setShowMenu] = useState(false);
const { navigate: stackNavigate } =
useNavigation<NavigationProp<StackParams>>();
const undo = useCallback(async () => {
const [{now}] = await getNow()
const created = now.split('T')[0]
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])
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)
setAnchor({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
setShowMenu(true);
},
[setShowMenu, setAnchor],
)
[setShowMenu, setAnchor]
);
const edit = async () => {
const [{now}] = await getNow()
const created = now.split('T')[0]
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})
}
order: { created: "desc" },
});
setShowMenu(false);
if (!first) return toast("Nothing to edit.");
stackNavigate("EditSet", { set: first });
}, [item.name, stackNavigate]);
const view = useCallback(() => {
setShowMenu(false);
stackNavigate("ViewSetList", { name: item.name });
}, [item.name, stackNavigate]);
const graph = useCallback(() => {
setShowMenu(false);
stackNavigate("ViewGraph", { name: item.name });
}, [item.name, stackNavigate]);
const left = useCallback(
() => (
<View style={{ alignItems: "center", justifyContent: "center" }}>
<RadioButton
onPress={() => onSelect(index)}
value={index.toString()}
status={selected === index ? "checked" : "unchecked"}
color={colors.primary}
/>
</View>
),
[index, selected, colors.primary, onSelect]
);
const right = useCallback(
() => (
<View
style={{
width: "25%",
justifyContent: "center",
}}
>
<Menu
anchor={anchor}
visible={showMenu}
onDismiss={() => setShowMenu(false)}
>
<Menu.Item leadingIcon="eye-outline" onPress={view} title="Peek" />
<Menu.Item
leadingIcon="chart-bell-curve-cumulative"
onPress={graph}
title="Graph"
/>
<Menu.Item leadingIcon="pencil" onPress={edit} title="Edit" />
<Menu.Item leadingIcon="undo" onPress={undo} title="Undo" />
</Menu>
</View>
),
[anchor, showMenu, edit, undo, view, graph]
);
return (
<List.Item
@ -70,31 +127,8 @@ export default function StartPlanItem(props: Props) {
item.sets ? `${item.total} / ${item.sets}` : item.total.toString()
}
onPress={() => onSelect(index)}
left={() => (
<View style={{alignItems: 'center', justifyContent: 'center'}}>
<RadioButton
onPress={() => onSelect(index)}
value={index.toString()}
status={selected === index ? 'checked' : 'unchecked'}
color={colors.primary}
/>
</View>
)}
right={() => (
<View
style={{
width: '25%',
justifyContent: 'center',
}}>
<Menu
anchor={anchor}
visible={showMenu}
onDismiss={() => setShowMenu(false)}>
<Menu.Item icon="edit" onPress={edit} title="Edit" />
<Menu.Item icon="undo" onPress={undo} title="Undo" />
</Menu>
</View>
)}
left={left}
right={right}
/>
)
);
}

View File

@ -1,36 +1,38 @@
import {Platform, Pressable} from 'react-native'
import {Switch as PaperSwitch, Text, useTheme} from 'react-native-paper'
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
value?: boolean;
onChange: (value: boolean) => void;
title: string;
}) {
const {colors} = useTheme()
const { colors } = useTheme();
return (
<Pressable
onPress={onPress}
onPress={() => onChange(!value)}
style={{
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
marginBottom: Platform.OS === 'ios' ? MARGIN : null,
}}>
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
marginBottom: Platform.OS === "ios" ? MARGIN : null,
}}
>
<PaperSwitch
color={colors.primary}
style={{marginRight: MARGIN}}
style={{ marginRight: MARGIN }}
value={value}
onValueChange={onValueChange}
onValueChange={onChange}
/>
<Text>{children}</Text>
<Text>{title}</Text>
</Pressable>
)
);
}
export default React.memo(Switch);

View File

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

View File

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

267
ViewGraph.tsx Normal file
View File

@ -0,0 +1,267 @@
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import { RouteProp, useFocusEffect, useRoute } from "@react-navigation/native";
import { format } from "date-fns";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Keyboard, ScrollView, View } from "react-native";
import { FileSystem } from "react-native-file-access";
import { IconButton, List } from "react-native-paper";
import Share from "react-native-share";
import { captureScreen } from "react-native-view-shot";
import AppInput from "./AppInput";
import AppLineChart from "./AppLineChart";
import { StackParams } from "./AppStack";
import Select from "./Select";
import StackHeader from "./StackHeader";
import { MARGIN, PADDING } from "./constants";
import { setRepo, settingsRepo } from "./db";
import GymSet from "./gym-set";
import { Metrics } from "./metrics";
import { Periods } from "./periods";
import Settings from "./settings";
import Volume from "./volume";
import { convert } from "./conversions";
export default function ViewGraph() {
const { params } = useRoute<RouteProp<StackParams, "ViewGraph">>();
const [weights, setWeights] = useState<GymSet[]>();
const [volumes, setVolumes] = useState<Volume[]>();
const [metric, setMetric] = useState(Metrics.OneRepMax);
const [period, setPeriod] = useState(Periods.Monthly);
const [unit, setUnit] = useState("kg");
const [start, setStart] = useState<Date | null>(null);
const [end, setEnd] = useState<Date | null>(null);
const [settings, setSettings] = useState<Settings>({} as Settings);
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
}, [])
);
useEffect(() => {
let difference = "-7 days";
if (period === Periods.Monthly) difference = "-1 months";
else if (period === Periods.Yearly) difference = "-1 years";
else if (period === Periods.TwoMonths) difference = "-2 months";
else if (period === Periods.ThreeMonths) difference = "-3 months";
else if (period === Periods.SixMonths) difference = "-6 months";
else if (period === Periods.AllTime) difference = null;
let group = "%Y-%m-%d";
if (period === Periods.Yearly) group = "%Y-%m";
const builder = setRepo
.createQueryBuilder()
.select("STRFTIME('%Y-%m-%d', created)", "created")
.addSelect("unit")
.where("name = :name", { name: params.name })
.andWhere("NOT hidden");
if (start) builder.andWhere("DATE(created) >= :start", { start });
if (end) builder.andWhere("DATE(created) <= :end", { end });
if (difference)
builder.andWhere(
"DATE(created) >= DATE('now', 'weekday 0', :difference)",
{
difference,
}
);
builder.groupBy("name").addGroupBy(`STRFTIME('${group}', created)`);
switch (metric) {
case Metrics.Best:
builder
.addSelect("ROUND(MAX(weight), 2)", "weight")
.getRawMany()
.then((newWeights) =>
newWeights.map((set) => {
let weight = convert(set.weight, set.unit, unit);
if (isNaN(weight)) weight = 0;
return ({
...set,
weight: weight
});
})
)
.then(setWeights);
break;
case Metrics.Volume:
builder
.addSelect("ROUND(SUM(weight * reps), 2)", "value")
.getRawMany()
.then((newWeights) =>
newWeights.map((set) => {
let weight = convert(set.value, set.unit, unit);
if (isNaN(weight)) weight = 0;
return ({
...set,
value: weight
});
})
)
.then(setVolumes);
break;
default:
// Brzycki formula https://en.wikipedia.org/wiki/One-repetition_maximum#Brzycki
builder
.addSelect(
"ROUND(MAX(weight / (1.0278 - 0.0278 * reps)), 2)",
"weight"
)
.getRawMany()
.then((newWeights) =>
newWeights.map((set) => {
let weight = convert(set.weight, set.unit, unit);
if (isNaN(weight)) weight = 0;
return ({
...set,
weight: weight,
});
})
)
.then((newWeights) => {
console.log(`${ViewGraph.name}.oneRepMax:`, {
weights: newWeights,
});
setWeights(newWeights);
});
}
}, [params.name, metric, period, unit, start, end]);
const weightChart = useMemo(() => {
if (weights === undefined) return null;
if (weights.length === 0) return <List.Item title="No data yet." />;
return (
<AppLineChart
data={weights.map((set) => set.weight)}
labels={weights.map((set) =>
format(new Date(set.created), "yyyy-MM-d")
)}
/>
);
}, [weights]);
const volumeChart = useMemo(() => {
if (volumes === undefined) return null;
if (volumes.length === 0) return <List.Item title="No data yet." />;
return (
<AppLineChart
data={volumes.map((volume) => volume.value)}
labels={volumes.map((volume) =>
format(new Date(volume.created), "yyyy-MM-d")
)}
/>
);
}, [volumes]);
const pickStart = useCallback(() => {
DateTimePickerAndroid.open({
value: start || new Date(),
onChange: (event, date) => {
if (event.type === "dismissed") return;
if (date === start) return;
setStart(date);
setPeriod(Periods.AllTime);
Keyboard.dismiss();
},
mode: "date",
});
}, [start]);
const pickEnd = useCallback(() => {
DateTimePickerAndroid.open({
value: end || new Date(),
onChange: (event, date) => {
if (event.type === "dismissed") return;
if (date === end) return;
setEnd(date);
setPeriod(Periods.AllTime);
Keyboard.dismiss();
},
mode: "date",
});
}, [end]);
return (
<>
<StackHeader title={params.name}>
<IconButton
onPress={() =>
captureScreen().then(async (uri) => {
const base64 = await FileSystem.readFile(uri, "base64");
const url = `data:image/jpeg;base64,${base64}`;
Share.open({
type: "image/jpeg",
url,
});
})
}
icon="share"
/>
</StackHeader>
<ScrollView style={{ padding: PADDING }}>
<Select
label="Metric"
items={[
{ value: Metrics.OneRepMax, label: Metrics.OneRepMax },
{ label: Metrics.Best, value: Metrics.Best },
{ value: Metrics.Volume, label: Metrics.Volume },
]}
onChange={(value) => setMetric(value as Metrics)}
value={metric}
/>
<Select
label="Period"
items={[
{ value: Periods.Weekly, label: Periods.Weekly },
{ value: Periods.Monthly, label: Periods.Monthly },
{ value: Periods.TwoMonths, label: Periods.TwoMonths },
{ value: Periods.ThreeMonths, label: Periods.ThreeMonths },
{ value: Periods.SixMonths, label: Periods.SixMonths },
{ value: Periods.Yearly, label: Periods.Yearly },
{ value: Periods.AllTime, label: Periods.AllTime },
]}
onChange={(value) => {
setPeriod(value as Periods);
setStart(null);
setEnd(null);
}}
value={period}
/>
<View style={{ flexDirection: "row", marginBottom: MARGIN }}>
<AppInput
label="Start date"
value={start ? format(start, settings.date || "Pp") : null}
onPressOut={pickStart}
style={{ flex: 1, marginRight: MARGIN }}
/>
<AppInput
label="End date"
value={end ? format(end, settings.date || "Pp") : null}
onPressOut={pickEnd}
style={{ flex: 1 }}
/>
</View>
<Select
label="Unit"
value={unit}
onChange={setUnit}
items={[
{ label: "Pounds (lb)", value: "lb" },
{ label: "Kilograms (kg)", value: "kg" },
{ label: "Stone", value: "stone" },
]}
/>
<View style={{ paddingTop: PADDING }}>
{metric === Metrics.Volume ? volumeChart : weightChart}
</View>
</ScrollView>
</>
);
}

96
ViewSetList.tsx Normal file
View File

@ -0,0 +1,96 @@
import { RouteProp, useRoute } from "@react-navigation/native";
import { format } from "date-fns";
import { useEffect, useState } from "react";
import { FlatList } from "react-native";
import { List, Text, useTheme } from "react-native-paper";
import { Like } from "typeorm";
import { StackParams } from "./AppStack";
import StackHeader from "./StackHeader";
import { LIMIT } from "./constants";
import { setRepo, settingsRepo } from "./db";
import GymSet from "./gym-set";
import Settings from "./settings";
interface ColorSet extends GymSet {
color?: string;
}
export default function ViewSetList() {
const [sets, setSets] = useState<ColorSet[]>();
const [settings, setSettings] = useState<Settings>();
const { colors } = useTheme();
const { params } = useRoute<RouteProp<StackParams, "ViewSetList">>();
useEffect(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
const reset = async () => {
const newSets: ColorSet[] = await setRepo.find({
where: { name: Like(`%${params.name}%`), hidden: 0 as any },
take: LIMIT,
skip: 0,
order: { created: "DESC" },
});
let prevDate = null;
const elevate = colors.elevation.level2;
const transparent = colors.elevation.level0;
let color = elevate;
for (let i = 0; i < newSets.length; i++) {
let currDate = new Date(newSets[i].created).toDateString();
if (currDate !== prevDate)
color = color === elevate ? transparent : elevate;
newSets[i].color = color;
prevDate = currDate;
}
setSets(newSets);
};
reset();
}, [params.name, colors]);
const renderItem = ({ item }: { item: ColorSet; index: number }) => (
<List.Item
title={format(new Date(item.created), settings.date || "Pp")}
style={{ backgroundColor: item.color }}
right={() => (
<Text
style={{
alignSelf: "center",
}}
>
{`${item.reps} x ${item.weight}${item.unit || "kg"}`}
</Text>
)}
/>
);
const getContent = () => {
if (!settings) return null;
if (sets?.length === 0)
return (
<List.Item
title="No sets yet"
description="A set is a group of repetitions. E.g. 8 reps of Squats."
/>
);
return (
<FlatList
data={sets ?? []}
style={{ flex: 1 }}
renderItem={renderItem}
keyExtractor={(set) => set.id?.toString()}
/>
);
};
return (
<>
<StackHeader title={params.name} />
{getContent()}
</>
);
}

167
ViewWeightGraph.tsx Normal file
View File

@ -0,0 +1,167 @@
import { format } from "date-fns";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Keyboard, ScrollView, View } from "react-native";
import { FileSystem } from "react-native-file-access";
import { IconButton, List } from "react-native-paper";
import Share from "react-native-share";
import { captureScreen } from "react-native-view-shot";
import AppLineChart from "./AppLineChart";
import { MARGIN, PADDING } from "./constants";
import { settingsRepo, weightRepo } from "./db";
import { Periods } from "./periods";
import Select from "./Select";
import StackHeader from "./StackHeader";
import Weight from "./weight";
import { useFocusEffect } from "@react-navigation/native";
import Settings from "./settings";
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import AppInput from "./AppInput";
export default function ViewWeightGraph() {
const [weights, setWeights] = useState<Weight[]>();
const [period, setPeriod] = useState(Periods.TwoMonths);
const [start, setStart] = useState<Date | null>(null)
const [end, setEnd] = useState<Date | null>(null)
const [settings, setSettings] = useState<Settings>({} as Settings);
useFocusEffect(useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings)
}, []))
useEffect(() => {
let difference = "-7 days";
if (period === Periods.Monthly) difference = "-1 months";
else if (period === Periods.TwoMonths) difference = "-2 months";
else if (period === Periods.ThreeMonths) difference = "-3 months";
else if (period === Periods.SixMonths) difference = "-6 months";
else if (period === Periods.Yearly) difference = "-1 years";
else if (period === Periods.AllTime) difference = null;
let group = "%Y-%m-%d";
if (period === Periods.Yearly) group = "%Y-%m";
const builder = weightRepo
.createQueryBuilder()
.select("STRFTIME('%Y-%m-%d', created)", "created")
.addSelect("AVG(value) as value")
.addSelect("unit")
.groupBy(`STRFTIME('${group}', created)`)
if (difference)
builder.where("DATE(created) >= DATE('now', 'weekday 0', :difference)", {
difference,
})
if (start)
builder.andWhere("DATE(created) >= :start", { start });
if (end)
builder.andWhere("DATE(created) <= :end", { end });
builder
.getRawMany()
.then(setWeights);
}, [period, start, end]);
const pickStart = useCallback(() => {
DateTimePickerAndroid.open({
value: start || new Date(),
onChange: (event, date) => {
if (event.type === 'dismissed') return;
if (date === start) return;
setStart(date);
setPeriod(Periods.AllTime);
Keyboard.dismiss();
},
mode: "date",
});
}, [start]);
const pickEnd = useCallback(() => {
DateTimePickerAndroid.open({
value: end || new Date(),
onChange: (event, date) => {
if (event.type === 'dismissed') return;
if (date === end) return;
setEnd(date);
setPeriod(Periods.AllTime);
Keyboard.dismiss();
},
mode: "date",
});
}, [end]);
const charts = useMemo(() => {
if (!weights) return;
if (weights?.length === 0) {
return <List.Item title="No data yet." />;
}
return (
<AppLineChart
data={weights.map((set) => set.value)}
labels={weights.map((weight) =>
format(new Date(weight.created), "yyyy-MM-d")
)}
/>
);
}, [weights]);
return (
<>
<StackHeader title="Weight graph">
<IconButton
onPress={() =>
captureScreen().then(async (uri) => {
const base64 = await FileSystem.readFile(uri, "base64");
const url = `data:image/jpeg;base64,${base64}`;
Share.open({
type: "image/jpeg",
url,
});
})
}
icon="share"
/>
</StackHeader>
<ScrollView style={{ padding: PADDING }}>
<Select
label="Period"
items={[
{ value: Periods.Weekly, label: Periods.Weekly },
{ value: Periods.Monthly, label: Periods.Monthly },
{ value: Periods.TwoMonths, label: Periods.TwoMonths },
{ value: Periods.ThreeMonths, label: Periods.ThreeMonths },
{ value: Periods.SixMonths, label: Periods.SixMonths },
{ value: Periods.Yearly, label: Periods.Yearly },
{ value: Periods.AllTime, label: Periods.AllTime },
]}
onChange={(value) => {
setPeriod(value as Periods);
if (value === Periods.AllTime) return;
setStart(null);
setEnd(null);
}}
value={period}
/>
<View style={{ flexDirection: 'row', marginBottom: MARGIN }}>
<AppInput
label="Start date"
value={start ? format(start, settings.date || "Pp") : null}
onPressOut={pickStart}
style={{ flex: 1, marginRight: MARGIN }}
/>
<AppInput
label="End date"
value={end ? format(end, settings.date || "Pp") : null}
onPressOut={pickEnd}
style={{ flex: 1 }}
/>
</View>
{charts}
</ScrollView>
</>
);
}

49
WeightItem.tsx Normal file
View File

@ -0,0 +1,49 @@
import { NavigationProp, useNavigation } from "@react-navigation/native";
import { format } from "date-fns";
import { useCallback, useMemo } from "react";
import { List, Text } from "react-native-paper";
import { StackParams } from "./AppStack";
import Settings from "./settings";
import Weight from "./weight";
export default function WeightItem({
item,
settings,
}: {
item: Weight;
settings: Settings;
}) {
const navigation = useNavigation<NavigationProp<StackParams>>();
const press = useCallback(() => {
navigation.navigate("EditWeight", { weight: item });
}, [item, navigation]);
const today = useMemo(() => {
const now = new Date();
const created = new Date(item.created);
return (
now.getFullYear() === created.getFullYear() &&
now.getMonth() === created.getMonth() &&
now.getDate() === created.getDate()
);
}, [item.created]);
return (
<List.Item
onPress={press}
title={`${item.value}${item.unit || "kg"}`}
right={() => (
<Text
style={{
alignSelf: "center",
textDecorationLine: today ? "underline" : "none",
fontWeight: today ? "bold" : "normal",
}}
>
{format(new Date(item.created), settings.date || "Pp")}
</Text>
)}
/>
);
}

150
WeightList.tsx Normal file
View File

@ -0,0 +1,150 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from "@react-navigation/native";
import { useCallback, useState } from "react";
import { FlatList } from "react-native";
import { IconButton, List } from "react-native-paper";
import { Like } from "typeorm";
import { StackParams } from "./AppStack";
import { LIMIT } from "./constants";
import { getNow, settingsRepo, weightRepo } from "./db";
import DrawerHeader from "./DrawerHeader";
import Page from "./Page";
import Settings from "./settings";
import { default as Weight, defaultWeight } from "./weight";
import WeightItem from "./WeightItem";
export default function WeightList() {
const [refreshing, setRefreshing] = useState(false);
const [weights, setWeights] = useState<Weight[]>();
const [offset, setOffset] = useState(0);
const [end, setEnd] = useState(false);
const [settings, setSettings] = useState<Settings>();
const { navigate } = useNavigation<NavigationProp<StackParams>>();
const [term, setTerm] = useState("");
const reset = useCallback(
async (value: string) => {
const newWeights = await weightRepo.find({
where: [
{
value: isNaN(Number(term)) ? undefined : Number(term),
},
{
created: Like(`%${term}%`),
},
],
take: LIMIT,
skip: 0,
order: { created: "DESC" },
});
console.log(`${WeightList.name}.reset:`, { value, offset });
setWeights(newWeights);
setEnd(false);
},
[offset, term]
);
useFocusEffect(
useCallback(() => {
settingsRepo.findOne({ where: {} }).then(setSettings);
reset(term);
// eslint-disable-next-line
}, [term])
);
const search = (value: string) => {
console.log(`${WeightList.name}.search:`, value);
setTerm(value);
setOffset(0);
reset(value);
};
const renderItem = useCallback(
({ item }: { item: Weight }) => (
<WeightItem settings={settings} item={item} key={item.id} />
),
[settings]
);
const next = async () => {
console.log(`${WeightList.name}.next:`, { end, refreshing });
if (end || refreshing) return;
const newOffset = offset + LIMIT;
console.log(`${WeightList.name}.next:`, { offset, newOffset, term });
const newWeights = await weightRepo.find({
where: [
{
value: Number(term),
},
{
created: Like(`%${term}%`),
},
],
take: LIMIT,
skip: newOffset,
order: { created: "DESC" },
});
if (newWeights.length === 0) return setEnd(true);
if (!weights) return;
const map = new Map<number, Weight>();
for (const weight of weights) map.set(weight.id, weight);
for (const weight of newWeights) map.set(weight.id, weight);
const unique = Array.from(map.values());
setWeights(unique);
if (newWeights.length < LIMIT) return setEnd(true);
setOffset(newOffset);
};
const onAdd = useCallback(async () => {
const now = await getNow();
let weight: Partial<Weight> = { ...weights[0] };
if (!weight) weight = { ...defaultWeight };
weight.created = now;
delete weight.id;
navigate("EditWeight", { weight });
}, [navigate, weights]);
const getContent = () => {
if (!settings) return null;
if (weights?.length === 0)
return (
<List.Item
title="No sets yet"
description="A set is a group of repetitions. E.g. 8 reps of Squats."
/>
);
return (
<FlatList
data={weights ?? []}
style={{ flex: 1 }}
renderItem={renderItem}
onEndReached={next}
refreshing={refreshing}
keyExtractor={(set) => set.id?.toString()}
onRefresh={() => {
setOffset(0);
setRefreshing(true);
reset(term).finally(() => setRefreshing(false));
}}
/>
);
};
return (
<>
<DrawerHeader name="Weight">
<IconButton
onPress={() => navigate("ViewWeightGraph")}
icon="chart-bell-curve-cumulative"
/>
</DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}>
{getContent()}
</Page>
</>
);
}

View File

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

View File

@ -1,122 +0,0 @@
import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native'
import {useCallback, useState} from 'react'
import {FlatList} from 'react-native'
import {List} from 'react-native-paper'
import DrawerHeader from './DrawerHeader'
import Page from './Page'
import GymSet from './gym-set'
import SetList from './SetList'
import WorkoutItem from './WorkoutItem'
import {WorkoutsPageParams} from './WorkoutsPage'
import {setRepo, settingsRepo} from './db'
import Settings from './settings'
const limit = 15
export default function WorkoutList() {
const [workouts, setWorkouts] = useState<GymSet[]>()
const [offset, setOffset] = useState(0)
const [term, setTerm] = useState('')
const [end, setEnd] = useState(false)
const [settings, setSettings] = useState<Settings>()
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>()
const refresh = useCallback(async (value: string) => {
const newWorkouts = await setRepo
.createQueryBuilder()
.select()
.where('name LIKE :name', {name: `%${value}%`})
.groupBy('name')
.orderBy('name')
.limit(limit)
.getMany()
console.log(`${WorkoutList.name}`, {newWorkout: newWorkouts[0]})
setWorkouts(newWorkouts)
setOffset(0)
setEnd(false)
}, [])
useFocusEffect(
useCallback(() => {
refresh(term)
settingsRepo.findOne({where: {}}).then(setSettings)
}, [refresh, term]),
)
const renderItem = useCallback(
({item}: {item: GymSet}) => (
<WorkoutItem
images={settings?.images}
item={item}
key={item.name}
onRemove={() => refresh(term)}
/>
),
[refresh, term, settings?.images],
)
const next = useCallback(async () => {
if (end) return
const newOffset = offset + limit
console.log(`${SetList.name}.next:`, {
offset,
limit,
newOffset,
term,
})
const newWorkouts = await setRepo
.createQueryBuilder()
.select()
.where('name LIKE :name', {name: `%${term}%`})
.groupBy('name')
.orderBy('name')
.limit(limit)
.offset(newOffset)
.getMany()
if (newWorkouts.length === 0) return setEnd(true)
if (!workouts) return
setWorkouts([...workouts, ...newWorkouts])
if (newWorkouts.length < limit) return setEnd(true)
setOffset(newOffset)
}, [term, end, offset, workouts])
const onAdd = useCallback(async () => {
navigation.navigate('EditWorkout', {
value: new GymSet(),
})
}, [navigation])
const search = useCallback(
(value: string) => {
setTerm(value)
refresh(value)
},
[refresh],
)
return (
<>
<DrawerHeader name="Workouts" />
<Page onAdd={onAdd} term={term} search={search}>
{workouts?.length === 0 ? (
<List.Item
title="No workouts yet."
description="A workout is something you do at the gym. For example Deadlifts are a workout."
/>
) : (
<FlatList
data={workouts}
style={{flex: 1}}
renderItem={renderItem}
keyExtractor={w => w.name}
onEndReached={next}
/>
)}
</Page>
</>
)
}

View File

@ -1,23 +0,0 @@
import {createStackNavigator} from '@react-navigation/stack'
import EditWorkout from './EditWorkout'
import GymSet from './gym-set'
import WorkoutList from './WorkoutList'
export type WorkoutsPageParams = {
WorkoutList: {}
EditWorkout: {
value: GymSet
}
}
const Stack = createStackNavigator<WorkoutsPageParams>()
export default function WorkoutsPage() {
return (
<Stack.Navigator
screenOptions={{headerShown: false, animationEnabled: false}}>
<Stack.Screen name="WorkoutList" component={WorkoutList} />
<Stack.Screen name="EditWorkout" component={EditWorkout} />
</Stack.Navigator>
)
}

View File

@ -1,27 +1,27 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.5)
CFPropertyList (3.0.6)
rexml
addressable (2.8.1)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.657.0)
aws-sdk-core (3.166.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-eventstream (1.3.0)
aws-partitions (1.888.0)
aws-sdk-core (3.191.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.59.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-kms (1.77.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.117.1)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-s3 (1.143.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.2)
aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
@ -30,14 +30,13 @@ GEM
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.6.4)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.93.1)
faraday (1.10.2)
excon (0.109.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@ -65,8 +64,8 @@ GEM
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.6)
fastlane (2.210.1)
fastimage (2.3.0)
fastlane (2.219.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@ -85,20 +84,22 @@ GEM
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (~> 2.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
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)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
@ -106,9 +107,9 @@ GEM
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.31.0)
google-apis-core (>= 0.9.1, < 2.a)
google-apis-core (0.9.1)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@ -116,31 +117,29 @@ GEM
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.16.0)
google-apis-core (>= 0.9.1, < 2.a)
google-apis-playcustomapp_v1 (0.12.0)
google-apis-core (>= 0.9.1, < 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-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.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.0)
google-cloud-storage (1.44.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.19.0)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.3.0)
googleauth (1.8.1)
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)
@ -148,55 +147,50 @@ GEM
http-cookie (1.0.5)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.1)
json (2.6.2)
jwt (2.5.0)
memoist (0.16.2)
mini_magick (4.11.0)
mini_mime (1.1.2)
jmespath (1.6.2)
json (2.7.1)
jwt (2.7.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.0.0)
multipart-post (2.4.0)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.1.1)
optparse (0.4.0)
os (1.1.4)
plist (3.6.0)
public_suffix (5.0.0)
rake (13.0.6)
plist (3.7.1)
public_suffix (5.0.4)
rake (13.1.0)
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)
rexml (3.2.6)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
signet (0.17.0)
signet (0.18.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.8)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-screen (0.8.2)
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.7.0)
unicode-display_width (2.5.0)
word_wrap (1.0.0)
xcodeproj (1.22.0)
xcodeproj (1.24.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
@ -210,6 +204,7 @@ GEM
PLATFORMS
ruby
x64-mingw-ucrt
x86_64-linux
DEPENDENCIES

View File

@ -1,115 +1,94 @@
apply plugin: "com.android.application"
apply plugin: "com.facebook.react"
apply plugin: "kotlin-android"
apply plugin: "org.jetbrains.kotlin.android"
project.ext.react = [
enableHermes: true, // clean and rebuild if changing
]
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '..'
// root = file("../")
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
// reactNativeDir = file("../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen
// codegenDir = file("../node_modules/@react-native/codegen")
// The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js
// cliFile = file("../node_modules/react-native/cli.js")
project.ext.vectoricons = [
iconFontNames: ['MaterialIcons.ttf']
]
/* 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"]
apply from: "../../node_modules/react-native/react.gradle"
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
/* 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 = []
def enableSeparateBuildPerCPUArchitecture = true
def enableProguardInReleaseBuilds = true
def jscFlavor = 'org.webkit:android-jsc:+'
def enableHermes = project.ext.react.get("enableHermes", true);
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
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk 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 36124
versionName "1.98"
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 36249
versionName "2.34"
}
signingConfigs {
release {
@ -129,15 +108,14 @@ android {
}
}
}
buildFeatures {
viewBinding true
}
buildTypes {
debug {
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"
}
@ -145,65 +123,24 @@ android {
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.4.+'
// 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.4'
implementation 'androidx.databinding:databinding-runtime:7.1.2'
def work_version = "2.7.1"
implementation "androidx.work:work-runtime:$work_version"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.work:work-rxjava2:$work_version"
androidTestImplementation "androidx.work:work-testing:$work_version"
implementation "androidx.work:work-multiprocess:$work_version"
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "com.facebook.react:react-native:+" // From node_modules
implementation "androidx.core:core-ktx:1.8.0"
implementation 'com.opencsv:opencsv:5.5.2'
implementation project(':react-native-sqlite-storage')
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
implementation project(':react-native-vector-icons')
implementation("com.facebook.react:flipper-integration")
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
exclude group: 'com.facebook.fbjni'
}
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'
}
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: ['MaterialCommunityIcons.ttf']
]
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"

View File

@ -7,7 +7,5 @@
<application
android:usesCleartextTraffic="true"
tools:targetApi="28"
tools:ignore="GoogleAppIndexingWarning">
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" android:exported="false" />
</application>
tools:ignore="GoogleAppIndexingWarning"/>
</manifest>

View File

@ -1,73 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* <p>This source code is licensed under the MIT license found in the LICENSE file in the root
* directory of this source tree.
*/
package com.massive;
import android.content.Context;
import com.facebook.flipper.android.AndroidFlipperClient;
import com.facebook.flipper.android.utils.FlipperUtils;
import com.facebook.flipper.core.FlipperClient;
import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin;
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin;
import com.facebook.flipper.plugins.inspector.DescriptorMapping;
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
import com.facebook.flipper.plugins.react.ReactFlipperPlugin;
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
import com.facebook.react.ReactInstanceEventListener;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.network.NetworkingModule;
import okhttp3.OkHttpClient;
public class ReactNativeFlipper {
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
if (FlipperUtils.shouldEnableFlipper(context)) {
final FlipperClient client = AndroidFlipperClient.getInstance(context);
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
client.addPlugin(new ReactFlipperPlugin());
client.addPlugin(new DatabasesFlipperPlugin(context));
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
client.addPlugin(CrashReporterPlugin.getInstance());
NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin();
NetworkingModule.setCustomClientBuilder(
new NetworkingModule.CustomClientBuilder() {
@Override
public void apply(OkHttpClient.Builder builder) {
builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin));
}
});
client.addPlugin(networkFlipperPlugin);
client.start();
// Fresco Plugin needs to ensure that ImagePipelineFactory is initialized
// Hence we run if after all native modules have been initialized
ReactContext reactContext = reactInstanceManager.getCurrentReactContext();
if (reactContext == null) {
reactInstanceManager.addReactInstanceEventListener(
new ReactInstanceEventListener() {
@Override
public void onReactContextInitialized(ReactContext reactContext) {
reactInstanceManager.removeReactInstanceEventListener(this);
reactContext.runOnNativeModulesQueueThread(
new Runnable() {
@Override
public void run() {
client.addPlugin(new FrescoFlipperPlugin());
}
});
}
});
} else {
client.addPlugin(new FrescoFlipperPlugin());
}
}
}
}

View File

@ -1,32 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<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.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 android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission
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:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/AppTheme">
<activity
android:name=".TimerDone"
android:exported="false">
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<activity
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
@ -35,17 +31,30 @@
android:windowSoftInputMode="adjustResize">
<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>
<service
android:name=".TimerService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="App does not require SCHEDULE_EXACT_ALARM or USE_EXACT_ALARM, but needs foreground service for foreground timer."/>
</service>
</application>
</manifest>

View File

@ -1,236 +1,25 @@
package com.massive
import android.annotation.SuppressLint
import android.app.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.CountDownTimer
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule
import kotlin.math.floor
class AlarmModule constructor(context: ReactApplicationContext?) :
@RequiresApi(Build.VERSION_CODES.O)
class AlarmModule(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) {
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?) {
val vibrate = intent?.extras?.getBoolean("vibrate") == true
val sound = intent?.extras?.getString("sound")
val noSound = intent?.extras?.getBoolean("noSound") == true
Log.d("AlarmModule", "vibrate=$vibrate,sound=$sound,noSound=$noSound")
add(vibrate, sound, noSound)
}
}
init {
reactApplicationContext.registerReceiver(stopReceiver, IntentFilter(STOP_BROADCAST))
reactApplicationContext.registerReceiver(addReceiver, IntentFilter(ADD_BROADCAST))
}
override fun onCatalystInstanceDestroy() {
reactApplicationContext.unregisterReceiver(stopReceiver)
reactApplicationContext.unregisterReceiver(addReceiver)
super.onCatalystInstanceDestroy()
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
fun add(vibrate: Boolean, sound: String?, noSound: Boolean = false) {
Log.d("AlarmModule", "Add 1 min to alarm.")
countdownTimer?.cancel()
val newMs = if (running) currentMs.toInt().plus(60000) else 60000
countdownTimer = getTimer(newMs, vibrate, sound, noSound)
countdownTimer?.start()
running = true
val manager = getManager()
manager.cancel(NOTIFICATION_ID_DONE)
val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.stopService(intent)
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
fun stop() {
Log.d("AlarmModule", "Stop alarm.")
countdownTimer?.cancel()
running = false
val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext?.stopService(intent)
val manager = getManager()
manager.cancel(NOTIFICATION_ID_DONE)
manager.cancel(NOTIFICATION_ID_PENDING)
val params = Arguments.createMap().apply {
putString("minutes", "00")
putString("seconds", "00")
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("tick", params)
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
fun timer(milliseconds: Int, vibrate: Boolean, sound: String?, noSound: Boolean = false) {
fun timer(milliseconds: Int, description: String) {
Log.d("AlarmModule", "Queue alarm for $milliseconds delay")
val manager = getManager()
manager.cancel(NOTIFICATION_ID_DONE)
val intent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.stopService(intent)
countdownTimer?.cancel()
countdownTimer = getTimer(milliseconds, vibrate, sound, noSound)
countdownTimer?.start()
running = true
}
@RequiresApi(Build.VERSION_CODES.M)
private fun getTimer(
endMs: Int,
vibrate: Boolean,
sound: String?,
noSound: Boolean
): CountDownTimer {
val builder = getBuilder(vibrate, sound, noSound)
return object : CountDownTimer(endMs.toLong(), 1000) {
@RequiresApi(Build.VERSION_CODES.O)
override fun onTick(current: Long) {
currentMs = current
val seconds =
floor((current / 1000).toDouble() % 60).toInt().toString().padStart(2, '0')
val minutes =
floor((current / 1000).toDouble() / 60).toInt().toString().padStart(2, '0')
builder.setContentText("$minutes:$seconds").setAutoCancel(false).setDefaults(0)
.setProgress(endMs, current.toInt(), false)
.setCategory(NotificationCompat.CATEGORY_PROGRESS).priority =
NotificationCompat.PRIORITY_LOW
val manager = getManager()
manager.notify(NOTIFICATION_ID_PENDING, builder.build())
val params = Arguments.createMap().apply {
putString("minutes", minutes)
putString("seconds", seconds)
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("tick", params)
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onFinish() {
val context = reactApplicationContext
val finishIntent = Intent(context, StopAlarm::class.java)
val finishPending = PendingIntent.getActivity(
context, 0, finishIntent, PendingIntent.FLAG_IMMUTABLE
)
val fullIntent = Intent(context, TimerDone::class.java)
val fullPending = PendingIntent.getActivity(
context, 0, fullIntent, PendingIntent.FLAG_IMMUTABLE
)
builder.setContentText("Timer finished.").setProgress(0, 0, false)
.setAutoCancel(true).setOngoing(true).setFullScreenIntent(fullPending, true)
.setContentIntent(finishPending).setChannelId(CHANNEL_ID_DONE)
.setCategory(NotificationCompat.CATEGORY_ALARM).priority =
NotificationCompat.PRIORITY_HIGH
val manager = getManager()
manager.notify(NOTIFICATION_ID_DONE, builder.build())
manager.cancel(NOTIFICATION_ID_PENDING)
Log.d("AlarmModule", "Finished: vibrate=$vibrate,sound=$sound,noSound=$noSound")
val alarmIntent = Intent(context, AlarmService::class.java).apply {
putExtra("vibrate", vibrate)
putExtra("sound", sound)
putExtra("noSound", noSound)
}
context.startService(alarmIntent)
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("finish", Arguments.createMap().apply {
putString("minutes", "00")
putString("seconds", "00")
})
}
}
}
@SuppressLint("UnspecifiedImmutableFlag")
@RequiresApi(Build.VERSION_CODES.M)
private fun getBuilder(
vibrate: Boolean,
sound: String?,
noSound: Boolean
): NotificationCompat.Builder {
val context = reactApplicationContext
val contentIntent = Intent(context, MainActivity::class.java)
val pendingContent =
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
val addBroadcast = Intent(ADD_BROADCAST).apply {
setPackage(reactApplicationContext.packageName)
putExtra("vibrate", vibrate)
putExtra("sound", sound)
putExtra("noSound", noSound)
}
val pendingAdd =
PendingIntent.getBroadcast(context, 0, addBroadcast, PendingIntent.FLAG_MUTABLE)
val stopBroadcast = Intent(STOP_BROADCAST)
stopBroadcast.setPackage(reactApplicationContext.packageName)
val pendingStop =
PendingIntent.getBroadcast(context, 0, stopBroadcast, PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(context, CHANNEL_ID_PENDING)
.setSmallIcon(R.drawable.ic_baseline_hourglass_bottom_24).setContentTitle("Resting")
.setContentIntent(pendingContent)
.addAction(R.drawable.ic_baseline_stop_24, "Stop", pendingStop)
.addAction(R.drawable.ic_baseline_stop_24, "Add 1 min", pendingAdd)
.setDeleteIntent(pendingStop)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun getManager(): NotificationManager {
val alarmsChannel = NotificationChannel(
CHANNEL_ID_DONE, CHANNEL_ID_DONE, NotificationManager.IMPORTANCE_HIGH
)
alarmsChannel.description = "Alarms for rest timers."
alarmsChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
alarmsChannel.setSound(null, null)
val notificationManager = reactApplicationContext.getSystemService(
NotificationManager::class.java
)
notificationManager.createNotificationChannel(alarmsChannel)
val timersChannel = NotificationChannel(
CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, NotificationManager.IMPORTANCE_LOW
)
timersChannel.setSound(null, null)
timersChannel.description = "Progress on rest timers."
notificationManager.createNotificationChannel(timersChannel)
return notificationManager
}
companion object {
const val STOP_BROADCAST = "stop-timer-event"
const val ADD_BROADCAST = "add-timer-event"
const val CHANNEL_ID_PENDING = "Timer"
const val CHANNEL_ID_DONE = "Alarm"
const val NOTIFICATION_ID_PENDING = 1
const val NOTIFICATION_ID_DONE = 2
val intent = Intent(reactApplicationContext, TimerService::class.java)
intent.putExtra("milliseconds", milliseconds)
intent.putExtra("description", description)
reactApplicationContext.startForegroundService(intent)
}
}

View File

@ -1,78 +0,0 @@
package com.massive
import android.app.Service
import android.content.Context
import android.media.MediaPlayer.OnPreparedListener
import android.media.MediaPlayer
import androidx.annotation.RequiresApi
import android.content.Intent
import android.media.AudioAttributes
import android.net.Uri
import android.os.*
class AlarmService : Service(), OnPreparedListener {
var mediaPlayer: MediaPlayer? = null
private var vibrator: Vibrator? = null
@RequiresApi(api = Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.action == "stop") {
onDestroy()
return START_STICKY
}
val sound = intent.extras?.getString("sound")
val noSound = intent.extras?.getBoolean("noSound") == true
if (sound == null && !noSound) {
mediaPlayer = MediaPlayer.create(applicationContext, R.raw.argon)
mediaPlayer?.start()
mediaPlayer?.setOnCompletionListener { vibrator?.cancel() }
} else if (sound != null && !noSound) {
mediaPlayer = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setDataSource(applicationContext, Uri.parse(sound))
prepare()
start()
setOnCompletionListener { vibrator?.cancel() }
}
}
val pattern = longArrayOf(0, 300, 1300, 300, 1300, 300)
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager =
getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(VIBRATOR_SERVICE) as Vibrator
}
val audioAttributes = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ALARM)
.build()
val vibrate = intent.extras!!.getBoolean("vibrate")
if (vibrate)
vibrator!!.vibrate(VibrationEffect.createWaveform(pattern, 1), audioAttributes)
return START_STICKY
}
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onPrepared(player: MediaPlayer) {
player.start()
}
override fun onDestroy() {
super.onDestroy()
mediaPlayer?.stop()
mediaPlayer?.release()
vibrator?.cancel()
}
}

View File

@ -0,0 +1,138 @@
package com.massive
import android.annotation.SuppressLint
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.*
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import java.io.*
import java.util.*
@SuppressLint("UnspecifiedRegisterReceiverFlag")
class BackupModule(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) {
val context: ReactApplicationContext = reactApplicationContext
private val copyReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val targetDir = intent?.getStringExtra("targetDir")
Log.d("BackupModule", "onReceive $targetDir")
val treeUri: Uri = Uri.parse(targetDir)
val documentFile = context?.let { DocumentFile.fromTreeUri(it, treeUri) }
val file = documentFile?.createFile("application/octet-stream", "massive.db")
val output = context?.contentResolver?.openOutputStream(file!!.uri)
val sourceFile = File(context?.getDatabasePath("massive.db")!!.path)
val input = FileInputStream(sourceFile)
if (output != null) {
input.copyTo(output)
}
output?.flush()
output?.close()
}
}
@ReactMethod
fun once(target: String, promise: Promise) {
Log.d("BackupModule", "once $target")
try {
val treeUri: Uri = Uri.parse(target)
val documentFile = context.let { DocumentFile.fromTreeUri(it, treeUri) }
val file = documentFile?.createFile("application/octet-stream", "massive.db")
val output = context.contentResolver?.openOutputStream(file!!.uri)
val sourceFile = File(context.getDatabasePath("massive.db")!!.path)
val input = FileInputStream(sourceFile)
if (output != null) {
input.copyTo(output)
}
output?.flush()
output?.close()
promise.resolve(0)
}
catch (error: Exception) {
promise.reject("ERROR", error)
}
}
@ReactMethod
fun start(baseUri: String) {
Log.d("BackupModule", "start $baseUri")
val alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(COPY_BROADCAST)
intent.putExtra("targetDir", baseUri)
val pendingIntent =
PendingIntent.getBroadcast(context, baseUri.hashCode(), intent, PendingIntent.FLAG_IMMUTABLE)
pendingIntent.send()
val calendar = Calendar.getInstance().apply {
timeInMillis = System.currentTimeMillis()
set(Calendar.HOUR_OF_DAY, 6)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
}
alarmMgr.setRepeating(
AlarmManager.RTC_WAKEUP,
calendar.timeInMillis,
AlarmManager.INTERVAL_DAY,
pendingIntent
)
}
@ReactMethod(isBlockingSynchronousMethod = true)
fun stop() {
val alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(COPY_BROADCAST)
val pendingIntent =
PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
alarmMgr.cancel(pendingIntent)
}
@ReactMethod
fun exportPlans(target: String, promise: Promise) {
try {
val db = DatabaseHelper(reactApplicationContext)
db.exportPlans(target, reactApplicationContext)
promise.resolve("Export successful!")
}
catch (e: Exception) {
promise.reject("ERROR", e)
}
}
@ReactMethod
fun exportSets(target: String, promise: Promise) {
try {
val db = DatabaseHelper(reactApplicationContext)
db.exportSets(target, reactApplicationContext)
promise.resolve("Export successful!")
}
catch (e: Exception) {
promise.reject("ERROR", e)
}
}
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
reactApplicationContext.registerReceiver(copyReceiver, IntentFilter(COPY_BROADCAST),
Context.RECEIVER_NOT_EXPORTED)
}
else {
reactApplicationContext.registerReceiver(copyReceiver, IntentFilter(COPY_BROADCAST))
}
}
companion object {
const val COPY_BROADCAST = "copy-event"
}
override fun getName(): String {
return "BackupModule"
}
}

View File

@ -0,0 +1,79 @@
package com.massive
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.net.Uri
import android.os.Environment
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.opencsv.CSVWriter
import java.io.File
import java.io.FileWriter
class DatabaseHelper(context: Context) :
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
companion object {
private const val DATABASE_NAME = "massive.db"
private const val DATABASE_VERSION = 1
}
fun exportSets(target: String, context: Context) {
Log.d("DatabaseHelper", "exportSets $target")
val treeUri: Uri = Uri.parse(target)
val documentFile = context.let { DocumentFile.fromTreeUri(it, treeUri) }
val file = documentFile?.createFile("application/octet-stream", "sets.csv") ?: return
context.contentResolver.openOutputStream(file.uri).use { outputStream ->
val csvWrite = CSVWriter(outputStream?.writer())
val db = this.readableDatabase
val cursor = db.rawQuery("SELECT * FROM sets", null)
csvWrite.writeNext(cursor.columnNames)
while(cursor.moveToNext()) {
val arrStr = arrayOfNulls<String>(cursor.columnCount)
for(i in 0 until cursor.columnCount) {
arrStr[i] = cursor.getString(i)
}
csvWrite.writeNext(arrStr)
}
csvWrite.close()
cursor.close()
}
}
fun exportPlans(target: String, context: Context) {
Log.d("DatabaseHelper", "exportPlans $target")
val treeUri: Uri = Uri.parse(target)
val documentFile = context.let { DocumentFile.fromTreeUri(it, treeUri) }
val file = documentFile?.createFile("application/octet-stream", "plans.csv") ?: return
context.contentResolver.openOutputStream(file.uri).use { outputStream ->
val csvWrite = CSVWriter(outputStream?.writer())
val db = this.readableDatabase
val cursor = db.rawQuery("SELECT * FROM plans", null)
csvWrite.writeNext(cursor.columnNames)
while(cursor.moveToNext()) {
val arrStr = arrayOfNulls<String>(cursor.columnCount)
for(i in 0 until cursor.columnCount) {
arrStr[i] = cursor.getString(i)
}
csvWrite.writeNext(arrStr)
}
csvWrite.close()
cursor.close()
}
}
override fun onCreate(db: SQLiteDatabase) {
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
}
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
}
}

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

@ -1,33 +1,31 @@
package com.massive
import android.os.Bundle
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() {
override fun getMainComponentName(): String? {
return "massive"
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "massive"
/**
* Returns the instance of the [ReactActivityDelegate]. Here we use a util class [ ] which allows you to easily enable Fabric and Concurrent React
* (aka React 18) with two boolean flags.
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
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,70 +1,45 @@
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.ReactHost
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.flipper.ReactNativeFlipper
import com.facebook.soloader.SoLoader
import com.massive.newarchitecture.MainApplicationReactNativeHost
import java.lang.reflect.InvocationTargetException
class MainApplication : Application(), ReactApplication {
private val mReactNativeHost: ReactNativeHost = object : ReactNativeHost(this) {
override fun getUseDeveloperSupport(): Boolean {
return BuildConfig.DEBUG
override val reactNativeHost: ReactNativeHost =
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
add(MassivePackage())
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
override fun getJSMainModuleName(): String = "index"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
}
override fun getPackages(): List<ReactPackage> {
val packages: MutableList<ReactPackage> = PackageList(this).packages
packages.add(MassivePackage())
return packages
}
override fun getJSMainModuleName(): String {
return "index"
}
}
private val mNewArchitectureNativeHost: ReactNativeHost = MainApplicationReactNativeHost(this)
override fun getReactNativeHost(): ReactNativeHost {
return if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
mNewArchitectureNativeHost
} else {
mReactNativeHost
}
}
override val reactHost: ReactHost
get() = getDefaultReactHost(this.applicationContext, reactNativeHost)
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()
}
}
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

@ -4,7 +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 java.util.ArrayList
class MassivePackage : ReactPackage {
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
@ -16,8 +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

@ -21,13 +21,13 @@ class SettingsModule constructor(context: ReactApplicationContext?) :
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun ignoringBattery(callback: Callback) {
fun ignoringBattery(promise: Promise) {
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))
promise.resolve(pm.isIgnoringBatteryOptimizations(packageName))
} else {
callback.invoke(true)
promise.resolve(true)
}
}

View File

@ -1,17 +1,11 @@
package com.massive
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.AttributeSet
import android.util.Log
import android.view.View
import android.view.WindowManager
import androidx.annotation.RequiresApi
import com.massive.AlarmService
import com.massive.MainActivity
class StopAlarm : Activity() {
@RequiresApi(Build.VERSION_CODES.O_MR1)
@ -19,7 +13,7 @@ class StopAlarm : Activity() {
Log.d("AlarmActivity", "Call to AlarmActivity")
super.onCreate(savedInstanceState)
val context = applicationContext
context.stopService(Intent(context, AlarmService::class.java))
context.stopService(Intent(context, TimerService::class.java))
savedInstanceState.apply { setShowWhenLocked(true) }
val intent = Intent(context, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK

View File

@ -0,0 +1,171 @@
package com.massive
import android.app.AlarmManager
import android.app.AlarmManager.ELAPSED_REALTIME_WAKEUP
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.SystemClock
import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
import android.widget.Toast
import androidx.annotation.RequiresApi
@RequiresApi(Build.VERSION_CODES.O)
class Timer(private var msTimerDuration: Long) {
enum class State {
Running,
Paused,
Expired
}
fun start(context: Context) {
if (state != State.Paused) return
endTime = SystemClock.elapsedRealtime() + msTimerDuration
registerPendingIntent(context)
state = State.Running
}
fun stop(context: Context) {
if (state != State.Running) return
msTimerDuration = endTime - SystemClock.elapsedRealtime()
unregisterPendingIntent(context)
state = State.Paused
}
fun expire() {
state = State.Expired
msTimerDuration = 0
totalTimerDuration = 0
}
fun getRemainingSeconds(): Int {
return (getRemainingMillis() / 1000).toInt()
}
fun increaseDuration(context: Context, milli: Long) {
val wasRunning = isRunning()
if (wasRunning) stop(context)
msTimerDuration += milli
totalTimerDuration += milli
if (wasRunning) start(context)
}
fun isExpired(): Boolean {
return state == State.Expired
}
fun getDurationSeconds(): Int {
return (totalTimerDuration / 1000).toInt()
}
fun getRemainingMillis(): Long {
return if (state == State.Running) endTime - SystemClock.elapsedRealtime()
else
msTimerDuration
}
private fun isRunning(): Boolean {
return state == State.Running
}
private fun requestPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
val intent = Intent(ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
intent.data = Uri.parse("package:" + context.packageName)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
return try {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context2: Context?, intent: Intent?) {
context.unregisterReceiver(this)
registerPendingIntent(context)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(
receiver,
IntentFilter(AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED),
Context.RECEIVER_NOT_EXPORTED
)
} else {
context.registerReceiver(
receiver,
IntentFilter(AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED)
)
}
context.startActivity(intent)
false
} catch (e: ActivityNotFoundException) {
Toast.makeText(
context,
"Request for SCHEDULE_EXACT_ALARM rejected on your device",
Toast.LENGTH_LONG
).show()
false
}
}
private fun incorrectPermissions(context: Context, alarmManager: AlarmManager): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& !alarmManager.canScheduleExactAlarms()
&& !requestPermission(context)
}
private fun getAlarmManager(context: Context): AlarmManager {
return context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
}
private fun unregisterPendingIntent(context: Context) {
val intent = Intent(context, TimerService::class.java)
.setAction(TimerService.TIMER_EXPIRED)
val pendingIntent = PendingIntent.getService(
context,
0,
intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = getAlarmManager(context)
if (incorrectPermissions(context, alarmManager)) return
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
}
private fun registerPendingIntent(context: Context) {
val intent = Intent(context, TimerService::class.java)
.setAction(TimerService.TIMER_EXPIRED)
val pendingIntent = PendingIntent.getService(
context,
0,
intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = getAlarmManager(context)
if (incorrectPermissions(context, alarmManager)) return
alarmManager.setExactAndAllowWhileIdle(
ELAPSED_REALTIME_WAKEUP,
endTime,
pendingIntent
)
}
private var endTime: Long = 0
private var totalTimerDuration: Long = msTimerDuration
private var state: State = State.Paused
companion object {
fun emptyTimer(): Timer {
return Timer(0)
}
const val ONE_MINUTE_MILLI: Long = 60000
}
}

View File

@ -1,8 +1,5 @@
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
@ -10,49 +7,24 @@ import android.util.Log
import android.view.View
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationManagerCompat
class TimerDone : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_timer_done)
Log.d("TimerDone", "Rendered.")
}
@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(AlarmModule.NOTIFICATION_ID_DONE)
manager.cancel(AlarmModule.NOTIFICATION_ID_PENDING)
applicationContext.stopService(Intent(applicationContext, TimerService::class.java))
val manager = NotificationManagerCompat.from(this)
manager.cancel(TimerService.ONGOING_ID)
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(
AlarmModule.CHANNEL_ID_DONE,
AlarmModule.CHANNEL_ID_DONE,
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Alarms for rest timers."
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
}
val timersChannel = NotificationChannel(
AlarmModule.CHANNEL_ID_PENDING,
AlarmModule.CHANNEL_ID_PENDING,
NotificationManager.IMPORTANCE_LOW
).apply {
setSound(null, null)
description = "Progress on rest timers."
}
val notificationManager = applicationContext.getSystemService(
NotificationManager::class.java
)
notificationManager.createNotificationChannel(alarmsChannel)
notificationManager.createNotificationChannel(timersChannel)
return notificationManager
}
}

View File

@ -0,0 +1,358 @@
package com.massive
import android.Manifest
import android.annotation.SuppressLint
import android.app.*
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.*
import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
class Settings(val sound: String?, val noSound: Boolean, val vibrate: Boolean, val duration: Long)
@RequiresApi(Build.VERSION_CODES.O)
class TimerService : Service() {
private lateinit var timerHandler: Handler
private var timerRunnable: Runnable? = null
private var timer: Timer = Timer.emptyTimer()
private var mediaPlayer: MediaPlayer? = null
private var vibrator: Vibrator? = null
private var currentDescription = ""
private val stopReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Log.d("TimerService", "Received stop broadcast intent")
timer.stop(applicationContext)
timer.expire()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private val addReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
timer.increaseDuration(applicationContext, Timer.ONE_MINUTE_MILLI)
updateNotification(timer.getRemainingSeconds())
mediaPlayer?.stop()
vibrator?.cancel()
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
override fun onCreate() {
super.onCreate()
timerHandler = Handler(Looper.getMainLooper())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
applicationContext.registerReceiver(
stopReceiver, IntentFilter(STOP_BROADCAST),
Context.RECEIVER_NOT_EXPORTED
)
applicationContext.registerReceiver(
addReceiver, IntentFilter(ADD_BROADCAST),
Context.RECEIVER_NOT_EXPORTED
)
} else {
applicationContext.registerReceiver(stopReceiver, IntentFilter(STOP_BROADCAST))
applicationContext.registerReceiver(addReceiver, IntentFilter(ADD_BROADCAST))
}
}
private fun onTimerStart(intent: Intent?) {
timerRunnable?.let { timerHandler.removeCallbacks(it) }
currentDescription = intent?.getStringExtra("description").toString()
timer.stop(applicationContext)
timer = Timer((intent?.getIntExtra("milliseconds", 0) ?: 0).toLong())
timer.start(applicationContext)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
ONGOING_ID,
getProgress(timer.getRemainingSeconds()).build(),
FOREGROUND_SERVICE_TYPE_SPECIAL_USE
)
} else {
startForeground(ONGOING_ID, getProgress(timer.getRemainingSeconds()).build())
}
battery()
Log.d("TimerService", "onTimerStart seconds=${timer.getDurationSeconds()}")
timerRunnable = object : Runnable {
override fun run() {
val startTime = SystemClock.elapsedRealtime()
if (timer.isExpired()) return
updateNotification(timer.getRemainingSeconds())
val delay = timer.getRemainingMillis() % 1000
timerHandler.postDelayed(this, if (SystemClock.elapsedRealtime() - startTime + delay > 980) 20 else delay)
}
}
timerHandler.postDelayed(timerRunnable!!, 20)
}
private fun onTimerExpired() {
Log.d("TimerService", "onTimerExpired duration=${timer.getDurationSeconds()}")
timer.expire()
val settings = getSettings()
vibrate(settings)
playSound(settings)
notifyFinished()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null && intent.action == TIMER_EXPIRED) onTimerExpired()
else onTimerStart(intent)
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
timerRunnable?.let { timerHandler.removeCallbacks(it) }
applicationContext.unregisterReceiver(stopReceiver)
applicationContext.unregisterReceiver(addReceiver)
mediaPlayer?.stop()
mediaPlayer?.release()
vibrator?.cancel()
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
@SuppressLint("BatteryLife")
fun battery() {
val powerManager =
applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
val ignoring =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
powerManager.isIgnoringBatteryOptimizations(
applicationContext.packageName
)
else true
if (ignoring) return
val intent = Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.parse("package:" + applicationContext.packageName)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
try {
applicationContext.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(
applicationContext,
"Requests to ignore battery optimizations are disabled on your device.",
Toast.LENGTH_LONG
).show()
}
}
@SuppressLint("Range")
private fun getSettings(): Settings {
val db = DatabaseHelper(applicationContext).readableDatabase
val cursor = db.rawQuery("SELECT sound, noSound, vibrate, duration FROM settings", null)
cursor.moveToFirst()
val sound = cursor.getString(cursor.getColumnIndex("sound"))
val noSound = cursor.getInt(cursor.getColumnIndex("noSound")) == 1
val vibrate = cursor.getInt(cursor.getColumnIndex("vibrate")) == 1
var duration = cursor.getLong(cursor.getColumnIndex("duration"))
if (duration.toInt() == 0) duration = 300
cursor.close()
return Settings(sound, noSound, vibrate, duration)
}
private fun playSound(settings: Settings) {
if (settings.noSound) return
if (settings.sound == null) {
mediaPlayer = MediaPlayer.create(applicationContext, R.raw.argon)
mediaPlayer?.start()
mediaPlayer?.setOnCompletionListener { vibrator?.cancel() }
} else {
mediaPlayer = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setDataSource(applicationContext, Uri.parse(settings.sound))
prepare()
start()
setOnCompletionListener { vibrator?.cancel() }
}
}
}
private fun getProgress(timeLeftInSeconds: Int): NotificationCompat.Builder {
val notificationText = formatTime(timeLeftInSeconds)
val notificationChannelId = "timer_channel"
val notificationIntent = Intent(this, MainActivity::class.java)
val contentPending = PendingIntent.getActivity(
this,
0,
notificationIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val stopBroadcast = Intent(STOP_BROADCAST)
stopBroadcast.setPackage(applicationContext.packageName)
val stopPending =
PendingIntent.getBroadcast(
applicationContext,
0,
stopBroadcast,
PendingIntent.FLAG_IMMUTABLE
)
val addBroadcast =
Intent(ADD_BROADCAST).apply { setPackage(applicationContext.packageName) }
val addPending =
PendingIntent.getBroadcast(
applicationContext,
0,
addBroadcast,
PendingIntent.FLAG_MUTABLE
)
val notificationBuilder = NotificationCompat.Builder(this, notificationChannelId)
.setContentTitle(currentDescription)
.setContentText(notificationText)
.setSmallIcon(R.drawable.ic_baseline_timer_24)
.setProgress(timer.getDurationSeconds(), timeLeftInSeconds, false)
.setContentIntent(contentPending)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setAutoCancel(false)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setDeleteIntent(stopPending)
.addAction(R.drawable.ic_baseline_stop_24, "Stop", stopPending)
.addAction(R.drawable.ic_baseline_stop_24, "Add 1 min", addPending)
val notificationManager = NotificationManagerCompat.from(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
notificationChannelId,
"Timer Channel",
NotificationManager.IMPORTANCE_LOW
)
notificationManager.createNotificationChannel(channel)
}
return notificationBuilder
}
private fun vibrate(settings: Settings) {
if (!settings.vibrate) return
val pattern =
longArrayOf(0, settings.duration, 1000, settings.duration, 1000, settings.duration)
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager =
getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(VIBRATOR_SERVICE) as Vibrator
}
vibrator!!.vibrate(VibrationEffect.createWaveform(pattern, 2))
}
private fun notifyFinished() {
val channelId = "finished_channel"
val notificationManager = NotificationManagerCompat.from(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
channelId,
"Timer Finished Channel",
NotificationManager.IMPORTANCE_HIGH
)
channel.setSound(null, null)
channel.setBypassDnd(true)
channel.enableVibration(false)
channel.description = "Plays an alarm when a rest timer completes."
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
notificationManager.createNotificationChannel(channel)
}
val fullIntent = Intent(applicationContext, TimerDone::class.java)
val fullPending = PendingIntent.getActivity(
applicationContext, 0, fullIntent, PendingIntent.FLAG_MUTABLE
)
val finishIntent = Intent(applicationContext, StopAlarm::class.java)
val finishPending = PendingIntent.getActivity(
applicationContext, 0, finishIntent, PendingIntent.FLAG_IMMUTABLE
)
val stopBroadcast = Intent(STOP_BROADCAST)
stopBroadcast.setPackage(applicationContext.packageName)
val pendingStop =
PendingIntent.getBroadcast(
applicationContext,
0,
stopBroadcast,
PendingIntent.FLAG_IMMUTABLE
)
val builder = NotificationCompat.Builder(this, channelId)
.setContentTitle("Timer finished")
.setContentText(currentDescription)
.setSmallIcon(R.drawable.ic_baseline_timer_24)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(finishPending)
.setFullScreenIntent(fullPending, true)
.setAutoCancel(true)
.setDeleteIntent(pendingStop)
if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
notificationManager.notify(FINISHED_ID, builder.build())
}
private fun updateNotification(seconds: Int) {
val notificationManager = NotificationManagerCompat.from(this)
val notification = getProgress(seconds)
if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
notificationManager.notify(ONGOING_ID, notification.build())
}
private fun formatTime(timeInSeconds: Int): String {
val minutes = timeInSeconds / 60
val seconds = timeInSeconds % 60
return String.format("%02d:%02d", minutes, seconds)
}
companion object {
const val STOP_BROADCAST = "stop-timer-event"
const val ADD_BROADCAST = "add-timer-event"
const val TIMER_EXPIRED = "timer-expired-event"
const val ONGOING_ID = 1
const val FINISHED_ID = 1
}
}

View File

@ -5,70 +5,6 @@
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
<path android:fillColor="#1d1f21"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -1,55 +1,25 @@
import org.apache.tools.ant.taskdefs.condition.Os
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext {
kotlin_version = '1.6.10'
buildToolsVersion = "31.0.0"
buildToolsVersion = "34.0.0"
minSdkVersion = 21
compileSdkVersion = 31
targetSdkVersion = 31
compileSdkVersion = 34
targetSdkVersion = 34
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 = "25.1.8937393"
kotlinVersion = "1.8.0"
}
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
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
}
}
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' }
}
}
apply plugin: "com.facebook.react.rootproject"

View File

@ -24,9 +24,6 @@ android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Version of flipper SDK to use with React Native
FLIPPER_VERSION=0.125.0
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
@ -38,3 +35,7 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# to write custom TurboModules/Fabric components OR use libraries that
# 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,7 @@
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.3-all.zip
validateDistributionUrl=true
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

31
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,13 +80,11 @@ 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##*/}
# 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"'
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@ -133,22 +131,29 @@ location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
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
@ -193,6 +198,10 @@ if "$cygwin" || "$msys" ; then
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
@ -205,6 +214,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,11 +1,11 @@
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
presets: ['module:@react-native/babel-preset'],
plugins: [
'@babel/plugin-transform-flow-strip-types',
['@babel/plugin-proposal-decorators', {legacy: true}],
['@babel/plugin-proposal-class-properties', {loose: true}],
'react-native-reanimated/plugin',
['@babel/plugin-proposal-decorators', { legacy: true }],
['@babel/plugin-proposal-class-properties', { loose: true }],
'react-native-paper/babel',
'react-native-reanimated/plugin',
],
env: {
production: {

View File

@ -1,15 +1,42 @@
import {setRepo} from './db'
import GymSet from './gym-set'
import { LIMIT } from "./constants";
import { setRepo } from "./db";
import GymSet from "./gym-set";
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()
}
.addSelect("MAX(weight)", "weight")
.where("name = :name", { name })
.groupBy("name")
.addGroupBy("reps")
.orderBy("weight", "DESC")
.addOrderBy("reps", "DESC")
.getOne();
};
export const getBestSets = ({
term: term,
offset,
}: {
term: string;
offset?: number;
}) => {
return setRepo
.createQueryBuilder("gym_set")
.select(["gym_set.name", "gym_set.reps", "gym_set.weight"])
.groupBy("gym_set.name")
.innerJoin(
(qb) =>
qb
.select(["gym_set2.name", "MAX(gym_set2.weight) AS max_weight"])
.from(GymSet, "gym_set2")
.where("gym_set2.name LIKE (:name)", { name: `%${term.trim()}%` })
.groupBy("gym_set2.name"),
"subquery",
"gym_set.name = subquery.gym_set2_name AND gym_set.weight = subquery.max_weight"
)
.limit(LIMIT)
.offset(offset || 0)
.getMany();
};

View File

@ -1,40 +1,27 @@
import {DarkTheme, DefaultTheme} from 'react-native-paper'
import { DefaultTheme, MD3DarkTheme } from "react-native-paper";
export const lightColors = [
{hex: DarkTheme.colors.primary, name: 'Purple'},
{hex: '#B3E5FC', name: 'Blue'},
{hex: '#FA8072', name: 'Salmon'},
{hex: '#FFC0CB', name: 'Pink'},
{hex: '#E9DCC9', name: 'Linen'},
]
export const LIGHT_COLORS = [
{ hex: MD3DarkTheme.colors.primary, name: "Purple" },
{ hex: "#B3E5FC", name: "Blue" },
{ hex: "#FA8072", name: "Salmon" },
{ hex: "#FFC0CB", name: "Pink" },
{ hex: "#E9DCC9", name: "Linen" },
{ hex: "#9ACD32", name: "Green" },
{ hex: "#FFD700", name: "Gold" },
{ hex: "#00CED1", name: "Turquoise" },
];
export const darkColors = [
{hex: DefaultTheme.colors.primary, name: 'Purple'},
{hex: '#0051a9', name: 'Blue'},
{hex: '#000000', name: 'Black'},
{hex: '#863c3c', name: 'Red'},
{hex: '#1c6000', name: 'Kermit'},
]
export const DARK_COLORS = [
{ hex: DefaultTheme.colors.primary, name: "Purple" },
{ hex: "#0051a9", name: "Blue" },
{ hex: "#000000", name: "Black" },
{ hex: "#863c3c", name: "Brandy" },
{ hex: "#1c6000", name: "Kermit" },
{ hex: "#990000", name: "Red" },
{ hex: "#660066", name: "Magenta" },
];
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}`
export function darkenRgba(rgba: string, amount: number) {
let [r, g, b, a] = rgba.match(/\d+/g).map(Number);
return `rgba(${r}, ${g}, ${b}, ${Math.max(0, a - amount)})`;
}

View File

@ -1,5 +1,8 @@
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 MARGIN = 10;
export const PADDING = 10;
export const ITEM_PADDING = 8;
export const DARK_RIPPLE = "#444444";
export const LIGHT_RIPPLE = "#c2c2c2";
export const DARK_SUBDUED = "#909090ff";
export const LIGHT_SUBDUED = "#717171ff";
export const LIMIT = 15;

11
conversions.ts Normal file
View File

@ -0,0 +1,11 @@
export function convert(weight: number, fromUnit: string, toUnit: string) {
let result = Number(weight);
if (fromUnit === "lb" && toUnit === "kg") result /= 2.2;
else if (fromUnit === "kg" && toUnit === "lb") result *= 2.2;
else if (fromUnit === "stone" && toUnit === "kg") result *= 6.35;
else if (fromUnit === "kg" && toUnit === "stone") result /= 6.35;
else if (fromUnit === "stone" && toUnit === "lb") result *= 14;
else if (fromUnit === "lb" && toUnit === "stone") result /= 14;
result = Math.round((result + Number.EPSILON) * 100) / 100;
return result;
}

View File

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

View File

@ -1,39 +1,53 @@
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 {Plan} from './plan'
import Settings from './settings'
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 { planTitle1692654882408 } from "./migrations/1692654882408-plan-title";
import { weight1697766633971 } from "./migrations/1697766633971-weight";
import { exercises1699508495726 } from "./migrations/1699508495726-exercises";
import { exercisesFix1699613077628 } from "./migrations/1699613077628-exercises-fix";
import { settingsDuration1699743753975 } from "./migrations/1699743753975-settings-duration";
import { settingsStartup1699783784680 } from "./migrations/1699783784680-settings-startup";
import { settingsBackupDir1699839054226 } from "./migrations/1699839054226-settings-backup-dir";
import { homeHistoryStartup1699853245534 } from "./migrations/1699853245534-home-history-startup";
import { autoConvert1699948105001 } from "./migrations/1699948105001-auto-convert";
import { Plan } from "./plan";
import Settings from "./settings";
import Weight from "./weight";
import { settingsDefaultSets1700009253976 } from "./migrations/1700009253976-settings-default-sets";
import { settingsDefaults1700009729468 } from "./migrations/1700009729468-settings-defaults";
import { leadingZeros1707094662099 } from "./migrations/1707094662099-leading-zeros";
export const AppDataSource = new DataSource({
type: 'react-native',
database: 'massive.db',
location: 'default',
entities: [GymSet, Plan, Settings],
type: "react-native",
database: "massive.db",
location: "default",
entities: [GymSet, Plan, Settings, Weight],
migrationsRun: true,
migrationsTableName: 'typeorm_migrations',
migrationsTableName: "typeorm_migrations",
migrations: [
sets1667185586014,
plans1667186124792,
@ -59,5 +73,18 @@ export const AppDataSource = new DataSource({
addNoSound1667186456118,
dropMigrations1667190214743,
splitColor1669420187764,
addBackup1678334268359,
planTitle1692654882408,
weight1697766633971,
exercises1699508495726,
exercisesFix1699613077628,
settingsDuration1699743753975,
settingsStartup1699783784680,
settingsBackupDir1699839054226,
homeHistoryStartup1699853245534,
autoConvert1699948105001,
settingsDefaultSets1700009253976,
settingsDefaults1700009729468,
leadingZeros1707094662099,
],
})
});

9
days.ts Normal file
View File

@ -0,0 +1,9 @@
export const DAYS = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];

27
db.ts
View File

@ -1,14 +1,17 @@
import {AppDataSource} from './data-source'
import GymSet from './gym-set'
import {Plan} from './plan'
import Settings from './settings'
import { AppDataSource } from "./data-source";
import GymSet from "./gym-set";
import { Plan } from "./plan";
import Settings from "./settings";
import Weight from "./weight";
export const setRepo = AppDataSource.manager.getRepository(GymSet)
export const planRepo = AppDataSource.manager.getRepository(Plan)
export const settingsRepo = AppDataSource.manager.getRepository(Settings)
export const setRepo = AppDataSource.manager.getRepository(GymSet);
export const planRepo = AppDataSource.manager.getRepository(Plan);
export const settingsRepo = AppDataSource.manager.getRepository(Settings);
export const weightRepo = AppDataSource.manager.getRepository(Weight);
export const getNow = (): Promise<{now: string}[]> => {
return AppDataSource.manager.query(
"SELECT STRFTIME('%Y-%m-%dT%H:%M:%S','now','localtime') AS now",
)
}
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"]
}
}

47
deploy.mjs Normal file
View File

@ -0,0 +1,47 @@
import { execSync } from 'child_process';
import { readFileSync, writeFileSync } from 'fs';
import simpleGit from 'simple-git';
import os from 'os';
execSync('npx tsc', { stdio: 'inherit' });
let build = readFileSync('android/app/build.gradle', 'utf8');
const codeMatch = build.match(/versionCode (\d+)/);
if (!codeMatch) throw new Error('versionCode not found in build.gradle');
const versionCode = parseInt(codeMatch[1], 10) + 1;
build = build.replace(/versionCode \d+/, `versionCode ${versionCode}`);
const nameMatch = build.match(/versionName "(\d+\.\d+)"/);
if (!nameMatch) throw new Error('versionName not found in build.gradle');
const versionParts = nameMatch[1].split('.');
versionParts[1] = (parseInt(versionParts[1], 10) + 1).toString();
const versionName = versionParts.join('.');
build = build.replace(/versionName "\d+\.\d+"/, `versionName "${versionName}"`);
writeFileSync('android/app/build.gradle', build);
let packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
packageJson.version = versionName;
writeFileSync('package.json', JSON.stringify(packageJson, null, 2));
const git = simpleGit();
await git.add(['package.json', 'android/app/build.gradle']);
await git.log(['-1']).then(log => {
const newTitle = `${log.latest.message} - ${versionName} 🚀`;
console.log(newTitle);
const message = [newTitle, log.latest.body].join('\n');
return git.commit(message, [], ['--amend']);
}).then(() => {
return git.addTag(versionCode.toString());
}).then(() => {
return git.push('origin', 'HEAD', ['--tags', '--force']);
}).catch(err => {
console.error('Error amending commit:', err);
});
process.chdir('android')
const isWindows = os.platform() === 'win32';
execSync(isWindows ? '.\\gradlew.bat bundleRelease -q' : './gradlew bundleRelease -q', { stdio: 'inherit' });
execSync('bundle install --quiet', { stdio: 'inherit' });
execSync('bundle exec fastlane supply --aab app/build/outputs/bundle/release/app-release.aab', { stdio: 'inherit' });

View File

@ -1,42 +0,0 @@
#!/bin/bash
set -ex
yarn tsc
yarn lint
git push origin HEAD
cd android || exit 1
build=app/build.gradle
versionCode=$(
grep '^\s*versionCode [0-9]*$' "$build" | awk '{print $2+1}'
)
major=$(
grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" |
sed 's/"//g' | cut -d '.' -f 1 | awk '{print $2}'
)
minor=$(
grep '^\s*versionName "[0-9]*\.[0-9]*"' "$build" |
sed 's/"//g' | cut -d '.' -f 2
)
minor=$((minor + 1))
sed -i "s/\(^\s*\)versionCode [0-9]*$/\1versionCode $versionCode/" \
"$build"
sed -i "s/\(^\s*\)versionName \"[0-9]*.[0-9]*\"$/\1versionName \"$major.$minor\"/" "$build"
sed -i "s/\"version\": \"[0-9]*.[0-9]*\"/\"version\": \"$major.$minor\"/" ../package.json
[ "$1" != "--nobundle" ] && ./gradlew bundleRelease
bundle install
bundle exec fastlane supply --aab app/build/outputs/bundle/release/app-release.aab
git add app/build.gradle ../package.json
git commit --no-verify --message "Set versionCode=$versionCode"
git tag "$versionCode"
git push origin HEAD &
git push --tags
cd ..
./install.sh

View File

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

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