diff --git a/CHANGELOG.md b/CHANGELOG.md index c839cf9..491da0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Stats for Nerds**: New "Longest Range" record showing maximum distance traveled between charges (fixes #24) + - Tap to see all drives that made up the record - **Dashboard**: Breathing glow effect around car image when charging - Glow pulses smoothly in opacity with 2-second cycle - Color shifts from palette accent toward AC (green) or DC (orange) charging color @@ -31,6 +33,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Dashboard**: AC charging details below SoC bar showing Voltage, Current, and Phases - **Domain**: Battery chemistry detection (LFP vs NMC) based on trim_badging +### Changed +- **Requirements**: Minimum Android version raised from 8.0 to 10 (released 2019) + ## [0.8.3] - 2026-01-11 ### Fixed diff --git a/README.md b/README.md index ca7012a..81b0e66 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ A native Android application for viewing Tesla vehicle data from your self-hoste ## Requirements -- Android 8.0 (API 26) or higher +- Android 10 (API 29) or higher - A running [Teslamate](https://github.com/adriankumpf/teslamate) instance - [TeslamateApi](https://github.com/tobiasehlert/teslamateapi) deployed and accessible diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 50b779c..b958854 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,7 +17,7 @@ android { defaultConfig { applicationId = "com.matedroid" - minSdk = 26 + minSdk = 29 targetSdk = 35 versionCode = 13 versionName = "0.8.2" diff --git a/app/src/main/java/com/matedroid/data/local/dao/ChargeSummaryDao.kt b/app/src/main/java/com/matedroid/data/local/dao/ChargeSummaryDao.kt index ffdb2bd..ffaca73 100644 --- a/app/src/main/java/com/matedroid/data/local/dao/ChargeSummaryDao.kt +++ b/app/src/main/java/com/matedroid/data/local/dao/ChargeSummaryDao.kt @@ -157,4 +157,84 @@ interface ChargeSummaryDao { ORDER BY year DESC """) suspend fun getYears(carId: Int): List + + // === Range Records Queries === + + /** + * Find the maximum distance traveled between two consecutive charges. + * Sums actual logged drives between charges (not odometer diff, which can include unlogged drives). + */ + @Query(""" + SELECT + prev.chargeId as fromChargeId, + curr.chargeId as toChargeId, + COALESCE(( + SELECT SUM(d.distance) + FROM drives_summary d + WHERE d.carId = curr.carId + AND d.startDate > prev.startDate + AND d.startDate < curr.startDate + ), 0) as distance, + prev.startDate as fromDate, + curr.startDate as toDate + FROM charges_summary curr + INNER JOIN charges_summary prev ON prev.carId = curr.carId + AND prev.startDate = ( + SELECT MAX(p.startDate) + FROM charges_summary p + WHERE p.carId = curr.carId AND p.startDate < curr.startDate + ) + WHERE curr.carId = :carId + ORDER BY distance DESC + LIMIT 1 + """) + suspend fun maxDistanceBetweenCharges(carId: Int): MaxDistanceBetweenChargesResult? + + /** + * Find the maximum distance traveled between two consecutive charges within a date range. + * Both charges must be within the range. + * Sums actual logged drives between charges (not odometer diff, which can include unlogged drives). + */ + @Query(""" + SELECT + prev.chargeId as fromChargeId, + curr.chargeId as toChargeId, + COALESCE(( + SELECT SUM(d.distance) + FROM drives_summary d + WHERE d.carId = curr.carId + AND d.startDate > prev.startDate + AND d.startDate < curr.startDate + ), 0) as distance, + prev.startDate as fromDate, + curr.startDate as toDate + FROM charges_summary curr + INNER JOIN charges_summary prev ON prev.carId = curr.carId + AND prev.startDate = ( + SELECT MAX(p.startDate) + FROM charges_summary p + WHERE p.carId = curr.carId AND p.startDate < curr.startDate + ) + WHERE curr.carId = :carId + AND prev.startDate >= :startDate + AND curr.startDate < :endDate + ORDER BY distance DESC + LIMIT 1 + """) + suspend fun maxDistanceBetweenChargesInRange( + carId: Int, + startDate: String, + endDate: String + ): MaxDistanceBetweenChargesResult? } + +/** + * Result of max distance between charges query. + */ +data class MaxDistanceBetweenChargesResult( + val fromChargeId: Int, + val toChargeId: Int, + val distance: Double, + val fromDate: String, + val toDate: String +) diff --git a/app/src/main/java/com/matedroid/data/local/dao/DriveSummaryDao.kt b/app/src/main/java/com/matedroid/data/local/dao/DriveSummaryDao.kt index 3681131..0bce047 100644 --- a/app/src/main/java/com/matedroid/data/local/dao/DriveSummaryDao.kt +++ b/app/src/main/java/com/matedroid/data/local/dao/DriveSummaryDao.kt @@ -236,6 +236,21 @@ interface DriveSummaryDao { ORDER BY year DESC """) suspend fun getYears(carId: Int): List + + // === Range Record Queries === + + /** + * Get all drives between two dates (exclusive), ordered by start date. + * Used for showing drives in a "longest range" record. + */ + @Query(""" + SELECT * FROM drives_summary + WHERE carId = :carId + AND startDate > :afterDate + AND startDate < :beforeDate + ORDER BY startDate ASC + """) + suspend fun getDrivesBetweenDates(carId: Int, afterDate: String, beforeDate: String): List } data class BusiestDayResult( 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 b86d8ed..e3c7f30 100644 --- a/app/src/main/java/com/matedroid/data/repository/StatsRepository.kt +++ b/app/src/main/java/com/matedroid/data/repository/StatsRepository.kt @@ -12,6 +12,7 @@ import com.matedroid.domain.model.DeepStats import com.matedroid.domain.model.DriveElevationRecord import com.matedroid.domain.model.DriveTempRecord import com.matedroid.domain.model.QuickStats +import com.matedroid.domain.model.MaxDistanceBetweenChargesRecord import com.matedroid.domain.model.YearFilter import javax.inject.Inject import javax.inject.Singleton @@ -81,7 +82,17 @@ class StatsRepository @Inject constructor( firstDriveDate = driveSummaryDao.firstDriveDate(carId), firstChargeDate = chargeSummaryDao.firstChargeDate(carId), busiestDay = driveSummaryDao.busiestDay(carId), - mostDistanceDay = driveSummaryDao.mostDistanceDay(carId) + mostDistanceDay = driveSummaryDao.mostDistanceDay(carId), + + maxDistanceBetweenCharges = chargeSummaryDao.maxDistanceBetweenCharges(carId)?.let { + MaxDistanceBetweenChargesRecord( + distance = it.distance, + fromChargeId = it.fromChargeId, + toChargeId = it.toChargeId, + fromDate = it.fromDate, + toDate = it.toDate + ) + } ) } @@ -115,7 +126,17 @@ class StatsRepository @Inject constructor( firstDriveDate = driveSummaryDao.firstDriveDate(carId), // Always show first ever firstChargeDate = chargeSummaryDao.firstChargeDate(carId), // Always show first ever busiestDay = driveSummaryDao.busiestDayInRange(carId, startDate, endDate), - mostDistanceDay = driveSummaryDao.mostDistanceDayInRange(carId, startDate, endDate) + mostDistanceDay = driveSummaryDao.mostDistanceDayInRange(carId, startDate, endDate), + + maxDistanceBetweenCharges = chargeSummaryDao.maxDistanceBetweenChargesInRange(carId, startDate, endDate)?.let { + MaxDistanceBetweenChargesRecord( + distance = it.distance, + fromChargeId = it.fromChargeId, + toChargeId = it.toChargeId, + fromDate = it.fromDate, + toDate = it.toDate + ) + } ) } @@ -375,6 +396,12 @@ class StatsRepository @Inject constructor( return progress != null && progress.phase.isProcessing() } + /** + * Get drives between two dates (for range record details). + */ + suspend fun getDrivesBetweenDates(carId: Int, afterDate: String, beforeDate: String) = + driveSummaryDao.getDrivesBetweenDates(carId, afterDate, beforeDate) + /** * Get the sync completion percentage for deep stats. * Returns 1.0 if sync is marked complete, regardless of actual count diff --git a/app/src/main/java/com/matedroid/domain/model/CarStats.kt b/app/src/main/java/com/matedroid/domain/model/CarStats.kt index 4ed9a50..9ae08d0 100644 --- a/app/src/main/java/com/matedroid/domain/model/CarStats.kt +++ b/app/src/main/java/com/matedroid/domain/model/CarStats.kt @@ -62,7 +62,10 @@ data class QuickStats( val firstDriveDate: String?, val firstChargeDate: String?, val busiestDay: BusiestDayResult?, - val mostDistanceDay: MostDistanceDayResult? + val mostDistanceDay: MostDistanceDayResult?, + + // === Range Records === + val maxDistanceBetweenCharges: MaxDistanceBetweenChargesRecord? ) /** @@ -154,3 +157,14 @@ data class ChargePowerRecord( val powerKw: Int, val date: String? ) + +/** + * Record for maximum distance traveled between two consecutive charges. + */ +data class MaxDistanceBetweenChargesRecord( + val distance: Double, // km + val fromChargeId: Int, + val toChargeId: Int, + val fromDate: String, + val toDate: String +) 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 064071a..11154c8 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 @@ -72,8 +72,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.matedroid.data.local.entity.DriveSummary import com.matedroid.domain.model.CarStats import com.matedroid.domain.model.DeepStats +import com.matedroid.domain.model.MaxDistanceBetweenChargesRecord import com.matedroid.domain.model.QuickStats import com.matedroid.domain.model.SyncPhase import com.matedroid.domain.model.YearFilter @@ -99,6 +101,20 @@ fun StatsScreen( val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme) var showSyncLogsDialog by remember { mutableStateOf(false) } + // State for range record dialog + var rangeRecordToShow by remember { mutableStateOf(null) } + var rangeRecordDrives by remember { mutableStateOf>(emptyList()) } + var isLoadingRangeRecordDrives by remember { mutableStateOf(false) } + + // Load drives when range record dialog is opened + LaunchedEffect(rangeRecordToShow) { + rangeRecordToShow?.let { record -> + isLoadingRangeRecordDrives = true + rangeRecordDrives = viewModel.getDrivesForRangeRecord(record.fromDate, record.toDate) + isLoadingRangeRecordDrives = false + } + } + LaunchedEffect(carId) { viewModel.setCarId(carId) } @@ -126,6 +142,21 @@ fun StatsScreen( ) } + // Range record details dialog + rangeRecordToShow?.let { record -> + RangeRecordDialog( + record = record, + drives = rangeRecordDrives, + isLoading = isLoadingRangeRecordDrives, + palette = palette, + onDriveClick = { driveId -> + rangeRecordToShow = null + onNavigateToDriveDetail(driveId) + }, + onDismiss = { rangeRecordToShow = null } + ) + } + Scaffold( topBar = { TopAppBar( @@ -178,6 +209,7 @@ fun StatsScreen( onNavigateToDriveDetail = onNavigateToDriveDetail, onNavigateToChargeDetail = onNavigateToChargeDetail, onNavigateToDayDetail = onNavigateToDayDetail, + onRangeRecordClick = { rangeRecordToShow = it }, onSyncProgressClick = if (BuildConfig.DEBUG) { { showSyncLogsDialog = true } } else null @@ -272,6 +304,7 @@ private fun StatsContent( onNavigateToDriveDetail: (Int) -> Unit, onNavigateToChargeDetail: (Int) -> Unit, onNavigateToDayDetail: (String) -> Unit, + onRangeRecordClick: (MaxDistanceBetweenChargesRecord) -> Unit, onSyncProgressClick: (() -> Unit)? = null ) { LazyColumn( @@ -309,7 +342,8 @@ private fun StatsContent( currencySymbol = currencySymbol, onDriveClick = onNavigateToDriveDetail, onChargeClick = onNavigateToChargeDetail, - onDayClick = onNavigateToDayDetail + onDayClick = onNavigateToDayDetail, + onRangeRecordClick = onRangeRecordClick ) } @@ -542,7 +576,8 @@ private fun RecordsCard( currencySymbol: String, onDriveClick: (Int) -> Unit, onChargeClick: (Int) -> Unit, - onDayClick: (String) -> Unit + onDayClick: (String) -> Unit, + onRangeRecordClick: (MaxDistanceBetweenChargesRecord) -> Unit ) { // Build list of record groups - each group starts on left column val groups = mutableListOf() @@ -552,6 +587,9 @@ private fun RecordsCard( quickStats.longestDrive?.let { drive -> driveRecords.add(RecordData("📏", "Longest Drive", "%.1f km".format(drive.distance), drive.startDate.take(10)) { onDriveClick(drive.driveId) }) } + quickStats.maxDistanceBetweenCharges?.let { record -> + driveRecords.add(RecordData("🔋", "Longest Range", "%.1f km".format(record.distance), "${record.fromDate.take(10)} → ${record.toDate.take(10)}") { onRangeRecordClick(record) }) + } quickStats.fastestDrive?.let { drive -> driveRecords.add(RecordData("🏎️", "Top Speed", "${drive.speedMax} km/h", drive.startDate.take(10)) { onDriveClick(drive.driveId) }) } @@ -1036,3 +1074,213 @@ private fun SyncLogsDialog( } ) } + +/** + * Dialog showing details of a "longest range" record with scrollable list of drives. + */ +@Composable +private fun RangeRecordDialog( + record: MaxDistanceBetweenChargesRecord, + drives: List, + isLoading: Boolean, + palette: CarColorPalette, + onDriveClick: (Int) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("🔋", style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.width(8.dp)) + Text("Longest Range") + } + }, + text = { + Column( + modifier = Modifier.fillMaxWidth() + ) { + // Summary info + Card( + colors = CardDefaults.cardColors( + containerColor = palette.surface + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Total Distance", + style = MaterialTheme.typography.labelMedium, + color = palette.onSurfaceVariant + ) + Text( + text = "%.1f km".format(record.distance), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = palette.onSurface + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "From", + style = MaterialTheme.typography.labelSmall, + color = palette.onSurfaceVariant + ) + Text( + text = record.fromDate.take(10), + style = MaterialTheme.typography.bodyMedium, + color = palette.onSurface + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = "To", + style = MaterialTheme.typography.labelSmall, + color = palette.onSurfaceVariant + ) + Text( + text = record.toDate.take(10), + style = MaterialTheme.typography.bodyMedium, + color = palette.onSurface + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Drives header + Text( + text = "Drives (${drives.size})", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = palette.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Scrollable list of drives + Box( + modifier = Modifier + .fillMaxWidth() + .height(250.dp) + ) { + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + } + } else if (drives.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No drives found", + style = MaterialTheme.typography.bodyMedium, + color = palette.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(drives) { drive -> + DriveListItem( + drive = drive, + palette = palette, + onClick = { onDriveClick(drive.driveId) } + ) + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + } + ) +} + +/** + * Single drive item in the range record dialog. + */ +@Composable +private fun DriveListItem( + drive: DriveSummary, + palette: CarColorPalette, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + colors = CardDefaults.cardColors( + containerColor = palette.surface.copy(alpha = 0.7f) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = drive.startDate.take(10), + style = MaterialTheme.typography.labelSmall, + color = palette.onSurfaceVariant + ) + Text( + text = "${drive.startAddress.take(25)}${if (drive.startAddress.length > 25) "..." else ""}", + style = MaterialTheme.typography.bodySmall, + color = palette.onSurface, + maxLines = 1 + ) + Text( + text = "→ ${drive.endAddress.take(25)}${if (drive.endAddress.length > 25) "..." else ""}", + style = MaterialTheme.typography.bodySmall, + color = palette.onSurfaceVariant, + maxLines = 1 + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Column(horizontalAlignment = Alignment.End) { + Text( + text = "%.1f km".format(drive.distance), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = palette.onSurface + ) + Text( + text = "${drive.durationMin} min", + style = MaterialTheme.typography.labelSmall, + color = palette.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "View drive", + modifier = Modifier.size(18.dp), + tint = palette.onSurfaceVariant + ) + } + } +} diff --git a/app/src/main/java/com/matedroid/ui/screens/stats/StatsViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/stats/StatsViewModel.kt index 6395355..9d4a898 100644 --- a/app/src/main/java/com/matedroid/ui/screens/stats/StatsViewModel.kt +++ b/app/src/main/java/com/matedroid/ui/screens/stats/StatsViewModel.kt @@ -15,6 +15,7 @@ import com.matedroid.data.repository.StatsRepository import com.matedroid.data.sync.DataSyncWorker import com.matedroid.data.sync.SyncLogCollector import com.matedroid.data.sync.SyncManager +import com.matedroid.data.local.entity.DriveSummary import com.matedroid.domain.model.CarStats import com.matedroid.domain.model.SyncPhase import com.matedroid.domain.model.SyncProgress @@ -163,6 +164,14 @@ class StatsViewModel @Inject constructor( } } + /** + * Fetch drives between two dates for displaying range record details. + */ + suspend fun getDrivesForRangeRecord(fromDate: String, toDate: String): List { + val id = carId ?: return emptyList() + return statsRepository.getDrivesBetweenDates(id, fromDate, toDate) + } + private suspend fun loadStatsInternal() { val id = carId ?: return