feat(stats): add new records and categorized swipeable UI (#44)

* feat(stats): add new records and categorized swipeable UI

- Add new records: longest gap without charging, longest gap without
  driving, longest driving streak, biggest battery gain, biggest
  battery drain
- Organize records into 4 swipeable categories: Drives, Battery,
  Weather & Altitude, Distances
- Add horizontal pager with emoji+label indicators
- Compute driving streak in Kotlin (Room doesn't support window
  functions in CTEs)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(stats): fixed page height with 6 records per page constraint

- Each page now displays exactly 6 record slots (3 rows × 2 columns)
- Categories with more than 6 records are split into multiple pages
- Added page indicator dots for multi-page categories
- Added RECORDS_PER_PAGE constant with comment documenting the hard
  constraint for future updates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(stats): enforce fixed row height for consistent page size

- Set RECORD_CARD_HEIGHT = 72.dp for each row
- Each row now has fixed height regardless of content
- Empty placeholder boxes fill the same space as cards
- Prevents page resizing when swiping between categories

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(stats): remove redundant category title from record pages

The swipe indicator already shows current category, no need for title

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: update changelog with swipeable record categories

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Davide Ferrari
2026-01-11 21:38:33 +01:00
committed by GitHub
co-authored by Claude Opus 4.5
parent 5a2e3df4ae
commit a80433d00d
6 changed files with 574 additions and 63 deletions
+6
View File
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Stats for Nerds**: New records organized into swipeable categories
- Swipe left/right between Drives, Battery, Weather & Altitude, and Distances categories
- Drives: Longest drive, Top speed, Most efficient, Longest driving streak, Most distance day, Busiest day
- Battery: Biggest gain, Biggest drain, Biggest charge, Peak power, Most expensive, Priciest per kWh
- Weather & Altitude: Highest point, Most climbing, Hottest/coldest drives and charges
- Distances: Longest range (tap to see drives), Longest gap without charging/driving
- **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
@@ -226,6 +226,79 @@ interface ChargeSummaryDao {
startDate: String,
endDate: String
): MaxDistanceBetweenChargesResult?
/**
* Find the longest gap (in days) between two consecutive charges.
*/
@Query("""
SELECT
prev.chargeId as fromChargeId,
curr.chargeId as toChargeId,
CAST(julianday(curr.startDate) - julianday(prev.startDate) AS REAL) as gapDays,
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 gapDays DESC
LIMIT 1
""")
suspend fun longestGapBetweenCharges(carId: Int): GapBetweenChargesResult?
@Query("""
SELECT
prev.chargeId as fromChargeId,
curr.chargeId as toChargeId,
CAST(julianday(curr.startDate) - julianday(prev.startDate) AS REAL) as gapDays,
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 gapDays DESC
LIMIT 1
""")
suspend fun longestGapBetweenChargesInRange(
carId: Int,
startDate: String,
endDate: String
): GapBetweenChargesResult?
/**
* Find the charge with the biggest battery gain (endBatteryLevel - startBatteryLevel).
*/
@Query("""
SELECT * FROM charges_summary
WHERE carId = :carId
ORDER BY (endBatteryLevel - startBatteryLevel) DESC
LIMIT 1
""")
suspend fun biggestBatteryGainCharge(carId: Int): ChargeSummary?
@Query("""
SELECT * FROM charges_summary
WHERE carId = :carId
AND startDate >= :startDate AND startDate < :endDate
ORDER BY (endBatteryLevel - startBatteryLevel) DESC
LIMIT 1
""")
suspend fun biggestBatteryGainChargeInRange(
carId: Int,
startDate: String,
endDate: String
): ChargeSummary?
}
/**
@@ -238,3 +311,14 @@ data class MaxDistanceBetweenChargesResult(
val fromDate: String,
val toDate: String
)
/**
* Result of longest gap between charges query.
*/
data class GapBetweenChargesResult(
val fromChargeId: Int,
val toChargeId: Int,
val gapDays: Double,
val fromDate: String,
val toDate: String
)
@@ -251,6 +251,99 @@ interface DriveSummaryDao {
ORDER BY startDate ASC
""")
suspend fun getDrivesBetweenDates(carId: Int, afterDate: String, beforeDate: String): List<DriveSummary>
/**
* Find the longest gap (in days) between two consecutive drives.
*/
@Query("""
SELECT
prev.driveId as fromDriveId,
curr.driveId as toDriveId,
CAST(julianday(curr.startDate) - julianday(prev.startDate) AS REAL) as gapDays,
prev.startDate as fromDate,
curr.startDate as toDate
FROM drives_summary curr
INNER JOIN drives_summary prev ON prev.carId = curr.carId
AND prev.startDate = (
SELECT MAX(p.startDate)
FROM drives_summary p
WHERE p.carId = curr.carId AND p.startDate < curr.startDate
)
WHERE curr.carId = :carId
ORDER BY gapDays DESC
LIMIT 1
""")
suspend fun longestGapBetweenDrives(carId: Int): GapBetweenDrivesResult?
@Query("""
SELECT
prev.driveId as fromDriveId,
curr.driveId as toDriveId,
CAST(julianday(curr.startDate) - julianday(prev.startDate) AS REAL) as gapDays,
prev.startDate as fromDate,
curr.startDate as toDate
FROM drives_summary curr
INNER JOIN drives_summary prev ON prev.carId = curr.carId
AND prev.startDate = (
SELECT MAX(p.startDate)
FROM drives_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 gapDays DESC
LIMIT 1
""")
suspend fun longestGapBetweenDrivesInRange(
carId: Int,
startDate: String,
endDate: String
): GapBetweenDrivesResult?
/**
* Find the drive with the biggest battery drain (startBatteryLevel - endBatteryLevel).
*/
@Query("""
SELECT * FROM drives_summary
WHERE carId = :carId
ORDER BY (startBatteryLevel - endBatteryLevel) DESC
LIMIT 1
""")
suspend fun biggestBatteryDrainDrive(carId: Int): DriveSummary?
@Query("""
SELECT * FROM drives_summary
WHERE carId = :carId
AND startDate >= :startDate AND startDate < :endDate
ORDER BY (startBatteryLevel - endBatteryLevel) DESC
LIMIT 1
""")
suspend fun biggestBatteryDrainDriveInRange(
carId: Int,
startDate: String,
endDate: String
): DriveSummary?
/**
* Get all distinct driving days (for computing streak in Kotlin).
*/
@Query("""
SELECT DISTINCT DATE(startDate) as day
FROM drives_summary
WHERE carId = :carId
ORDER BY day ASC
""")
suspend fun getDistinctDrivingDays(carId: Int): List<String>
@Query("""
SELECT DISTINCT DATE(startDate) as day
FROM drives_summary
WHERE carId = :carId
AND startDate >= :startDate AND startDate < :endDate
ORDER BY day ASC
""")
suspend fun getDistinctDrivingDaysInRange(carId: Int, startDate: String, endDate: String): List<String>
}
data class BusiestDayResult(
@@ -262,3 +355,23 @@ data class MostDistanceDayResult(
val day: String,
val totalDistance: Double
)
/**
* Result of longest gap between drives query.
*/
data class GapBetweenDrivesResult(
val fromDriveId: Int,
val toDriveId: Int,
val gapDays: Double,
val fromDate: String,
val toDate: String
)
/**
* Result of longest driving streak query.
*/
data class DrivingStreakResult(
val streakDays: Int,
val startDate: String,
val endDate: String
)
@@ -12,7 +12,10 @@ 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.BatteryChangeRecord
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 javax.inject.Inject
import javax.inject.Singleton
@@ -92,6 +95,38 @@ class StatsRepository @Inject constructor(
fromDate = it.fromDate,
toDate = it.toDate
)
},
longestGapWithoutCharging = chargeSummaryDao.longestGapBetweenCharges(carId)?.let {
GapRecord(gapDays = it.gapDays, fromDate = it.fromDate, toDate = it.toDate)
},
longestGapWithoutDriving = driveSummaryDao.longestGapBetweenDrives(carId)?.let {
GapRecord(gapDays = it.gapDays, fromDate = it.fromDate, toDate = it.toDate)
},
longestDrivingStreak = computeLongestStreak(
driveSummaryDao.getDistinctDrivingDays(carId)
),
biggestBatteryGainCharge = chargeSummaryDao.biggestBatteryGainCharge(carId)?.let {
BatteryChangeRecord(
percentChange = it.endBatteryLevel - it.startBatteryLevel,
startLevel = it.startBatteryLevel,
endLevel = it.endBatteryLevel,
recordId = it.chargeId,
date = it.startDate,
isCharge = true
)
},
biggestBatteryDrainDrive = driveSummaryDao.biggestBatteryDrainDrive(carId)?.let {
BatteryChangeRecord(
percentChange = it.startBatteryLevel - it.endBatteryLevel,
startLevel = it.startBatteryLevel,
endLevel = it.endBatteryLevel,
recordId = it.driveId,
date = it.startDate,
isCharge = false
)
}
)
}
@@ -136,6 +171,38 @@ class StatsRepository @Inject constructor(
fromDate = it.fromDate,
toDate = it.toDate
)
},
longestGapWithoutCharging = chargeSummaryDao.longestGapBetweenChargesInRange(carId, startDate, endDate)?.let {
GapRecord(gapDays = it.gapDays, fromDate = it.fromDate, toDate = it.toDate)
},
longestGapWithoutDriving = driveSummaryDao.longestGapBetweenDrivesInRange(carId, startDate, endDate)?.let {
GapRecord(gapDays = it.gapDays, fromDate = it.fromDate, toDate = it.toDate)
},
longestDrivingStreak = computeLongestStreak(
driveSummaryDao.getDistinctDrivingDaysInRange(carId, startDate, endDate)
),
biggestBatteryGainCharge = chargeSummaryDao.biggestBatteryGainChargeInRange(carId, startDate, endDate)?.let {
BatteryChangeRecord(
percentChange = it.endBatteryLevel - it.startBatteryLevel,
startLevel = it.startBatteryLevel,
endLevel = it.endBatteryLevel,
recordId = it.chargeId,
date = it.startDate,
isCharge = true
)
},
biggestBatteryDrainDrive = driveSummaryDao.biggestBatteryDrainDriveInRange(carId, startDate, endDate)?.let {
BatteryChangeRecord(
percentChange = it.startBatteryLevel - it.endBatteryLevel,
startLevel = it.startBatteryLevel,
endLevel = it.endBatteryLevel,
recordId = it.driveId,
date = it.startDate,
isCharge = false
)
}
)
}
@@ -431,3 +498,58 @@ private fun com.matedroid.domain.model.SyncPhase.isProcessing(): Boolean {
this == com.matedroid.domain.model.SyncPhase.SYNCING_DRIVE_DETAILS ||
this == com.matedroid.domain.model.SyncPhase.SYNCING_CHARGE_DETAILS
}
/**
* Compute the longest consecutive driving streak from a sorted list of date strings.
* Each date string should be in "YYYY-MM-DD" format.
*/
private fun computeLongestStreak(sortedDays: List<String>): StreakRecord? {
if (sortedDays.isEmpty()) return null
if (sortedDays.size == 1) {
return StreakRecord(
streakDays = 1,
startDate = sortedDays.first(),
endDate = sortedDays.first()
)
}
var maxStreak = 1
var maxStreakStart = sortedDays.first()
var maxStreakEnd = sortedDays.first()
var currentStreak = 1
var currentStreakStart = sortedDays.first()
for (i in 1 until sortedDays.size) {
val prevDate = java.time.LocalDate.parse(sortedDays[i - 1])
val currDate = java.time.LocalDate.parse(sortedDays[i])
if (currDate == prevDate.plusDays(1)) {
// Consecutive day
currentStreak++
} else {
// Gap found - check if previous streak was longest
if (currentStreak > maxStreak) {
maxStreak = currentStreak
maxStreakStart = currentStreakStart
maxStreakEnd = sortedDays[i - 1]
}
// Start new streak
currentStreak = 1
currentStreakStart = sortedDays[i]
}
}
// Check final streak
if (currentStreak > maxStreak) {
maxStreak = currentStreak
maxStreakStart = currentStreakStart
maxStreakEnd = sortedDays.last()
}
return StreakRecord(
streakDays = maxStreak,
startDate = maxStreakStart,
endDate = maxStreakEnd
)
}
@@ -65,7 +65,18 @@ data class QuickStats(
val mostDistanceDay: MostDistanceDayResult?,
// === Range Records ===
val maxDistanceBetweenCharges: MaxDistanceBetweenChargesRecord?
val maxDistanceBetweenCharges: MaxDistanceBetweenChargesRecord?,
// === Gap Records ===
val longestGapWithoutCharging: GapRecord?,
val longestGapWithoutDriving: GapRecord?,
// === Streak Records ===
val longestDrivingStreak: StreakRecord?,
// === Battery Records ===
val biggestBatteryGainCharge: BatteryChangeRecord?,
val biggestBatteryDrainDrive: BatteryChangeRecord?
)
/**
@@ -168,3 +179,33 @@ data class MaxDistanceBetweenChargesRecord(
val fromDate: String,
val toDate: String
)
/**
* Record for a gap (time without driving or charging).
*/
data class GapRecord(
val gapDays: Double,
val fromDate: String,
val toDate: String
)
/**
* Record for a driving streak (consecutive days with driving).
*/
data class StreakRecord(
val streakDays: Int,
val startDate: String,
val endDate: String
)
/**
* Record for battery change (gain from charging or drain from driving).
*/
data class BatteryChangeRecord(
val percentChange: Int, // % gained or drained
val startLevel: Int, // starting battery %
val endLevel: Int, // ending battery %
val recordId: Int, // chargeId or driveId
val date: String,
val isCharge: Boolean // true for charge, false for drive
)
@@ -18,6 +18,9 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -563,9 +566,20 @@ private data class RecordData(
val onClick: (() -> Unit)?
)
/** Group of related records */
private data class RecordGroup(
val records: List<RecordData>
/**
* HARD CONSTRAINT: Each page displays exactly 6 record slots (3 rows × 2 columns).
* If a category has more than 6 records, it MUST be split into multiple pages.
* This ensures consistent page height and smooth swiping experience.
*/
private const val RECORDS_PER_PAGE = 6
/** A page of records to display in the pager */
private data class RecordPage(
val categoryTitle: String,
val categoryEmoji: String,
val records: List<RecordData>, // Max RECORDS_PER_PAGE items
val pageIndex: Int, // 0-based index within the category (for multi-page categories)
val totalPagesInCategory: Int // Total pages for this category
)
@Composable
@@ -579,90 +593,115 @@ private fun RecordsCard(
onDayClick: (String) -> Unit,
onRangeRecordClick: (MaxDistanceBetweenChargesRecord) -> Unit
) {
// Build list of record groups - each group starts on left column
val groups = mutableListOf<RecordGroup>()
// Group 1: Drive performance records
// Category 1: Drives
val driveRecords = mutableListOf<RecordData>()
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) })
}
quickStats.mostEfficientDrive?.let { drive ->
driveRecords.add(RecordData("🌱", "Most Efficient", "%.0f Wh/km".format(drive.efficiency ?: 0.0), drive.startDate.take(10)) { onDriveClick(drive.driveId) })
}
if (driveRecords.isNotEmpty()) groups.add(RecordGroup(driveRecords))
quickStats.longestDrivingStreak?.let { streak ->
driveRecords.add(RecordData("🔥", "Longest Streak", "${streak.streakDays} days", "${streak.startDate}${streak.endDate}", null))
}
quickStats.mostDistanceDay?.let { day ->
driveRecords.add(RecordData("🛣️", "Most Distance Day", "%.1f km".format(day.totalDistance), day.day) { onDayClick(day.day) })
}
quickStats.busiestDay?.let { day ->
driveRecords.add(RecordData("📅", "Busiest Day", "${day.count} drives", day.day) { onDayClick(day.day) })
}
// Group 2: Elevation records
val elevationRecords = mutableListOf<RecordData>()
deepStats?.driveWithMaxElevation?.let { record ->
elevationRecords.add(RecordData("🏔️", "Highest Point", "${record.elevationM} m", record.date?.take(10) ?: "") { onDriveClick(record.driveId) })
// Category 2: Battery
val batteryRecords = mutableListOf<RecordData>()
quickStats.biggestBatteryGainCharge?.let { record ->
batteryRecords.add(RecordData("🔋", "Biggest Gain", "+${record.percentChange}%", "${record.startLevel}% → ${record.endLevel}%") { onChargeClick(record.recordId) })
}
deepStats?.driveWithMostClimbing?.let { record ->
elevationRecords.add(RecordData("⛰️", "Most Climbing", record.elevationGainM?.let { "+$it m" } ?: "N/A", record.date?.take(10) ?: "") { onDriveClick(record.driveId) })
quickStats.biggestBatteryDrainDrive?.let { record ->
batteryRecords.add(RecordData("📉", "Biggest Drain", "-${record.percentChange}%", "${record.startLevel}% → ${record.endLevel}%") { onDriveClick(record.recordId) })
}
if (elevationRecords.isNotEmpty()) groups.add(RecordGroup(elevationRecords))
// Group 3: Driving temperature records
val driveTempRecords = mutableListOf<RecordData>()
deepStats?.hottestDrive?.let { record ->
driveTempRecords.add(RecordData("🌡️", "Hottest Drive", "%.1f°C".format(record.tempC), record.date?.take(10) ?: "") { onDriveClick(record.driveId) })
}
deepStats?.coldestDrive?.let { record ->
driveTempRecords.add(RecordData("🧊", "Coldest Drive", "%.1f°C".format(record.tempC), record.date?.take(10) ?: "") { onDriveClick(record.driveId) })
}
if (driveTempRecords.isNotEmpty()) groups.add(RecordGroup(driveTempRecords))
// Group 4: Charge records
val chargeRecords = mutableListOf<RecordData>()
quickStats.biggestCharge?.let { charge ->
chargeRecords.add(RecordData("", "Biggest Charge", "%.0f kWh".format(charge.energyAdded), charge.startDate.take(10)) { onChargeClick(charge.chargeId) })
batteryRecords.add(RecordData("", "Biggest Charge", "%.0f kWh".format(charge.energyAdded), charge.startDate.take(10)) { onChargeClick(charge.chargeId) })
}
deepStats?.chargeWithMaxPower?.let { record ->
chargeRecords.add(RecordData("", "Peak Power", "${record.powerKw} kW", record.date?.take(10) ?: "") { onChargeClick(record.chargeId) })
batteryRecords.add(RecordData("", "Peak Power", "${record.powerKw} kW", record.date?.take(10) ?: "") { onChargeClick(record.chargeId) })
}
quickStats.mostExpensiveCharge?.let { charge ->
charge.cost?.let { cost ->
chargeRecords.add(RecordData("💸", "Most Expensive", "%.2f %s".format(cost, currencySymbol), charge.startDate.take(10)) { onChargeClick(charge.chargeId) })
batteryRecords.add(RecordData("💸", "Most Expensive", "%.2f %s".format(cost, currencySymbol), charge.startDate.take(10)) { onChargeClick(charge.chargeId) })
}
}
quickStats.mostExpensivePerKwhCharge?.let { charge ->
charge.cost?.let { cost ->
if (charge.energyAdded > 0) {
chargeRecords.add(RecordData("📈", "Priciest/kWh", "%.3f %s".format(cost / charge.energyAdded, currencySymbol), charge.startDate.take(10)) { onChargeClick(charge.chargeId) })
batteryRecords.add(RecordData("📈", "Priciest/kWh", "%.3f %s".format(cost / charge.energyAdded, currencySymbol), charge.startDate.take(10)) { onChargeClick(charge.chargeId) })
}
}
}
if (chargeRecords.isNotEmpty()) groups.add(RecordGroup(chargeRecords))
// Group 5: Charging temperature records
val chargeTempRecords = mutableListOf<RecordData>()
// Category 3: Weather & Altitude
val weatherRecords = mutableListOf<RecordData>()
deepStats?.driveWithMaxElevation?.let { record ->
weatherRecords.add(RecordData("🏔️", "Highest Point", "${record.elevationM} m", record.date?.take(10) ?: "") { onDriveClick(record.driveId) })
}
deepStats?.driveWithMostClimbing?.let { record ->
weatherRecords.add(RecordData("⛰️", "Most Climbing", record.elevationGainM?.let { "+$it m" } ?: "N/A", record.date?.take(10) ?: "") { onDriveClick(record.driveId) })
}
deepStats?.hottestDrive?.let { record ->
weatherRecords.add(RecordData("🌡️", "Hottest Drive", "%.1f°C".format(record.tempC), record.date?.take(10) ?: "") { onDriveClick(record.driveId) })
}
deepStats?.coldestDrive?.let { record ->
weatherRecords.add(RecordData("🧊", "Coldest Drive", "%.1f°C".format(record.tempC), record.date?.take(10) ?: "") { onDriveClick(record.driveId) })
}
deepStats?.hottestCharge?.let { record ->
chargeTempRecords.add(RecordData("☀️", "Hottest Charge", "%.1f°C".format(record.tempC), record.date?.take(10) ?: "") { onChargeClick(record.chargeId) })
weatherRecords.add(RecordData("☀️", "Hottest Charge", "%.1f°C".format(record.tempC), record.date?.take(10) ?: "") { onChargeClick(record.chargeId) })
}
deepStats?.coldestCharge?.let { record ->
chargeTempRecords.add(RecordData("❄️", "Coldest Charge", "%.1f°C".format(record.tempC), record.date?.take(10) ?: "") { onChargeClick(record.chargeId) })
weatherRecords.add(RecordData("❄️", "Coldest Charge", "%.1f°C".format(record.tempC), record.date?.take(10) ?: "") { onChargeClick(record.chargeId) })
}
if (chargeTempRecords.isNotEmpty()) groups.add(RecordGroup(chargeTempRecords))
// Group 6: Day records
val dayRecords = mutableListOf<RecordData>()
quickStats.mostDistanceDay?.let { day ->
dayRecords.add(RecordData("🛣️", "Most Distance Day", "%.1f km".format(day.totalDistance), day.day) { onDayClick(day.day) })
// Category 4: Distances & Gaps
val distanceRecords = mutableListOf<RecordData>()
quickStats.maxDistanceBetweenCharges?.let { record ->
distanceRecords.add(RecordData("🔋", "Longest Range", "%.1f km".format(record.distance), "${record.fromDate.take(10)}${record.toDate.take(10)}") { onRangeRecordClick(record) })
}
quickStats.busiestDay?.let { day ->
dayRecords.add(RecordData("📅", "Busiest Day", "${day.count} drives", day.day) { onDayClick(day.day) })
quickStats.longestGapWithoutCharging?.let { gap ->
distanceRecords.add(RecordData("", "Longest w/o Charging", "%.1f days".format(gap.gapDays), "${gap.fromDate.take(10)}${gap.toDate.take(10)}", null))
}
if (dayRecords.isNotEmpty()) groups.add(RecordGroup(dayRecords))
quickStats.longestGapWithoutDriving?.let { gap ->
distanceRecords.add(RecordData("🅿️", "Longest w/o Driving", "%.1f days".format(gap.gapDays), "${gap.fromDate.take(10)}${gap.toDate.take(10)}", null))
}
// Build list of all categories with their records
data class CategoryData(val title: String, val emoji: String, val records: List<RecordData>)
val allCategories = mutableListOf<CategoryData>()
if (driveRecords.isNotEmpty()) allCategories.add(CategoryData("Drives", "🚗", driveRecords))
if (batteryRecords.isNotEmpty()) allCategories.add(CategoryData("Battery", "🔋", batteryRecords))
if (weatherRecords.isNotEmpty()) allCategories.add(CategoryData("Weather & Altitude", "🌡️", weatherRecords))
if (distanceRecords.isNotEmpty()) allCategories.add(CategoryData("Distances", "📍", distanceRecords))
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Don't render anything if no categories
if (allCategories.isEmpty()) return
// Split categories into pages of max RECORDS_PER_PAGE records each
val pages = mutableListOf<RecordPage>()
allCategories.forEach { category ->
val chunks = category.records.chunked(RECORDS_PER_PAGE)
chunks.forEachIndexed { index, chunk ->
pages.add(RecordPage(
categoryTitle = category.title,
categoryEmoji = category.emoji,
records = chunk,
pageIndex = index,
totalPagesInCategory = chunks.size
))
}
}
val pagerState = rememberPagerState(pageCount = { pages.size })
Column {
// Section header
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -683,15 +722,120 @@ private fun RecordsCard(
)
}
// Display records in 2-column grid, grouped by theme
// Each group starts on the left column (leaves blank space if previous group had odd count)
groups.forEach { group ->
group.records.chunked(2).forEach { rowRecords ->
Card(
colors = CardDefaults.cardColors(containerColor = palette.surface),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
// Pager with pages (fixed height for 6 records = 3 rows)
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth()
) { pageIndex ->
val page = pages[pageIndex]
RecordCategoryPage(
page = page,
palette = palette
)
}
Spacer(modifier = Modifier.height(12.dp))
// Page indicators - group by category with sub-dots for multi-page categories
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
rowRecords.forEach { record ->
var pageOffset = 0
allCategories.forEach { category ->
val categoryPageCount = (category.records.size + RECORDS_PER_PAGE - 1) / RECORDS_PER_PAGE
val isCurrentCategory = pagerState.currentPage >= pageOffset &&
pagerState.currentPage < pageOffset + categoryPageCount
val currentPageInCategory = if (isCurrentCategory) pagerState.currentPage - pageOffset else -1
Row(
modifier = Modifier
.padding(horizontal = 6.dp)
.clip(RoundedCornerShape(12.dp))
.background(
if (isCurrentCategory) palette.accent.copy(alpha = 0.2f)
else Color.Transparent
)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = category.emoji,
style = MaterialTheme.typography.bodySmall
)
if (isCurrentCategory) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = category.title,
style = MaterialTheme.typography.labelSmall,
color = palette.accent,
fontWeight = FontWeight.Bold
)
// Show page dots for multi-page categories
if (categoryPageCount > 1) {
Spacer(modifier = Modifier.width(4.dp))
repeat(categoryPageCount) { dotIndex ->
Box(
modifier = Modifier
.padding(horizontal = 2.dp)
.size(6.dp)
.clip(CircleShape)
.background(
if (dotIndex == currentPageInCategory) palette.accent
else palette.accent.copy(alpha = 0.3f)
)
)
}
}
}
}
pageOffset += categoryPageCount
}
}
}
}
}
}
/**
* Fixed height for each record card row.
* This ensures consistent page height regardless of content.
*/
private val RECORD_CARD_HEIGHT = 72.dp
/**
* A single page showing records for one category.
* HARD CONSTRAINT: Always renders exactly 3 rows (space for 6 records) to maintain fixed height.
*/
@Composable
private fun RecordCategoryPage(
page: RecordPage,
palette: CarColorPalette
) {
// Pad records to exactly RECORDS_PER_PAGE (6) slots for consistent height
val paddedRecords = page.records + List(RECORDS_PER_PAGE - page.records.size) { null }
val rows = paddedRecords.chunked(2) // Always 3 rows of 2
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Records in 2-column grid - always 3 rows for fixed height
// Note: Category title removed - the swipe indicator at the bottom shows current category
rows.forEach { rowRecords ->
Row(
modifier = Modifier
.fillMaxWidth()
.height(RECORD_CARD_HEIGHT),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
rowRecords.forEach { record ->
if (record != null) {
RecordCard(
emoji = record.emoji,
label = record.label,
@@ -699,12 +843,13 @@ private fun RecordsCard(
subtext = record.subtext,
palette = palette,
onClick = record.onClick,
modifier = Modifier.weight(1f)
modifier = Modifier
.weight(1f)
.fillMaxHeight()
)
}
// Fill remaining space if odd number of records
if (rowRecords.size == 1) {
Spacer(modifier = Modifier.weight(1f))
} else {
// Empty placeholder to maintain grid layout - same size as RecordCard
Box(modifier = Modifier.weight(1f).fillMaxHeight())
}
}
}