Merge branch 'alarm-module'
This commit is contained in:
commit
84b369d54b
12
App.tsx
12
App.tsx
|
@ -50,11 +50,15 @@ const App = () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
DeviceEventEmitter.addListener(TOAST, ({value}: {value: string}) => {
|
const description = DeviceEventEmitter.addListener(
|
||||||
|
TOAST,
|
||||||
|
({value}: {value: string}) => {
|
||||||
console.log(`${Routes.name}.toast:`, {value})
|
console.log(`${Routes.name}.toast:`, {value})
|
||||||
setSnackbar(value)
|
setSnackbar(value)
|
||||||
})
|
},
|
||||||
if (AppDataSource.isInitialized) return setInitialized(true)
|
)
|
||||||
|
if (AppDataSource.isInitialized) setInitialized(true)
|
||||||
|
else {
|
||||||
AppDataSource.initialize().then(async () => {
|
AppDataSource.initialize().then(async () => {
|
||||||
const settings = await settingsRepo.findOne({where: {}})
|
const settings = await settingsRepo.findOne({where: {}})
|
||||||
console.log(`${App.name}.useEffect:`, {gotSettings: settings})
|
console.log(`${App.name}.useEffect:`, {gotSettings: settings})
|
||||||
|
@ -62,6 +66,8 @@ const App = () => {
|
||||||
setColor(settings.color)
|
setColor(settings.color)
|
||||||
setInitialized(true)
|
setInitialized(true)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
return description.remove
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const paperTheme = useMemo(() => {
|
const paperTheme = useMemo(() => {
|
||||||
|
|
|
@ -7,6 +7,7 @@ import HomePage from './HomePage'
|
||||||
import PlanPage from './PlanPage'
|
import PlanPage from './PlanPage'
|
||||||
import Route from './route'
|
import Route from './route'
|
||||||
import SettingsPage from './SettingsPage'
|
import SettingsPage from './SettingsPage'
|
||||||
|
import TimerPage from './TimerPage'
|
||||||
import useDark from './use-dark'
|
import useDark from './use-dark'
|
||||||
import WorkoutsPage from './WorkoutsPage'
|
import WorkoutsPage from './WorkoutsPage'
|
||||||
|
|
||||||
|
@ -21,6 +22,7 @@ export default function Routes() {
|
||||||
{name: 'Plans', component: PlanPage, icon: 'event'},
|
{name: 'Plans', component: PlanPage, icon: 'event'},
|
||||||
{name: 'Best', component: BestPage, icon: 'insights'},
|
{name: 'Best', component: BestPage, icon: 'insights'},
|
||||||
{name: 'Workouts', component: WorkoutsPage, icon: 'fitness-center'},
|
{name: 'Workouts', component: WorkoutsPage, icon: 'fitness-center'},
|
||||||
|
{name: 'Timer', component: TimerPage, icon: 'access-time'},
|
||||||
{name: 'Settings', component: SettingsPage, icon: 'settings'},
|
{name: 'Settings', component: SettingsPage, icon: 'settings'},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
|
|
59
TimerPage.tsx
Normal file
59
TimerPage.tsx
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import React, {useEffect, useState} from 'react'
|
||||||
|
import {NativeEventEmitter, NativeModules, View} from 'react-native'
|
||||||
|
import {Button, Subheading, Title} from 'react-native-paper'
|
||||||
|
import {PADDING} from './constants'
|
||||||
|
import {settingsRepo} from './db'
|
||||||
|
import DrawerHeader from './DrawerHeader'
|
||||||
|
import MassiveFab from './MassiveFab'
|
||||||
|
import Settings from './settings'
|
||||||
|
|
||||||
|
interface TickEvent {
|
||||||
|
minutes: string
|
||||||
|
seconds: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimerPage() {
|
||||||
|
const [minutes, setMinutes] = useState('00')
|
||||||
|
const [seconds, setSeconds] = useState('00')
|
||||||
|
const [settings, setSettings] = useState<Settings>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
settingsRepo.findOne({where: {}}).then(setSettings)
|
||||||
|
const emitter = new NativeEventEmitter()
|
||||||
|
const listener = emitter.addListener('tick', (event: TickEvent) => {
|
||||||
|
console.log(`${TimerPage.name}.tick:`, {event})
|
||||||
|
setMinutes(event.minutes)
|
||||||
|
setSeconds(event.seconds)
|
||||||
|
})
|
||||||
|
return listener.remove
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
NativeModules.AlarmModule.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
NativeModules.AlarmModule.add(settings.vibrate, settings.sound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DrawerHeader name="Timer" />
|
||||||
|
<View style={{flexGrow: 1, padding: PADDING}}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<Title>Remaining</Title>
|
||||||
|
<Subheading>
|
||||||
|
{minutes}:{seconds}
|
||||||
|
</Subheading>
|
||||||
|
<Button onPress={add}>Add 1 min</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<MassiveFab icon="stop" onPress={stop} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,69 +1,111 @@
|
||||||
package com.massive
|
package com.massive
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.*
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.CountDownTimer
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import com.facebook.react.bridge.Callback
|
import androidx.core.app.NotificationCompat
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
import com.facebook.react.bridge.*
|
||||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||||
import com.facebook.react.bridge.ReactMethod
|
import kotlin.math.floor
|
||||||
|
|
||||||
|
|
||||||
class AlarmModule internal constructor(context: ReactApplicationContext?) :
|
class AlarmModule constructor(context: ReactApplicationContext?) :
|
||||||
ReactContextBaseJavaModule(context) {
|
ReactContextBaseJavaModule(context) {
|
||||||
|
|
||||||
|
var countdownTimer: CountDownTimer? = null
|
||||||
|
var currentMs: Long = 0
|
||||||
|
var running = false
|
||||||
|
|
||||||
override fun getName(): String {
|
override fun getName(): String {
|
||||||
return "AlarmModule"
|
return "AlarmModule"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val stopReceiver = object : BroadcastReceiver() {
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
Log.d("AlarmModule", "Received broadcast intent")
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
reactApplicationContext.registerReceiver(stopReceiver, IntentFilter(STOP_BROADCAST))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCatalystInstanceDestroy() {
|
||||||
|
reactApplicationContext.unregisterReceiver(stopReceiver)
|
||||||
|
super.onCatalystInstanceDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun add(milliseconds: Int, vibrate: Boolean, sound: String?) {
|
fun add(vibrate: Boolean, sound: String?) {
|
||||||
Log.d("AlarmModule", "Add 1 min to alarm.")
|
Log.d("AlarmModule", "Add 1 min to alarm.")
|
||||||
val addIntent = Intent(reactApplicationContext, TimerService::class.java)
|
countdownTimer?.cancel()
|
||||||
addIntent.action = "add"
|
val newMs = if (running) currentMs.toInt().plus(60000) else 60000
|
||||||
addIntent.putExtra("vibrate", vibrate)
|
countdownTimer = getTimer(newMs, vibrate, sound)
|
||||||
addIntent.putExtra("sound", sound)
|
countdownTimer?.start()
|
||||||
addIntent.data = Uri.parse("$milliseconds")
|
running = true
|
||||||
reactApplicationContext.startService(addIntent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun stop() {
|
fun stop() {
|
||||||
Log.d("AlarmModule", "Stop alarm.")
|
Log.d("AlarmModule", "Stop alarm.")
|
||||||
val timerIntent = Intent(reactApplicationContext, TimerService::class.java)
|
countdownTimer?.cancel()
|
||||||
reactApplicationContext.stopService(timerIntent)
|
running = false
|
||||||
val alarmIntent = Intent(reactApplicationContext, AlarmService::class.java)
|
reactApplicationContext?.stopService(
|
||||||
reactApplicationContext.stopService(alarmIntent)
|
Intent(
|
||||||
|
reactApplicationContext,
|
||||||
|
AlarmService::class.java
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val manager = getManager()
|
||||||
|
manager.cancel(NOTIFICATION_ID_DONE)
|
||||||
|
manager.cancel(NOTIFICATION_ID_PENDING)
|
||||||
|
val params = Arguments.createMap().apply {
|
||||||
|
putString("minutes", "00")
|
||||||
|
putString("seconds", "00")
|
||||||
|
}
|
||||||
|
reactApplicationContext
|
||||||
|
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||||
|
.emit("tick", params)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun timer(milliseconds: Int, vibrate: Boolean, sound: String?, noSound: Boolean = false) {
|
fun timer(milliseconds: Int, vibrate: Boolean, sound: String?, noSound: Boolean = false) {
|
||||||
Log.d("AlarmModule", "Queue alarm for $milliseconds delay")
|
Log.d("AlarmModule", "Queue alarm for $milliseconds delay")
|
||||||
val intent = Intent(reactApplicationContext, TimerService::class.java)
|
val manager = getManager()
|
||||||
intent.putExtra("milliseconds", milliseconds)
|
manager.cancel(NOTIFICATION_ID_DONE)
|
||||||
intent.putExtra("vibrate", vibrate)
|
reactApplicationContext.stopService(
|
||||||
intent.putExtra("sound", sound)
|
Intent(
|
||||||
intent.putExtra("noSound", noSound)
|
reactApplicationContext, AlarmService::class.java
|
||||||
reactApplicationContext.startService(intent)
|
)
|
||||||
|
)
|
||||||
|
countdownTimer?.cancel()
|
||||||
|
countdownTimer = getTimer(milliseconds, vibrate, sound)
|
||||||
|
countdownTimer?.start()
|
||||||
|
running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.M)
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun ignoringBattery(callback: Callback) {
|
fun ignoringBattery(callback: Callback) {
|
||||||
val packageName = reactApplicationContext.packageName
|
val packageName = reactApplicationContext.packageName
|
||||||
val pm =
|
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
callback.invoke(pm.isIgnoringBatteryOptimizations(packageName))
|
callback.invoke(pm.isIgnoringBatteryOptimizations(packageName))
|
||||||
} else {
|
} else {
|
||||||
|
@ -88,4 +130,106 @@ class AlarmModule internal constructor(context: ReactApplicationContext?) :
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
private fun getTimer(endMs: Int, vibrate: Boolean, sound: String?): CountDownTimer {
|
||||||
|
val builder = getBuilder()
|
||||||
|
return object : CountDownTimer(endMs.toLong(), 1000) {
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
override fun onTick(current: Long) {
|
||||||
|
currentMs = current
|
||||||
|
val seconds =
|
||||||
|
floor((current / 1000).toDouble() % 60).toInt().toString().padStart(2, '0')
|
||||||
|
val minutes =
|
||||||
|
floor((current / 1000).toDouble() / 60).toInt().toString().padStart(2, '0')
|
||||||
|
builder.setContentText("$minutes:$seconds").setAutoCancel(false).setDefaults(0)
|
||||||
|
.setProgress(endMs, current.toInt(), false)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS).priority =
|
||||||
|
NotificationCompat.PRIORITY_LOW
|
||||||
|
val manager = getManager()
|
||||||
|
manager.notify(NOTIFICATION_ID_PENDING, builder.build())
|
||||||
|
val params = Arguments.createMap().apply {
|
||||||
|
putString("minutes", minutes)
|
||||||
|
putString("seconds", seconds)
|
||||||
|
}
|
||||||
|
reactApplicationContext
|
||||||
|
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||||
|
.emit("tick", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
override fun onFinish() {
|
||||||
|
val context = reactApplicationContext
|
||||||
|
val finishIntent = Intent(context, StopAlarm::class.java)
|
||||||
|
val finishPending = PendingIntent.getActivity(
|
||||||
|
context, 0, finishIntent, PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val fullIntent = Intent(context, TimerDone::class.java)
|
||||||
|
val fullPending = PendingIntent.getActivity(
|
||||||
|
context, 0, fullIntent, PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
builder.setContentText("Timer finished.").setProgress(0, 0, false)
|
||||||
|
.setAutoCancel(true).setOngoing(true).setFullScreenIntent(fullPending, true)
|
||||||
|
.setContentIntent(finishPending).setChannelId(CHANNEL_ID_DONE)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_ALARM).priority =
|
||||||
|
NotificationCompat.PRIORITY_HIGH
|
||||||
|
val manager = getManager()
|
||||||
|
manager.notify(NOTIFICATION_ID_DONE, builder.build())
|
||||||
|
manager.cancel(NOTIFICATION_ID_PENDING)
|
||||||
|
val alarmIntent = Intent(context, AlarmService::class.java)
|
||||||
|
alarmIntent.putExtra("vibrate", vibrate)
|
||||||
|
alarmIntent.putExtra("sound", sound)
|
||||||
|
context.startService(alarmIntent)
|
||||||
|
reactApplicationContext
|
||||||
|
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||||
|
.emit("finish", Arguments.createMap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnspecifiedImmutableFlag")
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
private fun getBuilder(): NotificationCompat.Builder {
|
||||||
|
val context = reactApplicationContext
|
||||||
|
val contentIntent = Intent(context, MainActivity::class.java)
|
||||||
|
val pendingContent =
|
||||||
|
PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
val stopBroadcast = Intent(STOP_BROADCAST)
|
||||||
|
stopBroadcast.setPackage(reactApplicationContext.packageName)
|
||||||
|
val pendingStop =
|
||||||
|
PendingIntent.getBroadcast(context, 0, stopBroadcast, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
return NotificationCompat.Builder(context, CHANNEL_ID_PENDING)
|
||||||
|
.setSmallIcon(R.drawable.ic_baseline_hourglass_bottom_24).setContentTitle("Resting")
|
||||||
|
.setContentIntent(pendingContent)
|
||||||
|
.addAction(R.drawable.ic_baseline_stop_24, "Stop", pendingStop)
|
||||||
|
.setDeleteIntent(pendingStop)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
fun getManager(): NotificationManager {
|
||||||
|
val alarmsChannel = NotificationChannel(
|
||||||
|
CHANNEL_ID_DONE, CHANNEL_ID_DONE, NotificationManager.IMPORTANCE_HIGH
|
||||||
|
)
|
||||||
|
alarmsChannel.description = "Alarms for rest timers."
|
||||||
|
alarmsChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||||
|
val notificationManager = reactApplicationContext.getSystemService(
|
||||||
|
NotificationManager::class.java
|
||||||
|
)
|
||||||
|
notificationManager.createNotificationChannel(alarmsChannel)
|
||||||
|
val timersChannel = NotificationChannel(
|
||||||
|
CHANNEL_ID_PENDING, CHANNEL_ID_PENDING, NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
timersChannel.setSound(null, null)
|
||||||
|
timersChannel.description = "Progress on rest timers."
|
||||||
|
notificationManager.createNotificationChannel(timersChannel)
|
||||||
|
return notificationManager
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val STOP_BROADCAST = "stop-timer-event"
|
||||||
|
const val CHANNEL_ID_PENDING = "Timer"
|
||||||
|
const val CHANNEL_ID_DONE = "Alarm"
|
||||||
|
const val NOTIFICATION_ID_PENDING = 1
|
||||||
|
const val NOTIFICATION_ID_DONE = 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,52 @@
|
||||||
package com.massive
|
package com.massive
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
|
||||||
class StopTimer : Service() {
|
class StopTimer : Service() {
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
applicationContext.stopService(Intent(applicationContext, TimerService::class.java))
|
Log.d("StopTimer", "onStartCommand")
|
||||||
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
|
applicationContext.stopService(Intent(applicationContext, AlarmService::class.java))
|
||||||
|
val manager = getManager();
|
||||||
|
manager.cancel(AlarmModule.NOTIFICATION_ID_DONE)
|
||||||
|
manager.cancel(AlarmModule.NOTIFICATION_ID_PENDING)
|
||||||
|
val mod = AlarmModule(applicationContext as ReactApplicationContext?)
|
||||||
|
Log.d("StopTimer", "countdownTimer=${mod.countdownTimer}")
|
||||||
return super.onStartCommand(intent, flags, startId)
|
return super.onStartCommand(intent, flags, startId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(p0: Intent?): IBinder? {
|
override fun onBind(p0: Intent?): IBinder? {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
private fun getManager(): NotificationManager {
|
||||||
|
val alarmsChannel = NotificationChannel(
|
||||||
|
AlarmModule.CHANNEL_ID_DONE,
|
||||||
|
AlarmModule.CHANNEL_ID_DONE,
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
)
|
||||||
|
alarmsChannel.description = "Alarms for rest timers."
|
||||||
|
alarmsChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||||
|
val notificationManager = applicationContext.getSystemService(
|
||||||
|
NotificationManager::class.java
|
||||||
|
)
|
||||||
|
notificationManager.createNotificationChannel(alarmsChannel)
|
||||||
|
val timersChannel =
|
||||||
|
NotificationChannel(AlarmModule.CHANNEL_ID_PENDING, AlarmModule.CHANNEL_ID_PENDING, NotificationManager.IMPORTANCE_LOW)
|
||||||
|
timersChannel.setSound(null, null)
|
||||||
|
timersChannel.description = "Progress on rest timers."
|
||||||
|
notificationManager.createNotificationChannel(timersChannel)
|
||||||
|
return notificationManager
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -4,4 +4,5 @@ export type DrawerParamList = {
|
||||||
Best: {}
|
Best: {}
|
||||||
Plans: {}
|
Plans: {}
|
||||||
Workouts: {}
|
Workouts: {}
|
||||||
|
Timer: {}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user