From 9ecb2c818f858d92898feb84b19c0d08897cc89b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:23:45 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Bolt:=20Optimize=20time=20formattin?= =?UTF-8?q?g,=20session=20calculations=20&=20UI=20state=20stability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache DateTimeFormatter instances in TimeExtensions.kt to eliminate repeated allocations. - Decouple session list aggregate stats in SleepViewModel into a separate mapped flow to avoid O(N) recalculations on 1s live duration timer ticks. - Annotate SleepTrackerUiState with @Immutable to enable Compose recomposition skipping. Co-authored-by: felixbrucker <5189997+felixbrucker@users.noreply.github.com> --- .jules/bolt.md | 3 ++ .../ui/viewmodel/SleepViewModel.kt | 38 ++++++++++++++----- .../sleeptracker/util/TimeExtensions.kt | 17 +++++---- gradlew | 0 4 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 .jules/bolt.md mode change 100644 => 100755 gradlew diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 0000000..839a831 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,3 @@ +## 2026-09-18 - Decoupling High-Frequency Timers in Flow Combine & Compose Stability +**Learning:** Combining a high-frequency ticker flow (like a 1s live duration timer) with list statistics recalculations in `combine` causes O(N) list operations on every tick. Additionally, data classes with `List` properties are inferred as unstable by Compose unless marked `@Immutable`. +**Action:** Extract list transforms into a separate `map` flow before `combine` so operations run only when database emissions occur, and annotate state data classes with `@Immutable`. diff --git a/app/src/main/java/com/felixbrucker/sleeptracker/ui/viewmodel/SleepViewModel.kt b/app/src/main/java/com/felixbrucker/sleeptracker/ui/viewmodel/SleepViewModel.kt index d96ed22..aba7593 100644 --- a/app/src/main/java/com/felixbrucker/sleeptracker/ui/viewmodel/SleepViewModel.kt +++ b/app/src/main/java/com/felixbrucker/sleeptracker/ui/viewmodel/SleepViewModel.kt @@ -1,6 +1,7 @@ package com.felixbrucker.sleeptracker.ui.viewmodel import android.app.Application +import androidx.compose.runtime.Immutable import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -18,11 +19,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds +@Immutable data class SleepTrackerUiState( val isTrackingActive: Boolean = false, val trackingStartTimeMillis: Long = 0L, @@ -43,6 +46,13 @@ data class SleepTrackerUiState( val statusMessage: String? = null ) +private data class SessionStats( + val sessions: List = emptyList(), + val totalSessions: Int = 0, + val totalDurationMillis: Long = 0L, + val averageDurationMillis: Long = 0L +) + class SleepViewModel( application: Application, private val repository: SleepRepository, @@ -57,17 +67,25 @@ class SleepViewModel( Pair(healthConnectManager.isAvailable, false) ) + private val _sessionStats = repository.allSessions.map { sessions -> + val totalSessions = sessions.size + val totalDuration = sessions.sumOf { it.durationMillis } + val avgDuration = if (totalSessions > 0) totalDuration / totalSessions else 0L + SessionStats( + sessions = sessions, + totalSessions = totalSessions, + totalDurationMillis = totalDuration, + averageDurationMillis = avgDuration + ) + } + val uiState: StateFlow = combine( repository.preferencesFlow, - repository.allSessions, + _sessionStats, _liveDuration, _healthConnectStatus, _statusMessage - ) { prefs, sessions, duration, hcStatus, message -> - val totalSessions = sessions.size - val totalDuration = sessions.sumOf { it.durationMillis } - val avgDuration = if (totalSessions > 0) totalDuration / totalSessions else 0L - + ) { prefs, stats, duration, hcStatus, message -> val now = System.currentTimeMillis() val fallAsleepMillis = prefs.fallAsleepDurationMinutes * 60_000L val sleepStartTime = prefs.trackingStartTimeMillis + fallAsleepMillis @@ -92,10 +110,10 @@ class SleepViewModel( autoSyncHealthConnect = prefs.autoSyncHealthConnect, healthConnectAvailable = hcStatus.first, healthConnectPermissionsGranted = hcStatus.second, - sessions = sessions, - totalSleepSessions = totalSessions, - totalSleepDurationMillis = totalDuration, - averageSleepDurationMillis = avgDuration, + sessions = stats.sessions, + totalSleepSessions = stats.totalSessions, + totalSleepDurationMillis = stats.totalDurationMillis, + averageSleepDurationMillis = stats.averageDurationMillis, statusMessage = message ) }.stateIn( diff --git a/app/src/main/java/com/felixbrucker/sleeptracker/util/TimeExtensions.kt b/app/src/main/java/com/felixbrucker/sleeptracker/util/TimeExtensions.kt index 02da4b3..cf9c14d 100644 --- a/app/src/main/java/com/felixbrucker/sleeptracker/util/TimeExtensions.kt +++ b/app/src/main/java/com/felixbrucker/sleeptracker/util/TimeExtensions.kt @@ -26,33 +26,34 @@ fun Long.toFormattedDuration(): String { } } +// Thread-safe cached formatters to prevent repeated allocations in hot rendering paths. +private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) +private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE, MMM d", Locale.getDefault()) +private val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, HH:mm", Locale.getDefault()) + fun Long.toFormattedTime(zoneId: ZoneId = ZoneId.systemDefault()): String { if (this <= 0L) return "--:--" val instant = Instant.ofEpochMilli(this) - val formatter = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) - return instant.atZone(zoneId).format(formatter) + return instant.atZone(zoneId).format(timeFormatter) } fun Long.toFormattedDate(zoneId: ZoneId = ZoneId.systemDefault()): String { if (this <= 0L) return "" val instant = Instant.ofEpochMilli(this) - val formatter = DateTimeFormatter.ofPattern("EEE, MMM d", Locale.getDefault()) - return instant.atZone(zoneId).format(formatter) + return instant.atZone(zoneId).format(dateFormatter) } fun Long.toFormattedDateTime(zoneId: ZoneId = ZoneId.systemDefault()): String { if (this <= 0L) return "" val instant = Instant.ofEpochMilli(this) - val formatter = DateTimeFormatter.ofPattern("MMM d, HH:mm", Locale.getDefault()) - return instant.atZone(zoneId).format(formatter) + return instant.atZone(zoneId).format(dateTimeFormatter) } fun Int.padTwoDigits(): String = String.format(Locale.getDefault(), "%02d", this) fun formatTimeDisplay(hour: Int, minute: Int): String { - val formatter = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) val localDateTime = LocalDateTime.of(2026, 1, 1, hour, minute) - return localDateTime.format(formatter) + return localDateTime.format(timeFormatter) } fun calculateNextAlarmTime( diff --git a/gradlew b/gradlew old mode 100644 new mode 100755