Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ package expo.modules.workoutworker.handlers


import android.annotation.SuppressLint
import android.app.Notification
import android.util.Log
import com.limajuice.liftlog.DistanceCardioTarget
import com.limajuice.liftlog.RecordedCardioExercise
import com.limajuice.liftlog.RecordedCardioExerciseSet
import com.limajuice.liftlog.RecordedWeightedExercise
import com.limajuice.liftlog.RestTimerInfo
import com.limajuice.liftlog.TimeCardioTarget
import com.limajuice.liftlog.Translations
import com.limajuice.liftlog.Weight
Expand All @@ -17,7 +17,10 @@ import com.limajuice.liftlog.WorkoutUpdatedEvent
import expo.modules.workoutworker.utils.RepeatingTimerAction
import expo.modules.workoutworker.utils.RestWindow
import expo.modules.workoutworker.utils.WorkoutNotificationManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Clock
Expand All @@ -36,8 +39,12 @@ class WorkoutUpdatedHandler(

val timer = RepeatingTimerAction(MainScope(), {})

// Every workout update restarts the timer callback, so "have we announced this target yet" has to
// outlive it - otherwise a set left running past its target re-announces on every update.
private val restAlertScope = MainScope()
private var restAlertJob: Job? = null
private var scheduledRestAlert: Triple<Long, Long, Boolean>? = null

// Every workout update restarts the timer callback, so "have we announced this target yet" has
// to outlive it - otherwise a set left running past its target re-announces on every update.
private var announcedCardioTarget: Pair<Int, Int>? = null

override suspend fun handle(
Expand All @@ -46,9 +53,16 @@ class WorkoutUpdatedHandler(
) {
try {
val workoutUpdatedEvent = event.payload as WorkoutUpdatedEvent
if (workoutUpdatedEvent.restTimerInfo == null) {
cancelRestAlerts()
}

when {
workoutUpdatedEvent.restTimerInfo != null -> showRestTimerNotification(event.translations, workoutUpdatedEvent)
workoutUpdatedEvent.restTimerInfo != null -> showRestTimerNotification(
event.translations,
workoutUpdatedEvent,
event.appConfiguration.restCountdownTonesEnabled,
)
workoutUpdatedEvent.cardioTimerInfo != null -> showCardioTimerNotification(event.translations, workoutUpdatedEvent)
workoutUpdatedEvent.currentExerciseDetails != null -> showCurrentExerciseNotification(
event.translations,
Expand Down Expand Up @@ -99,17 +113,19 @@ class WorkoutUpdatedHandler(
private fun showRestTimerNotification(
translations: Translations,
workoutUpdatedEvent: WorkoutUpdatedEvent,
restCountdownTonesEnabled: Boolean,
) {

val restTimerInfo = workoutUpdatedEvent.restTimerInfo ?:return
scheduleRestAlerts(translations, restTimerInfo, restCountdownTonesEnabled)

fun getProgress(): Long {
val timeStartSecs = restTimerInfo.startedAt.epochSeconds
val now = Clock.System.now().epochSeconds
return now - timeStartSecs
}

val currentExerciseMessage = getCurrentExerciseMessage(translations, workoutUpdatedEvent)
var previousProgress = getProgress()
timer.updateCallback {
val timeStartSecs = restTimerInfo.startedAt.epochSeconds
val timePartiallyEndSecs = restTimerInfo.partiallyEndAt.epochSeconds
Expand All @@ -124,29 +140,6 @@ class WorkoutUpdatedHandler(
partialProgressMax else
fullProgressMax


val restNotif: Notification? = when {
partialProgressMax in (previousProgress + 1)..progress && partialProgressMax != 0L -> notificationManager.createRestNotificationBuilder()
.setContentTitle(translations.workoutPersistentNotificationMinRestOverMessage)
.build()

fullProgressMax in (previousProgress + 1)..progress && fullProgressMax != 0L -> notificationManager.createRestNotificationBuilder()
.setContentTitle(translations.workoutPersistentNotificationMaxRestOverMessage)
.build()

else -> null
}
if (restNotif != null) {
notificationManager.notifyRest(restNotif)

MainScope().launch {
delay(10_000)
notificationManager.clearRestNotification()
}
}
@Suppress("AssignedValueIsNeverRead")
previousProgress = progress

val message = when {
now < timePartiallyEndSecs -> translations.workoutPersistentNotificationRestBreakMessage
now in timePartiallyEndSecs..timeEndSecs -> translations.workoutPersistentNotificationStartSoonMessage
Expand Down Expand Up @@ -186,6 +179,70 @@ class WorkoutUpdatedHandler(
timer.start()
}

@OptIn(ExperimentalTime::class)
private fun scheduleRestAlerts(
translations: Translations,
restTimerInfo: RestTimerInfo,
countdownTonesEnabled: Boolean,
) {
val schedule = Triple(
restTimerInfo.partiallyEndAt.toEpochMilliseconds(),
restTimerInfo.endAt.toEpochMilliseconds(),
countdownTonesEnabled,
)
if (scheduledRestAlert == schedule) return

cancelRestAlerts()
scheduledRestAlert = schedule
restAlertJob = restAlertScope.launch {
val boundaries = listOf(
schedule.first to translations.workoutPersistentNotificationMinRestOverMessage,
schedule.second to translations.workoutPersistentNotificationMaxRestOverMessage,
).filter { (targetMs) -> targetMs > restTimerInfo.startedAt.toEpochMilliseconds() }
.distinctBy { (targetMs) -> targetMs }

for ((targetMs, title) in boundaries) {
if (countdownTonesEnabled) {
for (remainingSecs in 3L downTo 1L) {
scheduleAtTime(targetMs - remainingSecs * 1_000) {
notificationManager.playRestCountdownTone(remainingSecs, targetMs)
}
}
}
scheduleAtTime(targetMs) {
notificationManager.notifyRest(
notificationManager.createRestNotificationBuilder()
.setContentTitle(title)
.build(),
targetMs,
)
MainScope().launch {
delay(10_000)
notificationManager.clearRestNotification()
}
}
}
}
}

@OptIn(ExperimentalTime::class)
private fun CoroutineScope.scheduleAtTime(targetEpochMs: Long, action: suspend () -> Unit) {
launch {
val delayMs = targetEpochMs - Clock.System.now().toEpochMilliseconds()
if (delayMs <= 0) {
return@launch
}
delay(delayMs)
action()
}
}

private fun cancelRestAlerts() {
if (scheduledRestAlert != null) notificationManager.cancelRestToneSequence()
restAlertJob?.cancel()
restAlertJob = null
scheduledRestAlert = null
}

@OptIn(ExperimentalTime::class)
private fun showCardioTimerNotification(
Expand Down Expand Up @@ -351,5 +408,8 @@ class WorkoutUpdatedHandler(

override fun onDestroy() {
timer.destroy()
cancelRestAlerts()
restAlertScope.cancel()
notificationManager.cancelRestToneSequence()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package expo.modules.workoutworker.utils

import kotlin.math.PI
import kotlin.math.min
import kotlin.math.sin

/** One audio buffer keeps playback running through the silent gaps, even without music. */
internal object RestTonePcm {
const val SAMPLE_RATE = 48_000
const val FINAL_DURATION_MS = 1_000
const val COUNTDOWN_DURATION_FRACTION = 0.5
const val TAIL_MS = 200

fun create(remainingSecs: Int): ShortArray {
require(remainingSecs in 0..3)
val totalMs = remainingSecs * 1_000 + FINAL_DURATION_MS + TAIL_MS
val samples = ShortArray(totalMs * SAMPLE_RATE / 1_000)
val fadeFrames = SAMPLE_RATE * 5 / 1_000
for (second in 0..remainingSecs) {
val durationMs = if (second == remainingSecs) FINAL_DURATION_MS
else (FINAL_DURATION_MS * COUNTDOWN_DURATION_FRACTION).toInt()
val highFrequencyHz = if (second == remainingSecs) 1477 else 1209
val frames = durationMs * SAMPLE_RATE / 1_000
val offset = second * SAMPLE_RATE
for (frame in 0 until frames) {
// DTMF 4 for countdown, DTMF 6 for final; short ramps avoid edge clicks.
val time = frame.toDouble() / SAMPLE_RATE
val envelope = min(1.0, min(frame, frames - 1 - frame).toDouble() / fadeFrames)
val wave = sin(2 * PI * 770 * time) + sin(2 * PI * highFrequencyHz * time)
samples[offset + frame] = (wave * envelope * 0.35 * Short.MAX_VALUE).toInt().toShort()
}
}
return samples
}
}
Loading
Loading