feat(stats): add "Longest Range" record (max distance between charges) (#41)

* chore: bump minSdk from API 26 to API 29

This enables SQLite window functions (LAG, LEAD, etc.) for efficient
record calculations without requiring sync pre-computation.

Android 10 (API 29) was released in September 2019 and covers 95%+
of active Android devices as of 2026.

* feat(stats): add SQL query for max distance between charges

Uses SQLite LAG window function to efficiently find the maximum
distance traveled between consecutive charges. Includes both
all-time and date-range variants for year filtering support.

* feat(stats): add MaxDistanceBetweenChargesRecord domain model

Add new record type to QuickStats for tracking the maximum distance
driven between two consecutive charging sessions.

* feat(stats): integrate max distance between charges in repository

Wire up the new DAO query to QuickStats for both all-time and
year-filtered views. No sync required - data is computed instantly
from the existing charges summary table.

* feat(stats): display max distance between charges in Records UI

Add 'Longest Range' record showing the maximum distance traveled
between two consecutive charges. Displays with battery emoji and
date range, tapping navigates to the ending charge detail.

* feat(stats): add SQL query for max distance between charges

Uses a self-join with correlated subquery to efficiently find the
maximum distance traveled between consecutive charges. Includes both
all-time and date-range variants for year filtering support.

* fix(stats): use sum of drives instead of odometer diff for longest range

The odometer difference between charges can include unlogged drives
(e.g., when TeslaMate was down). Now we sum actual logged drives
between charges, which gives accurate results even with data gaps.

* feat(stats): add popup dialog for longest range record details

When tapping the 'Longest Range' record, show a dialog with:
- Total distance and date range summary
- Scrollable list of all drives that make up the record
- Each drive is tappable to navigate to drive details

This provides full context about what drove the record instead
of just navigating to a single charge.

* docs: simplify changelog entries for users
This commit is contained in:
Davide Ferrari
2026-01-11 18:37:46 +01:00
committed by GitHub
parent c6b1fec3e3
commit 5a2e3df4ae
9 changed files with 405 additions and 7 deletions
+5
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -17,7 +17,7 @@ android {
defaultConfig {
applicationId = "com.matedroid"
minSdk = 26
minSdk = 29
targetSdk = 35
versionCode = 13
versionName = "0.8.2"
@@ -157,4 +157,84 @@ interface ChargeSummaryDao {
ORDER BY year DESC
""")
suspend fun getYears(carId: Int): List<Int>
// === 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
)
@@ -236,6 +236,21 @@ interface DriveSummaryDao {
ORDER BY year DESC
""")
suspend fun getYears(carId: Int): List<Int>
// === 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<DriveSummary>
}
data class BusiestDayResult(
@@ -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
@@ -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
)
@@ -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<MaxDistanceBetweenChargesRecord?>(null) }
var rangeRecordDrives by remember { mutableStateOf<List<DriveSummary>>(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<RecordGroup>()
@@ -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<DriveSummary>,
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
)
}
}
}
@@ -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<DriveSummary> {
val id = carId ?: return emptyList()
return statsRepository.getDrivesBetweenDates(id, fromDate, toDate)
}
private suspend fun loadStatsInternal() {
val id = carId ?: return