From 21d914949834d2acbcabfbc2a8f52eb9e81754af Mon Sep 17 00:00:00 2001 From: Brandon Presley Date: Mon, 24 Oct 2022 14:45:21 +1300 Subject: [PATCH] Quit trying to move timer logic into AlarmModule I just can't figure out how to make the stop button and delete intents work. --- EditSet.tsx | 5 + Page.tsx | 20 +-- Routes.tsx | 4 + TimerPage.tsx | 76 +++++++++ .../src/main/java/com/massive/AlarmModule.kt | 153 +++++++++++++++++- .../src/main/java/com/massive/StopTimer.kt | 36 ++++- db.ts | 3 + drawer-param-list.ts | 1 + settings.ts | 1 + 9 files changed, 284 insertions(+), 15 deletions(-) create mode 100644 TimerPage.tsx diff --git a/EditSet.tsx b/EditSet.tsx index 7c1046f..e965521 100644 --- a/EditSet.tsx +++ b/EditSet.tsx @@ -7,6 +7,7 @@ import {useSnackbar} from './MassiveSnack'; import Set from './set'; import {addSet, getSet, updateSet} from './set.service'; import SetForm from './SetForm'; +import {updateSettings} from './settings.service'; import StackHeader from './StackHeader'; import {useSettings} from './use-settings'; @@ -22,11 +23,15 @@ export default function EditSet() { if (!settings.alarm) return; const {minutes, seconds} = await getSet(name); const milliseconds = (minutes ?? 3) * 60 * 1000 + (seconds ?? 0) * 1000; + console.log(`startTimer:`, `Starting timer in ${minutes}:${seconds}`); NativeModules.AlarmModule.timer( milliseconds, !!settings.vibrate, settings.sound, ); + const nextAlarm = new Date(); + nextAlarm.setTime(nextAlarm.getTime() + milliseconds); + updateSettings({...settings, nextAlarm: nextAlarm.toISOString()}); }, [settings], ); diff --git a/Page.tsx b/Page.tsx index c945fc4..7651ed4 100644 --- a/Page.tsx +++ b/Page.tsx @@ -12,18 +12,20 @@ export default function Page({ }: { children: JSX.Element | JSX.Element[]; onAdd?: () => void; - search: string; - setSearch: (value: string) => void; + search?: string; + setSearch?: (value: string) => void; }) { return ( - + {typeof search === 'string' && setSearch && ( + + )} {children} {onAdd && } diff --git a/Routes.tsx b/Routes.tsx index 7bad039..a070d3a 100644 --- a/Routes.tsx +++ b/Routes.tsx @@ -1,4 +1,5 @@ import {createDrawerNavigator} from '@react-navigation/drawer'; +import {useNavigation} from '@react-navigation/native'; import React from 'react'; import {IconButton} from 'react-native-paper'; import BestPage from './BestPage'; @@ -7,6 +8,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'; @@ -14,12 +16,14 @@ const Drawer = createDrawerNavigator(); export default function Routes() { const dark = useDark(); + const navigation = useNavigation(); 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: '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..35d7c67 --- /dev/null +++ b/TimerPage.tsx @@ -0,0 +1,76 @@ +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useState} from 'react'; +import {NativeModules, View} from 'react-native'; +import {Button, Subheading, Title} from 'react-native-paper'; +import DrawerHeader from './DrawerHeader'; +import MassiveFab from './MassiveFab'; +import Page from './Page'; +import {getSettings, updateSettings} from './settings.service'; +import {useSettings} from './use-settings'; + +export default function TimerPage() { + const [remaining, setRemaining] = useState(0); + const {settings} = useSettings(); + + const minutes = Math.floor(remaining / 1000 / 60); + const seconds = Math.floor((remaining / 1000) % 60); + let interval = 0; + + const tick = useCallback(() => { + let newRemaining = 0; + getSettings().then(gotSettings => { + if (!gotSettings.nextAlarm) return; + const date = new Date(gotSettings.nextAlarm); + newRemaining = date.getTime() - new Date().getTime(); + if (newRemaining < 0) setRemaining(0); + else setRemaining(newRemaining); + }); + interval = setInterval(() => { + console.log({newRemaining}); + newRemaining -= 1000; + if (newRemaining > 0) return setRemaining(newRemaining); + clearInterval(interval); + setRemaining(0); + }, 1000); + return () => clearInterval(interval); + }, []); + + useFocusEffect(tick); + + const stop = () => { + NativeModules.AlarmModule.stop(); + clearInterval(interval); + setRemaining(0); + updateSettings({...settings, nextAlarm: undefined}); + }; + + const add = async () => { + if (!settings.nextAlarm) return; + const date = new Date(settings.nextAlarm); + date.setTime(date.getTime() + 1000 * 60); + NativeModules.AlarmModule.add(date, settings.vibrate, settings.sound); + await updateSettings({...settings, nextAlarm: date.toISOString()}); + tick(); + }; + + 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 d2a30d0..f353afb 100644 --- a/android/app/src/main/java/com/massive/AlarmModule.kt +++ b/android/app/src/main/java/com/massive/AlarmModule.kt @@ -1,29 +1,55 @@ package com.massive import android.annotation.SuppressLint +import android.app.* import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent 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 androidx.core.app.NotificationCompat +import com.facebook.react.bridge.ActivityEventListener +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.BaseActivityEventListener 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.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter +import kotlin.math.floor class AlarmModule internal constructor(context: ReactApplicationContext?) : ReactContextBaseJavaModule(context) { + var countdownTimer: CountDownTimer? = null + override fun getName(): String { return "AlarmModule" } + private val mActivityEventListener: ActivityEventListener = + object : BaseActivityEventListener() { + override fun onActivityResult( + activity: Activity?, + requestCode: Int, + resultCode: Int, + data: Intent? + ) { + Log.d("AlarmModule", "onActivityResult") + } + } + + init { + reactApplicationContext.addActivityEventListener(mActivityEventListener) + } + @RequiresApi(api = Build.VERSION_CODES.O) @ReactMethod fun add(milliseconds: Int, vibrate: Boolean, sound: String?) { @@ -50,11 +76,19 @@ class AlarmModule internal constructor(context: ReactApplicationContext?) : @ReactMethod fun timer(milliseconds: Int, vibrate: Boolean, sound: 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) - reactApplicationContext.startService(intent) + val intent = Intent(reactApplicationContext, AlarmModule::class.java) + currentActivity?.startActivityForResult(intent, 0) + val manager = getManager() + manager.cancel(NOTIFICATION_ID_DONE) + reactApplicationContext.stopService( + Intent( + reactApplicationContext, + AlarmService::class.java + ) + ) + countdownTimer?.cancel() + countdownTimer = getTimer(milliseconds, vibrate, sound) + countdownTimer?.start() } @RequiresApi(Build.VERSION_CODES.M) @@ -87,4 +121,113 @@ 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) { + 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()) + } + + @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) + } + } + } + + @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 stopIntent = Intent(context, StopTimer::class.java) + val pendingStop = + PendingIntent.getService(context, 0, stopIntent, 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 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/db.ts b/db.ts index bb576bb..bed371e 100644 --- a/db.ts +++ b/db.ts @@ -117,6 +117,9 @@ const migrations = [ ` ALTER TABLE settings ADD COLUMN showSets BOOLEAN DEFAULT 1 `, + ` + CREATE INDEX sets_created ON sets(created) + `, ]; export let db: SQLiteDatabase; diff --git a/drawer-param-list.ts b/drawer-param-list.ts index e590f4c..8fea9ed 100644 --- a/drawer-param-list.ts +++ b/drawer-param-list.ts @@ -4,4 +4,5 @@ export type DrawerParamList = { Best: {}; Plans: {}; Workouts: {}; + Timer: {}; }; diff --git a/settings.ts b/settings.ts index 6a422e6..d16fdea 100644 --- a/settings.ts +++ b/settings.ts @@ -11,4 +11,5 @@ export default interface Settings { showDate: number; theme: 'system' | 'dark' | 'light'; showSets: number; + nextAlarm?: string; }