diff --git a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/handlers/WorkoutUpdatedHandler.kt b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/handlers/WorkoutUpdatedHandler.kt index 96ed86515..d6e392ded 100644 --- a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/handlers/WorkoutUpdatedHandler.kt +++ b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/handlers/WorkoutUpdatedHandler.kt @@ -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 @@ -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 @@ -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? = 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? = null override suspend fun handle( @@ -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, @@ -99,9 +113,12 @@ 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 @@ -109,7 +126,6 @@ class WorkoutUpdatedHandler( } val currentExerciseMessage = getCurrentExerciseMessage(translations, workoutUpdatedEvent) - var previousProgress = getProgress() timer.updateCallback { val timeStartSecs = restTimerInfo.startedAt.epochSeconds val timePartiallyEndSecs = restTimerInfo.partiallyEndAt.epochSeconds @@ -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 @@ -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( @@ -351,5 +408,8 @@ class WorkoutUpdatedHandler( override fun onDestroy() { timer.destroy() + cancelRestAlerts() + restAlertScope.cancel() + notificationManager.cancelRestToneSequence() } } diff --git a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/RestTonePcm.kt b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/RestTonePcm.kt new file mode 100644 index 000000000..26bf66e62 --- /dev/null +++ b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/RestTonePcm.kt @@ -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 + } +} diff --git a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt index f05c5503e..5f00421f9 100644 --- a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt +++ b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt @@ -3,6 +3,18 @@ package expo.modules.workoutworker.utils import android.app.Notification import android.app.NotificationManager import android.content.Context +import android.media.AudioAttributes +import android.media.AudioDeviceInfo +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.media.AudioFormat +import android.media.AudioRouting +import android.media.AudioTrack +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log import androidx.annotation.DrawableRes import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE @@ -31,12 +43,36 @@ enum class RestWindow(@DrawableRes val icon: Int) { class WorkoutNotificationManager(private val context: Context) { + private val audioManager = context.getSystemService(AudioManager::class.java) + private val restToneHandler = Handler(Looper.getMainLooper()) + private val restTonePlaybacks = mutableMapOf() + private var toneSequence = 0L + private var restToneAudioFocusRequest: AudioFocusRequest? = null + private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> + if (focusChange == AudioManager.AUDIOFOCUS_LOSS || focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) { + releaseAllRestTones() + } + } + companion object { const val PERSISTENT_NOTIFICATION_ID = 123 const val REST_NOTIFICATION_ID = 1234 const val PERSISTENT_CHANNEL_ID = "workout_channel" const val REST_CHANNEL_ID = "rest_channel" + + private val HEADPHONE_AUDIO_DEVICE_TYPES = setOf( + AudioDeviceInfo.TYPE_BLE_HEADSET, + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + AudioDeviceInfo.TYPE_USB_HEADSET, + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_WIRED_HEADSET, + ) + + private val REST_TONE_AUDIO_ATTRIBUTES = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() } // Set once the user swipes the Live Update away; from then on we stop requesting promotion so @@ -114,12 +150,143 @@ class WorkoutNotificationManager(private val context: Context) { manager.notify(PERSISTENT_NOTIFICATION_ID, notification) } - fun notifyRest(notification: Notification) { + fun notifyRest(notification: Notification, targetEpochMs: Long? = null) { val manager = context.getSystemService(NotificationManager::class.java) manager.notify(REST_NOTIFICATION_ID, notification) + playRestToneSequence(targetEpochMs, 0) + } + + fun playRestCountdownTone(remainingSecs: Long, targetEpochMs: Long) { + playRestToneSequence(targetEpochMs, remainingSecs.toInt()) + } + private fun playRestToneSequence(targetEpochMs: Long?, remainingSecs: Int) { + val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + if (audioManager.ringerMode == AudioManager.RINGER_MODE_NORMAL || + outputs.none { it.type in HEADPHONE_AUDIO_DEVICE_TYPES }) { + releaseAllRestTones() + return + } + // Later countdown ticks and the final notification must not replay buffered tones. + if (targetEpochMs != null && restTonePlaybacks.containsKey(targetEpochMs)) { + return + } + val key = targetEpochMs ?: -(++toneSequence) + var track: AudioTrack? = null + try { + if (restTonePlaybacks.isEmpty() && !requestRestToneAudioFocus(audioManager)) { + return + } + val samples = RestTonePcm.create(remainingSecs) + val audioTrack = AudioTrack.Builder() + .setAudioAttributes(REST_TONE_AUDIO_ATTRIBUTES) + .setAudioFormat(AudioFormat.Builder() + .setSampleRate(RestTonePcm.SAMPLE_RATE) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build()) + .setTransferMode(AudioTrack.MODE_STATIC) + .setBufferSizeInBytes(samples.size * 2) + .build() + track = audioTrack + // MODE_STATIC remains STATE_NO_STATIC_DATA until its first successful write. + val initialState = audioTrack.state + check(initialState != AudioTrack.STATE_UNINITIALIZED) { "AudioTrack initialization failed: state=$initialState" } + val written = audioTrack.write(samples, 0, samples.size) + check(written == samples.size) { "Incomplete PCM write: $written/${samples.size}" } + check(audioTrack.state == AudioTrack.STATE_INITIALIZED) { "AudioTrack not ready after write: state=${audioTrack.state}" } + val playback = audioTrack + restTonePlaybacks[key] = playback + audioTrack.setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener { + override fun onMarkerReached(track: AudioTrack) { + if (restTonePlaybacks[key] === playback) releaseRestTone(key) + } + override fun onPeriodicNotification(track: AudioTrack) { + if (restTonePlaybacks[key] !== playback) return + if (audioManager.ringerMode == AudioManager.RINGER_MODE_NORMAL) { + releaseAllRestTones() + } + } + }, restToneHandler) + audioTrack.addOnRoutingChangedListener(AudioRouting.OnRoutingChangedListener { routing -> + if (restTonePlaybacks[key] === playback) { + val deviceType = routing.routedDevice?.type + if (deviceType != null && deviceType !in HEADPHONE_AUDIO_DEVICE_TYPES) { + releaseRestTone(key) + } + } + }, restToneHandler) + check(audioTrack.setNotificationMarkerPosition(samples.size - 1) == AudioTrack.SUCCESS) + check(audioTrack.setPositionNotificationPeriod(RestTonePcm.SAMPLE_RATE) == AudioTrack.SUCCESS) + audioTrack.play() + val returnedAt = SystemClock.uptimeMillis() + val bufferMs = samples.size * 1_000L / RestTonePcm.SAMPLE_RATE + // Marker completion follows playback progress, not wall time. This only bounds leaks + // if a device fails to deliver its completion callback. + restToneHandler.postAtTime({ + if (restTonePlaybacks[key] === playback) releaseRestTone(key) + }, playback, returnedAt + bufferMs + 2_000) + } catch (e: Exception) { + if (restTonePlaybacks.containsKey(key)) releaseRestTone(key) + else { + track?.release() + if (restTonePlaybacks.isEmpty()) abandonRestToneAudioFocus(audioManager) + } + Log.e("WorkoutNotificationManager", "Failed to play rest tone sequence", e) + } } + private fun requestRestToneAudioFocus(audioManager: AudioManager): Boolean { + val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + .setAudioAttributes(REST_TONE_AUDIO_ATTRIBUTES) + .setOnAudioFocusChangeListener(audioFocusChangeListener) + .build() + restToneAudioFocusRequest = request + audioManager.requestAudioFocus(request) + } else { + @Suppress("DEPRECATION") + audioManager.requestAudioFocus( + audioFocusChangeListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK, + ) + } + val granted = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + if (!granted) restToneAudioFocusRequest = null + return granted + } + + fun cancelRestToneSequence() { + releaseAllRestTones() + } + + private fun releaseAllRestTones() { + restTonePlaybacks.keys.toList().forEach { releaseRestTone(it) } + } + + private fun releaseRestTone(key: Long) { + val playback = restTonePlaybacks.remove(key) ?: return + restToneHandler.removeCallbacksAndMessages(playback) + try { + playback.stop() + } finally { + playback.release() + if (restTonePlaybacks.isEmpty()) abandonRestToneAudioFocus(audioManager) + } + } + + private fun abandonRestToneAudioFocus(audioManager: AudioManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + restToneAudioFocusRequest?.let(audioManager::abandonAudioFocusRequest) + restToneAudioFocusRequest = null + } else { + @Suppress("DEPRECATION") + audioManager.abandonAudioFocus(audioFocusChangeListener) + } + } + + fun clearPersistentNotification() { val manager = context.getSystemService(NotificationManager::class.java) manager.cancel(PERSISTENT_NOTIFICATION_ID) diff --git a/app/src/app/(tabs)/settings/notifications.tsx b/app/src/app/(tabs)/settings/notifications.tsx index 1104b6866..d862c7fc4 100644 --- a/app/src/app/(tabs)/settings/notifications.tsx +++ b/app/src/app/(tabs)/settings/notifications.tsx @@ -2,12 +2,14 @@ import { RootState, useAppSelector } from '@/store'; import { broadcastWorkoutEvent } from '@/store/workout-worker'; import { workoutUpdatedEvent } from '@/store/workout-worker/helpers'; import { selectActiveSession } from '@/store/stored-sessions'; -import { setRestNotifications, setRestTimersEnabled } from '@/store/settings'; +import { setRestCountdownTones, setRestNotifications, setRestTimersEnabled } from '@/store/settings'; import { useTranslate } from '@tolgee/react'; import { useDispatch } from 'react-redux'; import { SettingsPage } from '@/components/layout/settings-page'; import { SegmentedListSwitch } from '@/components/presentation/foundation/segmented-list-switch'; import { SegmentedGroup } from '@/components/presentation/foundation/segmented-list'; +import { spacing } from '@/hooks/useAppTheme'; +import { Platform } from 'react-native'; export default function NotificationsPage() { const { t } = useTranslate(); @@ -34,6 +36,26 @@ export default function NotificationsPage() { } }} /> + {Platform.OS === 'android' ? ( + { + dispatch(setRestCountdownTones(value)); + if (currentWorkout) { + dispatch(broadcastWorkoutEvent(workoutUpdatedEvent(currentWorkout, settings.restTimersEnabled))); + } + }} + /> + ) : undefined} void; testID?: string; disabled?: boolean; + style?: StyleProp; } export function SegmentedListSwitch(props: ListSwitchProps) { @@ -19,7 +21,8 @@ export function SegmentedListSwitch(props: ListSwitchProps) { label={props.label} supportingText={props.supportingText} icon={props.icon} - onPress={() => props.onValueChange(!props.value)} + onPress={props.disabled ? undefined : () => props.onValueChange(!props.value)} + style={props.style} right={ {time}", diff --git a/app/src/models/workout-worker-messages.spec.ts b/app/src/models/workout-worker-messages.spec.ts index 32ba2b968..d47aaf4d4 100644 --- a/app/src/models/workout-worker-messages.spec.ts +++ b/app/src/models/workout-worker-messages.spec.ts @@ -66,6 +66,7 @@ const TRANSLATIONS: Translations = { const APP_CONFIG: AppConfiguration = { notificationsEnabled: true, + restCountdownTonesEnabled: false, }; function makeWeightedExercise(): RecordedWeightedExercise { @@ -307,7 +308,7 @@ describe('WorkoutMessage JSON schema validation', () => { it('validates a WorkoutMessage with notifications disabled', () => { const message: WorkoutMessage = { translations: TRANSLATIONS, - appConfiguration: { notificationsEnabled: false }, + appConfiguration: { notificationsEnabled: false, restCountdownTonesEnabled: false }, payload: { type: 'WorkoutStartedEvent' }, }; expect(validate('WorkoutMessage', message)).toBe(true); @@ -352,11 +353,17 @@ describe('WorkoutMessage JSON schema validation', () => { describe('AppConfiguration', () => { it('validates AppConfiguration with notifications enabled', () => { - expect(validate('AppConfiguration', { notificationsEnabled: true })).toBe(true); + expect(validate('AppConfiguration', { notificationsEnabled: true, restCountdownTonesEnabled: false })).toBe(true); }); it('validates AppConfiguration with notifications disabled', () => { - expect(validate('AppConfiguration', { notificationsEnabled: false })).toBe(true); + expect(validate('AppConfiguration', { notificationsEnabled: false, restCountdownTonesEnabled: false })).toBe( + true, + ); + }); + + it('validates AppConfiguration with countdown tones enabled', () => { + expect(validate('AppConfiguration', { notificationsEnabled: true, restCountdownTonesEnabled: true })).toBe(true); }); }); // ------------------------------------------------------------------------- @@ -468,7 +475,13 @@ describe('WorkoutMessage JSON schema validation', () => { }); it('rejects an AppConfiguration where notificationsEnabled is not a boolean', () => { - expect(validate('AppConfiguration', { notificationsEnabled: 'yes' })).toBe(false); + expect(validate('AppConfiguration', { notificationsEnabled: 'yes', restCountdownTonesEnabled: false })).toBe( + false, + ); + }); + + it('rejects an AppConfiguration missing restCountdownTonesEnabled', () => { + expect(validate('AppConfiguration', { notificationsEnabled: true })).toBe(false); }); }); diff --git a/app/src/models/workout-worker-messages.ts b/app/src/models/workout-worker-messages.ts index 798dea437..d3074224f 100644 --- a/app/src/models/workout-worker-messages.ts +++ b/app/src/models/workout-worker-messages.ts @@ -93,4 +93,5 @@ export interface Translations { export interface AppConfiguration { notificationsEnabled: boolean; + restCountdownTonesEnabled: boolean; } diff --git a/app/src/services/preference-service.spec.ts b/app/src/services/preference-service.spec.ts index 07251b2b5..ae41e51a7 100644 --- a/app/src/services/preference-service.spec.ts +++ b/app/src/services/preference-service.spec.ts @@ -51,6 +51,7 @@ const booleanPrefs: BoolPref[] = [ { key: 'showBodyweight', default: true }, { key: 'showFeed', default: true }, { key: 'restNotifications', default: true }, + { key: 'restCountdownTones', default: false }, { key: 'restTimersEnabled', default: true }, { key: 'crashReportsEnabled', default: true }, { key: 'welcomeWizardCompleted', default: false }, diff --git a/app/src/services/workout-worker.ts b/app/src/services/workout-worker.ts index 073c8de97..5a90e418c 100644 --- a/app/src/services/workout-worker.ts +++ b/app/src/services/workout-worker.ts @@ -35,6 +35,7 @@ export class WorkoutWorker { private getAppConfigurationMessage(): AppConfiguration { return { notificationsEnabled: this.getState().settings.restNotifications, + restCountdownTonesEnabled: this.getState().settings.restCountdownTones, }; } diff --git a/app/src/store/settings/index.ts b/app/src/store/settings/index.ts index d27c94e70..870495fa2 100644 --- a/app/src/store/settings/index.ts +++ b/app/src/store/settings/index.ts @@ -88,6 +88,7 @@ export const { setLastSeenWhatsNewId, setShowFeed, setRestNotifications, + setRestCountdownTones, setRestTimersEnabled, setCrashReportsEnabled, setWelcomeWizardCompleted, diff --git a/app/src/store/settings/preferences.spec.ts b/app/src/store/settings/preferences.spec.ts index a8bfa9861..afa570b69 100644 --- a/app/src/store/settings/preferences.spec.ts +++ b/app/src/store/settings/preferences.spec.ts @@ -22,6 +22,7 @@ describe('settings slice - generated preference actions', () => { const state = settingsReducer(undefined, { type: '@@init' }); expect(state.notesExpandedByDefault).toBe(true); expect(state.keepScreenAwakeDuringWorkout).toBe(true); + expect(state.restCountdownTones).toBe(false); expect(state.isHydrated).toBe(false); }); }); diff --git a/app/src/store/settings/registry.ts b/app/src/store/settings/registry.ts index 7a969866d..6cef8e7f3 100644 --- a/app/src/store/settings/registry.ts +++ b/app/src/store/settings/registry.ts @@ -61,6 +61,7 @@ export const preferenceRegistry = { showBodyweight: pref({ default: true, codec: boolCodec }), showFeed: pref({ default: true, codec: boolCodec }), restNotifications: pref({ default: true, codec: boolCodec }), + restCountdownTones: pref({ default: false, codec: boolCodec }), restTimersEnabled: pref({ default: true, codec: boolCodec }), crashReportsEnabled: pref({ default: true, codec: boolCodec }), welcomeWizardCompleted: pref({ default: false, codec: boolCodec }), diff --git a/docs/schemas/workout-worker/AppConfiguration.json b/docs/schemas/workout-worker/AppConfiguration.json index c6f83becd..0e9e34aa5 100644 --- a/docs/schemas/workout-worker/AppConfiguration.json +++ b/docs/schemas/workout-worker/AppConfiguration.json @@ -4,9 +4,10 @@ "properties": { "notificationsEnabled": { "type": "boolean" + }, + "restCountdownTonesEnabled": { + "type": "boolean" } }, - "required": [ - "notificationsEnabled" - ] -} \ No newline at end of file + "required": ["notificationsEnabled", "restCountdownTonesEnabled"] +}