From 2b1d6c3106fc2377caf6f72ac438a629c81e977c Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Fri, 3 Jul 2026 13:02:38 +0200 Subject: [PATCH 1/4] perf: lifecycle-aware collection, off-main stats, list contentType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier A — visual-safe runtime wins: - Swap all 21 collectAsState() → collectAsStateWithLifecycle() so screens stop recomposing while backgrounded/covered. - Dashboard: pause the 5s status poll when the screen isn't STARTED (LifecycleStartEffect + resume/pauseAutoRefresh), so it no longer polls the network or churns state off-screen. - StatsRepository.getStats: run off Dispatchers.IO and fan the three independent groups (quick/deep/sync) out with async instead of ~50 sequential DAO queries on the main thread. - Add contentType to the charges/drives/trips/mileage lists so Compose reuses row layout nodes during fling. - ChargingNotificationWorker: stop refetching the full car list once per car (pass the already-fetched CarData into checkCarStatus). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/repository/StatsRepository.kt | 20 +++++++++++------- .../data/sync/ChargingNotificationWorker.kt | 21 +++++-------------- .../com/matedroid/ui/navigation/NavGraph.kt | 6 +++--- .../ui/screens/battery/BatteryScreen.kt | 4 ++-- .../ui/screens/charges/ChargeDetailScreen.kt | 4 ++-- .../ui/screens/charges/ChargesScreen.kt | 6 +++--- .../screens/charges/CompareChargesScreen.kt | 4 ++-- .../ui/screens/charges/CurrentChargeScreen.kt | 4 ++-- .../ui/screens/dashboard/DashboardScreen.kt | 11 ++++++++-- .../screens/dashboard/DashboardViewModel.kt | 13 ++++++++++++ .../ui/screens/drives/CompareDrivesScreen.kt | 4 ++-- .../ui/screens/drives/DriveDetailScreen.kt | 4 ++-- .../ui/screens/drives/DrivesScreen.kt | 6 +++--- .../ui/screens/mileage/MileageScreen.kt | 12 +++++------ .../ui/screens/sentry/SentryHistoryScreen.kt | 4 ++-- .../ui/screens/settings/SettingsScreen.kt | 4 ++-- .../screens/stats/CountriesVisitedScreen.kt | 4 ++-- .../ui/screens/stats/RegionsVisitedScreen.kt | 4 ++-- .../matedroid/ui/screens/stats/StatsScreen.kt | 6 +++--- .../ui/screens/trips/CreateTripScreen.kt | 4 ++-- .../ui/screens/trips/TripDetailScreen.kt | 4 ++-- .../matedroid/ui/screens/trips/TripsScreen.kt | 7 ++++--- .../screens/updates/SoftwareVersionsScreen.kt | 4 ++-- .../ui/screens/wherewasi/WhereWasIScreen.kt | 4 ++-- 24 files changed, 89 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/com/matedroid/data/repository/StatsRepository.kt b/app/src/main/java/com/matedroid/data/repository/StatsRepository.kt index 8284053c..d4ebd028 100644 --- a/app/src/main/java/com/matedroid/data/repository/StatsRepository.kt +++ b/app/src/main/java/com/matedroid/data/repository/StatsRepository.kt @@ -21,8 +21,11 @@ import com.matedroid.domain.model.GapRecord import com.matedroid.domain.model.MaxDistanceBetweenChargesRecord import com.matedroid.domain.model.StreakRecord import com.matedroid.domain.model.YearFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @@ -41,17 +44,18 @@ class StatsRepository @Inject constructor( /** * Get complete stats for a car with the given year filter. */ - suspend fun getStats(carId: Int, yearFilter: YearFilter): CarStats { - val quickStats = getQuickStats(carId, yearFilter) - val deepStats = getDeepStats(carId, yearFilter) - val syncProgress = syncManager.getProgressForCar(carId) + suspend fun getStats(carId: Int, yearFilter: YearFilter): CarStats = withContext(Dispatchers.IO) { + // Off the main thread, and the three groups are independent — run them in parallel. + val quickStats = async { getQuickStats(carId, yearFilter) } + val deepStats = async { getDeepStats(carId, yearFilter) } + val syncProgress = async { syncManager.getProgressForCar(carId) } - return CarStats( + CarStats( carId = carId, yearFilter = yearFilter, - quickStats = quickStats, - deepStats = deepStats, - syncProgress = syncProgress + quickStats = quickStats.await(), + deepStats = deepStats.await(), + syncProgress = syncProgress.await() ) } diff --git a/app/src/main/java/com/matedroid/data/sync/ChargingNotificationWorker.kt b/app/src/main/java/com/matedroid/data/sync/ChargingNotificationWorker.kt index a3106849..7429dd53 100644 --- a/app/src/main/java/com/matedroid/data/sync/ChargingNotificationWorker.kt +++ b/app/src/main/java/com/matedroid/data/sync/ChargingNotificationWorker.kt @@ -14,6 +14,7 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import com.matedroid.data.local.ChargeSessionStateDataStore import com.matedroid.data.local.SettingsDataStore +import com.matedroid.data.api.models.CarData import com.matedroid.data.repository.ApiResult import com.matedroid.data.repository.SentryEvent import com.matedroid.data.repository.SentryStateRepository @@ -156,7 +157,7 @@ class ChargingNotificationWorker @AssistedInject constructor( // Check each car for (car in cars) { try { - checkCarStatus(car.carId) + checkCarStatus(car) } catch (e: Exception) { Log.e(TAG, "Error checking car ${car.carId}", e) } @@ -173,21 +174,9 @@ class ChargingNotificationWorker @AssistedInject constructor( } } - private suspend fun checkCarStatus(carId: Int) { - // Get car info and status - val carsResult = teslamateRepository.getCars() - val car = when (carsResult) { - is ApiResult.Success -> carsResult.data.find { it.carId == carId } - is ApiResult.Error -> { - Log.e(TAG, "Failed to fetch car info: ${carsResult.message}") - return - } - } - - if (car == null) { - Log.e(TAG, "Car $carId not found") - return - } + private suspend fun checkCarStatus(car: CarData) { + // The car object is already in hand from doWork()'s getCars() — no refetch. + val carId = car.carId val statusResult = teslamateRepository.getCarStatus(carId) val statusData = when (statusResult) { diff --git a/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt b/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt index 565303a6..3f936db5 100644 --- a/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt @@ -10,7 +10,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.res.stringResource @@ -130,8 +130,8 @@ fun NavGraph( startViewModel: StartDestinationViewModel = hiltViewModel() ) { val navController = rememberNavController() - val startDestination by startViewModel.startDestination.collectAsState() - val notificationPermissionAsked by startViewModel.notificationPermissionAsked.collectAsState() + val startDestination by startViewModel.startDestination.collectAsStateWithLifecycle() + val notificationPermissionAsked by startViewModel.notificationPermissionAsked.collectAsStateWithLifecycle() val coroutineScope = rememberCoroutineScope() if (startDestination == null) { diff --git a/app/src/main/java/com/matedroid/ui/screens/battery/BatteryScreen.kt b/app/src/main/java/com/matedroid/ui/screens/battery/BatteryScreen.kt index 7773d316..85672c29 100644 --- a/app/src/main/java/com/matedroid/ui/screens/battery/BatteryScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/battery/BatteryScreen.kt @@ -46,7 +46,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.AlertDialog import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -86,7 +86,7 @@ fun BatteryScreen( onNavigateBack: () -> Unit, viewModel: BatteryViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val isDarkTheme = isSystemInDarkTheme() val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) diff --git a/app/src/main/java/com/matedroid/ui/screens/charges/ChargeDetailScreen.kt b/app/src/main/java/com/matedroid/ui/screens/charges/ChargeDetailScreen.kt index 654cf105..bad79276 100644 --- a/app/src/main/java/com/matedroid/ui/screens/charges/ChargeDetailScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/charges/ChargeDetailScreen.kt @@ -54,7 +54,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -110,7 +110,7 @@ fun ChargeDetailScreen( onNavigateToCompare: (baseChargeId: Int) -> Unit = {}, viewModel: ChargeDetailViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(carId, chargeId) { diff --git a/app/src/main/java/com/matedroid/ui/screens/charges/ChargesScreen.kt b/app/src/main/java/com/matedroid/ui/screens/charges/ChargesScreen.kt index ca758ebc..335d5b99 100644 --- a/app/src/main/java/com/matedroid/ui/screens/charges/ChargesScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/charges/ChargesScreen.kt @@ -54,7 +54,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -100,7 +100,7 @@ fun ChargesScreen( onNavigateToChargeDetail: (Int) -> Unit = {}, viewModel: ChargesViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val isDarkTheme = isSystemInDarkTheme() val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) @@ -329,7 +329,7 @@ private fun ChargesContent( } } } else { - items(charges, key = { it.chargeId }) { charge -> + items(charges, key = { it.chargeId }, contentType = { "charge" }) { charge -> ChargeItem( charge = charge, // Exact for processed charges; for not-yet-synced ones, fall back to an diff --git a/app/src/main/java/com/matedroid/ui/screens/charges/CompareChargesScreen.kt b/app/src/main/java/com/matedroid/ui/screens/charges/CompareChargesScreen.kt index ffeb477f..9ed67e93 100644 --- a/app/src/main/java/com/matedroid/ui/screens/charges/CompareChargesScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/charges/CompareChargesScreen.kt @@ -36,7 +36,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -90,7 +90,7 @@ fun CompareChargesScreen( viewModel: ChargeComparisonViewModel = hiltViewModel() ) { LaunchedEffect(carId, baseChargeId) { viewModel.load(carId, baseChargeId) } - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val palette = CarColorPalettes.forExteriorColor(exteriorColor, isSystemInDarkTheme()) Scaffold( diff --git a/app/src/main/java/com/matedroid/ui/screens/charges/CurrentChargeScreen.kt b/app/src/main/java/com/matedroid/ui/screens/charges/CurrentChargeScreen.kt index 3a4f13bf..5b852e9e 100644 --- a/app/src/main/java/com/matedroid/ui/screens/charges/CurrentChargeScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/charges/CurrentChargeScreen.kt @@ -44,7 +44,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf @@ -86,7 +86,7 @@ fun CurrentChargeScreen( onNavigateBack: () -> Unit, viewModel: CurrentChargeViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(carId) { diff --git a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt index 238d8fe5..d53d671d 100644 --- a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt @@ -116,7 +116,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -197,11 +198,17 @@ fun DashboardScreen( onNavigateToTrips: (carId: Int, exteriorColor: String?) -> Unit = { _, _ -> }, viewModel: DashboardViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } var menuExpanded by remember { mutableStateOf(false) } var showWhereWasIPicker by remember { mutableStateOf(false) } + // Only poll for live car status while the dashboard is actually on screen. + LifecycleStartEffect(Unit) { + viewModel.resumeAutoRefresh() + onStopOrDispose { viewModel.pauseAutoRefresh() } + } + // When opened from a widget tap, select the car that belongs to that widget. // Wait until the cars list is populated before switching, in case the app is // cold-starting and the ViewModel hasn't loaded cars yet. diff --git a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardViewModel.kt index 740d330d..f3fc8925 100644 --- a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardViewModel.kt @@ -86,6 +86,7 @@ class DashboardViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private var autoRefreshJob: Job? = null + private var autoRefreshCarId: Int? = null private var lastGeocodedLocation: Pair? = null companion object { @@ -314,7 +315,19 @@ class DashboardViewModel @Inject constructor( } } + /** Resume polling after the dashboard becomes visible again (see [pauseAutoRefresh]). */ + fun resumeAutoRefresh() { + autoRefreshCarId?.let { startAutoRefresh(it) } + } + + /** Stop polling while the dashboard is not visible, to avoid off-screen network/CPU/battery cost. */ + fun pauseAutoRefresh() { + autoRefreshJob?.cancel() + autoRefreshJob = null + } + private fun startAutoRefresh(carId: Int) { + autoRefreshCarId = carId autoRefreshJob?.cancel() autoRefreshJob = viewModelScope.launch { while (isActive) { diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/CompareDrivesScreen.kt b/app/src/main/java/com/matedroid/ui/screens/drives/CompareDrivesScreen.kt index 4b513ce0..7ee06007 100644 --- a/app/src/main/java/com/matedroid/ui/screens/drives/CompareDrivesScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/drives/CompareDrivesScreen.kt @@ -40,7 +40,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -95,7 +95,7 @@ fun CompareDrivesScreen( viewModel: DriveComparisonViewModel = hiltViewModel() ) { LaunchedEffect(carId, baseDriveId) { viewModel.load(carId, baseDriveId) } - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val palette = CarColorPalettes.forExteriorColor(exteriorColor, isSystemInDarkTheme()) Scaffold( diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt index e377af95..9e235379 100644 --- a/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/drives/DriveDetailScreen.kt @@ -54,7 +54,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshotFlow import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -110,7 +110,7 @@ fun DriveDetailScreen( onNavigateToCompare: (baseDriveId: Int) -> Unit = {}, viewModel: DriveDetailViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val isDarkTheme = isSystemInDarkTheme() val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/DrivesScreen.kt b/app/src/main/java/com/matedroid/ui/screens/drives/DrivesScreen.kt index 0e9e71d6..9fef8b12 100644 --- a/app/src/main/java/com/matedroid/ui/screens/drives/DrivesScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/drives/DrivesScreen.kt @@ -44,7 +44,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -93,7 +93,7 @@ fun DrivesScreen( onNavigateToDriveDetail: (driveId: Int) -> Unit, viewModel: DrivesViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val isDarkTheme = isSystemInDarkTheme() val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) @@ -275,7 +275,7 @@ private fun DrivesContent( } } } else { - items(drives, key = { it.id }) { drive -> + items(drives, key = { it.id }, contentType = { "drive" }) { drive -> DriveItem( drive = drive, units = units, diff --git a/app/src/main/java/com/matedroid/ui/screens/mileage/MileageScreen.kt b/app/src/main/java/com/matedroid/ui/screens/mileage/MileageScreen.kt index cc7bdb00..4bbf727d 100644 --- a/app/src/main/java/com/matedroid/ui/screens/mileage/MileageScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/mileage/MileageScreen.kt @@ -54,7 +54,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -97,7 +97,7 @@ fun MileageScreen( onNavigateToDriveDetail: (Int) -> Unit = {}, viewModel: MileageViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val isDarkTheme = isSystemInDarkTheme() val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) @@ -265,7 +265,7 @@ private fun YearOverviewContent( } // Year list - items(uiState.yearlyData) { yearData -> + items(uiState.yearlyData, contentType = { "year" }) { yearData -> YearRow( yearData = yearData, units = uiState.units, @@ -496,7 +496,7 @@ private fun YearDetailScreen( } // Monthly list - items(uiState.monthlyData) { monthData -> + items(uiState.monthlyData, contentType = { "month" }) { monthData -> MonthRow( monthData = monthData, units = uiState.units, @@ -754,7 +754,7 @@ private fun MonthDetailScreen( } // Daily trip rows - items(dailyData) { dayData -> + items(dailyData, contentType = { "day" }) { dayData -> DayTripRow( dayData = dayData, units = units, @@ -1412,7 +1412,7 @@ private fun DayDetailScreen( } // Drive rows - items(dayData.drives) { drive -> + items(dayData.drives, contentType = { "drive" }) { drive -> DriveRow( drive = drive, units = units, diff --git a/app/src/main/java/com/matedroid/ui/screens/sentry/SentryHistoryScreen.kt b/app/src/main/java/com/matedroid/ui/screens/sentry/SentryHistoryScreen.kt index 322ce58f..69779a19 100644 --- a/app/src/main/java/com/matedroid/ui/screens/sentry/SentryHistoryScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/sentry/SentryHistoryScreen.kt @@ -34,7 +34,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -124,7 +124,7 @@ fun SentryHistoryScreen( viewModel: SentryHistoryViewModel = hiltViewModel() ) { viewModel.setCarId(carId) - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val isDarkTheme = isSystemInDarkTheme() val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) val listState = rememberLazyListState() diff --git a/app/src/main/java/com/matedroid/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/matedroid/ui/screens/settings/SettingsScreen.kt index ff2c9b3b..0318b342 100644 --- a/app/src/main/java/com/matedroid/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/settings/SettingsScreen.kt @@ -51,7 +51,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -88,7 +88,7 @@ fun SettingsScreen( onNavigateToPalettePreview: () -> Unit = {}, viewModel: SettingsViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(uiState.error) { diff --git a/app/src/main/java/com/matedroid/ui/screens/stats/CountriesVisitedScreen.kt b/app/src/main/java/com/matedroid/ui/screens/stats/CountriesVisitedScreen.kt index cca1d217..36d9224e 100644 --- a/app/src/main/java/com/matedroid/ui/screens/stats/CountriesVisitedScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/stats/CountriesVisitedScreen.kt @@ -35,7 +35,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -70,7 +70,7 @@ fun CountriesVisitedScreen( onNavigateToRegions: (countryCode: String, countryName: String) -> Unit, viewModel: CountriesVisitedViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val isDarkTheme = isSystemInDarkTheme() val palette = remember(exteriorColor, isDarkTheme) { CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) diff --git a/app/src/main/java/com/matedroid/ui/screens/stats/RegionsVisitedScreen.kt b/app/src/main/java/com/matedroid/ui/screens/stats/RegionsVisitedScreen.kt index e049061d..79ae3dad 100644 --- a/app/src/main/java/com/matedroid/ui/screens/stats/RegionsVisitedScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/stats/RegionsVisitedScreen.kt @@ -39,7 +39,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -93,7 +93,7 @@ fun RegionsVisitedScreen( onNavigateBack: () -> Unit, viewModel: RegionsVisitedViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val isDarkTheme = isSystemInDarkTheme() val palette = remember(exteriorColor, isDarkTheme) { CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) diff --git a/app/src/main/java/com/matedroid/ui/screens/stats/StatsScreen.kt b/app/src/main/java/com/matedroid/ui/screens/stats/StatsScreen.kt index e42035c0..928e9f5e 100644 --- a/app/src/main/java/com/matedroid/ui/screens/stats/StatsScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/stats/StatsScreen.kt @@ -58,7 +58,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -105,8 +105,8 @@ fun StatsScreen( onNavigateToCountriesVisited: (Int?) -> Unit = {}, // year (null for all time) viewModel: StatsViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() - val syncLogs by viewModel.syncLogs.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val syncLogs by viewModel.syncLogs.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val isDarkTheme = isSystemInDarkTheme() val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) diff --git a/app/src/main/java/com/matedroid/ui/screens/trips/CreateTripScreen.kt b/app/src/main/java/com/matedroid/ui/screens/trips/CreateTripScreen.kt index 67456bae..261823fe 100644 --- a/app/src/main/java/com/matedroid/ui/screens/trips/CreateTripScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/trips/CreateTripScreen.kt @@ -38,7 +38,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -78,7 +78,7 @@ fun CreateTripScreen( onTripCreated: (startDate: String) -> Unit = {}, viewModel: CreateTripViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val isDark = isSystemInDarkTheme() val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDark) diff --git a/app/src/main/java/com/matedroid/ui/screens/trips/TripDetailScreen.kt b/app/src/main/java/com/matedroid/ui/screens/trips/TripDetailScreen.kt index baa9420d..7f763fee 100644 --- a/app/src/main/java/com/matedroid/ui/screens/trips/TripDetailScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/trips/TripDetailScreen.kt @@ -56,7 +56,7 @@ import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -135,7 +135,7 @@ fun TripDetailScreen( onNavigateToCountryStats: (countryCode: String) -> Unit = {}, viewModel: TripDetailViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val isDarkTheme = isSystemInDarkTheme() val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) diff --git a/app/src/main/java/com/matedroid/ui/screens/trips/TripsScreen.kt b/app/src/main/java/com/matedroid/ui/screens/trips/TripsScreen.kt index 1be67b3a..8da02684 100644 --- a/app/src/main/java/com/matedroid/ui/screens/trips/TripsScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/trips/TripsScreen.kt @@ -46,7 +46,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -92,7 +92,7 @@ fun TripsScreen( onNavigateToCreateTrip: () -> Unit = {}, viewModel: TripsViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val isDarkTheme = isSystemInDarkTheme() val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) @@ -307,7 +307,8 @@ private fun TripsContent( trips, key = { index, trip -> "${trip.startDate}|${trip.endDate}|${trip.drives.firstOrNull()?.driveId ?: 0}|$index" - } + }, + contentType = { _, _ -> "trip" } ) { index, trip -> TripItem( trip = trip, diff --git a/app/src/main/java/com/matedroid/ui/screens/updates/SoftwareVersionsScreen.kt b/app/src/main/java/com/matedroid/ui/screens/updates/SoftwareVersionsScreen.kt index 119bec96..0559e6bb 100644 --- a/app/src/main/java/com/matedroid/ui/screens/updates/SoftwareVersionsScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/updates/SoftwareVersionsScreen.kt @@ -41,7 +41,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -88,7 +88,7 @@ fun SoftwareVersionsScreen( onNavigateBack: () -> Unit, viewModel: SoftwareVersionsViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } var selectedFilter by remember { mutableStateOf(UpdatesDateFilter.ALL_TIME) } val isDarkTheme = isSystemInDarkTheme() diff --git a/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt index a2fa3958..78655b7f 100644 --- a/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/wherewasi/WhereWasIScreen.kt @@ -42,7 +42,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -88,7 +88,7 @@ fun WhereWasIScreen( ) { val isDarkTheme = isSystemInDarkTheme() val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) - val state by viewModel.uiState.collectAsState() + val state by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(carId, targetTimestamp) { viewModel.load(carId, targetTimestamp) From 214ca719bf72e58997bb2a6d8e9cfda3d855a8c9 Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Fri, 3 Jul 2026 13:13:16 +0200 Subject: [PATCH 2/4] perf: hoist chart Paints, pre-sort overlay curves, memoize mileage charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier B — visual-safe per-frame/allocation wins: - ChartDrawUtils text helpers (drawYAxisLabels/drawDualYAxisLabels/ drawTimeLabels) now take a pre-built android.graphics.Paint instead of allocating one per draw. OptimizedLineChart/DualAxisLineChart build the paints once with remember(color). The 800ms entrance animation redraws ~48 frames, so this removes ~3-5 Paint (+Color/String) allocations per frame per chart. Same pixels. - PowerSocOverlayChart: sort each curve's points by SoC once (remember) and pre-order curves by base, instead of re-sorting inside the draw loop and twice per crosshair-drag frame via interpolatePower. Same curves. - MileageScreen: memoize the yearly/monthly/daily chart-data derivation and the inner BarChartData mapping with remember, and mark BarChartData/ BarSegment @Immutable so InteractiveBarChart can skip. Deliberately skipped two low-value Tier B items: caching the widget progress-bar bitmap (updates are 3-min and each carries a new battery level, so a cache almost never hits) and caching AppSettings in SettingsDataStore (negligible gain over the already in-memory-cached DataStore, and a stale server URL/token would silently break API calls). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../matedroid/ui/components/ChartDrawUtils.kt | 25 ++-------- .../ui/components/DualAxisLineChart.kt | 31 ++++++++++-- .../ui/components/InteractiveBarChart.kt | 3 ++ .../ui/components/OptimizedLineChart.kt | 13 ++++- .../ui/components/PowerSocOverlayChart.kt | 21 ++++---- .../ui/screens/mileage/MileageScreen.kt | 48 +++++++++++-------- 6 files changed, 85 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/com/matedroid/ui/components/ChartDrawUtils.kt b/app/src/main/java/com/matedroid/ui/components/ChartDrawUtils.kt index 0a88ee49..61aa1f3a 100644 --- a/app/src/main/java/com/matedroid/ui/components/ChartDrawUtils.kt +++ b/app/src/main/java/com/matedroid/ui/components/ChartDrawUtils.kt @@ -401,18 +401,12 @@ private fun axisLabelValues(chartData: ChartData, steps: Int = 4): Pair, width: Float, chartHeight: Float, timeLabelHeight: Float ) { drawContext.canvas.nativeCanvas.apply { - val textPaint = Paint().apply { - color = surfaceColor.copy(alpha = 0.7f).toArgb() - textSize = 26f - isAntiAlias = true - } - val timeY = chartHeight + timeLabelHeight - 4f val positions = listOf(0f, width * 0.25f, width * 0.5f, width * 0.75f, width) diff --git a/app/src/main/java/com/matedroid/ui/components/DualAxisLineChart.kt b/app/src/main/java/com/matedroid/ui/components/DualAxisLineChart.kt index 7408ddc9..cf158206 100644 --- a/app/src/main/java/com/matedroid/ui/components/DualAxisLineChart.kt +++ b/app/src/main/java/com/matedroid/ui/components/DualAxisLineChart.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity @@ -67,6 +68,30 @@ fun DualAxisLineChart( if (dataLeft.size < 2 && dataRight.size < 2) return val surfaceColor = MaterialTheme.colorScheme.onSurface + // Built once and reused across draws (the 800ms entrance redraws every frame). + val leftLabelPaint = remember(colorLeft) { + android.graphics.Paint().apply { + color = colorLeft.copy(alpha = 0.8f).toArgb() + textSize = 24f + isAntiAlias = true + textAlign = android.graphics.Paint.Align.LEFT + } + } + val rightLabelPaint = remember(colorRight) { + android.graphics.Paint().apply { + color = colorRight.copy(alpha = 0.8f).toArgb() + textSize = 24f + isAntiAlias = true + textAlign = android.graphics.Paint.Align.RIGHT + } + } + val timeLabelPaint = remember(surfaceColor) { + android.graphics.Paint().apply { + color = surfaceColor.copy(alpha = 0.7f).toArgb() + textSize = 26f + isAntiAlias = true + } + } val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) val tooltipBg = MaterialTheme.colorScheme.inverseSurface val tooltipFg = MaterialTheme.colorScheme.inverseOnSurface @@ -234,12 +259,12 @@ fun DualAxisLineChart( } // Y-axis labels - drawDualYAxisLabels(chartDataLeft, unitLeft, chartHeightPx, isLeft = true, color = colorLeft) - drawDualYAxisLabels(chartDataRight, unitRight, chartHeightPx, isLeft = false, color = colorRight, width = width) + drawDualYAxisLabels(leftLabelPaint, chartDataLeft, unitLeft, chartHeightPx, isLeft = true) + drawDualYAxisLabels(rightLabelPaint, chartDataRight, unitRight, chartHeightPx, isLeft = false, width = width) // Time labels if (timeLabels.size == 5) { - drawTimeLabels(surfaceColor, timeLabels, width - rLabelWidth, chartHeightPx, timeLabelHeightPx) + drawTimeLabels(timeLabelPaint, timeLabels, width - rLabelWidth, chartHeightPx, timeLabelHeightPx) } // Selection indicators diff --git a/app/src/main/java/com/matedroid/ui/components/InteractiveBarChart.kt b/app/src/main/java/com/matedroid/ui/components/InteractiveBarChart.kt index 5859b47d..cc738e07 100644 --- a/app/src/main/java/com/matedroid/ui/components/InteractiveBarChart.kt +++ b/app/src/main/java/com/matedroid/ui/components/InteractiveBarChart.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -40,6 +41,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +@Immutable data class BarChartData( val label: String, val value: Double, @@ -48,6 +50,7 @@ data class BarChartData( val segments: List = emptyList() // Segment list ) +@Immutable data class BarSegment( val value: Double, val color: Color, diff --git a/app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt b/app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt index 614a7199..71b3e767 100644 --- a/app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt +++ b/app/src/main/java/com/matedroid/ui/components/OptimizedLineChart.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity @@ -69,6 +70,14 @@ fun OptimizedLineChart( if (data.size < 2) return val surfaceColor = MaterialTheme.colorScheme.onSurface + // Built once and reused across draws (the 800ms entrance redraws every frame). + val labelPaint = remember(surfaceColor) { + android.graphics.Paint().apply { + this.color = surfaceColor.copy(alpha = 0.7f).toArgb() + textSize = 26f + isAntiAlias = true + } + } val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) val tooltipBg = MaterialTheme.colorScheme.inverseSurface val tooltipFg = MaterialTheme.colorScheme.inverseOnSurface @@ -203,11 +212,11 @@ fun OptimizedLineChart( } // Y-axis labels - drawYAxisLabels(surfaceColor, chartData, unit, chartHeightPx) + drawYAxisLabels(labelPaint, chartData, unit, chartHeightPx) // Time labels (5 positions) if (timeLabels.size == 5) { - drawTimeLabels(surfaceColor, timeLabels, width, chartHeightPx, timeLabelHeightPx) + drawTimeLabels(labelPaint, timeLabels, width, chartHeightPx, timeLabelHeightPx) } // Selection indicators diff --git a/app/src/main/java/com/matedroid/ui/components/PowerSocOverlayChart.kt b/app/src/main/java/com/matedroid/ui/components/PowerSocOverlayChart.kt index ceb7d521..5ba09a49 100644 --- a/app/src/main/java/com/matedroid/ui/components/PowerSocOverlayChart.kt +++ b/app/src/main/java/com/matedroid/ui/components/PowerSocOverlayChart.kt @@ -72,6 +72,12 @@ fun PowerSocOverlayChart( ) { val valid = curves.filter { it.points.size >= 2 } if (valid.isEmpty()) return + // Sort each curve's points by SoC once (reused by drawing and interpolation instead of + // re-sorting on every draw/crosshair frame) and pre-order so the base curve draws last (on top). + val renderCurves = remember(curves) { + valid.map { it.copy(points = it.points.sortedBy { p -> p.x }) } + .sortedBy { it.isBase } + } val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) val labelColor = MaterialTheme.colorScheme.onSurfaceVariant @@ -151,9 +157,9 @@ fun PowerSocOverlayChart( ) } - // Curves — others first so the base draws on top - valid.sortedBy { it.isBase }.forEach { c -> - val pts = c.points.sortedBy { it.x } + // Curves — others first so the base draws on top (points pre-sorted above) + renderCurves.forEach { c -> + val pts = c.points val path = Path() pts.forEachIndexed { idx, p -> val px = xFor(p.x) @@ -193,7 +199,7 @@ fun PowerSocOverlayChart( Offset(cx, chartHeightPx), strokeWidth = 1.5f ) - valid.forEach { c -> + renderCurves.forEach { c -> val power = interpolatePower(c.points, soc) ?: return@forEach drawCircle(c.color, radius = if (c.isBase) 7f else 5f, center = Offset(cx, yFor(power))) } @@ -202,7 +208,7 @@ fun PowerSocOverlayChart( // Tooltip — SoC and each session's power at the crosshair selectedSoc?.let { soc -> - val rows = valid.mapNotNull { c -> interpolatePower(c.points, soc)?.let { c to it } } + val rows = renderCurves.mapNotNull { c -> interpolatePower(c.points, soc)?.let { c to it } } if (rows.isNotEmpty()) { Column( modifier = Modifier @@ -241,9 +247,8 @@ fun PowerSocOverlayChart( } } -/** Linear interpolation of power at [soc] along a curve sorted by SoC; null when out of range. */ -private fun interpolatePower(points: List, soc: Float): Float? { - val sorted = points.sortedBy { it.x } +/** Linear interpolation of power at [soc]; [sorted] must already be ordered by SoC. Null when out of range. */ +private fun interpolatePower(sorted: List, soc: Float): Float? { if (sorted.isEmpty() || soc < sorted.first().x || soc > sorted.last().x) return null for (i in 1 until sorted.size) { val a = sorted[i - 1] diff --git a/app/src/main/java/com/matedroid/ui/screens/mileage/MileageScreen.kt b/app/src/main/java/com/matedroid/ui/screens/mileage/MileageScreen.kt index 4bbf727d..bc813e63 100644 --- a/app/src/main/java/com/matedroid/ui/screens/mileage/MileageScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/mileage/MileageScreen.kt @@ -155,7 +155,7 @@ fun MileageScreen( } else { YearOverviewContent( uiState = uiState, - chartData = viewModel.getYearlyChartData(), + chartData = remember(uiState.yearlyData) { viewModel.getYearlyChartData() }, palette = palette, onYearClick = { viewModel.selectYear(it) } ) @@ -173,7 +173,7 @@ fun MileageScreen( YearDetailScreen( year = year, uiState = uiState, - chartData = viewModel.getMonthlyChartData(), + chartData = remember(uiState.monthlyData) { viewModel.getMonthlyChartData() }, palette = palette, onClose = { viewModel.clearSelectedYear() }, onMonthClick = { viewModel.selectMonth(it) } @@ -193,7 +193,7 @@ fun MileageScreen( yearMonth = month, monthData = monthData, dailyData = uiState.dailyData, - dailyChartData = viewModel.getDailyChartData(), + dailyChartData = remember(uiState.dailyData, uiState.selectedMonth) { viewModel.getDailyChartData() }, currencySymbol = uiState.currencySymbol, units = uiState.units, palette = palette, @@ -321,12 +321,14 @@ private fun YearlyChartCard(chartData: List>, palette: CarColo Spacer(modifier = Modifier.height(16.dp)) - val barChartData = chartData.map { (year, distance) -> - BarChartData( - label = year.toString(), - value = distance, - displayValue = UnitFormatter.formatDistance(distance, units) - ) + val barChartData = remember(chartData, units) { + chartData.map { (year, distance) -> + BarChartData( + label = year.toString(), + value = distance, + displayValue = UnitFormatter.formatDistance(distance, units) + ) + } } InteractiveBarChart( @@ -553,12 +555,14 @@ private fun MonthlyChartCard(chartData: List>, palette: CarCol Spacer(modifier = Modifier.height(16.dp)) - val barChartData = chartData.map { (month, distance) -> - BarChartData( - label = month.toString(), - value = distance, - displayValue = UnitFormatter.formatDistance(distance, units) - ) + val barChartData = remember(chartData, units) { + chartData.map { (month, distance) -> + BarChartData( + label = month.toString(), + value = distance, + displayValue = UnitFormatter.formatDistance(distance, units) + ) + } } InteractiveBarChart( @@ -1180,12 +1184,14 @@ private fun DailyChartCard( Spacer(modifier = Modifier.height(16.dp)) - val barChartData = chartData.map { (day, distance) -> - BarChartData( - label = day.toString(), - value = distance, - displayValue = UnitFormatter.formatDistance(distance, units) - ) + val barChartData = remember(chartData, units) { + chartData.map { (day, distance) -> + BarChartData( + label = day.toString(), + value = distance, + displayValue = UnitFormatter.formatDistance(distance, units) + ) + } } InteractiveBarChart( From 047c8acc54605f2088f48f85264900d1a2507b19 Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Fri, 3 Jul 2026 13:18:09 +0200 Subject: [PATCH 3/4] perf: move comparison/trip CPU off the main thread; cheaper sync & route simplify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier C — data-layer wins (visual-safe; correctness preserved): - ChargeComparisonRepository/DriveComparisonRepository.findComparable: wrap the full-history load + per-row Haversine + sort in withContext(Dispatchers.Default) so it no longer runs on the caller's main thread. (Room already runs the queries on its own executor.) - TripRepository.getTrips: run off Dispatchers.Default, and only run the beta-era duplicate-cleanup (SHA-256 per saved trip) once per car per session instead of on every trip-list open. - SyncRepository.syncChargeDetails: preload charge summaries once (getAllForCar) instead of a per-charge DB read inside the batch loop. - RouteSimplifier: rewrite Douglas-Peucker iteratively with a keep[] bitset + explicit stack — same output, no per-level list allocation, bounded stack (was O(n) recursion depth + list rebuild per level). Deferred (correctness surface): pushing the comparison bounding-box prefilter into SQL — the Kotlin Haversine already gates matches, but a too-tight SQL box would silently drop valid comparisons, so it needs a new indexed query + margin care. Moving the work off-main removes the actual jank without that risk. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/matedroid/data/sync/SyncRepository.kt | 8 ++- .../domain/ChargeComparisonRepository.kt | 14 +++-- .../domain/DriveComparisonRepository.kt | 22 ++++---- .../com/matedroid/domain/RouteSimplifier.kt | 52 +++++++++++-------- .../com/matedroid/domain/TripRepository.kt | 17 ++++-- 5 files changed, 73 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/matedroid/data/sync/SyncRepository.kt b/app/src/main/java/com/matedroid/data/sync/SyncRepository.kt index 1ffa846b..8c5a730e 100644 --- a/app/src/main/java/com/matedroid/data/sync/SyncRepository.kt +++ b/app/src/main/java/com/matedroid/data/sync/SyncRepository.kt @@ -251,6 +251,10 @@ class SyncRepository @Inject constructor( val total = unprocessedIds.size log("Processing $total charge details for car $carId (batch size: $BATCH_SIZE)") + // Preload summaries once (they were just written by summary sync) instead of a + // per-charge DB read inside the batch loop, which was ~one query per charge. + val summariesById = chargeSummaryDao.getAllForCar(carId).associateBy { it.chargeId } + // Process in batches unprocessedIds.chunked(BATCH_SIZE).forEachIndexed { batchIndex, batch -> val processed = batchIndex * BATCH_SIZE @@ -276,8 +280,8 @@ class SyncRepository @Inject constructor( val aggregate = computeChargeAggregate(carId, result.data) aggregates.add(aggregate) - // Get location from charge summary for geocoding - val summary = chargeSummaryDao.get(chargeId) + // Get location from charge summary for geocoding (preloaded above) + val summary = summariesById[chargeId] if (summary != null && summary.latitude != 0.0 && summary.longitude != 0.0) { batchLocations.add(summary.latitude to summary.longitude) } diff --git a/app/src/main/java/com/matedroid/domain/ChargeComparisonRepository.kt b/app/src/main/java/com/matedroid/domain/ChargeComparisonRepository.kt index 83485746..b18ccccb 100644 --- a/app/src/main/java/com/matedroid/domain/ChargeComparisonRepository.kt +++ b/app/src/main/java/com/matedroid/domain/ChargeComparisonRepository.kt @@ -5,6 +5,8 @@ import com.matedroid.data.local.dao.ChargeSummaryDao import com.matedroid.data.local.entity.ChargeSummary import com.matedroid.util.haversineMeters import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @@ -97,12 +99,14 @@ class ChargeComparisonRepository @Inject constructor( carId: Int, baseChargeId: Int, radiusMeters: Double = DEFAULT_RADIUS_METERS - ): ChargeComparison? { + ): ChargeComparison? = withContext(Dispatchers.Default) { + // Loading the full history and running Haversine per row is CPU work — keep it off the + // caller's (main) thread. Room still runs the queries themselves on its own executor. val summaries = chargeSummaryDao.getAllForCar(carId) - val baseSummary = summaries.firstOrNull { it.chargeId == baseChargeId } ?: return null + val baseSummary = summaries.firstOrNull { it.chargeId == baseChargeId } ?: return@withContext null val dcIds = aggregateDao.getDcChargeIds(carId).toSet() - if (baseChargeId !in dcIds) return null + if (baseChargeId !in dcIds) return@withContext null val aggregates = aggregateDao.getChargeAggregatesForCar(carId).associateBy { it.chargeId } @@ -135,8 +139,8 @@ class ChargeComparisonRepository @Inject constructor( .sortedByDescending { it.peakKw ?: -1 } .toList() - if (others.isEmpty()) return null - return ChargeComparison( + if (others.isEmpty()) return@withContext null + ChargeComparison( base = toComparable(baseSummary, 0.0), others = others, radiusMeters = radiusMeters diff --git a/app/src/main/java/com/matedroid/domain/DriveComparisonRepository.kt b/app/src/main/java/com/matedroid/domain/DriveComparisonRepository.kt index fd5052f5..e159e9ad 100644 --- a/app/src/main/java/com/matedroid/domain/DriveComparisonRepository.kt +++ b/app/src/main/java/com/matedroid/domain/DriveComparisonRepository.kt @@ -7,6 +7,8 @@ import com.matedroid.util.haversineMeters import com.matedroid.util.parseIsoDateTime import java.time.temporal.ChronoUnit import kotlin.math.abs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @@ -100,16 +102,18 @@ class DriveComparisonRepository @Inject constructor( carId: Int, baseDriveId: Int, endpointRadiusMeters: Double = DEFAULT_ENDPOINT_RADIUS_METERS - ): DriveComparison? { + ): DriveComparison? = withContext(Dispatchers.Default) { + // Loading the full history and running Haversine per row is CPU work — keep it off the + // caller's (main) thread. Room still runs the queries themselves on its own executor. val summaries = driveSummaryDao.getAllForCar(carId) - val baseSummary = summaries.firstOrNull { it.driveId == baseDriveId } ?: return null + val baseSummary = summaries.firstOrNull { it.driveId == baseDriveId } ?: return@withContext null val aggregates = aggregateDao.getDriveAggregatesForCar(carId).associateBy { it.driveId } - val baseAgg = aggregates[baseDriveId] ?: return null - val baseStartLat = baseAgg.startLatitude ?: return null - val baseStartLon = baseAgg.startLongitude ?: return null - val baseEndLat = baseAgg.endLatitude ?: return null - val baseEndLon = baseAgg.endLongitude ?: return null + val baseAgg = aggregates[baseDriveId] ?: return@withContext null + val baseStartLat = baseAgg.startLatitude ?: return@withContext null + val baseStartLon = baseAgg.startLongitude ?: return@withContext null + val baseEndLat = baseAgg.endLatitude ?: return@withContext null + val baseEndLon = baseAgg.endLongitude ?: return@withContext null val baseDistance = baseSummary.distance fun toComparable(s: DriveSummary): ComparableDrive { @@ -148,8 +152,8 @@ class DriveComparisonRepository @Inject constructor( .map { toComparable(it) } .sortedBy { it.efficiency ?: Double.MAX_VALUE } - if (others.isEmpty()) return null - return DriveComparison( + if (others.isEmpty()) return@withContext null + DriveComparison( base = toComparable(baseSummary), others = others, endpointRadiusMeters = endpointRadiusMeters diff --git a/app/src/main/java/com/matedroid/domain/RouteSimplifier.kt b/app/src/main/java/com/matedroid/domain/RouteSimplifier.kt index f11d4e47..09177f97 100644 --- a/app/src/main/java/com/matedroid/domain/RouteSimplifier.kt +++ b/app/src/main/java/com/matedroid/domain/RouteSimplifier.kt @@ -20,31 +20,41 @@ object RouteSimplifier { ): List { if (points.size <= 2) return points - // Find the point with the maximum distance from the line start→end - var maxDist = 0.0 - var maxIndex = 0 - val start = points.first() - val end = points.last() + // Iterative Douglas-Peucker: mark points to keep in a bitset, using an explicit + // stack of index ranges. Same result as the classic recursion, but with no + // per-level list allocation and a bounded heap stack (no deep call recursion). + val keep = BooleanArray(points.size) + keep[0] = true + keep[points.size - 1] = true - for (i in 1 until points.size - 1) { - val d = perpendicularDistance( - lat(points[i]), lon(points[i]), - lat(start), lon(start), - lat(end), lon(end) - ) - if (d > maxDist) { - maxDist = d - maxIndex = i + val stack = ArrayDeque() + stack.addLast(0) + stack.addLast(points.size - 1) + while (stack.isNotEmpty()) { + val end = stack.removeLast() + val start = stack.removeLast() + + val ax = lat(points[start]); val ay = lon(points[start]) + val bx = lat(points[end]); val by = lon(points[end]) + + var maxDist = 0.0 + var maxIndex = -1 + for (i in start + 1 until end) { + val d = perpendicularDistance(lat(points[i]), lon(points[i]), ax, ay, bx, by) + if (d > maxDist) { + maxDist = d + maxIndex = i + } } - } - return if (maxDist > epsilon) { - val left = simplify(points.subList(0, maxIndex + 1), epsilon, lat, lon) - val right = simplify(points.subList(maxIndex, points.size), epsilon, lat, lon) - left.dropLast(1) + right - } else { - listOf(start, end) + if (maxIndex != -1 && maxDist > epsilon) { + keep[maxIndex] = true + stack.addLast(start); stack.addLast(maxIndex) + stack.addLast(maxIndex); stack.addLast(end) + } } + + return points.filterIndexed { i, _ -> keep[i] } } private fun perpendicularDistance( diff --git a/app/src/main/java/com/matedroid/domain/TripRepository.kt b/app/src/main/java/com/matedroid/domain/TripRepository.kt index 84c18584..44adec1a 100644 --- a/app/src/main/java/com/matedroid/domain/TripRepository.kt +++ b/app/src/main/java/com/matedroid/domain/TripRepository.kt @@ -13,6 +13,8 @@ import com.matedroid.data.local.entity.SavedTripWithLegs import com.matedroid.domain.model.Trip import com.matedroid.util.parseIsoDateTime import java.security.MessageDigest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.time.LocalDateTime import java.time.temporal.ChronoUnit import javax.inject.Inject @@ -41,17 +43,26 @@ class TripRepository @Inject constructor( private val tripDetector: TripDetector ) { + // Cars whose beta-era duplicate cleanup has already run this session (see cleanupDuplicateSavedTrips). + private val duplicatesCleanedForCar = mutableSetOf() + /** Returns all trips for a car (saved + newly auto-detected this call), newest first. */ - suspend fun getTrips(carId: Int): List { + suspend fun getTrips(carId: Int): List = withContext(Dispatchers.Default) { + // Detection, duplicate healing (SHA-256 per trip) and trip building are CPU work — keep + // them off the caller's (main) thread. Room still runs the queries on its own executor. val drives = driveSummaryDao.getAllChronological(carId) val dcCharges = aggregateDao.getDcChargeSummaries(carId) val allCharges = chargeSummaryDao.getAllForCar(carId) autoPersistNewTrips(carId, drives, dcCharges) - cleanupDuplicateSavedTrips(carId) + // Beta-era duplicate healing only needs to run once per car per session, not every open. + // It's idempotent, so a redundant run (e.g. from a concurrent call) is harmless. + if (duplicatesCleanedForCar.add(carId)) { + cleanupDuplicateSavedTrips(carId) + } val saved = savedTripDao.getAllWithLegs(carId) - return buildTripsFromSaved(saved, drives, allCharges) + buildTripsFromSaved(saved, drives, allCharges) .sortedByDescending { it.startDate } } From ef8ff71d4a84776e952b3d33dce329f1bd119c49 Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Fri, 3 Jul 2026 13:23:33 +0200 Subject: [PATCH 4/4] docs: changelog note for performance pass Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 418adb48..eeea6191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- **Snappier throughout** — smoother list scrolling, lighter chart rendering, the Stats and Trips screens do their heavy work off the UI thread, and the dashboard stops polling for status while it's off screen (less background battery use). No change to how anything looks. - **Large cost totals now use a thousands separator** (e.g. "1,234.56") consistently across the stats, mileage and trip screens, matching how distances and energy are already shown. ### Fixed