Skip to content
Merged
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
23 changes: 0 additions & 23 deletions app/src/main/java/com/podometer/service/NotificationHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 14 additions & 1 deletion app/src/main/java/com/podometer/service/NotificationStyle.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
71 changes: 57 additions & 14 deletions app/src/main/java/com/podometer/service/StepTrackingService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions app/src/test/java/com/podometer/service/NotificationHelperTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(""))
}
}