diff --git a/app/src/main/java/com/podometer/service/NotificationHelper.kt b/app/src/main/java/com/podometer/service/NotificationHelper.kt index ec8e15c..f108a00 100644 --- a/app/src/main/java/com/podometer/service/NotificationHelper.kt +++ b/app/src/main/java/com/podometer/service/NotificationHelper.kt @@ -2,7 +2,6 @@ package com.podometer.service import android.app.Notification -import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -33,9 +32,6 @@ class NotificationHelper @Inject constructor( @ApplicationContext private val context: Context, ) { - private val notificationManager: NotificationManager = - context.getSystemService(NotificationManager::class.java) - private fun buildPendingIntent(): PendingIntent = PendingIntent.getActivity( context, @@ -69,25 +65,6 @@ class NotificationHelper @Inject constructor( .build() } - /** - * Updates the existing foreground notification in-place via - * [NotificationManager.notify] so the system does not recreate it. - * - * @param steps Current step count. - * @param distanceKm Current distance in km. - * @param activity Current activity state. - * @param style Display style to use. - */ - fun updateNotification( - steps: Int, - distanceKm: Float, - activity: ActivityState, - style: NotificationStyle, - ) { - val notification = buildNotification(steps, distanceKm, activity, style) - notificationManager.notify(NOTIFICATION_ID, notification) - } - // ─── Pure formatting helpers (companion — no Context needed) ───────────── companion object { diff --git a/app/src/main/java/com/podometer/service/NotificationStyle.kt b/app/src/main/java/com/podometer/service/NotificationStyle.kt index e92e86a..9653bc5 100644 --- a/app/src/main/java/com/podometer/service/NotificationStyle.kt +++ b/app/src/main/java/com/podometer/service/NotificationStyle.kt @@ -10,5 +10,18 @@ package com.podometer.service */ enum class NotificationStyle { MINIMAL, - DETAILED, + DETAILED; + + companion object { + /** + * Converts a DataStore preference string to the corresponding enum value. + * + * Recognised strings: `"minimal"`, `"detailed"` (case-insensitive). + * Unrecognised values fall back to [MINIMAL]. + */ + fun fromPreference(value: String): NotificationStyle = when (value.lowercase()) { + "detailed" -> DETAILED + else -> MINIMAL + } + } } diff --git a/app/src/main/java/com/podometer/service/StepTrackingService.kt b/app/src/main/java/com/podometer/service/StepTrackingService.kt index 96897d9..b1289d4 100644 --- a/app/src/main/java/com/podometer/service/StepTrackingService.kt +++ b/app/src/main/java/com/podometer/service/StepTrackingService.kt @@ -30,7 +30,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -103,6 +105,7 @@ class StepTrackingService : Service() { private var collectorJob: Job? = null private var notificationTickerJob: Job? = null + private var notificationStyleJob: Job? = null private var classifierJob: Job? = null private var orphanCleanupJob: Job? = null @@ -124,10 +127,10 @@ class StepTrackingService : Service() { private var lastStepEventMs: Long = 0L /** - * Stride length in kilometres, read once from [PreferencesManager] during - * [onCreate] and cached here so that [collectStepEvents] can construct - * partial-hour [DailySummary] records for live dashboard updates without - * re-reading the preference on every step event. + * Stride length in kilometres, read from [PreferencesManager] during + * [onCreate] and refreshed by the notification ticker every 30 s. + * Used by [collectStepEvents] for live dashboard distance updates and + * by [updateForegroundNotification] for notification content. * * Defaults to [StepAccumulator.DEFAULT_STRIDE_LENGTH_KM] if the preference * read fails. @@ -173,6 +176,9 @@ class StepTrackingService : Service() { if (notificationTickerJob == null || notificationTickerJob?.isActive != true) { notificationTickerJob = launchNotificationTicker() } + if (notificationStyleJob == null || notificationStyleJob?.isActive != true) { + notificationStyleJob = launchNotificationStyleObserver() + } // Orphan cleanup must be launched before the classifier so the classifier // can join() it before its first evaluation (see launchClassifier). if (orphanCleanupJob == null || orphanCleanupJob?.isActive != true) { @@ -242,11 +248,15 @@ class StepTrackingService : Service() { } private fun startForegroundWithNotification() { + val style = runBlockingWithDefault( + default = NotificationStyle.MINIMAL, + tag = "read notification style preference", + ) { NotificationStyle.fromPreference(preferencesManager.notificationStyle().first()) } val notification = notificationHelper.buildNotification( steps = accumulator.totalStepsToday, distanceKm = 0f, activity = ActivityState.STILL, - style = NotificationStyle.MINIMAL, + style = style, ) ServiceCompat.startForeground( this, @@ -495,6 +505,29 @@ class StepTrackingService : Service() { // ─── Notification ticker ────────────────────────────────────────────────── + /** + * Re-posts the foreground notification via [ServiceCompat.startForeground] + * with the current step data and the given [style]. + */ + private fun updateForegroundNotification(style: NotificationStyle) { + val strideKm = strideLengthKm + val distanceKm = accumulator.totalStepsToday * strideKm + val currentActivity = cyclingClassifier.getCurrentState() + val notification = notificationHelper.buildNotification( + steps = accumulator.totalStepsToday, + distanceKm = distanceKm, + activity = currentActivity, + style = style, + ) + ServiceCompat.startForeground( + this, + NotificationHelper.NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH, + ) + Log.d(TAG, "Notification updated: ${accumulator.totalStepsToday} steps, $distanceKm km, style=$style") + } + /** * Coroutine that updates the foreground notification every [NOTIFICATION_UPDATE_INTERVAL_MS] * milliseconds with the current step count, computed distance, and detected @@ -503,19 +536,29 @@ class StepTrackingService : Service() { private fun launchNotificationTicker(): Job = serviceScope.launch { while (isActive) { delay(NOTIFICATION_UPDATE_INTERVAL_MS) - val strideKm = preferencesManager.strideLengthKm().first() - val distanceKm = accumulator.totalStepsToday * strideKm - val currentActivity = cyclingClassifier.getCurrentState() - notificationHelper.updateNotification( - steps = accumulator.totalStepsToday, - distanceKm = distanceKm, - activity = currentActivity, - style = NotificationStyle.MINIMAL, + strideLengthKm = preferencesManager.strideLengthKm().first() + val style = NotificationStyle.fromPreference( + preferencesManager.notificationStyle().first(), ) - Log.d(TAG, "Notification updated: ${accumulator.totalStepsToday} steps, $distanceKm km, activity=$currentActivity") + updateForegroundNotification(style) } } + /** + * Observes the notification style preference and immediately updates the + * notification when it changes. The initial emission is dropped because + * the ticker (or the initial [startForegroundWithNotification]) already + * handles the first value. + */ + private fun launchNotificationStyleObserver(): Job = serviceScope.launch { + preferencesManager.notificationStyle() + .map { NotificationStyle.fromPreference(it) } + .drop(1) // skip the initial value — already applied + .collect { style -> + updateForegroundNotification(style) + } + } + // ─── Constants and helpers ──────────────────────────────────────────────── // Companion is `internal` rather than `private` to allow direct unit-test access from diff --git a/app/src/test/java/com/podometer/service/NotificationHelperTest.kt b/app/src/test/java/com/podometer/service/NotificationHelperTest.kt index 9469e9f..9a12624 100644 --- a/app/src/test/java/com/podometer/service/NotificationHelperTest.kt +++ b/app/src/test/java/com/podometer/service/NotificationHelperTest.kt @@ -179,4 +179,28 @@ class NotificationHelperTest { val clazz = NotificationHelper::class.java assertEquals("com.podometer.service", clazz.packageName) } + + // ─── NotificationStyle.fromPreference ───────────────────────────────────── + + @Test + fun `fromPreference returns MINIMAL for minimal`() { + assertEquals(NotificationStyle.MINIMAL, NotificationStyle.fromPreference("minimal")) + } + + @Test + fun `fromPreference returns DETAILED for detailed`() { + assertEquals(NotificationStyle.DETAILED, NotificationStyle.fromPreference("detailed")) + } + + @Test + fun `fromPreference is case-insensitive`() { + assertEquals(NotificationStyle.DETAILED, NotificationStyle.fromPreference("Detailed")) + assertEquals(NotificationStyle.DETAILED, NotificationStyle.fromPreference("DETAILED")) + } + + @Test + fun `fromPreference falls back to MINIMAL for unknown value`() { + assertEquals(NotificationStyle.MINIMAL, NotificationStyle.fromPreference("unknown")) + assertEquals(NotificationStyle.MINIMAL, NotificationStyle.fromPreference("")) + } }