feat(stats): add Stats for Nerds screen and navigation

- Create StatsViewModel with year filter support
- Create StatsScreen with Quick Stats and Deep Stats cards
- Add Trophy icon to CustomIcons
- Add Screen.Stats route to NavGraph
- Add onNavigateToStats callback to DashboardScreen

Stats cards include:
- Drives Overview (total, distance, energy, efficiency)
- Charges Overview (total, energy added, cost)
- Records (longest drive, top speed, most efficient, etc.)
- Elevation stats (highest/lowest point, most climbing)
- Temperature extremes (driving and cabin)
- Charging power (max achieved)
- AC/DC charging ratio

Part of Stats for Nerds feature (Phase 4 & 5)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Davide Ferrari
2026-01-04 22:59:26 +01:00
co-authored by Claude Opus 4.5
parent ca4dd487c0
commit 9a78656fc4
5 changed files with 948 additions and 0 deletions
@@ -136,4 +136,65 @@ object CustomIcons {
}
}.build()
}
/**
* Trophy icon from Material Symbols Outlined.
* Source: https://fonts.google.com/icons?icon.query=trophy
*/
val Trophy: ImageVector by lazy {
ImageVector.Builder(
name = "Trophy",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f
).apply {
path(fill = SolidColor(Color.Black)) {
// Trophy SVG path (viewport 0 -960 960 960, transform y to 0-960)
// M280-120v-80h160v-124q-49-11-87.5-41.5T296-442q-75-9-125.5-65.5T120-640v-40q0-33 23.5-56.5T200-760h80v-80h400v80h80q33 0 56.5 23.5T840-680v40q0 76-50.5 132.5T664-442q-18 46-56.5 76.5T520-324v124h160v80H280Z
moveTo(280f, 840f)
verticalLineToRelative(-80f)
horizontalLineToRelative(160f)
verticalLineToRelative(-124f)
quadToRelative(-49f, -11f, -87.5f, -41.5f)
reflectiveQuadTo(296f, 518f)
quadToRelative(-75f, -9f, -125.5f, -65.5f)
reflectiveQuadTo(120f, 320f)
verticalLineToRelative(-40f)
quadToRelative(0f, -33f, 23.5f, -56.5f)
reflectiveQuadTo(200f, 200f)
horizontalLineToRelative(80f)
verticalLineToRelative(-80f)
horizontalLineToRelative(400f)
verticalLineToRelative(80f)
horizontalLineToRelative(80f)
quadToRelative(33f, 0f, 56.5f, 23.5f)
reflectiveQuadTo(840f, 280f)
verticalLineToRelative(40f)
quadToRelative(0f, 76f, -50.5f, 132.5f)
reflectiveQuadTo(664f, 518f)
quadToRelative(-18f, 46f, -56.5f, 76.5f)
reflectiveQuadTo(520f, 636f)
verticalLineToRelative(124f)
horizontalLineToRelative(160f)
verticalLineToRelative(80f)
horizontalLineTo(280f)
close()
// m80-400h40q0 45 29 79t71 44v-283H280v160Z
moveTo(360f, 440f)
horizontalLineToRelative(-160f)
verticalLineToRelative(-160f)
horizontalLineToRelative(80f)
verticalLineToRelative(-80f)
quadToRelative(0f, 45f, 29f, 79f)
reflectiveQuadToRelative(71f, 44f)
verticalLineToRelative(117f)
close()
// Additional path components for the trophy handles and base
// Simplified - using basic shape
}
}.build()
}
}
@@ -18,6 +18,7 @@ import com.matedroid.ui.screens.drives.DriveDetailScreen
import com.matedroid.ui.screens.drives.DrivesScreen
import com.matedroid.ui.screens.mileage.MileageScreen
import com.matedroid.ui.screens.settings.SettingsScreen
import com.matedroid.ui.screens.stats.StatsScreen
import com.matedroid.ui.screens.updates.SoftwareVersionsScreen
sealed class Screen(val route: String) {
@@ -90,6 +91,15 @@ sealed class Screen(val route: String) {
}
}
data object PalettePreview : Screen("palette_preview")
data object Stats : Screen("stats/{carId}?exteriorColor={exteriorColor}") {
fun createRoute(carId: Int, exteriorColor: String? = null): String {
return if (exteriorColor != null) {
"stats/$carId?exteriorColor=$exteriorColor"
} else {
"stats/$carId"
}
}
}
}
@Composable
@@ -139,6 +149,9 @@ fun NavGraph(
},
onNavigateToUpdates = { carId, exteriorColor ->
navController.navigate(Screen.Updates.createRoute(carId, exteriorColor))
},
onNavigateToStats = { carId, exteriorColor ->
navController.navigate(Screen.Stats.createRoute(carId, exteriorColor))
}
)
}
@@ -307,5 +320,25 @@ fun NavGraph(
onNavigateBack = { navController.popBackStack() }
)
}
composable(
route = Screen.Stats.route,
arguments = listOf(
navArgument("carId") { type = NavType.IntType },
navArgument("exteriorColor") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val carId = backStackEntry.arguments?.getInt("carId") ?: return@composable
val exteriorColor = backStackEntry.arguments?.getString("exteriorColor")
StatsScreen(
carId = carId,
exteriorColor = exteriorColor,
onNavigateBack = { navController.popBackStack() }
)
}
}
}
@@ -120,6 +120,7 @@ fun DashboardScreen(
onNavigateToBattery: (carId: Int, efficiency: Double?, exteriorColor: String?) -> Unit = { _, _, _ -> },
onNavigateToMileage: (carId: Int, exteriorColor: String?) -> Unit = { _, _ -> },
onNavigateToUpdates: (carId: Int, exteriorColor: String?) -> Unit = { _, _ -> },
onNavigateToStats: (carId: Int, exteriorColor: String?) -> Unit = { _, _ -> },
viewModel: DashboardViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
@@ -0,0 +1,748 @@
package com.matedroid.ui.screens.stats
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Analytics
import androidx.compose.material.icons.filled.BatteryChargingFull
import androidx.compose.material.icons.filled.DirectionsCar
import androidx.compose.material.icons.filled.ElectricBolt
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Terrain
import androidx.compose.material.icons.filled.Thermostat
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
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.domain.model.CarStats
import com.matedroid.domain.model.DeepStats
import com.matedroid.domain.model.QuickStats
import com.matedroid.domain.model.SyncPhase
import com.matedroid.domain.model.YearFilter
import com.matedroid.ui.icons.CustomIcons
import com.matedroid.ui.theme.CarColorPalette
import com.matedroid.ui.theme.CarColorPalettes
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatsScreen(
carId: Int,
exteriorColor: String? = null,
onNavigateBack: () -> Unit,
viewModel: StatsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val isDarkTheme = isSystemInDarkTheme()
val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme)
LaunchedEffect(carId) {
viewModel.setCarId(carId)
}
LaunchedEffect(uiState.error) {
uiState.error?.let { error ->
snackbarHostState.showSnackbar(error)
viewModel.clearError()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Stats for Nerds") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
PullToRefreshBox(
isRefreshing = false,
onRefresh = { viewModel.refresh() },
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (uiState.carStats == null) {
EmptyState(
message = "No stats available yet.\nSync is in progress...",
syncProgress = uiState.deepSyncProgress
)
} else {
StatsContent(
stats = uiState.carStats!!,
availableYears = uiState.availableYears,
selectedYearFilter = uiState.selectedYearFilter,
deepSyncProgress = uiState.deepSyncProgress,
palette = palette,
onYearFilterSelected = { viewModel.setYearFilter(it) }
)
}
}
}
}
@Composable
private fun EmptyState(
message: String,
syncProgress: Float
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp)
) {
Icon(
imageVector = Icons.Default.Analytics,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (syncProgress > 0) {
Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator(
progress = { syncProgress },
modifier = Modifier.fillMaxWidth(0.6f)
)
Text(
text = "${(syncProgress * 100).toInt()}% synced",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StatsContent(
stats: CarStats,
availableYears: List<Int>,
selectedYearFilter: YearFilter,
deepSyncProgress: Float,
palette: CarColorPalette,
onYearFilterSelected: (YearFilter) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Year filter chips
item {
YearFilterChips(
availableYears = availableYears,
selectedFilter = selectedYearFilter,
palette = palette,
onFilterSelected = onYearFilterSelected
)
}
// Sync progress indicator if deep sync is ongoing
if (deepSyncProgress < 1f && deepSyncProgress > 0f) {
item {
SyncProgressCard(progress = deepSyncProgress, palette = palette)
}
}
// Quick Stats - Drives Overview
item {
QuickStatsDrivesCard(quickStats = stats.quickStats, palette = palette)
}
// Quick Stats - Charges Overview
item {
QuickStatsChargesCard(quickStats = stats.quickStats, palette = palette)
}
// Quick Stats - Records
item {
RecordsCard(quickStats = stats.quickStats, palette = palette)
}
// Deep Stats - only if available
stats.deepStats?.let { deepStats ->
// Elevation Stats
item {
ElevationStatsCard(deepStats = deepStats, palette = palette)
}
// Temperature Stats
item {
TemperatureStatsCard(deepStats = deepStats, palette = palette)
}
// Charging Power Stats
item {
ChargingPowerCard(deepStats = deepStats, palette = palette)
}
// AC/DC Ratio
item {
AcDcRatioCard(deepStats = deepStats, palette = palette)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun YearFilterChips(
availableYears: List<Int>,
selectedFilter: YearFilter,
palette: CarColorPalette,
onFilterSelected: (YearFilter) -> Unit
) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// All Time option
item {
FilterChip(
selected = selectedFilter is YearFilter.AllTime,
onClick = { onFilterSelected(YearFilter.AllTime) },
label = { Text("All Time") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = palette.surface,
selectedLabelColor = palette.onSurface
)
)
}
// Year options
items(availableYears) { year ->
FilterChip(
selected = selectedFilter is YearFilter.Year && selectedFilter.year == year,
onClick = { onFilterSelected(YearFilter.Year(year)) },
label = { Text(year.toString()) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = palette.surface,
selectedLabelColor = palette.onSurface
)
)
}
}
}
@Composable
private fun SyncProgressCard(progress: Float, palette: CarColorPalette) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Sync,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Deep Stats Sync in Progress",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth()
)
Text(
text = "${(progress * 100).toInt()}% complete",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
}
// ======== Quick Stats Cards ========
@Composable
private fun QuickStatsDrivesCard(quickStats: QuickStats, palette: CarColorPalette) {
StatsCard(
title = "Drives Overview",
icon = Icons.Default.DirectionsCar,
palette = palette
) {
Row(modifier = Modifier.fillMaxWidth()) {
StatItem(
label = "Total Drives",
value = quickStats.totalDrives.toString(),
modifier = Modifier.weight(1f)
)
StatItem(
label = "Driving Days",
value = quickStats.totalDrivingDays.toString(),
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth()) {
StatItem(
label = "Total Distance",
value = "%.0f km".format(quickStats.totalDistanceKm),
modifier = Modifier.weight(1f)
)
StatItem(
label = "Energy Used",
value = "%.1f kWh".format(quickStats.totalEnergyConsumedKwh),
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth()) {
StatItem(
label = "Avg Efficiency",
value = "%.0f Wh/km".format(quickStats.avgEfficiencyWhKm),
modifier = Modifier.weight(1f)
)
StatItem(
label = "Top Speed",
value = quickStats.maxSpeedKmh?.let { "$it km/h" } ?: "N/A",
modifier = Modifier.weight(1f)
)
}
}
}
@Composable
private fun QuickStatsChargesCard(quickStats: QuickStats, palette: CarColorPalette) {
StatsCard(
title = "Charges Overview",
icon = Icons.Default.BatteryChargingFull,
palette = palette
) {
Row(modifier = Modifier.fillMaxWidth()) {
StatItem(
label = "Total Charges",
value = quickStats.totalCharges.toString(),
modifier = Modifier.weight(1f)
)
StatItem(
label = "Energy Added",
value = "%.1f kWh".format(quickStats.totalEnergyAddedKwh),
modifier = Modifier.weight(1f)
)
}
if (quickStats.totalCost != null && quickStats.totalCost > 0) {
Spacer(modifier = Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth()) {
StatItem(
label = "Total Cost",
value = "%.2f €".format(quickStats.totalCost),
modifier = Modifier.weight(1f)
)
StatItem(
label = "Avg Cost/kWh",
value = quickStats.avgCostPerKwh?.let { "%.3f €".format(it) } ?: "N/A",
modifier = Modifier.weight(1f)
)
}
}
}
}
@Composable
private fun RecordsCard(quickStats: QuickStats, palette: CarColorPalette) {
StatsCard(
title = "Records",
icon = CustomIcons.Trophy,
palette = palette
) {
quickStats.longestDrive?.let { drive ->
RecordItem(
emoji = "📏",
label = "Longest Drive",
value = "%.1f km".format(drive.distance),
subtext = drive.startAddress.take(25) + " → " + drive.endAddress.take(25)
)
}
quickStats.fastestDrive?.let { drive ->
RecordItem(
emoji = "🏎️",
label = "Top Speed",
value = "${drive.speedMax} km/h",
subtext = drive.startAddress.take(25) + " → " + drive.endAddress.take(25)
)
}
quickStats.mostEfficientDrive?.let { drive ->
RecordItem(
emoji = "🌱",
label = "Most Efficient",
value = "%.0f Wh/km".format(drive.efficiency ?: 0.0),
subtext = "%.1f km drive".format(drive.distance)
)
}
quickStats.biggestCharge?.let { charge ->
RecordItem(
emoji = "⚡",
label = "Biggest Charge",
value = "%.1f kWh".format(charge.energyAdded),
subtext = charge.address.take(40)
)
}
quickStats.busiestDay?.let { day ->
RecordItem(
emoji = "📅",
label = "Busiest Day",
value = "${day.count} drives",
subtext = day.day
)
}
}
}
// ======== Deep Stats Cards ========
@Composable
private fun ElevationStatsCard(deepStats: DeepStats, palette: CarColorPalette) {
if (deepStats.maxElevationM == null && deepStats.minElevationM == null) {
return // No elevation data
}
StatsCard(
title = "Elevation",
icon = Icons.Default.Terrain,
palette = palette
) {
Row(modifier = Modifier.fillMaxWidth()) {
StatItem(
label = "Highest Point",
value = deepStats.maxElevationM?.let { "$it m" } ?: "N/A",
modifier = Modifier.weight(1f)
)
StatItem(
label = "Lowest Point",
value = deepStats.minElevationM?.let { "$it m" } ?: "N/A",
modifier = Modifier.weight(1f)
)
}
deepStats.driveWithMostClimbing?.let { record ->
Spacer(modifier = Modifier.height(12.dp))
RecordItem(
emoji = "⛰️",
label = "Most Climbing",
value = record.elevationGainM?.let { "+$it m" } ?: "N/A",
subtext = record.date?.take(10) ?: ""
)
}
}
}
@Composable
private fun TemperatureStatsCard(deepStats: DeepStats, palette: CarColorPalette) {
if (deepStats.maxOutsideTempDrivingC == null && deepStats.minOutsideTempDrivingC == null) {
return // No temperature data
}
StatsCard(
title = "Temperature Extremes",
icon = Icons.Default.Thermostat,
palette = palette
) {
Text(
text = "While Driving",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = palette.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth()) {
StatItem(
label = "Hottest",
value = deepStats.maxOutsideTempDrivingC?.let { "%.1f°C".format(it) } ?: "N/A",
modifier = Modifier.weight(1f)
)
StatItem(
label = "Coldest",
value = deepStats.minOutsideTempDrivingC?.let { "%.1f°C".format(it) } ?: "N/A",
modifier = Modifier.weight(1f)
)
}
if (deepStats.maxCabinTempC != null || deepStats.minCabinTempC != null) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Cabin Temperature",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = palette.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth()) {
StatItem(
label = "Hottest",
value = deepStats.maxCabinTempC?.let { "%.1f°C".format(it) } ?: "N/A",
modifier = Modifier.weight(1f)
)
StatItem(
label = "Coldest",
value = deepStats.minCabinTempC?.let { "%.1f°C".format(it) } ?: "N/A",
modifier = Modifier.weight(1f)
)
}
}
}
}
@Composable
private fun ChargingPowerCard(deepStats: DeepStats, palette: CarColorPalette) {
if (deepStats.maxChargerPowerKw == null) {
return // No charging power data
}
StatsCard(
title = "Charging Power",
icon = Icons.Default.ElectricBolt,
palette = palette
) {
Row(modifier = Modifier.fillMaxWidth()) {
StatItem(
label = "Max Power Achieved",
value = "${deepStats.maxChargerPowerKw} kW",
modifier = Modifier.weight(1f)
)
}
deepStats.chargeWithMaxPower?.let { record ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "on ${record.date?.take(10) ?: "unknown date"}",
style = MaterialTheme.typography.bodySmall,
color = palette.onSurfaceVariant
)
}
}
}
@Composable
private fun AcDcRatioCard(deepStats: DeepStats, palette: CarColorPalette) {
val total = deepStats.acChargeCount + deepStats.dcChargeCount
if (total == 0) {
return // No charge data
}
val acPercent = (deepStats.acChargeCount * 100f / total).toInt()
val dcPercent = (deepStats.dcChargeCount * 100f / total).toInt()
StatsCard(
title = "AC/DC Charging Ratio",
icon = Icons.Default.BatteryChargingFull,
palette = palette
) {
Row(modifier = Modifier.fillMaxWidth()) {
StatItem(
label = "AC Charges",
value = "${deepStats.acChargeCount} ($acPercent%)",
modifier = Modifier.weight(1f)
)
StatItem(
label = "DC Charges",
value = "${deepStats.dcChargeCount} ($dcPercent%)",
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(12.dp))
// Progress bar showing ratio
LinearProgressIndicator(
progress = { acPercent / 100f },
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.tertiary
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "AC (Home/Destination)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "DC (Supercharger)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary
)
}
}
}
// ======== Reusable Components ========
@Composable
private fun StatsCard(
title: String,
icon: ImageVector,
palette: CarColorPalette,
content: @Composable () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = palette.surface
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = palette.accent
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = palette.onSurface
)
}
Spacer(modifier = Modifier.height(16.dp))
content()
}
}
}
@Composable
private fun StatItem(
label: String,
value: String,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun RecordItem(
emoji: String,
label: String,
value: String,
subtext: String
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = emoji,
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (subtext.isNotEmpty()) {
Text(
text = subtext,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -0,0 +1,105 @@
package com.matedroid.ui.screens.stats
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.matedroid.data.repository.StatsRepository
import com.matedroid.domain.model.CarStats
import com.matedroid.domain.model.SyncPhase
import com.matedroid.domain.model.SyncProgress
import com.matedroid.domain.model.YearFilter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class StatsUiState(
val isLoading: Boolean = true,
val carStats: CarStats? = null,
val availableYears: List<Int> = emptyList(),
val selectedYearFilter: YearFilter = YearFilter.AllTime,
val deepSyncProgress: Float = 0f,
val syncProgress: SyncProgress? = null,
val error: String? = null
)
@HiltViewModel
class StatsViewModel @Inject constructor(
private val statsRepository: StatsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(StatsUiState())
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
private var carId: Int? = null
fun setCarId(id: Int) {
carId = id
loadStats()
}
fun setYearFilter(yearFilter: YearFilter) {
_uiState.update { it.copy(selectedYearFilter = yearFilter) }
loadStats()
}
fun refresh() {
loadStats()
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
private fun loadStats() {
val id = carId ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
// Check if we have any data
val hasData = statsRepository.hasData(id)
if (!hasData) {
_uiState.update {
it.copy(
isLoading = false,
error = "No data available yet. Sync in progress..."
)
}
return@launch
}
// Load available years for filter
val years = statsRepository.getAvailableYears(id)
// Load stats with current filter
val yearFilter = _uiState.value.selectedYearFilter
val stats = statsRepository.getStats(id, yearFilter)
// Get deep sync progress
val deepProgress = statsRepository.getDeepSyncProgress(id)
_uiState.update {
it.copy(
isLoading = false,
carStats = stats,
availableYears = years,
deepSyncProgress = deepProgress,
syncProgress = stats.syncProgress,
error = null
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
error = e.message ?: "Failed to load stats"
)
}
}
}
}
}