Compare commits

...

731 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
Brandon Presley 27b7e91e91 Factor out buttons in SettingsPage 2022-12-24 13:36:11 +13:00
Brandon Presley fc6f5e3b53 Add missing key prop to SettingsPage 2022-12-24 13:18:03 +13:00
Brandon Presley 8625ca2189 Set versionCode=36124 2022-12-24 13:13:55 +13:00
Brandon Presley 250335800f Move clear button after select all 2022-12-24 13:10:40 +13:00
Brandon Presley d89721c718 Set versionCode=36123 2022-12-23 18:35:11 +13:00
Brandon Presley 930ebdc9ca Move clear after select all 2022-12-23 18:32:46 +13:00
Brandon Presley 777eddf943 Remove usage of FlatList in Settings page
Doing so looks like it improved the performance
of the switches.

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

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

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

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

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

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

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

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

I found an open issue on the react-native github which seems
related https://github.com/facebook/react-native/issues/31523
but after following all of their suggestions I still have the
same error. I tried:
- Removing @babel/plugin-proposal-class-properties & @babel/plugin-transform-flow-strip-types
- Adding @babel/plugin-transform-flow-strip-types
2022-10-31 13:20:36 +13:00
Brandon Presley 111ee4201f Set versionCode=36079 2022-10-30 15:44:19 +13:00
Brandon Presley 294c6ee639 Send yarn lint & test to background 2022-10-30 15:43:27 +13:00
Brandon Presley 8ad6189dfc Fix colors on header bar for light theme 2022-10-30 15:42:43 +13:00
Brandon Presley 9752aa9dd1 Set versionCode=36078 2022-10-30 15:35:31 +13:00
Brandon Presley e0da621198 Add undo feature to StartPlan 2022-10-30 15:34:17 +13:00
Brandon Presley e33ff1172a Factor out StartPlanItem 2022-10-30 15:23:22 +13:00
Brandon Presley 992b3d0ba6 Fix unit sometimes exporting as the string 'null' 2022-10-30 15:14:57 +13:00
Brandon Presley 2ae9d2a4c1 Improve performance of StartPlan 2022-10-30 14:46:32 +13:00
Brandon Presley eba33c2599 Revert "Revert "Optimize query in StartPlan""
This reverts commit 129b00dc5d.
2022-10-30 14:27:22 +13:00
Brandon Presley a804d9ef05 Set versionCode=36077 2022-10-30 14:09:51 +13:00
Brandon Presley 5fafc6a63a Fix plan starting 2022-10-30 14:08:41 +13:00
Brandon Presley a85bc04c35 Set versionCode=36076 2022-10-30 13:49:10 +13:00
Brandon Presley 129b00dc5d Revert "Optimize query in StartPlan"
This reverts commit 97827c68b2.
2022-10-30 13:42:20 +13:00
Brandon Presley ba2a2259f3 Set versionCode=36075 2022-10-30 13:16:41 +13:00
Brandon Presley e1a90a98fb Fix crashing of plans 2022-10-30 13:15:31 +13:00
Brandon Presley 8a240b78cd Set versionCode=36074 2022-10-30 12:59:02 +13:00
Brandon Presley 6e75614d10 Add basic working unit tests 2022-10-30 12:56:58 +13:00
Brandon Presley cc97c760bb Give up trying to add unit tests
Running tests gives the following error:
TypeError: _reactNative.BackHandler.addEventListener is not a function
2022-10-29 17:16:28 +13:00
Brandon Presley 5441aa164b Move registerReceiver to no avail 2022-10-28 17:31:10 +13:00
Brandon Presley 1c58dc2db1 Local broadcast receiver is not running on stop intent 2022-10-28 17:22:26 +13:00
Brandon Presley 8504f8b811 Merge branch 'master' into alarm-module 2022-10-28 16:49:39 +13:00
Brandon Presley 46dcfb96bf Add broadcast receiver to AlarmModule 2022-10-28 16:48:29 +13:00
Brandon Presley 21d9149498 Quit trying to move timer logic into AlarmModule
I just can't figure out how to make the stop button
and delete intents work.
2022-10-24 14:45:21 +13:00
214 changed files with 20153 additions and 13876 deletions

3
.Gemfile Normal file
View File

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

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

@ -1 +1 @@
2.7.4
2.7.5

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

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

142
App.tsx
View File

@ -1,24 +1,22 @@
import {
NavigationContainer,
DarkTheme as NavigationDarkTheme,
DefaultTheme as NavigationDefaultTheme,
NavigationContainer,
} from '@react-navigation/native';
import {useEffect, useMemo, useState} from 'react';
import {useColorScheme} from 'react-native';
} from "@react-navigation/native";
import React, { useEffect, useMemo, useState } from "react";
import { useColorScheme } from "react-native";
import {
DarkTheme as PaperDarkTheme,
DefaultTheme as PaperDefaultTheme,
Provider,
} from 'react-native-paper';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import {Color} from './color';
import {lightColors} from './colors';
import {runMigrations} from './db';
import MassiveSnack from './MassiveSnack';
import Routes from './Routes';
import Settings from './settings';
import {getSettings} from './settings.service';
import {SettingsContext} from './use-settings';
MD3DarkTheme as PaperDarkTheme,
MD3LightTheme as PaperDefaultTheme,
Provider as PaperProvider,
} 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,
@ -35,60 +33,92 @@ export const CombinedDarkTheme = {
colors: {
...NavigationDarkTheme.colors,
...PaperDarkTheme.colors,
primary: lightColors[0].hex,
background: '#0E0E0E',
},
};
const App = () => {
const isDark = useColorScheme() === 'dark';
const [settings, setSettings] = useState<Settings>();
const [color, setColor] = useState(
isDark
? CombinedDarkTheme.colors.primary.toUpperCase()
: CombinedDefaultTheme.colors.primary.toUpperCase(),
);
console.log("Re rendered app");
const systemTheme = useColorScheme();
const [appSettings, setAppSettings] = useState({
startup: undefined,
theme: "system",
lightColor: CombinedDefaultTheme.colors.primary,
darkColor: CombinedDarkTheme.colors.primary,
});
const [error, setError] = useState("");
useEffect(() => {
runMigrations().then(async () => {
const gotSettings = await getSettings();
console.log(`${App.name}.runMigrations:`, {gotSettings});
setSettings(gotSettings);
if (gotSettings.color) setColor(gotSettings.color);
});
}, [setColor]);
(async () => {
if (!AppDataSource.isInitialized)
await AppDataSource.initialize().catch((e) => setError(e.toString()));
const theme = useMemo(() => {
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 = {
...CombinedDarkTheme,
colors: {...CombinedDarkTheme.colors, primary: color},
colors: {
...CombinedDarkTheme.colors,
primary: appSettings.darkColor,
},
};
const lightTheme = {
...CombinedDefaultTheme,
colors: {...CombinedDefaultTheme.colors, primary: color},
colors: {
...CombinedDefaultTheme.colors,
primary: appSettings.lightColor,
},
};
let value = isDark ? darkTheme : lightTheme;
if (settings?.theme === 'dark') value = darkTheme;
else if (settings?.theme === 'light') value = lightTheme;
return value;
}, [color, isDark, settings]);
let theme = systemTheme === "dark" ? darkTheme : lightTheme;
if (appSettings.theme === "dark") theme = darkTheme;
else if (appSettings.theme === "light") theme = lightTheme;
return theme;
}, [systemTheme, appSettings]);
return (
<Color.Provider value={{color, setColor}}>
<Provider
theme={theme}
settings={{icon: props => <MaterialIcon {...props} />}}>
<NavigationContainer theme={theme}>
<MassiveSnack>
{settings && (
<SettingsContext.Provider value={{settings, setSettings}}>
<Routes />
</SettingsContext.Provider>
)}
</MassiveSnack>
</NavigationContainer>
</Provider>
</Color.Provider>
<PaperProvider
theme={paperTheme}
settings={{ icon: (props) => <MaterialIcon {...props} /> }}
>
<NavigationContainer theme={paperTheme}>
{error && (
<FatalError
message={error}
setAppSettings={setAppSettings}
setError={setError}
/>
)}
{appSettings.startup !== undefined && (
<ThemeContext.Provider
value={{
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>
<AppSnack textColor={paperTheme.colors.background} />
</PaperProvider>
);
};

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,77 +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 {getBestReps, getBestWeights} from './best.service';
import {BestPageParams} from './BestPage';
import DrawerHeader from './DrawerHeader';
import Page from './Page';
import Set from './set';
import {useSettings} from './use-settings';
export default function BestList() {
const [bests, setBests] = useState<Set[]>();
const [term, setTerm] = useState('');
const navigation = useNavigation<NavigationProp<BestPageParams>>();
const {settings} = useSettings();
const refresh = useCallback(async (value: string) => {
const weights = await getBestWeights(value);
console.log(`${BestList.name}.refresh:`, {length: weights.length});
let newBest: Set[] = [];
for (const set of weights) {
const reps = await getBestReps(set.name, set.weight);
newBest.push(...reps);
}
setBests(newBest);
}, []);
useFocusEffect(
useCallback(() => {
refresh(term);
}, [refresh, term]),
);
const search = useCallback(
(value: string) => {
setTerm(value);
refresh(value);
},
[refresh],
);
const renderItem = ({item}: {item: Set}) => (
<List.Item
key={item.name}
title={item.name}
description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
onPress={() => navigation.navigate('ViewBest', {best: item})}
left={() =>
(settings.images && item.image && (
<Image source={{uri: item.image}} style={{height: 75, width: 75}} />
)) ||
null
}
/>
);
return (
<>
<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 Set from './set';
import ViewBest from './ViewBest';
const Stack = createStackNavigator<BestPageParams>();
export type BestPageParams = {
BestList: {};
ViewBest: {
best: Set;
};
};
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,64 +0,0 @@
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 {useColor} from './color';
import {MARGIN, PADDING} from './constants';
import Set from './set';
import useDark from './use-dark';
export default function Chart({
yData,
xFormat,
xData,
yFormat,
}: {
yData: number[];
xData: Set[];
xFormat: (value: any, index: number) => string;
yFormat: (value: any) => string;
}) {
const {color} = useColor();
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: color,
}}>
<Grid />
</LineChart>
<XAxis
style={{marginHorizontal: -10, height: xAxisHeight}}
data={xData}
formatLabel={xFormat}
contentInset={{left: 10, right: 10}}
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,
@ -6,13 +6,20 @@ export default function ConfirmDialog({
onOk,
show,
setShow,
onCancel,
}: {
title: string;
children: JSX.Element | JSX.Element[] | string;
onOk: () => void;
show: boolean;
setShow: (show: boolean) => void;
onCancel?: () => void;
}) {
const cancel = () => {
setShow(false);
onCancel && onCancel();
};
return (
<Portal>
<Dialog visible={show} onDismiss={() => setShow(false)}>
@ -22,7 +29,7 @@ export default function ConfirmDialog({
</Dialog.Content>
<Dialog.Actions>
<Button onPress={onOk}>OK</Button>
<Button onPress={() => setShow(false)}>Cancel</Button>
<Button onPress={cancel}>Cancel</Button>
</Dialog.Actions>
</Dialog>
</Portal>

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,17 +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 DrawerMenu from './DrawerMenu';
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}: {name: keyof DrawerParamList}) {
const navigation = useNavigation<DrawerNavigationProp<DrawerParamList>>();
export default function DrawerHeader({
name,
children,
ids,
unSelect,
}: {
name: string;
children?: JSX.Element | JSX.Element[];
ids?: unknown[],
unSelect?: () => void,
}) {
const navigation = useNavigation<DrawerNavigationProp<DrawerParams>>();
return (
<Appbar.Header>
<IconButton 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} />
<DrawerMenu name={name} />
{children}
</Appbar.Header>
);
}

View File

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

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,63 +3,89 @@ 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 {DrawerParamList} from './drawer-param-list';
import {PlanPageParams} from './plan-page-params';
import {addPlan, updatePlan} from './plan.service';
import {getNames} from './set.service';
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(',') : [],
plan.days ? plan.days.split(",") : []
);
const [workouts, setWorkouts] = useState<string[]>(
plan.workouts ? plan.workouts.split(',') : [],
const [exercises, setExercises] = useState<string[]>(
plan.exercises ? plan.exercises.split(",") : []
);
const [names, setNames] = useState<string[]>([]);
const navigation = useNavigation<NavigationProp<DrawerParamList>>();
const { navigate: drawerNavigate } =
useNavigation<NavigationProp<DrawerParams>>();
const { navigate: stackNavigate } =
useNavigation<NavigationProp<StackParams>>();
useEffect(() => {
getNames().then(n => {
console.log(EditPlan.name, {n});
setNames(n);
});
setRepo
.createQueryBuilder()
.select("name")
.distinct(true)
.orderBy("name")
.getRawMany()
.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(',');
if (typeof plan.id === 'undefined')
await addPlan({days: newDays, workouts: newWorkouts});
else
await updatePlan({
days: newDays,
workouts: newWorkouts,
id: plan.id,
});
navigation.goBack();
}, [days, workouts, plan, navigation]);
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(
@ -67,68 +93,136 @@ export default function EditPlan() {
if (on) {
setDays([...days, day]);
} else {
setDays(days.filter(d => d !== day));
setDays(days.filter((d) => d !== day));
}
},
[setDays, days],
[setDays, days]
);
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>
{names.length === 0 ? (
<Button
disabled={workouts.length === 0 && days.length === 0}
mode="contained"
onPress={() => {
navigation.goBack();
navigation.navigate('Workouts', {
screen: 'EditWorkout',
params: {value: {name: ''}},
<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 },
});
}}>
Add workout
</Button>
) : (
<Button
disabled={workouts.length === 0 && days.length === 0}
style={{marginTop: MARGIN}}
mode="contained"
icon="save"
onPress={save}>
Save
</Button>
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"
/>
)}
</View>
</StackHeader>
<ScrollView style={{ padding: PADDING, flex: 1 }}>
<AppInput
label="Title"
value={title}
placeholder={days.join(", ")}
onChangeText={(value) => setTitle(value)}
/>
<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>
</>
);
}

View File

@ -1,76 +1,370 @@
import {RouteProp, useNavigation, useRoute} from '@react-navigation/native';
import {useCallback} from 'react';
import {NativeModules, View} from 'react-native';
import {PADDING} from './constants';
import {HomePageParams} from './home-page-params';
import {useSnackbar} from './MassiveSnack';
import Set from './set';
import {addSet, getSet, updateSet} from './set.service';
import SetForm from './SetForm';
import StackHeader from './StackHeader';
import {useSettings} from './use-settings';
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,
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 {toast} = useSnackbar();
const {settings} = useSettings();
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(gotSettings => {
setSettings(gotSettings);
console.log(`${EditSet.name}.focus:`, { gotSettings })
});
}, [])
);
const startTimer = useCallback(
async (name: string) => {
async (value: string) => {
if (!settings.alarm) return;
const {minutes, seconds} = await getSet(name);
const milliseconds = (minutes ?? 3) * 60 * 1000 + (seconds ?? 0) * 1000;
NativeModules.AlarmModule.timer(
milliseconds,
!!settings.vibrate,
settings.sound,
!!settings.noSound,
);
const first = await setRepo.findOne({ where: { name: value } });
const milliseconds =
(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 update = useCallback(
async (value: Set) => {
console.log(`${EditSet.name}.update`, value);
await updateSet(value);
navigation.goBack();
},
[navigation],
);
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 add = useCallback(
async (value: Set) => {
console.log(`${EditSet.name}.add`, {set: value});
startTimer(value.name);
await addSet(value);
if (!settings.notify) return navigation.goBack();
if (
value.weight > set.weight ||
(value.reps > set.reps && value.weight === set.weight)
)
toast("Great work King! That's a new record.", 3000);
navigation.goBack();
},
[navigation, startTimer, set, toast, settings],
);
const added = async (value: GymSet) => {
console.log(`${EditSet.name}.added:`, value);
startTimer(value.name);
};
const save = useCallback(
async (value: Set) => {
if (typeof set.id === 'number') return update(value);
return add(value);
},
[update, add, set.id],
);
const handleSubmit = async () => {
if (!name) return;
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,
reps: Number(reps || 0),
weight: newWeight,
unit: newUnit,
minutes: Number(set.minutes ?? 3),
seconds: Number(set.seconds ?? 30),
sets: set.sets ?? 3,
hidden: false,
};
newSet.image = newImage;
if (!newImage && !removeImage) {
newSet.image = await setRepo
.findOne({ where: { name } })
.then((s) => s?.image);
}
if (createdDirty) newSet.created = created.toISOString();
if (typeof set.id !== "number") newSet.created = await getNow();
const saved = await setRepo.save(newSet);
notify(newSet);
if (typeof set.id !== "number") added(saved);
navigate("History");
};
const changeImage = useCallback(async () => {
const { fileCopyUri } = await DocumentPicker.pickSingle({
type: DocumentPicker.types.images,
copyTo: "documentDirectory",
});
if (fileCopyUri) setNewImage(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
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" />
<View style={{padding: PADDING, flex: 1}}>
<SetForm save={save} set={set} />
<StackHeader title={typeof set.id === "number" ? "Edit set" : "Add set"}>
{typeof set.id === "number" ? (
<IconButton onPress={() => setShowDelete(true)} icon="delete" />
) : null}
</StackHeader>
<View style={{ padding: PADDING, flex: 1 }}>
<View>
<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>
<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}
/>
<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 && (
<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}
/>
)}
{settings.images && newImage && (
<TouchableRipple
style={{ marginBottom: MARGIN }}
onPress={changeImage}
onLongPress={() => setShowRemoveImage(true)}
>
<Card.Cover source={{ uri: newImage }} />
</TouchableRipple>
)}
{settings.images && !newImage && (
<Button
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="image-plus"
>
Image
</Button>
)}
</View>
<PrimaryButton
disabled={!name}
icon="content-save"
style={{ margin: MARGIN }}
onPress={handleSubmit}
>
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>
</>
);
}

203
EditSets.tsx Normal file
View File

@ -0,0 +1,203 @@
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, 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<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])
);
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({
type: DocumentPicker.types.images,
copyTo: "documentDirectory",
});
if (fileCopyUri) setNewImage(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setNewImage("");
setShowRemove(false);
}, []);
return (
<>
<StackHeader title={`Edit ${ids.length} sets`} />
<View style={{ padding: PADDING, flex: 1 }}>
<AppInput
label={`Names: ${names}`}
value={name}
onChangeText={setName}
autoCorrect={false}
autoFocus={!name}
/>
<View>
<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>
<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 && (
<Select
value={unit}
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 }}
onPress={changeImage}
onLongPress={() => setShowRemove(true)}
>
<Card.Cover source={{ uri: newImage }} />
</TouchableRipple>
)}
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemove}
setShow={setShowRemove}
>
Are you sure you want to remove the image?
</ConfirmDialog>
{settings.images && !newImage && (
<Button
style={{ marginBottom: MARGIN }}
onPress={changeImage}
icon="image-plus"
>
Image
</Button>
)}
</View>
<PrimaryButton
icon="content-save"
style={{ margin: MARGIN }}
onPress={save}
>
Save
</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,186 +0,0 @@
import {RouteProp, 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 MassiveInput from './MassiveInput';
import {useSnackbar} from './MassiveSnack';
import {updatePlanWorkouts} from './plan.service';
import {addSet, updateManySet, updateSetImage} from './set.service';
import StackHeader from './StackHeader';
import {useSettings} from './use-settings';
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 {toast} = useSnackbar();
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} = useSettings();
const update = async () => {
await updateManySet({
oldName: params.value.name,
newName: name || params.value.name,
sets: sets ?? '3',
seconds: seconds?.toString() ?? '30',
minutes: minutes?.toString() ?? '3',
steps,
});
await updatePlanWorkouts(params.value.name, name || params.value.name);
if (uri || removeImage) await updateSetImage(params.value.name, uri || '');
navigation.goBack();
};
const add = async () => {
await addSet({
name,
reps: 0,
weight: 0,
hidden: true,
image: uri,
minutes: minutes ? +minutes : 3,
seconds: seconds ? +seconds : 30,
sets: sets ? +sets : 3,
steps,
});
navigation.goBack();
};
const save = async () => {
if (params.value.name) return update();
return add();
};
const changeImage = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
type: 'image/*',
copyTo: 'documentDirectory',
});
if (fileCopyUri) setUri(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setUri('');
setRemoveImage(true);
setShowRemove(false);
}, []);
const handleName = (value: string) => {
setName(value.replace(/,|'/g, ''));
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
const handleSteps = (value: string) => {
setSteps(value.replace(/,|'/g, ''));
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
const submitName = () => {
if (settings.steps) stepsRef.current?.focus();
else setsRef.current?.focus();
};
return (
<>
<StackHeader title="Edit workout" />
<View style={{padding: PADDING, flex: 1}}>
<ScrollView style={{flex: 1}}>
<MassiveInput
autoFocus
label="Name"
value={name}
onChangeText={handleName}
onSubmitEditing={submitName}
/>
{!!settings.steps && (
<MassiveInput
innerRef={stepsRef}
selectTextOnFocus={false}
value={steps}
onChangeText={handleSteps}
label="Steps"
multiline
onSubmitEditing={() => setsRef.current?.focus()}
/>
)}
{!!settings.showSets && (
<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'

100
Gemfile.lock Normal file
View File

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

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,16 +0,0 @@
import {createStackNavigator} from '@react-navigation/stack';
import EditSet from './EditSet';
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.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>
</>
);
}

104
ListMenu.tsx Normal file
View File

@ -0,0 +1,104 @@
import { useState } from "react";
import { Divider, IconButton, Menu } from "react-native-paper";
import ConfirmDialog from "./ConfirmDialog";
export default function ListMenu({
onEdit,
onCopy,
onClear,
onDelete,
onSelect,
ids,
}: {
onEdit: () => void;
onCopy?: () => void;
onClear: () => void;
onDelete: () => void;
onSelect: () => void;
ids?: unknown[];
}) {
const [showMenu, setShowMenu] = useState(false);
const [showRemove, setShowRemove] = useState(false);
const edit = () => {
setShowMenu(false);
onEdit();
};
const copy = () => {
setShowMenu(false);
onCopy();
};
const clear = () => {
setShowMenu(false);
onClear();
};
const remove = () => {
setShowMenu(false);
setShowRemove(false);
onDelete();
};
const select = () => {
setShowMenu(false);
onSelect();
};
return (
<>
{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
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"}
show={showRemove}
setShow={setShowRemove}
onOk={remove}
onCancel={() => setShowMenu(false)}
>
{ids?.length === 0 ? (
<>This irreversibly deletes records from the app. Are you sure?</>
) : (
<>This will delete {ids.length} {ids?.length > 1 ? "records" : "record"}. Are you sure?</>
)}
</ConfirmDialog>
</>
);
}

View File

@ -1,25 +0,0 @@
import {ComponentProps} from 'react';
import {FAB} from 'react-native-paper';
import {useColor} from './color';
import {lightColors} from './colors';
export default function MassiveFab(props: Partial<ComponentProps<typeof FAB>>) {
const {color} = useColor();
const fabColor = lightColors.map(lightColor => lightColor.hex).includes(color)
? 'black'
: undefined;
return (
<FAB
icon="add"
color={fabColor}
style={{
position: 'absolute',
right: 10,
bottom: 10,
backgroundColor: color,
}}
{...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,49 +0,0 @@
import {createContext, useContext, useState} from 'react';
import {Snackbar} from 'react-native-paper';
import {CombinedDarkTheme, CombinedDefaultTheme} from './App';
import useDark from './use-dark';
export const SnackbarContext = createContext<{
toast: (value: string, timeout: number) => void;
}>({toast: () => null});
export const useSnackbar = () => {
return useContext(SnackbarContext);
};
export default function MassiveSnack({
children,
}: {
children?: JSX.Element[] | JSX.Element;
}) {
const [snackbar, setSnackbar] = useState('');
const [timeoutId, setTimeoutId] = useState(0);
const dark = useDark();
const toast = (value: string, timeout: number) => {
setSnackbar(value);
clearTimeout(timeoutId);
const id = setTimeout(() => setSnackbar(''), timeout);
setTimeoutId(id);
};
return (
<>
<SnackbarContext.Provider value={{toast}}>
{children}
</SnackbarContext.Provider>
<Snackbar
onDismiss={() => setSnackbar('')}
visible={!!snackbar}
action={{
label: 'Close',
onPress: () => setSnackbar(''),
color: dark
? CombinedDarkTheme.colors.background
: CombinedDefaultTheme.colors.background,
}}>
{snackbar}
</Snackbar>
</>
);
}

View File

@ -1,37 +1,39 @@
import {StyleSheet, View} from 'react-native';
import {Searchbar} from 'react-native-paper';
import {PADDING} from './constants';
import MassiveFab from './MassiveFab';
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
import { Searchbar } from "react-native-paper";
import AppFab from "./AppFab";
import { PADDING } from "./constants";
export default function Page({
onAdd,
children,
term,
search,
style,
}: {
children: JSX.Element | JSX.Element[];
onAdd?: () => void;
term: string;
search: (value: string) => void;
style?: StyleProp<ViewStyle>;
}) {
return (
<View style={styles.container}>
<View style={[styles.view, style]}>
<Searchbar
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({
container: {
flexGrow: 1,
view: {
padding: PADDING,
flexGrow: 1,
},
});

View File

@ -2,100 +2,105 @@ import {
NavigationProp,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import {useCallback, useMemo, useState} from 'react';
import {GestureResponderEvent, Text} from 'react-native';
import {Divider, List, Menu} from 'react-native-paper';
import {getBestSet} from './best.service';
import {Plan} from './plan';
import {PlanPageParams} from './plan-page-params';
import {deletePlan} from './plan.service';
import {DAYS} from './time';
} 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,
onRemove,
setIds,
ids,
}: {
item: Plan;
onRemove: () => void;
ids: number[];
setIds: (value: number[]) => void;
}) {
const [show, setShow] = useState(false);
const [anchor, setAnchor] = useState({x: 0, y: 0});
const [today, setToday] = useState<string>();
const days = useMemo(() => item.days.split(','), [item.days]);
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
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 remove = useCallback(async () => {
if (typeof item.id === 'number') await deletePlan(item.id);
setShow(false);
onRemove();
}, [setShow, item.id, onRemove]);
const start = useCallback(async () => {
const workouts = item.workouts.split(',');
const first = workouts[0];
const set = await getBestSet(first);
setShow(false);
navigation.navigate('StartPlan', {plan: item, set});
}, [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(
(e: GestureResponderEvent) => {
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
setShow(true);
},
[setAnchor, setShow],
);
const longPress = useCallback(() => {
if (ids.length > 0) return;
setIds([item.id]);
}, [ids.length, item.id, setIds]);
const edit = useCallback(() => {
setShow(false);
navigation.navigate('EditPlan', {plan: item});
}, [navigation, item]);
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]);
return (
<>
<List.Item
onPress={start}
title={title}
description={description}
onLongPress={longPress}
right={() => (
<Menu anchor={anchor} visible={show} onDismiss={() => setShow(false)}>
<Menu.Item icon="edit" onPress={edit} title="Edit" />
<Divider />
<Menu.Item icon="delete" onPress={remove} title="Delete" />
</Menu>
)}
/>
</>
<List.Item
onPress={start}
title={title}
description={description}
onLongPress={longPress}
style={{ backgroundColor }}
/>
);
}

View File

@ -2,30 +2,43 @@ 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 {Plan} from './plan';
import {PlanPageParams} from './plan-page-params';
import {getPlans} from './plan.service';
import PlanItem from './PlanItem';
} from "@react-navigation/native";
import { useCallback, useState } from "react";
import { FlatList } from "react-native";
import { List } from "react-native-paper";
import { Like } from "typeorm";
import { 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 [term, setTerm] = useState("");
const [plans, setPlans] = useState<Plan[]>();
const navigation = useNavigation<NavigationProp<PlanPageParams>>();
const [ids, setIds] = useState<number[]>([]);
const navigation = useNavigation<NavigationProp<StackParams>>();
const refresh = useCallback(async (value: string) => {
getPlans(value).then(setPlans);
console.log(`${PlanList.name}.refresh:`, value);
planRepo
.find({
where: [
{ title: Like(`%${value.trim()}%`) },
{ days: Like(`%${value.trim()}%`) },
{ exercises: Like(`%${value.trim()}%`) },
],
})
.then(setPlans);
}, []);
useFocusEffect(
useCallback(() => {
refresh(term);
}, [refresh, term]),
// eslint-disable-next-line
}, [term])
);
const search = useCallback(
@ -33,34 +46,79 @@ export default function PlanList() {
setTerm(value);
refresh(value);
},
[refresh],
[refresh]
);
const renderItem = useCallback(
({item}: {item: Plan}) => (
<PlanItem item={item} key={item.id} onRemove={() => refresh(term)} />
({ item }: { item: Plan }) => (
<PlanItem ids={ids} setIds={setIds} item={item} key={item.id} />
),
[refresh, term],
[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 copy = useCallback(async () => {
const plan = await planRepo.findOne({
where: { id: ids.pop() },
});
delete plan.id;
navigation.navigate("EditPlan", { plan });
setIds([]);
}, [ids, navigation]);
const clear = useCallback(() => {
setIds([]);
}, []);
const remove = useCallback(async () => {
await planRepo.delete(ids.length > 0 ? ids : {});
await refresh(term);
setIds([]);
}, [ids, refresh, term]);
const select = useCallback(() => {
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}
onDelete={remove}
onEdit={edit}
ids={ids}
onSelect={select}
/>
</DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}>
{plans?.length === 0 ? (
<List.Item
title="No plans yet"
description="A plan is a list of workouts for certain days."
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,18 +0,0 @@
import {createStackNavigator} from '@react-navigation/stack';
import EditPlan from './EditPlan';
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.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

View File

@ -1,44 +0,0 @@
import {createDrawerNavigator} from '@react-navigation/drawer';
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 useDark from './use-dark';
import WorkoutsPage from './WorkoutsPage';
const Drawer = createDrawerNavigator<DrawerParamList>();
export default function Routes() {
const dark = useDark();
const routes: Route[] = [
{name: 'Home', component: HomePage, icon: 'home'},
{name: 'Plans', component: PlanPage, icon: 'event'},
{name: 'Best', component: BestPage, icon: 'insights'},
{name: 'Workouts', component: WorkoutsPage, icon: 'fitness-center'},
{name: 'Settings', component: SettingsPage, icon: 'settings'},
];
return (
<Drawer.Navigator
screenOptions={{
headerTintColor: dark ? 'white' : 'black',
swipeEdgeWidth: 1000,
headerShown: false,
}}>
{routes.map(route => (
<Drawer.Screen
key={route.name}
name={route.name}
component={route.component}
options={{
drawerIcon: () => <IconButton icon={route.icon} />,
}}
/>
))}
</Drawer.Navigator>
);
}

75
Select.tsx Normal file
View File

@ -0,0 +1,75 @@
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;
icon?: string;
}
function Select({
value,
onChange,
items,
label,
}: {
value: string;
onChange: (value: string) => void;
items: Item[];
label?: string;
}) {
const [show, setShow] = useState(false);
const { colors } = useTheme();
let menuButton: React.Ref<View> = null;
const selected = useMemo(
() => items.find((item) => item.value === value) || items[0],
[items, value]
);
const press = useCallback(
(newValue: string) => {
onChange(newValue);
setShow(false);
},
[onChange]
);
return (
<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,160 +0,0 @@
import {useCallback, useRef, useState} from 'react';
import {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} from './constants';
import MassiveInput from './MassiveInput';
import {useSnackbar} from './MassiveSnack';
import Set from './set';
import {getSets} from './set.service';
import {useSettings} from './use-settings';
export default function SetForm({
save,
set,
}: {
set: Set;
save: (set: Set) => void;
}) {
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 [selection, setSelection] = useState({
start: 0,
end: set.reps.toString().length,
});
const [removeImage, setRemoveImage] = useState(false);
const {toast} = useSnackbar();
const {settings} = useSettings();
const weightRef = useRef<TextInput>(null);
const repsRef = useRef<TextInput>(null);
const unitRef = useRef<TextInput>(null);
const handleSubmit = async () => {
console.log(`${SetForm.name}.handleSubmit:`, {set, uri: newImage, name});
if (!name) return;
let image = newImage;
if (!newImage && !removeImage)
image = await getSets({term: name, limit: 1, offset: 0}).then(
([gotSet]) => gotSet?.image,
);
console.log(`${SetForm.name}.handleSubmit:`, {image});
save({
name,
reps: Number(reps),
weight: Number(weight),
id: set.id,
unit,
image,
minutes: Number(set.minutes ?? 3),
seconds: Number(set.seconds ?? 30),
sets: set.sets ?? 3,
});
};
const handleName = (value: string) => {
setName(value.replace(/,|'/g, ''));
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
const handleUnit = (value: string) => {
setUnit(value.replace(/,|'/g, ''));
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
};
const changeImage = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
type: 'image/*',
copyTo: 'documentDirectory',
});
if (fileCopyUri) setNewImage(fileCopyUri);
}, []);
const handleRemove = useCallback(async () => {
setNewImage('');
setRemoveImage(true);
setShowRemove(false);
}, []);
return (
<>
<View style={{flex: 1}}>
<MassiveInput
label="Name"
value={name}
onChangeText={handleName}
autoCorrect={false}
autoFocus={!name}
onSubmitEditing={() => repsRef.current?.focus()}
/>
<MassiveInput
label="Reps"
keyboardType="numeric"
value={reps}
onChangeText={setReps}
onSubmitEditing={() => weightRef.current?.focus()}
selection={selection}
onSelectionChange={e => setSelection(e.nativeEvent.selection)}
autoFocus={!!name}
innerRef={repsRef}
/>
<MassiveInput
label="Weight"
keyboardType="numeric"
value={weight}
onChangeText={setWeight}
onSubmitEditing={handleSubmit}
innerRef={weightRef}
/>
{!!settings.showUnit && (
<MassiveInput
autoCapitalize="none"
label="Unit"
value={unit}
onChangeText={handleUnit}
innerRef={unitRef}
/>
)}
{typeof set.id === 'number' && !!settings.showDate && (
<MassiveInput label="Created" disabled value={set.created} />
)}
{!!settings.images && newImage && (
<TouchableRipple
style={{marginBottom: MARGIN}}
onPress={changeImage}
onLongPress={() => setShowRemove(true)}>
<Card.Cover source={{uri: newImage}} />
</TouchableRipple>
)}
{!!settings.images && !newImage && (
<Button
style={{marginBottom: MARGIN}}
onPress={changeImage}
icon="add-photo-alternate">
Image
</Button>
)}
</View>
<Button
disabled={!name}
mode="contained"
icon="save"
onPress={handleSubmit}>
Save
</Button>
<ConfirmDialog
title="Remove image"
onOk={handleRemove}
show={showRemove}
setShow={setShowRemove}>
Are you sure you want to remove the image?
</ConfirmDialog>
</>
);
}

View File

@ -1,83 +1,91 @@
import {NavigationProp, useNavigation} from '@react-navigation/native';
import {useCallback, useState} from 'react';
import {GestureResponderEvent, Image} from 'react-native';
import {Divider, List, Menu, Text} from 'react-native-paper';
import {HomePageParams} from './home-page-params';
import Set from './set';
import {deleteSet} from './set.service';
import {format} from './time';
import useDark from './use-dark';
import {useSettings} from './use-settings';
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,
onRemove,
}: {
item: Set;
onRemove: () => void;
}) {
const [showMenu, setShowMenu] = useState(false);
const [anchor, setAnchor] = useState({x: 0, y: 0});
const {settings} = useSettings();
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 remove = useCallback(async () => {
if (typeof item.id === 'number') await deleteSet(item.id);
setShowMenu(false);
onRemove();
}, [setShowMenu, onRemove, item.id]);
const longPress = useCallback(() => {
if (ids.length > 0) return;
setIds([item.id]);
}, [ids.length, item.id, setIds]);
const copy = useCallback(() => {
const set: Set = {...item};
delete set.id;
setShowMenu(false);
navigation.navigate('EditSet', {set});
}, [navigation, item]);
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 longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({x: e.nativeEvent.pageX, y: e.nativeEvent.pageY});
setShowMenu(true);
},
[setShowMenu, setAnchor],
);
const backgroundColor = useMemo(() => {
if (!ids.includes(item.id)) return;
if (dark) return DARK_RIPPLE;
return LIGHT_RIPPLE;
}, [dark, ids, item.id]);
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={() => navigation.navigate('EditSet', {set: item})}
onPress={press}
title={item.name}
description={`${item.reps} x ${item.weight}${item.unit || 'kg'}`}
onLongPress={longPress}
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(item.created || '', settings.date)}
</Text>
)}
<Menu
anchor={anchor}
visible={showMenu}
onDismiss={() => setShowMenu(false)}>
<Menu.Item icon="content-copy" onPress={copy} title="Copy" />
<Divider />
<Menu.Item icon="delete" onPress={remove} title="Delete" />
</Menu>
</>
<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,110 +2,183 @@ 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 {HomePageParams} from './home-page-params';
import Page from './Page';
import Set from './set';
import {defaultSet, getSets, getToday} from './set.service';
import SetItem from './SetItem';
import {useSettings} from './use-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<Set[]>();
const [set, setSet] = useState<Set>();
const [refreshing, setRefreshing] = useState(false);
const [sets, setSets] = useState<GymSet[]>();
const [offset, setOffset] = useState(0);
const [term, setTerm] = useState('');
const [end, setEnd] = useState(false);
const {settings} = useSettings();
const navigation = useNavigation<NavigationProp<HomePageParams>>();
const [settings, setSettings] = useState<Settings>();
const [ids, setIds] = useState<number[]>([]);
const navigation = useNavigation<NavigationProp<StackParams>>();
const [term, setTerm] = useState("");
const refresh = useCallback(
const reset = useCallback(
async (value: string) => {
const todaysSet = await getToday();
if (todaysSet) setSet({...todaysSet});
const newSets = await getSets({
term: `%${value}%`,
limit,
offset: 0,
format: settings.date || '%Y-%m-%d %H:%M',
const newSets = await setRepo.find({
where: { name: Like(`%${value.trim()}%`), hidden: 0 as any },
take: LIMIT,
skip: 0,
order: { created: "DESC" },
});
console.log(`${SetList.name}.refresh:`, {first: newSets[0]});
if (newSets.length === 0) return setSets([]);
setSets(newSets);
setOffset(0);
console.log(`${SetList.name}.reset:`, { value, offset });
setEnd(false);
},
[settings.date],
[offset]
);
useFocusEffect(
useCallback(() => {
refresh(term);
}, [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: Set}) => (
<SetItem item={item} key={item.id} onRemove={() => refresh(term)} />
({ item }: { item: GymSet }) => (
<SetItem
settings={settings}
item={item}
key={item.id}
ids={ids}
setIds={setIds}
/>
),
[refresh, term],
[settings, ids]
);
const next = useCallback(async () => {
if (end) return;
const newOffset = offset + limit;
console.log(`${SetList.name}.next:`, {offset, newOffset, term});
const newSets = await getSets({
term: `%${term}%`,
limit,
offset: newOffset,
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,
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);
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);
}, [term, end, offset, sets]);
};
const onAdd = useCallback(async () => {
console.log(`${SetList.name}.onAdd`, {set});
navigation.navigate('EditSet', {
set: set || {...defaultSet},
});
}, [navigation, set]);
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 search = useCallback(
(value: string) => {
setTerm(value);
refresh(value);
},
[refresh],
);
const edit = useCallback(() => {
navigation.navigate("EditSets", { ids });
setIds([]);
}, [ids, navigation]);
const copy = useCallback(async () => {
const set = await setRepo.findOne({
where: { id: ids.pop() },
});
delete set.id;
delete set.created;
navigation.navigate("EditSet", { set });
setIds([]);
}, [ids, navigation]);
const clear = useCallback(() => {
setIds([]);
}, []);
const remove = async () => {
setIds([]);
await setRepo.delete(ids.length > 0 ? ids : {});
return reset(term);
};
const select = useCallback(() => {
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}
onDelete={remove}
onEdit={edit}
ids={ids}
onSelect={select}
/>
</DrawerHeader>
<Page onAdd={onAdd} term={term} search={search}>
{sets?.length === 0 ? (
<List.Item
title="No sets yet"
description="A set is a group of repetitions. E.g. 8 reps of Squats."
/>
) : (
<FlatList
data={sets}
style={{flex: 1}}
renderItem={renderItem}
keyExtractor={s => s.id!.toString()}
onEndReached={next}
/>
)}
{getContent()}
</Page>
</>
);

View File

@ -1,267 +1,605 @@
import {Picker} from '@react-native-picker/picker';
import {useFocusEffect} from '@react-navigation/native';
import {useCallback, useEffect, useState} from 'react';
import {NativeModules, ScrollView} from 'react-native';
import DocumentPicker from 'react-native-document-picker';
import {Button} from 'react-native-paper';
import {useColor} from './color';
import {darkColors, lightColors} from './colors';
import ConfirmDialog from './ConfirmDialog';
import {MARGIN} from './constants';
import DrawerHeader from './DrawerHeader';
import Input from './input';
import {useSnackbar} from './MassiveSnack';
import Page from './Page';
import Settings from './settings';
import {updateSettings} from './settings.service';
import Switch from './Switch';
import {useSettings} from './use-settings';
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 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 [battery, setBattery] = useState(false);
const [ignoring, setIgnoring] = useState(false);
const [term, setTerm] = useState('');
const {settings, setSettings} = useSettings();
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>>();
const { watch, setValue } = useForm<Settings>({
defaultValues: () => settingsRepo.findOne({ where: {} }),
});
const settings = watch();
const {
vibrate,
sound,
notify,
images,
showUnit,
steps,
showDate,
showSets,
theme,
alarm,
noSound,
} = settings;
const {color, setColor} = useColor();
const {toast} = useSnackbar();
setTheme,
lightColor,
setLightColor,
darkColor,
setDarkColor,
} = useAppTheme();
useEffect(() => {
console.log(`${SettingsPage.name}.useEffect:`, {settings});
}, [settings]);
useFocusEffect(
useCallback(() => {
NativeModules.AlarmModule.ignoringBattery(setIgnoring);
}, []),
);
const update = useCallback(
(value: boolean, field: keyof Settings) => {
updateSettings({...settings, [field]: +value});
setSettings({...settings, [field]: +value});
},
[settings, setSettings],
);
const changeAlarmEnabled = useCallback(
(enabled: boolean) => {
if (enabled) toast('Timers will now run after each set.', 4000);
else toast('Stopped timers running after each set.', 4000);
if (enabled && !ignoring) setBattery(true);
update(enabled, 'alarm');
},
[setBattery, ignoring, toast, update],
);
const changeVibrate = useCallback(
(enabled: boolean) => {
if (enabled) toast('When a timer completes, vibrate your phone.', 4000);
else toast('Stop vibrating at the end of timers.', 4000);
update(enabled, 'vibrate');
},
[toast, update],
);
const changeSound = useCallback(async () => {
const {fileCopyUri} = await DocumentPicker.pickSingle({
type: 'audio/*',
copyTo: 'documentDirectory',
NativeModules.SettingsModule.ignoringBattery().then(setIgnoring);
NativeModules.SettingsModule.is24().then((is24: boolean) => {
console.log(`${SettingsPage.name}.focus:`, { is24 });
if (is24) setFormatOptions(twentyFours);
else setFormatOptions(twelveHours);
});
if (!fileCopyUri) return;
updateSettings({sound: fileCopyUri} as Settings);
setSettings({...settings, sound: fileCopyUri});
toast('This song will now play after rest timers complete.', 4000);
}, [toast, setSettings, settings]);
}, []);
const changeNotify = useCallback(
(enabled: boolean) => {
update(enabled, 'notify');
if (enabled) toast('Show when a set is a new record.', 4000);
else toast('Stopped showing notifications for new records.', 4000);
const backupString = useMemo(() => {
if (!settings.backupDir) return null;
const split = decodeURIComponent(settings.backupDir).split(":");
return split.pop();
}, [settings.backupDir]);
const soundString = useMemo(() => {
if (!settings.sound) return null;
const split = settings.sound.split("/");
return split.pop();
}, [settings.sound]);
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 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");
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;
}
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}`);
}}
/>
),
},
[toast, update],
);
const changeImages = useCallback(
(enabled: boolean) => {
update(enabled, 'images');
if (enabled) toast('Show images for sets.', 4000);
else toast('Stopped showing images for sets.', 4000);
{
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.");
}}
/>
),
},
[toast, update],
);
const changeUnit = useCallback(
(enabled: boolean) => {
update(enabled, 'showUnit');
if (enabled) toast('Show option to select unit for sets.', 4000);
else toast('Hid unit option for sets.', 4000);
{
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.");
}}
/>
),
},
[toast, update],
);
const changeSteps = useCallback(
(enabled: boolean) => {
update(enabled, 'steps');
if (enabled) toast('Show steps for a workout.', 4000);
else toast('Stopped showing steps for workouts.', 4000);
{
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.");
}}
/>
),
},
[toast, update],
);
const changeShowDate = useCallback(
(enabled: boolean) => {
update(enabled, 'showDate');
if (enabled) toast('Show date for sets by default.', 4000);
else toast('Stopped showing date for sets by default.', 4000);
{
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
/>
),
},
[toast, update],
);
const changeShowSets = useCallback(
(enabled: boolean) => {
update(enabled, 'showSets');
if (enabled) toast('Show maximum sets for workouts.', 4000);
else toast('Stopped showing maximum sets for workouts.', 4000);
{
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
/>
),
},
[toast, update],
);
const changeNoSound = useCallback(
(enabled: boolean) => {
update(enabled, 'noSound');
if (enabled) toast('Disable sound on rest timer alarms.', 4000);
else toast('Enabled sound for rest timer alarms.', 4000);
{
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>
),
},
[toast, update],
);
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: 'Record 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},
{name: 'Show sets', value: !!showSets, onChange: changeShowSets},
];
const changeTheme = useCallback(
(value: string) => {
updateSettings({...settings, theme: value as any});
setSettings({...settings, theme: value as any});
},
[settings, setSettings],
);
const changeDate = useCallback(
(value: string) => {
updateSettings({...settings, date: value as any});
setSettings({...settings, date: value as any});
},
[settings, setSettings],
);
return (
<>
<DrawerHeader name="Settings" />
<Page term={term} search={setTerm}>
<ScrollView style={{marginTop: MARGIN}}>
{switches
.filter(input =>
input.name.toLowerCase().includes(term.toLowerCase()),
)
.map(input => (
<Switch
onPress={() => input.onChange(!input.value)}
key={input.name}
value={input.value}
onValueChange={input.onChange}>
{input.name}
</Switch>
))}
{'theme'.includes(term.toLowerCase()) && (
<Picker
style={{color}}
dropdownIconColor={color}
selectedValue={theme}
onValueChange={changeTheme}>
<Picker.Item value="system" label="Follow system theme" />
<Picker.Item value="dark" label="Dark theme" />
<Picker.Item value="light" label="Light theme" />
</Picker>
<FlatList
data={data.filter((item) =>
item.name.toLowerCase().includes(term.toLowerCase())
)}
{'color'.includes(term.toLowerCase()) && (
<Picker
style={{color, marginTop: -10}}
dropdownIconColor={color}
selectedValue={color}
onValueChange={value => setColor(value)}>
{lightColors.concat(darkColors).map(colorOption => (
<Picker.Item
key={colorOption.hex}
value={colorOption.hex}
label="Primary color"
color={colorOption.hex}
/>
))}
</Picker>
)}
{'date format'.includes(term.toLowerCase()) && (
<Picker
style={{color, marginTop: -10}}
dropdownIconColor={color}
selectedValue={settings.date}
onValueChange={changeDate}>
<Picker.Item value="%Y-%m-%d %H:%M" label="1990-12-24 15:05" />
<Picker.Item value="%Y-%m-%d" label="1990-12-24" />
<Picker.Item value="%d/%m" label="24/12 (dd/MM)" />
<Picker.Item value="%H:%M" label="15:05 (24-hour time)" />
<Picker.Item value="%h:%M %p" label="3:05 PM (12-hour time)" />
<Picker.Item value="%d/%m/%y" label="24/12/1996" />
<Picker.Item value="%A %h:%M %p" label="Monday 3:05 PM" />
<Picker.Item
value="%d/%m/%y %h:%M %p"
label="24/12/1990 3:05 PM"
/>
<Picker.Item value="%d/%m %h:%M %p" label="24/12 3:05 PM" />
</Picker>
)}
{'alarm sound'.includes(term.toLowerCase()) && (
<Button style={{alignSelf: 'flex-start'}} onPress={changeSound}>
Alarm sound
{sound
? ': ' + sound.split('/')[sound.split('/').length - 1]
: null}
</Button>
)}
</ScrollView>
<ConfirmDialog
title="Battery optimizations"
show={battery}
setShow={setBattery}
onOk={() => {
NativeModules.AlarmModule.ignoreBattery();
setBattery(false);
}}>
Disable battery optimizations for Massive to use rest timers.
</ConfirmDialog>
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}
>
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,29 +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 { useNavigation } from "@react-navigation/native";
import { Appbar, IconButton } from "react-native-paper";
export default function StackHeader({title}: {title: string}) {
export default function StackHeader({
title,
children,
}: {
title: string;
children?: JSX.Element | JSX.Element[];
}) {
const navigation = useNavigation();
return (
<Appbar.Header>
<IconButton icon="arrow-back" onPress={navigation.goBack} />
<IconButton icon="arrow-left" onPress={navigation.goBack} />
<Appbar.Content title={title} />
<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"
/>
{children}
</Appbar.Header>
);
}

View File

@ -1,173 +1,246 @@
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, List, RadioButton} from 'react-native-paper';
import {getBestSet} from './best.service';
import {useColor} from './color';
import {PADDING} from './constants';
import CountMany from './count-many';
import MassiveInput from './MassiveInput';
import {useSnackbar} from './MassiveSnack';
import {PlanPageParams} from './plan-page-params';
import Set from './set';
import {addSet, countMany} from './set.service';
import SetForm from './SetForm';
import StackHeader from './StackHeader';
import {useSettings} from './use-settings';
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 {set} = params;
const [name, setName] = useState(set.name);
const [reps, setReps] = useState(set.reps.toString());
const [weight, setWeight] = useState(set.weight.toString());
const [unit, setUnit] = useState<string>();
const {toast} = useSnackbar();
const [minutes, setMinutes] = useState(set.minutes);
const [seconds, setSeconds] = useState(set.seconds);
const [best, setBest] = useState<Set>(set);
const [selected, setSelected] = useState(0);
const {settings} = useSettings();
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 unitRef = useRef<TextInput>(null);
const workouts = useMemo(() => params.plan.workouts.split(','), [params]);
const {color} = useColor();
const exercises = useMemo(() => params.plan.exercises.split(","), [params]);
const navigation = useNavigation<NavigationProp<StackParams>>();
const [selection, setSelection] = useState({
start: 0,
end: set.reps.toString().length,
end: 0,
});
const refresh = useCallback(async () => {
const questions = exercises
.map((exercise, index) => `('${exercise}',${index})`)
.join(",");
const select = `
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 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);
}, [exercises]);
const select = useCallback(
async (index: number, newCounts?: CountMany[]) => {
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]
);
useFocusEffect(
useCallback(() => {
countMany(workouts).then(newCounts => {
setCounts(newCounts);
console.log(`${StartPlan.name}.focus:`, {newCounts});
});
}, [params]),
settingsRepo.findOne({ where: {} }).then(setSettings);
refresh();
// eslint-disable-next-line
}, [])
);
const handleSubmit = async () => {
console.log(`${SetForm.name}.handleSubmit:`, {reps, weight, unit, best});
await addSet({
name,
weight: +weight,
reps: +reps,
minutes: set.minutes,
seconds: set.seconds,
steps: set.steps,
image: set.image,
unit,
});
countMany(workouts).then(setCounts);
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: newWeight,
reps: Number(reps),
unit: newUnit,
created: now,
hidden: false,
};
await setRepo.save(newSet);
await refresh();
if (
settings.notify &&
(+weight > best.weight || (+reps > best.reps && +weight === best.weight))
)
toast("Great work King! That's a new record.", 5000);
else if (settings.alarm) toast('Resting...', 3000);
else toast('Added set', 3000);
(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(minutes) * 60 * 1000 + Number(seconds) * 1000;
const args = [milliseconds, !!settings.vibrate, settings.sound];
NativeModules.AlarmModule.timer(...args);
const milliseconds =
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})`);
};
const handleUnit = useCallback(
(value: string) => {
setUnit(value.replace(/,|'/g, ''));
if (value.match(/,|'/))
toast('Commas and single quotes would break CSV exports', 6000);
},
[toast],
);
const select = useCallback(
async (index: number) => {
setSelected(index);
console.log(`${StartPlan.name}.next:`, {name});
if (!counts) return;
const workout = counts[index];
console.log(`${StartPlan.name}.next:`, {workout});
const newBest = await getBestSet(workout.name);
setMinutes(newBest.minutes);
setSeconds(newBest.seconds);
setName(newBest.name);
setReps(newBest.reps.toString());
setWeight(newBest.weight.toString());
setUnit(newBest.unit);
setBest(newBest);
},
[name, workouts],
);
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
/>
{!!settings.showUnit && (
<MassiveInput
autoCapitalize="none"
label="Unit"
<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 && (
<Select
value={unit}
onChangeText={handleUnit}
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={({item, index}) => (
<List.Item
title={item.name}
description={
settings.showSets
? `${item.total} / ${item.sets ?? 3}`
: item.total.toString()
}
onPress={() => select(index)}
left={() => (
<View
style={{alignItems: 'center', justifyContent: 'center'}}>
<RadioButton
onPress={() => select(index)}
value={index.toString()}
status={selected === index ? 'checked' : 'unchecked'}
color={color}
/>
</View>
)}
/>
keyExtractor={(count) => count.name}
renderItem={(props) => (
<View>
<StartPlanItem
{...props}
onUndo={refresh}
onSelect={select}
selected={selected}
/>
<ProgressBar
progress={(props.item.total || 0) / (props.item.sets || 3)}
/>
</View>
)}
/>
)}
</View>
<Button mode="contained" icon="save" onPress={handleSubmit}>
<PrimaryButton icon="content-save" onPress={handleSubmit}>
Save
</Button>
</PrimaryButton>
</View>
</>
);

134
StartPlanItem.tsx Normal file
View File

@ -0,0 +1,134 @@
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;
}
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: stackNavigate } =
useNavigation<NavigationProp<StackParams>>();
const undo = useCallback(async () => {
const now = await getNow();
const created = now.split("T")[0];
const first = await setRepo.findOne({
where: {
name: item.name,
hidden: 0 as any,
created: Like(`${created}%`),
},
order: { created: "desc" },
});
setShowMenu(false);
if (!first) return toast("Nothing to undo.");
await setRepo.delete(first.id);
onUndo();
}, [setShowMenu, onUndo, item.name]);
const longPress = useCallback(
(e: GestureResponderEvent) => {
setAnchor({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
setShowMenu(true);
},
[setShowMenu, setAnchor]
);
const edit = useCallback(async () => {
const now = await getNow();
const created = now.split("T")[0];
const first = await setRepo.findOne({
where: {
name: item.name,
hidden: 0 as any,
created: Like(`${created}%`),
},
order: { created: "desc" },
});
setShowMenu(false);
if (!first) return toast("Nothing to edit.");
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
onLongPress={longPress}
title={item.name}
description={
item.sets ? `${item.total} / ${item.sets}` : item.total.toString()
}
onPress={() => onSelect(index)}
left={left}
right={right}
/>
);
}

View File

@ -1,54 +1,38 @@
import {useMemo} from 'react';
import {Pressable} from 'react-native';
import {Switch as PaperSwitch, Text} from 'react-native-paper';
import {CombinedDarkTheme, CombinedDefaultTheme} from './App';
import {useColor} from './color';
import {colorShade} from './colors';
import {MARGIN} from './constants';
import useDark from './use-dark';
import React from "react";
import { Platform, Pressable } from "react-native";
import { Switch as PaperSwitch, Text, useTheme } from "react-native-paper";
import { MARGIN } from "./constants";
export default function Switch({
function Switch({
value,
onValueChange,
onPress,
children,
onChange,
title,
}: {
value?: boolean;
onValueChange: (value: boolean) => void;
onPress: () => void;
children: string;
onChange: (value: boolean) => void;
title: string;
}) {
const {color} = useColor();
const dark = useDark();
const track = useMemo(() => {
if (dark)
return {
false: CombinedDarkTheme.colors.placeholder,
true: colorShade(color, -40),
};
return {
false: CombinedDefaultTheme.colors.placeholder,
true: colorShade(color, -40),
};
}, [dark, color]);
const { colors } = useTheme();
return (
<Pressable
onPress={onPress}
onPress={() => onChange(!value)}
style={{
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
}}>
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
marginBottom: Platform.OS === "ios" ? MARGIN : null,
}}
>
<PaperSwitch
trackColor={track}
color={color}
style={{marginRight: MARGIN}}
color={colors.primary}
style={{ marginRight: MARGIN }}
value={value}
onValueChange={onValueChange}
onValueChange={onChange}
/>
<Text>{children}</Text>
<Text>{title}</Text>
</Pressable>
);
}
export default React.memo(Switch);

View File

@ -1,84 +0,0 @@
import {Picker} from '@react-native-picker/picker';
import {RouteProp, useRoute} from '@react-navigation/native';
import {useEffect, useState} from 'react';
import {View} from 'react-native';
import {getOneRepMax, getVolumes, getWeightsBy} from './best.service';
import {BestPageParams} from './BestPage';
import Chart from './Chart';
import {PADDING} from './constants';
import {Metrics} from './metrics';
import {Periods} from './periods';
import Set from './set';
import StackHeader from './StackHeader';
import {formatMonth} from './time';
import useDark from './use-dark';
import Volume from './volume';
export default function ViewBest() {
const {params} = useRoute<RouteProp<BestPageParams, 'ViewBest'>>();
const dark = useDark();
const [weights, setWeights] = useState<Set[]>([]);
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});
switch (metric) {
case Metrics.Weight:
getWeightsBy(params.best.name, period).then(setWeights);
break;
case Metrics.Volume:
getVolumes(params.best.name, period).then(setVolumes);
break;
default:
getOneRepMax({name: params.best.name, period}).then(setWeights);
}
}, [params.best.name, metric, period]);
return (
<>
<StackHeader title={params.best.name} />
<View style={{padding: PADDING}}>
<Picker
style={{color: dark ? 'white' : 'black'}}
dropdownIconColor={dark ? 'white' : 'black'}
selectedValue={metric}
onValueChange={value => setMetric(value)}>
<Picker.Item value={Metrics.Volume} label={Metrics.Volume} />
<Picker.Item value={Metrics.Weight} label={Metrics.Weight} />
<Picker.Item value={Metrics.OneRepMax} label={Metrics.OneRepMax} />
</Picker>
<Picker
style={{color: dark ? 'white' : 'black'}}
dropdownIconColor={dark ? 'white' : 'black'}
selectedValue={period}
onValueChange={value => setPeriod(value)}>
<Picker.Item value={Periods.Weekly} label={Periods.Weekly} />
<Picker.Item value={Periods.Monthly} label={Periods.Monthly} />
<Picker.Item value={Periods.Yearly} label={Periods.Yearly} />
</Picker>
{metric === Metrics.Volume ? (
<Chart
yData={volumes.map(v => v.value)}
yFormat={(value: number) =>
`${value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${
volumes[0].unit || 'kg'
}`
}
xData={weights}
xFormat={(_value, index) => formatMonth(weights[index].created!)}
/>
) : (
<Chart
yData={weights.map(set => set.weight)}
yFormat={value => `${value}${weights[0].unit}`}
xData={weights}
xFormat={(_value, index) => formatMonth(weights[index].created!)}
/>
)}
</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,91 +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 Set from './set';
import {deleteSetsBy} from './set.service';
import {useSettings} from './use-settings';
import {WorkoutsPageParams} from './WorkoutsPage';
export default function WorkoutItem({
item,
onRemoved,
}: {
item: Set;
onRemoved: () => void;
}) {
const [showMenu, setShowMenu] = useState(false);
const [anchor, setAnchor] = useState({x: 0, y: 0});
const [showRemove, setShowRemove] = useState('');
const {settings} = useSettings();
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
const remove = useCallback(async () => {
await deleteSetsBy(item.name);
setShowMenu(false);
onRemoved();
}, [setShowMenu, onRemoved, 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');
if (settings.alarm && settings.showSets)
return `${item.sets} x ${item.minutes || 0}:${seconds}`;
else if (settings.alarm && !settings.showSets)
return `${item.minutes || 0}:${seconds}`;
return `${item.sets}`;
}, [item, settings]);
return (
<>
<List.Item
onPress={() => navigation.navigate('EditWorkout', {value: item})}
title={item.name}
description={description}
onLongPress={longPress}
left={() =>
!!settings.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,111 +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 Set from './set';
import {getDistinctSets} from './set.service';
import SetList from './SetList';
import WorkoutItem from './WorkoutItem';
import {WorkoutsPageParams} from './WorkoutsPage';
const limit = 15;
export default function WorkoutList() {
const [workouts, setWorkouts] = useState<Set[]>();
const [offset, setOffset] = useState(0);
const [term, setTerm] = useState('');
const [end, setEnd] = useState(false);
const navigation = useNavigation<NavigationProp<WorkoutsPageParams>>();
const refresh = useCallback(async (value: string) => {
const newWorkouts = await getDistinctSets({
term: `%${value}%`,
limit,
offset: 0,
});
console.log(`${WorkoutList.name}`, {newWorkout: newWorkouts[0]});
setWorkouts(newWorkouts);
setOffset(0);
setEnd(false);
}, []);
useFocusEffect(
useCallback(() => {
refresh(term);
}, [refresh, term]),
);
const renderItem = useCallback(
({item}: {item: Set}) => (
<WorkoutItem
item={item}
key={item.name}
onRemoved={() => refresh(term)}
/>
),
[refresh, term],
);
const next = useCallback(async () => {
if (end) return;
const newOffset = offset + limit;
console.log(`${SetList.name}.next:`, {
offset,
limit,
newOffset,
term,
});
const newWorkouts = await getDistinctSets({
term: `%${term}%`,
limit,
offset: newOffset,
});
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: {name: '', sets: 3, image: '', steps: '', reps: 0, weight: 0},
});
}, [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 Set from './set';
import WorkoutList from './WorkoutList';
export type WorkoutsPageParams = {
WorkoutList: {};
EditWorkout: {
value: Set;
};
};
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>
);
}

3
android/Gemfile Normal file
View File

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

214
android/Gemfile.lock Normal file
View File

@ -0,0 +1,214 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
rexml
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
atomos (0.1.3)
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.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.77.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.143.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.109.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.3.0)
fastlane (2.219.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-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, < 3.0.0)
naturally (~> 2.2)
optparse (>= 0.1.1)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.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)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
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.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.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.5)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.7.1)
jwt (2.7.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.4.0)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.4.0)
os (1.1.4)
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.6)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
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.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
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.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.5.0)
word_wrap (1.0.0)
xcodeproj (1.24.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
x64-mingw-ucrt
x86_64-linux
DEPENDENCIES
fastlane
BUNDLED WITH
2.3.25

View File

@ -1,117 +1,94 @@
apply plugin: "com.android.application"
apply plugin: "com.facebook.react"
apply plugin: "kotlin-android"
apply plugin: "org.jetbrains.kotlin.android"
import com.android.build.OutputFile
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '..'
// root = file("../")
// The folder where the react-native NPM package is. Default is ../node_modules/react-native
// reactNativeDir = file("../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../node_modules/@react-native/codegen
// codegenDir = file("../node_modules/@react-native/codegen")
// The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js
// cliFile = file("../node_modules/react-native/cli.js")
project.ext.react = [
enableHermes: true, // clean and rebuild if changing
]
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
project.ext.vectoricons = [
iconFontNames: ['MaterialIcons.ttf']
]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The command to run when bundling. By default is 'bundle'
// bundleCommand = "ram-bundle"
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
apply from: "../../node_modules/react-native/react.gradle"
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
def enableSeparateBuildPerCPUArchitecture = true
def enableProguardInReleaseBuilds = true
def jscFlavor = 'org.webkit:android-jsc:+'
def enableHermes = project.ext.react.get("enableHermes", 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 36073
versionName "1.47"
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 {
@ -131,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"
}
@ -147,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,25 +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=".StopTimer"
android:exported="false"
android:process=":remote" />
<service
android:name=".AlarmService"
android:exported="false" />
<service
android:name=".TimerService"
android:exported="false" />
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>
</manifest>

View File

@ -1,91 +1,25 @@
package com.massive
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.annotation.RequiresApi
import com.facebook.react.bridge.Callback
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.*
class AlarmModule internal constructor(context: ReactApplicationContext?) :
@RequiresApi(Build.VERSION_CODES.O)
class AlarmModule(context: ReactApplicationContext?) :
ReactContextBaseJavaModule(context) {
override fun getName(): String {
return "AlarmModule"
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
fun add(milliseconds: Int, vibrate: Boolean, sound: String?) {
Log.d("AlarmModule", "Add 1 min to alarm.")
val addIntent = Intent(reactApplicationContext, TimerService::class.java)
addIntent.action = "add"
addIntent.putExtra("vibrate", vibrate)
addIntent.putExtra("sound", sound)
addIntent.data = Uri.parse("$milliseconds")
reactApplicationContext.startService(addIntent)
}
@RequiresApi(api = Build.VERSION_CODES.O)
@ReactMethod
fun stop() {
Log.d("AlarmModule", "Stop alarm.")
val timerIntent = Intent(reactApplicationContext, TimerService::class.java)
reactApplicationContext.stopService(timerIntent)
val alarmIntent = Intent(reactApplicationContext, AlarmService::class.java)
reactApplicationContext.stopService(alarmIntent)
}
@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 intent = Intent(reactApplicationContext, TimerService::class.java)
intent.putExtra("milliseconds", milliseconds)
intent.putExtra("vibrate", vibrate)
intent.putExtra("sound", sound)
intent.putExtra("noSound", noSound)
reactApplicationContext.startService(intent)
}
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun ignoringBattery(callback: Callback) {
val packageName = reactApplicationContext.packageName
val pm =
reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
callback.invoke(pm.isIgnoringBatteryOptimizations(packageName))
} else {
callback.invoke(true)
}
}
@SuppressLint("BatteryLife")
@RequiresApi(Build.VERSION_CODES.M)
@ReactMethod
fun ignoreBattery() {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.parse("package:" + reactApplicationContext.packageName)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
try {
reactApplicationContext.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(
reactApplicationContext,
"Requests to ignore battery optimizations are disabled on your device.",
Toast.LENGTH_LONG
).show()
}
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

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

View File

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

View File

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

View File

@ -1,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

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

View File

@ -0,0 +1,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,21 +1,28 @@
package com.massive
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
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, TimerService::class.java))
applicationContext.stopService(Intent(applicationContext, AlarmService::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)

View File

@ -1,179 +1,358 @@
package com.massive
import android.Manifest
import android.annotation.SuppressLint
import android.app.*
import android.app.NotificationManager.IMPORTANCE_HIGH
import android.app.NotificationManager.IMPORTANCE_LOW
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.Build
import android.os.CountDownTimer
import android.os.IBinder
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 kotlin.math.floor
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 var countdownTimer: CountDownTimer? = null
private var endMs: Int = 0
private var currentMs: Long = 0
private var vibrate: Boolean = true
private var noSound: Boolean = false
private var sound: String? = null
@RequiresApi(Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
vibrate = intent?.extras?.getBoolean("vibrate") == true
noSound = intent?.extras?.getBoolean("noSound") == true
sound = intent?.extras?.getString("sound")
val manager = getManager()
manager.cancel(NOTIFICATION_ID_DONE)
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
if (intent?.action == "add") {
endMs = currentMs.toInt().plus(60000)
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
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 {
val ms = intent?.extras?.getInt("milliseconds")
if (ms != null) endMs = ms;
}
Log.d("TimerService", "endMs=$endMs,currentMs=$currentMs,vibrate=$vibrate,sound=$sound")
val builder = getBuilder(applicationContext)
countdownTimer?.cancel()
countdownTimer = getTimer(builder)
countdownTimer?.start()
return super.onStartCommand(intent, flags, startId)
applicationContext.registerReceiver(stopReceiver, IntentFilter(STOP_BROADCAST))
applicationContext.registerReceiver(addReceiver, IntentFilter(ADD_BROADCAST))
}
}
private fun getTimer(builder: NotificationCompat.Builder): CountDownTimer {
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())
}
private fun onTimerStart(intent: Intent?) {
timerRunnable?.let { timerHandler.removeCallbacks(it) }
currentDescription = intent?.getStringExtra("description").toString()
@RequiresApi(Build.VERSION_CODES.O)
override fun onFinish() {
val finishIntent = Intent(applicationContext, StopAlarm::class.java)
val finishPending =
PendingIntent.getActivity(
applicationContext,
0,
finishIntent,
PendingIntent.FLAG_IMMUTABLE
)
val fullIntent = Intent(applicationContext, TimerDone::class.java)
val fullPending =
PendingIntent.getActivity(
applicationContext,
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)
val alarmIntent = Intent(applicationContext, AlarmService::class.java).apply {
putExtra("vibrate", vibrate)
putExtra("sound", sound)
putExtra("noSound", noSound)
}
applicationContext.startService(alarmIntent)
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)
}
override fun onBind(p0: Intent?): IBinder? {
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
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onDestroy() {
Log.d("TimerService", "Destroying...")
countdownTimer?.cancel()
val manager = getManager()
manager.cancel(NOTIFICATION_ID_PENDING)
manager.cancel(NOTIFICATION_ID_DONE)
super.onDestroy()
@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("UnspecifiedImmutableFlag")
@RequiresApi(Build.VERSION_CODES.M)
private fun getBuilder(context: Context): NotificationCompat.Builder {
val contentIntent = Intent(context, MainActivity::class.java)
val pendingContent =
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
val stopIntent = Intent(context, StopTimer::class.java)
val pendingStop =
PendingIntent.getService(context, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE)
val addIntent = Intent(context, TimerService::class.java).apply {
action = "add"
putExtra("vibrate", vibrate)
putExtra("sound", sound)
putExtra("noSound", noSound)
data = Uri.parse("$currentMs")
}
val pendingAdd = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getService(context, 0, addIntent, PendingIntent.FLAG_MUTABLE)
@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 {
PendingIntent.getService(context, 0, addIntent, PendingIntent.FLAG_UPDATE_CURRENT)
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() }
}
}
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,
IMPORTANCE_HIGH
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
)
alarmsChannel.description = "Alarms for rest timers."
alarmsChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
val notificationManager = applicationContext.getSystemService(
NotificationManager::class.java
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
)
notificationManager.createNotificationChannel(alarmsChannel)
val timersChannel =
NotificationChannel(CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, IMPORTANCE_LOW)
timersChannel.setSound(null, null)
timersChannel.description = "Progress on rest timers."
notificationManager.createNotificationChannel(timersChannel)
return notificationManager
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 {
private const val CHANNEL_ID_PENDING = "Timer"
private const val CHANNEL_ID_DONE = "Alarm"
private const val NOTIFICATION_ID_PENDING = 1
private const val NOTIFICATION_ID_DONE = 2
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"

2
android/fastlane/Appfile Normal file
View File

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

38
android/fastlane/Fastfile Normal file
View File

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

View File

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

View File

@ -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,9 +1,15 @@
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: ['react-native-reanimated/plugin', 'react-native-paper/babel'],
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-paper/babel',
'react-native-reanimated/plugin',
],
env: {
production: {
plugins: ['transform-remove-console'],
},
},
};
}

View File

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

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