diff --git a/App.tsx b/App.tsx index 8b62db0..3b5b2e9 100644 --- a/App.tsx +++ b/App.tsx @@ -50,18 +50,24 @@ const App = () => { ) useEffect(() => { - DeviceEventEmitter.addListener(TOAST, ({value}: {value: string}) => { - console.log(`${Routes.name}.toast:`, {value}) - setSnackbar(value) - }) - if (AppDataSource.isInitialized) return setInitialized(true) - AppDataSource.initialize().then(async () => { - const settings = await settingsRepo.findOne({where: {}}) - console.log(`${App.name}.useEffect:`, {gotSettings: settings}) - setTheme(settings.theme) - setColor(settings.color) - setInitialized(true) - }) + const description = DeviceEventEmitter.addListener( + TOAST, + ({value}: {value: string}) => { + console.log(`${Routes.name}.toast:`, {value}) + setSnackbar(value) + }, + ) + if (AppDataSource.isInitialized) setInitialized(true) + else { + AppDataSource.initialize().then(async () => { + const settings = await settingsRepo.findOne({where: {}}) + console.log(`${App.name}.useEffect:`, {gotSettings: settings}) + setTheme(settings.theme) + setColor(settings.color) + setInitialized(true) + }) + } + return description.remove }, []) const paperTheme = useMemo(() => { diff --git a/Routes.tsx b/Routes.tsx index 23b2060..b8c1cac 100644 --- a/Routes.tsx +++ b/Routes.tsx @@ -7,6 +7,7 @@ import HomePage from './HomePage' import PlanPage from './PlanPage' import Route from './route' import SettingsPage from './SettingsPage' +import TimerPage from './TimerPage' import useDark from './use-dark' import WorkoutsPage from './WorkoutsPage' @@ -21,6 +22,7 @@ export default function Routes() { {name: 'Plans', component: PlanPage, icon: 'event'}, {name: 'Best', component: BestPage, icon: 'insights'}, {name: 'Workouts', component: WorkoutsPage, icon: 'fitness-center'}, + {name: 'Timer', component: TimerPage, icon: 'access-time'}, {name: 'Settings', component: SettingsPage, icon: 'settings'}, ], [], diff --git a/TimerPage.tsx b/TimerPage.tsx new file mode 100644 index 0000000..b07ca36 --- /dev/null +++ b/TimerPage.tsx @@ -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() + + 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 ( + <> + + + + Remaining + + {minutes}:{seconds} + + + + + + + ) +} diff --git a/android/app/src/main/java/com/massive/AlarmModule.kt b/android/app/src/main/java/com/massive/AlarmModule.kt index 7c4b0ca..99c4c35 100644 --- a/android/app/src/main/java/com/massive/AlarmModule.kt +++ b/android/app/src/main/java/com/massive/AlarmModule.kt @@ -1,69 +1,111 @@ package com.massive import android.annotation.SuppressLint +import android.app.* import android.content.ActivityNotFoundException +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.net.Uri import android.os.Build +import android.os.CountDownTimer 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 androidx.core.app.NotificationCompat +import com.facebook.react.bridge.* +import com.facebook.react.modules.core.DeviceEventManagerModule +import kotlin.math.floor -class AlarmModule internal constructor(context: ReactApplicationContext?) : +class AlarmModule constructor(context: ReactApplicationContext?) : ReactContextBaseJavaModule(context) { + var countdownTimer: CountDownTimer? = null + var currentMs: Long = 0 + var running = false + override fun getName(): String { return "AlarmModule" } + private val stopReceiver = object : BroadcastReceiver() { + @RequiresApi(Build.VERSION_CODES.O) + override fun onReceive(context: Context?, intent: Intent?) { + Log.d("AlarmModule", "Received broadcast intent") + stop() + } + } + + init { + reactApplicationContext.registerReceiver(stopReceiver, IntentFilter(STOP_BROADCAST)) + } + + override fun onCatalystInstanceDestroy() { + reactApplicationContext.unregisterReceiver(stopReceiver) + super.onCatalystInstanceDestroy() + } + @RequiresApi(api = Build.VERSION_CODES.O) @ReactMethod - fun add(milliseconds: Int, vibrate: Boolean, sound: String?) { + fun add(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) + countdownTimer?.cancel() + val newMs = if (running) currentMs.toInt().plus(60000) else 60000 + countdownTimer = getTimer(newMs, vibrate, sound) + countdownTimer?.start() + running = true } @RequiresApi(api = Build.VERSION_CODES.O) @ReactMethod fun stop() { Log.d("AlarmModule", "Stop alarm.") - val timerIntent = Intent(reactApplicationContext, TimerService::class.java) - reactApplicationContext.stopService(timerIntent) - val alarmIntent = Intent(reactApplicationContext, AlarmService::class.java) - reactApplicationContext.stopService(alarmIntent) + countdownTimer?.cancel() + running = false + reactApplicationContext?.stopService( + 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) @ReactMethod fun timer(milliseconds: Int, vibrate: Boolean, sound: String?, noSound: Boolean = false) { 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) + val manager = getManager() + manager.cancel(NOTIFICATION_ID_DONE) + reactApplicationContext.stopService( + Intent( + reactApplicationContext, AlarmService::class.java + ) + ) + countdownTimer?.cancel() + countdownTimer = getTimer(milliseconds, vibrate, sound) + countdownTimer?.start() + running = true } @RequiresApi(Build.VERSION_CODES.M) @ReactMethod fun ignoringBattery(callback: Callback) { val packageName = reactApplicationContext.packageName - val pm = - reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager + val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { callback.invoke(pm.isIgnoringBatteryOptimizations(packageName)) } else { @@ -88,4 +130,106 @@ class AlarmModule internal constructor(context: ReactApplicationContext?) : ).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 + } } diff --git a/android/app/src/main/java/com/massive/StopTimer.kt b/android/app/src/main/java/com/massive/StopTimer.kt index a65333b..1c02edf 100644 --- a/android/app/src/main/java/com/massive/StopTimer.kt +++ b/android/app/src/main/java/com/massive/StopTimer.kt @@ -1,18 +1,52 @@ package com.massive +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.app.Service import android.content.Intent +import android.os.Build import android.os.IBinder import android.util.Log +import androidx.annotation.RequiresApi +import com.facebook.react.bridge.ReactApplicationContext class StopTimer : Service() { + @RequiresApi(Build.VERSION_CODES.O) 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)) + 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) } override fun onBind(p0: Intent?): IBinder? { 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 + } } \ No newline at end of file diff --git a/drawer-param-list.ts b/drawer-param-list.ts index a7e8ac5..68cae78 100644 --- a/drawer-param-list.ts +++ b/drawer-param-list.ts @@ -4,4 +4,5 @@ export type DrawerParamList = { Best: {} Plans: {} Workouts: {} + Timer: {} }