From 4cbf0b11318150611299223e5b1cabc422aaaf3c Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Sat, 24 Jan 2026 19:16:54 +0100 Subject: [PATCH 1/5] chore: release v0.12.0 --- CHANGELOG.md | 11 ++++++++++- app/build.gradle.kts | 4 ++-- fastlane/metadata/android/ca-ES/changelogs/24.txt | 13 +++++++++++++ fastlane/metadata/android/en-US/changelogs/24.txt | 13 +++++++++++++ fastlane/metadata/android/es-ES/changelogs/24.txt | 13 +++++++++++++ fastlane/metadata/android/it-IT/changelogs/24.txt | 13 +++++++++++++ 6 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 fastlane/metadata/android/ca-ES/changelogs/24.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/24.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/24.txt create mode 100644 fastlane/metadata/android/it-IT/changelogs/24.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index a052b2c1..559cb480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0] - 2026-01-24 + ### Added - **Tire Pressure Notifications**: Background monitoring with alerts when any tire enters or exits a warning state - Uses Teslamate API's TPMS warning flags for detection @@ -24,13 +26,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tappable legend to filter by AC or DC charge type - Year filter chips to view data from specific years - **Geocoding**: Background location identification using OpenStreetMap Nominatim with rate limiting and caching +- **Drives/Charges**: "Today" filter option to quickly view today's activity +- **Model X**: Added SteelGrey color and Slipstream wheel support ### Changed - **Stats Sync**: Faster detail sync with parallel batch processing (10 concurrent API calls) - **Stats Sync**: Progress bar now accurately reflects reprocessing progress when app updates require data migration +- **Stats for Nerds**: Energy now displays in MWh when exceeding 999 kWh ### Fixed - **Stats for Nerds**: Deep sync progress bar now properly disappears when sync completes +- **Dashboard**: AC charging now shows actual number of phases instead of always showing 3 +- **Dashboard**: Show offline time duration when car has been offline +- Various translation fixes ## [0.11.3] - 2026-01-20 @@ -291,7 +299,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dashboard with basic vehicle status - Charges screen with history list -[Unreleased]: https://github.com/vide/matedroid/compare/v0.11.3...HEAD +[Unreleased]: https://github.com/vide/matedroid/compare/v0.12.0...HEAD +[0.12.0]: https://github.com/vide/matedroid/compare/v0.11.3...v0.12.0 [0.11.3]: https://github.com/vide/matedroid/compare/v0.11.2...v0.11.3 [0.11.2]: https://github.com/vide/matedroid/compare/v0.11.1...v0.11.2 [0.11.1]: https://github.com/vide/matedroid/compare/v0.11.0...v0.11.1 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 579c0af2..17fd86b6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "com.matedroid" minSdk = 28 targetSdk = 35 - versionCode = 23 - versionName = "0.11.3" + versionCode = 24 + versionName = "0.12.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/fastlane/metadata/android/ca-ES/changelogs/24.txt b/fastlane/metadata/android/ca-ES/changelogs/24.txt new file mode 100644 index 00000000..f6dc6f91 --- /dev/null +++ b/fastlane/metadata/android/ca-ES/changelogs/24.txt @@ -0,0 +1,13 @@ +A tothom li agrada viatjar, i amb aquesta versio pots admirar tots els teus viatges pel mon des del telefon! Sincronitzacio inicial 5 vegades mes rapida, alertes de pressio dels pneumatics i moltes correccions. + +Afegit: +- Paisos Visitats: explora on has conduit amb mapes interactius, estadistiques per pais i regio +- Notificacions de Pressio: rep alertes quan un pneumatic te baixa pressio +- Filtre Avui per viatges i carregues +- Model X color SteelGrey i llantes Slipstream + +Canviat: +- Sincronitzacio inicial 5 vegades mes rapida + +Corregit: +- Recompte de fases AC, temps offline, traduccions diff --git a/fastlane/metadata/android/en-US/changelogs/24.txt b/fastlane/metadata/android/en-US/changelogs/24.txt new file mode 100644 index 00000000..4d58d208 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/24.txt @@ -0,0 +1,13 @@ +Everyone likes travelling, and with this release you can now stare at all your trips across the world from your phone! Also a 5x faster initial sync, tire pressure alerts, and many fixes. + +Added: +- Countries Visited: explore where you've driven with interactive maps, stats by country and region +- Tire Pressure Notifications: get alerts when any tire has low pressure +- Today filter for drives and charges +- Model X SteelGrey color and Slipstream wheels + +Changed: +- 5x faster initial sync with parallel processing + +Fixed: +- AC charging phase count, offline time display, translations diff --git a/fastlane/metadata/android/es-ES/changelogs/24.txt b/fastlane/metadata/android/es-ES/changelogs/24.txt new file mode 100644 index 00000000..8f383b9f --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/24.txt @@ -0,0 +1,13 @@ +A todos nos gusta viajar, y con esta version puedes admirar todos tus viajes por el mundo desde tu telefono! Sincronizacion inicial 5 veces mas rapida, alertas de presion de neumaticos y muchas correcciones. + +Agregado: +- Paises Visitados: explora donde has conducido con mapas interactivos, estadisticas por pais y region +- Notificaciones de Presion: recibe alertas cuando un neumatico tiene baja presion +- Filtro Hoy para viajes y cargas +- Model X color SteelGrey y llantas Slipstream + +Cambiado: +- Sincronizacion inicial 5 veces mas rapida + +Corregido: +- Conteo de fases AC, tiempo offline, traducciones diff --git a/fastlane/metadata/android/it-IT/changelogs/24.txt b/fastlane/metadata/android/it-IT/changelogs/24.txt new file mode 100644 index 00000000..5c498ae1 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/24.txt @@ -0,0 +1,13 @@ +A tutti piace viaggiare, e con questa versione puoi finalmente ammirare tutti i tuoi viaggi nel mondo dal telefono! Sincronizzazione iniziale 5 volte più veloce, avvisi pressione gomme e tante correzioni. + +Aggiunto: +- Paesi Visitati: esplora dove hai guidato con mappe interattive, statistiche per paese e regione +- Notifiche Pressione Gomme: ricevi avvisi quando una gomma ha pressione bassa +- Filtro Oggi per viaggi e ricariche +- Model X colore SteelGrey e cerchi Slipstream + +Modificato: +- Sincronizzazione iniziale 5 volte più veloce + +Corretto: +- Conteggio fasi ricarica AC, tempo offline, traduzioni From e129f8965ebcbe893e34c70638263f4641ae0579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 26 Jan 2026 01:11:52 +0100 Subject: [PATCH 2/5] fix(#94): show all days when filtered by last 7 or last 30 --- .../ui/screens/charges/ChargesScreen.kt | 8 +- .../ui/screens/charges/ChargesViewModel.kt | 101 ++++++++++------ .../ui/screens/drives/DrivesScreen.kt | 9 +- .../ui/screens/drives/DrivesViewModel.kt | 112 +++++++++++------- 4 files changed, 145 insertions(+), 85 deletions(-) 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 b9879a60..5dfb514c 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 @@ -826,8 +826,12 @@ private fun ChargesChartPage( ChargesChartType.COUNT -> { v -> v.toInt().toString() } } - // Show max ~6 labels to avoid crowding - val labelInterval = ((barData.size + 5) / 6).coerceAtLeast(1) + // Set number of labels to display + val labelInterval = when { + barData.size <= 7 -> 1 // Show all for Today and last 7 days + barData.size <= 30 -> 3 // Show 1 label every 3 bars for lsat 30 days + else -> ((barData.size + 5) / 6).coerceAtLeast(1) + } InteractiveBarChart( data = barData, diff --git a/app/src/main/java/com/matedroid/ui/screens/charges/ChargesViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/charges/ChargesViewModel.kt index c9e489a5..a63ff3da 100644 --- a/app/src/main/java/com/matedroid/ui/screens/charges/ChargesViewModel.kt +++ b/app/src/main/java/com/matedroid/ui/screens/charges/ChargesViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate +import java.time.LocalDateTime import java.time.YearMonth import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit @@ -125,16 +126,17 @@ class ChargesViewModel @Inject constructor( fun setDateFilter(filter: DateFilter) { val endDate = LocalDate.now() - val startDate = filter.days?.let { endDate.minusDays(it) } - _uiState.update { it.copy(startDate = startDate, endDate = if (filter.days != null) endDate else null, selectedFilter = filter) } + val startDate = filter.days?.let { days -> + if (days > 0) endDate.minusDays(days - 1) else endDate + } + _uiState.update { it.copy( + selectedFilter = filter, + startDate = startDate, + endDate = if (filter.days != null) endDate else null + )} loadCharges(startDate, if (filter.days != null) endDate else null) } - fun clearDateFilter() { - _uiState.update { it.copy(startDate = null, endDate = null, selectedFilter = DateFilter.ALL_TIME) } - loadCharges(null, null) - } - fun refresh() { carId?.let { _uiState.update { it.copy(isRefreshing = true) } @@ -251,7 +253,7 @@ class ChargesViewModel @Inject constructor( // Calculate summary and chart data from filtered charges val summary = calculateSummary(chargesForStats) - val chartData = calculateChartData(chargesForStats, granularity) + val chartData = calculateChartData(chargesForStats, granularity, state.startDate) _uiState.update { it.copy( @@ -274,51 +276,78 @@ class ChargesViewModel @Inject constructor( } } - private fun calculateChartData(charges: List, granularity: ChartGranularity): List { + private fun calculateChartData(charges: List, granularity: ChartGranularity, startDate: LocalDate?): List { if (charges.isEmpty()) return emptyList() val formatter = DateTimeFormatter.ISO_DATE_TIME val weekFields = WeekFields.of(Locale.getDefault()) - return charges - .mapNotNull { charge -> + // Group the charges by day + val chargesByDay = charges.mapNotNull { charge -> + charge.startDate?.let { + try { + // Use of localdatetime to support the full ISO format + val date = LocalDateTime.parse(it, formatter).toLocalDate() + date.toEpochDay() to charge + } catch (e: Exception) { null } + } + }.groupBy({ it.first }, { it.second }) + + return if (granularity == ChartGranularity.DAILY) { + // DAILY ranges (today, last 7 and last 30 days + // If not startDate (All Time), get the first trip, or today + val start = startDate ?: (chargesByDay.keys.minOrNull()?.let { LocalDate.ofEpochDay(it) } ?: LocalDate.now()) + val end = LocalDate.now() + val result = mutableListOf() + var current = start + while (!current.isAfter(end)) { + val key = current.toEpochDay() + val itemsInDay = chargesByDay[key] ?: emptyList() + result.add( + createChargeChartPoint( + label = current.format(DateTimeFormatter.ofPattern("d/M")), + sortKey = key, + charges = itemsInDay + ) + ) + current = current.plusDays(1) + } + result + } else { + // WEEKLY and MONTHLY ranges + charges.mapNotNull { charge -> charge.startDate?.let { dateStr -> try { - val date = LocalDate.parse(dateStr, formatter) + val date = LocalDateTime.parse(dateStr, formatter).toLocalDate() val (label, sortKey) = when (granularity) { - ChartGranularity.DAILY -> { - val dayLabel = date.format(DateTimeFormatter.ofPattern("d/M")) - dayLabel to date.toEpochDay() - } ChartGranularity.WEEKLY -> { - val weekOfYear = date.get(weekFields.weekOfWeekBasedYear()) - val year = date.get(weekFields.weekBasedYear()) - "W$weekOfYear" to (year * 100L + weekOfYear) + val firstDay = date.with(weekFields.dayOfWeek(), 1) + "W${date.get(weekFields.weekOfYear())}" to firstDay.toEpochDay() } - ChartGranularity.MONTHLY -> { - val yearMonth = YearMonth.of(date.year, date.month) - yearMonth.format(DateTimeFormatter.ofPattern("MMM yy")) to (date.year * 12L + date.monthValue) + else -> { // MONTHLY + date.format(DateTimeFormatter.ofPattern("MMM yy")) to YearMonth.from(date).atDay(1).toEpochDay() } } Triple(label, sortKey, charge) - } catch (e: Exception) { - null - } + } catch (e: Exception) { null } } } - .groupBy { Pair(it.first, it.second) } - .map { (key, chargesInPeriod) -> - ChargeChartData( - label = key.first, - count = chargesInPeriod.size, - totalEnergy = chargesInPeriod.sumOf { it.third.chargeEnergyAdded ?: 0.0 }, - totalCost = chargesInPeriod.sumOf { it.third.cost ?: 0.0 }, - sortKey = key.second - ) - } - .sortedBy { it.sortKey } + .groupBy { it.first to it.second } + .map { (key, list) -> createChargeChartPoint(key.first, key.second, list.map { it.third }) } + .sortedBy { it.sortKey } + } } + // Helper function to centralize chart data creation + private fun createChargeChartPoint(label: String, sortKey: Long, charges: List): ChargeChartData { + return ChargeChartData( + label = label, + count = charges.size, + totalEnergy = charges.sumOf { it.chargeEnergyAdded ?: 0.0 }, + totalCost = charges.sumOf { it.cost ?: 0.0 }, + sortKey = sortKey + ) + } private fun calculateSummary(charges: List): ChargesSummary { if (charges.isEmpty()) return ChargesSummary() 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 6ee93775..ec26be7b 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 @@ -782,14 +782,17 @@ private fun DrivesChartPage( DrivesChartType.TOP_SPEED -> { v -> "${v.toInt()} $speedUnit" } } + // Set number of labels to display + val labelInterval = when { + barData.size <= 7 -> 1 // Show all for Today and last 7 days + barData.size <= 30 -> 3 // Show 1 label every 3 bars for lsat 30 days + else -> ((barData.size + 5) / 6).coerceAtLeast(1) + } val yAxisFormatter: (Double) -> String = when (chartType) { DrivesChartType.TIME -> { v -> formatDurationChart(v.toInt()) } else -> { v -> if (v >= 1000) "%.0fk".format(v / 1000) else "%.0f".format(v) } } - // Show max ~6 labels to avoid crowding - val labelInterval = ((barData.size + 5) / 6).coerceAtLeast(1) - InteractiveBarChart( data = barData, modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/DrivesViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/drives/DrivesViewModel.kt index b5583f02..8b5f7856 100644 --- a/app/src/main/java/com/matedroid/ui/screens/drives/DrivesViewModel.kt +++ b/app/src/main/java/com/matedroid/ui/screens/drives/DrivesViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate +import java.time.LocalDateTime import java.time.YearMonth import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit @@ -122,25 +123,21 @@ class DrivesViewModel @Inject constructor( // Only apply default filter on first initialization if (!isInitialized) { isInitialized = true - applyDateFilterEnum(_uiState.value.dateFilter) + setDateFilter(_uiState.value.dateFilter) } } fun setDateFilter(filter: DriveDateFilter) { - _uiState.update { it.copy(dateFilter = filter) } - applyDateFilterEnum(filter) - } - - private fun applyDateFilterEnum(filter: DriveDateFilter) { - if (filter.days != null) { - val endDate = LocalDate.now() - val startDate = endDate.minusDays(filter.days) - _uiState.update { it.copy(startDate = startDate, endDate = endDate) } - loadDrives(startDate, endDate) - } else { - _uiState.update { it.copy(startDate = null, endDate = null) } - loadDrives(null, null) + val endDate = LocalDate.now() + val startDate = filter.days?.let { days -> + if (days > 0) endDate.minusDays(days - 1) else endDate } + _uiState.update { it.copy( + dateFilter = filter, + startDate = startDate, + endDate = if (filter.days != null) endDate else null + )} + loadDrives(startDate, if (filter.days != null) endDate else null) } fun saveScrollPosition(index: Int, offset: Int) { @@ -251,7 +248,7 @@ class DrivesViewModel @Inject constructor( // Calculate summary and chart data from filtered drives val summary = calculateSummary(drivesForStats) - val chartData = calculateChartData(drivesForStats, granularity) + val chartData = calculateChartData(drivesForStats, granularity, state.startDate) _uiState.update { it.copy( @@ -274,50 +271,77 @@ class DrivesViewModel @Inject constructor( } } - private fun calculateChartData(drives: List, granularity: DriveChartGranularity): List { + private fun calculateChartData(drives: List, granularity: DriveChartGranularity, startDate: LocalDate?): List { if (drives.isEmpty()) return emptyList() val formatter = DateTimeFormatter.ISO_DATE_TIME val weekFields = WeekFields.of(Locale.getDefault()) - return drives - .mapNotNull { drive -> + // Group the charges by day + val drivesByDay = drives.mapNotNull { drive -> + drive.startDate?.let { + try { + val date = LocalDateTime.parse(it, formatter).toLocalDate() + date.toEpochDay() to drive + } catch (e: Exception) { null } + } + }.groupBy({ it.first }, { it.second }) + + return if (granularity == DriveChartGranularity.DAILY) { + // DAILY ranges (today, last 7 and last 30 days + // If not startDate (All Time), get the first trip, or today + val start = startDate ?: (drivesByDay.keys.minOrNull()?.let { LocalDate.ofEpochDay(it) } ?: LocalDate.now()) + val end = LocalDate.now() + val result = mutableListOf() + var current = start + while (!current.isAfter(end)) { + val key = current.toEpochDay() + val drivesInDay = drivesByDay[key] ?: emptyList() + result.add( + createChartPoint( + label = current.format(DateTimeFormatter.ofPattern("d/M")), + sortKey = key, + drives = drivesInDay + ) + ) + current = current.plusDays(1) + } + result + } else { + // WEEKLY and MONTHLY ranges + drives.mapNotNull { drive -> drive.startDate?.let { dateStr -> try { - val date = LocalDate.parse(dateStr, formatter) + val date = LocalDateTime.parse(dateStr, formatter).toLocalDate() val (label, sortKey) = when (granularity) { - DriveChartGranularity.DAILY -> { - val dayLabel = date.format(DateTimeFormatter.ofPattern("d/M")) - dayLabel to date.toEpochDay() - } DriveChartGranularity.WEEKLY -> { - val weekOfYear = date.get(weekFields.weekOfWeekBasedYear()) - val year = date.get(weekFields.weekBasedYear()) - "W$weekOfYear" to (year * 100L + weekOfYear) + val firstDay = date.with(weekFields.dayOfWeek(), 1) + "W${date.get(weekFields.weekOfYear())}" to firstDay.toEpochDay() } - DriveChartGranularity.MONTHLY -> { - val yearMonth = YearMonth.of(date.year, date.month) - yearMonth.format(DateTimeFormatter.ofPattern("MMM yy")) to (date.year * 12L + date.monthValue) + else -> { // MONTHLY + date.format(DateTimeFormatter.ofPattern("MMM yy")) to YearMonth.from(date).atDay(1).toEpochDay() } } Triple(label, sortKey, drive) - } catch (e: Exception) { - null - } + } catch (e: Exception) { null } } } - .groupBy { Pair(it.first, it.second) } - .map { (key, drivesInPeriod) -> - DriveChartData( - label = key.first, - count = drivesInPeriod.size, - totalDistance = drivesInPeriod.sumOf { it.third.distance ?: 0.0 }, - totalDurationMin = drivesInPeriod.sumOf { it.third.durationMin ?: 0 }, - maxSpeed = drivesInPeriod.maxOfOrNull { it.third.speedMax ?: 0 } ?: 0, - sortKey = key.second - ) - } - .sortedBy { it.sortKey } + .groupBy { it.first to it.second } + .map { (key, list) -> createChartPoint(key.first, key.second, list.map { it.third }) } + .sortedBy { it.sortKey } + } + } + + // Helper function to centralize chart data creation + private fun createChartPoint(label: String, sortKey: Long, drives: List): DriveChartData { + return DriveChartData( + label = label, + count = drives.size, + totalDistance = drives.sumOf { it.distance ?: 0.0 }, + totalDurationMin = drives.sumOf { it.durationMin ?: 0 }, + maxSpeed = drives.maxOfOrNull { it.speedMax ?: 0 } ?: 0, + sortKey = sortKey + ) } private fun calculateSummary(drives: List): DrivesSummary { From 27ce80937ab83e59288da28737bb2630e020b9e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 26 Jan 2026 01:11:52 +0100 Subject: [PATCH 3/5] fix(#94): show all days when filtered by last 7 or last 30 --- .../ui/screens/charges/ChargesScreen.kt | 8 +- .../ui/screens/charges/ChargesViewModel.kt | 101 ++++++++++------ .../ui/screens/drives/DrivesScreen.kt | 9 +- .../ui/screens/drives/DrivesViewModel.kt | 112 +++++++++++------- 4 files changed, 145 insertions(+), 85 deletions(-) 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 a68b4c62..0d44cce8 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 @@ -834,8 +834,12 @@ private fun ChargesChartPage( ChargesChartType.COUNT -> { v -> v.toInt().toString() } } - // Show max ~6 labels to avoid crowding - val labelInterval = ((barData.size + 5) / 6).coerceAtLeast(1) + // Set number of labels to display + val labelInterval = when { + barData.size <= 7 -> 1 // Show all for Today and last 7 days + barData.size <= 30 -> 3 // Show 1 label every 3 bars for lsat 30 days + else -> ((barData.size + 5) / 6).coerceAtLeast(1) + } InteractiveBarChart( data = barData, diff --git a/app/src/main/java/com/matedroid/ui/screens/charges/ChargesViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/charges/ChargesViewModel.kt index 5dbd7c80..15bd21fa 100644 --- a/app/src/main/java/com/matedroid/ui/screens/charges/ChargesViewModel.kt +++ b/app/src/main/java/com/matedroid/ui/screens/charges/ChargesViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate +import java.time.LocalDateTime import java.time.YearMonth import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit @@ -125,16 +126,17 @@ class ChargesViewModel @Inject constructor( fun setDateFilter(filter: DateFilter) { val endDate = LocalDate.now() - val startDate = filter.days?.let { endDate.minusDays(it) } - _uiState.update { it.copy(startDate = startDate, endDate = if (filter.days != null) endDate else null, selectedFilter = filter) } + val startDate = filter.days?.let { days -> + if (days > 0) endDate.minusDays(days - 1) else endDate + } + _uiState.update { it.copy( + selectedFilter = filter, + startDate = startDate, + endDate = if (filter.days != null) endDate else null + )} loadCharges(startDate, if (filter.days != null) endDate else null) } - fun clearDateFilter() { - _uiState.update { it.copy(startDate = null, endDate = null, selectedFilter = DateFilter.ALL_TIME) } - loadCharges(null, null) - } - fun refresh() { carId?.let { _uiState.update { it.copy(isRefreshing = true) } @@ -252,7 +254,7 @@ class ChargesViewModel @Inject constructor( // Calculate summary and chart data from filtered charges val summary = calculateSummary(chargesForStats) - val chartData = calculateChartData(chargesForStats, granularity) + val chartData = calculateChartData(chargesForStats, granularity, state.startDate) _uiState.update { it.copy( @@ -275,51 +277,78 @@ class ChargesViewModel @Inject constructor( } } - private fun calculateChartData(charges: List, granularity: ChartGranularity): List { + private fun calculateChartData(charges: List, granularity: ChartGranularity, startDate: LocalDate?): List { if (charges.isEmpty()) return emptyList() val formatter = DateTimeFormatter.ISO_DATE_TIME val weekFields = WeekFields.of(Locale.getDefault()) - return charges - .mapNotNull { charge -> + // Group the charges by day + val chargesByDay = charges.mapNotNull { charge -> + charge.startDate?.let { + try { + // Use of localdatetime to support the full ISO format + val date = LocalDateTime.parse(it, formatter).toLocalDate() + date.toEpochDay() to charge + } catch (e: Exception) { null } + } + }.groupBy({ it.first }, { it.second }) + + return if (granularity == ChartGranularity.DAILY) { + // DAILY ranges (today, last 7 and last 30 days + // If not startDate (All Time), get the first trip, or today + val start = startDate ?: (chargesByDay.keys.minOrNull()?.let { LocalDate.ofEpochDay(it) } ?: LocalDate.now()) + val end = LocalDate.now() + val result = mutableListOf() + var current = start + while (!current.isAfter(end)) { + val key = current.toEpochDay() + val itemsInDay = chargesByDay[key] ?: emptyList() + result.add( + createChargeChartPoint( + label = current.format(DateTimeFormatter.ofPattern("d/M")), + sortKey = key, + charges = itemsInDay + ) + ) + current = current.plusDays(1) + } + result + } else { + // WEEKLY and MONTHLY ranges + charges.mapNotNull { charge -> charge.startDate?.let { dateStr -> try { - val date = LocalDate.parse(dateStr, formatter) + val date = LocalDateTime.parse(dateStr, formatter).toLocalDate() val (label, sortKey) = when (granularity) { - ChartGranularity.DAILY -> { - val dayLabel = date.format(DateTimeFormatter.ofPattern("d/M")) - dayLabel to date.toEpochDay() - } ChartGranularity.WEEKLY -> { - val weekOfYear = date.get(weekFields.weekOfWeekBasedYear()) - val year = date.get(weekFields.weekBasedYear()) - "W$weekOfYear" to (year * 100L + weekOfYear) + val firstDay = date.with(weekFields.dayOfWeek(), 1) + "W${date.get(weekFields.weekOfYear())}" to firstDay.toEpochDay() } - ChartGranularity.MONTHLY -> { - val yearMonth = YearMonth.of(date.year, date.month) - yearMonth.format(DateTimeFormatter.ofPattern("MMM yy")) to (date.year * 12L + date.monthValue) + else -> { // MONTHLY + date.format(DateTimeFormatter.ofPattern("MMM yy")) to YearMonth.from(date).atDay(1).toEpochDay() } } Triple(label, sortKey, charge) - } catch (e: Exception) { - null - } + } catch (e: Exception) { null } } } - .groupBy { Pair(it.first, it.second) } - .map { (key, chargesInPeriod) -> - ChargeChartData( - label = key.first, - count = chargesInPeriod.size, - totalEnergy = chargesInPeriod.sumOf { it.third.chargeEnergyAdded ?: 0.0 }, - totalCost = chargesInPeriod.sumOf { it.third.cost ?: 0.0 }, - sortKey = key.second - ) - } - .sortedBy { it.sortKey } + .groupBy { it.first to it.second } + .map { (key, list) -> createChargeChartPoint(key.first, key.second, list.map { it.third }) } + .sortedBy { it.sortKey } + } } + // Helper function to centralize chart data creation + private fun createChargeChartPoint(label: String, sortKey: Long, charges: List): ChargeChartData { + return ChargeChartData( + label = label, + count = charges.size, + totalEnergy = charges.sumOf { it.chargeEnergyAdded ?: 0.0 }, + totalCost = charges.sumOf { it.cost ?: 0.0 }, + sortKey = sortKey + ) + } private fun calculateSummary(charges: List): ChargesSummary { if (charges.isEmpty()) return ChargesSummary() 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 6ee93775..ec26be7b 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 @@ -782,14 +782,17 @@ private fun DrivesChartPage( DrivesChartType.TOP_SPEED -> { v -> "${v.toInt()} $speedUnit" } } + // Set number of labels to display + val labelInterval = when { + barData.size <= 7 -> 1 // Show all for Today and last 7 days + barData.size <= 30 -> 3 // Show 1 label every 3 bars for lsat 30 days + else -> ((barData.size + 5) / 6).coerceAtLeast(1) + } val yAxisFormatter: (Double) -> String = when (chartType) { DrivesChartType.TIME -> { v -> formatDurationChart(v.toInt()) } else -> { v -> if (v >= 1000) "%.0fk".format(v / 1000) else "%.0f".format(v) } } - // Show max ~6 labels to avoid crowding - val labelInterval = ((barData.size + 5) / 6).coerceAtLeast(1) - InteractiveBarChart( data = barData, modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/DrivesViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/drives/DrivesViewModel.kt index 96f2e097..96bb12e4 100644 --- a/app/src/main/java/com/matedroid/ui/screens/drives/DrivesViewModel.kt +++ b/app/src/main/java/com/matedroid/ui/screens/drives/DrivesViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate +import java.time.LocalDateTime import java.time.YearMonth import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit @@ -122,25 +123,21 @@ class DrivesViewModel @Inject constructor( // Only apply default filter on first initialization if (!isInitialized) { isInitialized = true - applyDateFilterEnum(_uiState.value.dateFilter) + setDateFilter(_uiState.value.dateFilter) } } fun setDateFilter(filter: DriveDateFilter) { - _uiState.update { it.copy(dateFilter = filter) } - applyDateFilterEnum(filter) - } - - private fun applyDateFilterEnum(filter: DriveDateFilter) { - if (filter.days != null) { - val endDate = LocalDate.now() - val startDate = endDate.minusDays(filter.days) - _uiState.update { it.copy(startDate = startDate, endDate = endDate) } - loadDrives(startDate, endDate) - } else { - _uiState.update { it.copy(startDate = null, endDate = null) } - loadDrives(null, null) + val endDate = LocalDate.now() + val startDate = filter.days?.let { days -> + if (days > 0) endDate.minusDays(days - 1) else endDate } + _uiState.update { it.copy( + dateFilter = filter, + startDate = startDate, + endDate = if (filter.days != null) endDate else null + )} + loadDrives(startDate, if (filter.days != null) endDate else null) } fun saveScrollPosition(index: Int, offset: Int) { @@ -252,7 +249,7 @@ class DrivesViewModel @Inject constructor( // Calculate summary and chart data from filtered drives val summary = calculateSummary(drivesForStats) - val chartData = calculateChartData(drivesForStats, granularity) + val chartData = calculateChartData(drivesForStats, granularity, state.startDate) _uiState.update { it.copy( @@ -275,50 +272,77 @@ class DrivesViewModel @Inject constructor( } } - private fun calculateChartData(drives: List, granularity: DriveChartGranularity): List { + private fun calculateChartData(drives: List, granularity: DriveChartGranularity, startDate: LocalDate?): List { if (drives.isEmpty()) return emptyList() val formatter = DateTimeFormatter.ISO_DATE_TIME val weekFields = WeekFields.of(Locale.getDefault()) - return drives - .mapNotNull { drive -> + // Group the charges by day + val drivesByDay = drives.mapNotNull { drive -> + drive.startDate?.let { + try { + val date = LocalDateTime.parse(it, formatter).toLocalDate() + date.toEpochDay() to drive + } catch (e: Exception) { null } + } + }.groupBy({ it.first }, { it.second }) + + return if (granularity == DriveChartGranularity.DAILY) { + // DAILY ranges (today, last 7 and last 30 days + // If not startDate (All Time), get the first trip, or today + val start = startDate ?: (drivesByDay.keys.minOrNull()?.let { LocalDate.ofEpochDay(it) } ?: LocalDate.now()) + val end = LocalDate.now() + val result = mutableListOf() + var current = start + while (!current.isAfter(end)) { + val key = current.toEpochDay() + val drivesInDay = drivesByDay[key] ?: emptyList() + result.add( + createChartPoint( + label = current.format(DateTimeFormatter.ofPattern("d/M")), + sortKey = key, + drives = drivesInDay + ) + ) + current = current.plusDays(1) + } + result + } else { + // WEEKLY and MONTHLY ranges + drives.mapNotNull { drive -> drive.startDate?.let { dateStr -> try { - val date = LocalDate.parse(dateStr, formatter) + val date = LocalDateTime.parse(dateStr, formatter).toLocalDate() val (label, sortKey) = when (granularity) { - DriveChartGranularity.DAILY -> { - val dayLabel = date.format(DateTimeFormatter.ofPattern("d/M")) - dayLabel to date.toEpochDay() - } DriveChartGranularity.WEEKLY -> { - val weekOfYear = date.get(weekFields.weekOfWeekBasedYear()) - val year = date.get(weekFields.weekBasedYear()) - "W$weekOfYear" to (year * 100L + weekOfYear) + val firstDay = date.with(weekFields.dayOfWeek(), 1) + "W${date.get(weekFields.weekOfYear())}" to firstDay.toEpochDay() } - DriveChartGranularity.MONTHLY -> { - val yearMonth = YearMonth.of(date.year, date.month) - yearMonth.format(DateTimeFormatter.ofPattern("MMM yy")) to (date.year * 12L + date.monthValue) + else -> { // MONTHLY + date.format(DateTimeFormatter.ofPattern("MMM yy")) to YearMonth.from(date).atDay(1).toEpochDay() } } Triple(label, sortKey, drive) - } catch (e: Exception) { - null - } + } catch (e: Exception) { null } } } - .groupBy { Pair(it.first, it.second) } - .map { (key, drivesInPeriod) -> - DriveChartData( - label = key.first, - count = drivesInPeriod.size, - totalDistance = drivesInPeriod.sumOf { it.third.distance ?: 0.0 }, - totalDurationMin = drivesInPeriod.sumOf { it.third.durationMin ?: 0 }, - maxSpeed = drivesInPeriod.maxOfOrNull { it.third.speedMax ?: 0 } ?: 0, - sortKey = key.second - ) - } - .sortedBy { it.sortKey } + .groupBy { it.first to it.second } + .map { (key, list) -> createChartPoint(key.first, key.second, list.map { it.third }) } + .sortedBy { it.sortKey } + } + } + + // Helper function to centralize chart data creation + private fun createChartPoint(label: String, sortKey: Long, drives: List): DriveChartData { + return DriveChartData( + label = label, + count = drives.size, + totalDistance = drives.sumOf { it.distance ?: 0.0 }, + totalDurationMin = drives.sumOf { it.durationMin ?: 0 }, + maxSpeed = drives.maxOfOrNull { it.speedMax ?: 0 } ?: 0, + sortKey = sortKey + ) } private fun calculateSummary(drives: List): DrivesSummary { From eca671248713e0ef4d27fc543d20450f117b8e69 Mon Sep 17 00:00:00 2001 From: MARMdeveloper <48967356+MARMdeveloper@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:29:05 +0100 Subject: [PATCH 4/5] Update app/src/main/java/com/matedroid/ui/screens/charges/ChargesScreen.kt Co-authored-by: Davide Ferrari --- .../main/java/com/matedroid/ui/screens/charges/ChargesScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0d44cce8..89cab432 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 @@ -837,7 +837,7 @@ private fun ChargesChartPage( // Set number of labels to display val labelInterval = when { barData.size <= 7 -> 1 // Show all for Today and last 7 days - barData.size <= 30 -> 3 // Show 1 label every 3 bars for lsat 30 days + barData.size <= 30 -> 3 // Show 1 label every 3 bars for last 30 days else -> ((barData.size + 5) / 6).coerceAtLeast(1) } From 398eb00eacb8890ac5adad10c6f15b886ce4ba03 Mon Sep 17 00:00:00 2001 From: MARMdeveloper <48967356+MARMdeveloper@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:29:40 +0100 Subject: [PATCH 5/5] Update app/src/main/java/com/matedroid/ui/screens/drives/DrivesViewModel.kt Co-authored-by: Davide Ferrari --- .../java/com/matedroid/ui/screens/drives/DrivesViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/matedroid/ui/screens/drives/DrivesViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/drives/DrivesViewModel.kt index 96bb12e4..99784ebf 100644 --- a/app/src/main/java/com/matedroid/ui/screens/drives/DrivesViewModel.kt +++ b/app/src/main/java/com/matedroid/ui/screens/drives/DrivesViewModel.kt @@ -278,7 +278,7 @@ class DrivesViewModel @Inject constructor( val formatter = DateTimeFormatter.ISO_DATE_TIME val weekFields = WeekFields.of(Locale.getDefault()) - // Group the charges by day + // Group the drives by day val drivesByDay = drives.mapNotNull { drive -> drive.startDate?.let { try {