From a80433d00d04735fe04fe9ce319d0e6b30ef2449 Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Sun, 11 Jan 2026 21:38:33 +0100 Subject: [PATCH] feat(stats): add new records and categorized swipeable UI (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * docs: update changelog with swipeable record categories Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- CHANGELOG.md | 6 + .../data/local/dao/ChargeSummaryDao.kt | 84 ++++++ .../data/local/dao/DriveSummaryDao.kt | 113 ++++++++ .../data/repository/StatsRepository.kt | 122 ++++++++ .../com/matedroid/domain/model/CarStats.kt | 43 ++- .../matedroid/ui/screens/stats/StatsScreen.kt | 269 ++++++++++++++---- 6 files changed, 574 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 491da0f..0b188df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 ffaca73..6ec8da3 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 @@ -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 +) 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 0bce047..9d02a7d 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 @@ -251,6 +251,99 @@ interface DriveSummaryDao { ORDER BY startDate ASC """) suspend fun getDrivesBetweenDates(carId: Int, afterDate: String, beforeDate: String): List + + /** + * 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 + + @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 } 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 +) 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 e3c7f30..02c76b0 100644 --- a/app/src/main/java/com/matedroid/data/repository/StatsRepository.kt +++ b/app/src/main/java/com/matedroid/data/repository/StatsRepository.kt @@ -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): 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 + ) +} 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 9ae08d0..72bf74e 100644 --- a/app/src/main/java/com/matedroid/domain/model/CarStats.kt +++ b/app/src/main/java/com/matedroid/domain/model/CarStats.kt @@ -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 +) 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 11154c8..38a09e3 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 @@ -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 +/** + * 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, // 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() - - // Group 1: Drive performance records + // Category 1: Drives val driveRecords = mutableListOf() 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() - 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() + 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() - 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() 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() + // Category 3: Weather & Altitude + val weatherRecords = mutableListOf() + 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() - 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() + 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) + val allCategories = mutableListOf() + 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() + 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()) } } }