From ea80b16623ea0f8d9adcc56ee567050b41b9f643 Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Sun, 21 Dec 2025 12:04:08 +0100 Subject: [PATCH] feat: add Software Versions screen with update history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SoftwareVersionsScreen showing historic software updates - Each version shows install date and duration installed - Trophy badge highlights the longest-installed version - Date filter chips: Last 6 months, Last year, All time - Navigate by tapping Software item in Vehicle Info card - Clickable InfoItem with visual feedback (primary color) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../com/matedroid/ui/navigation/NavGraph.kt | 34 +- .../ui/screens/dashboard/DashboardScreen.kt | 48 ++- .../screens/updates/SoftwareVersionsScreen.kt | 382 ++++++++++++++++++ .../updates/SoftwareVersionsViewModel.kt | 164 ++++++++ 4 files changed, 617 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/matedroid/ui/screens/updates/SoftwareVersionsScreen.kt create mode 100644 app/src/main/java/com/matedroid/ui/screens/updates/SoftwareVersionsViewModel.kt diff --git a/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt b/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt index 599f0f3..1fa5800 100644 --- a/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt @@ -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.updates.SoftwareVersionsScreen sealed class Screen(val route: String) { data object Settings : Screen("settings") @@ -79,8 +80,14 @@ sealed class Screen(val route: String) { } } } - data object Updates : Screen("updates/{carId}") { - fun createRoute(carId: Int) = "updates/$carId" + data object Updates : Screen("updates/{carId}?exteriorColor={exteriorColor}") { + fun createRoute(carId: Int, exteriorColor: String? = null): String { + return if (exteriorColor != null) { + "updates/$carId?exteriorColor=$exteriorColor" + } else { + "updates/$carId" + } + } } data object PalettePreview : Screen("palette_preview") } @@ -129,6 +136,9 @@ fun NavGraph( }, onNavigateToMileage = { carId, exteriorColor -> navController.navigate(Screen.Mileage.createRoute(carId, exteriorColor)) + }, + onNavigateToUpdates = { carId, exteriorColor -> + navController.navigate(Screen.Updates.createRoute(carId, exteriorColor)) } ) } @@ -272,6 +282,26 @@ fun NavGraph( ) } + composable( + route = Screen.Updates.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") + SoftwareVersionsScreen( + carId = carId, + exteriorColor = exteriorColor, + onNavigateBack = { navController.popBackStack() } + ) + } + composable(Screen.PalettePreview.route) { PalettePreviewScreen( onNavigateBack = { navController.popBackStack() } diff --git a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt index 8b55c9c..01c0042 100644 --- a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt @@ -107,6 +107,7 @@ fun DashboardScreen( onNavigateToDrives: (carId: Int, exteriorColor: String?) -> Unit = { _, _ -> }, onNavigateToBattery: (carId: Int, efficiency: Double?, exteriorColor: String?) -> Unit = { _, _, _ -> }, onNavigateToMileage: (carId: Int, exteriorColor: String?) -> Unit = { _, _ -> }, + onNavigateToUpdates: (carId: Int, exteriorColor: String?) -> Unit = { _, _ -> }, viewModel: DashboardViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -170,6 +171,11 @@ fun DashboardScreen( uiState.selectedCarId?.let { carId -> onNavigateToMileage(carId, uiState.selectedCarExterior?.exteriorColor) } + }, + onNavigateToUpdates = { + uiState.selectedCarId?.let { carId -> + onNavigateToUpdates(carId, uiState.selectedCarExterior?.exteriorColor) + } } ) } @@ -261,7 +267,8 @@ private fun DashboardContent( onNavigateToCharges: () -> Unit = {}, onNavigateToDrives: () -> Unit = {}, onNavigateToBattery: () -> Unit = {}, - onNavigateToMileage: () -> Unit = {} + onNavigateToMileage: () -> Unit = {}, + onNavigateToUpdates: () -> Unit = {} ) { val isDarkTheme = isSystemInDarkTheme() val palette = CarColorPalettes.forExteriorColor(carExterior?.exteriorColor, isDarkTheme) @@ -292,7 +299,11 @@ private fun DashboardContent( } // Vehicle Info Section - VehicleInfoCard(status, units) + VehicleInfoCard( + status = status, + units = units, + onNavigateToUpdates = onNavigateToUpdates + ) } // Fixed bottom quick links @@ -806,7 +817,11 @@ private fun SmallLocationMap( } @Composable -private fun VehicleInfoCard(status: CarStatus, units: Units?) { +private fun VehicleInfoCard( + status: CarStatus, + units: Units?, + onNavigateToUpdates: () -> Unit = {} +) { val distanceUnit = UnitFormatter.getDistanceUnit(units) val pressureUnit = UnitFormatter.getPressureUnit(units) val tpms = status.tpmsDetails @@ -848,7 +863,8 @@ private fun VehicleInfoCard(status: CarStatus, units: Units?) { InfoItem( label = "Software", value = status.version ?: "--", - icon = Icons.Filled.Settings + icon = Icons.Filled.Settings, + onClick = onNavigateToUpdates ) } @@ -958,24 +974,38 @@ private fun TirePressureItem( private fun InfoItem( label: String, value: String, - icon: ImageVector + icon: ImageVector, + onClick: (() -> Unit)? = null ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + val modifier = if (onClick != null) { + Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(8.dp) + } else { + Modifier.padding(8.dp) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = if (onClick != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) Text( text = label, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = if (onClick != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) Text( text = value, style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium + fontWeight = FontWeight.Medium, + color = if (onClick != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface ) } } diff --git a/app/src/main/java/com/matedroid/ui/screens/updates/SoftwareVersionsScreen.kt b/app/src/main/java/com/matedroid/ui/screens/updates/SoftwareVersionsScreen.kt new file mode 100644 index 0000000..1013db0 --- /dev/null +++ b/app/src/main/java/com/matedroid/ui/screens/updates/SoftwareVersionsScreen.kt @@ -0,0 +1,382 @@ +package com.matedroid.ui.screens.updates + +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.EmojiEvents +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.SystemUpdate +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.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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.matedroid.ui.theme.CarColorPalette +import com.matedroid.ui.theme.CarColorPalettes +import java.time.format.DateTimeFormatter + +enum class UpdatesDateFilter(val label: String, val months: Int?) { + LAST_6_MONTHS("Last 6 months", 6), + LAST_YEAR("Last year", 12), + ALL_TIME("All time", null) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SoftwareVersionsScreen( + carId: Int, + exteriorColor: String? = null, + onNavigateBack: () -> Unit, + viewModel: SoftwareVersionsViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + var selectedFilter by remember { mutableStateOf(UpdatesDateFilter.ALL_TIME) } + 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() + } + } + + fun applyDateFilter(filter: UpdatesDateFilter) { + selectedFilter = filter + viewModel.setFilter(filter.months) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Software Versions") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = { viewModel.refresh() }, + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + if (uiState.isLoading && !uiState.isRefreshing) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + SoftwareVersionsContent( + updates = uiState.updates, + longestInstalledId = uiState.longestInstalledId, + selectedFilter = selectedFilter, + palette = palette, + onFilterSelected = { applyDateFilter(it) } + ) + } + } + } +} + +@Composable +private fun SoftwareVersionsContent( + updates: List, + longestInstalledId: Int?, + selectedFilter: UpdatesDateFilter, + palette: CarColorPalette, + onFilterSelected: (UpdatesDateFilter) -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + DateFilterChips( + selectedFilter = selectedFilter, + palette = palette, + onFilterSelected = onFilterSelected + ) + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Update History", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + if (updates.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No software updates found", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } else { + items(updates, key = { it.id }) { update -> + SoftwareVersionCard( + update = update, + isLongestInstalled = update.id == longestInstalledId, + palette = palette + ) + } + } + } +} + +@Composable +private fun DateFilterChips( + selectedFilter: UpdatesDateFilter, + palette: CarColorPalette, + onFilterSelected: (UpdatesDateFilter) -> Unit +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(UpdatesDateFilter.entries.toList()) { filter -> + FilterChip( + selected = filter == selectedFilter, + onClick = { onFilterSelected(filter) }, + label = { Text(filter.label) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = palette.surface, + selectedLabelColor = palette.onSurface + ) + ) + } + } +} + +@Composable +private fun SoftwareVersionCard( + update: SoftwareVersionItem, + isLongestInstalled: Boolean, + palette: CarColorPalette +) { + val dateFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy") + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (update.isCurrent) { + palette.surface + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.SystemUpdate, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (update.isCurrent) palette.accent else MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = update.version, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (update.isCurrent) palette.onSurface else MaterialTheme.colorScheme.onSurface + ) + if (update.isCurrent) { + Text( + text = "Current version", + style = MaterialTheme.typography.labelSmall, + color = palette.accent + ) + } + } + } + + if (isLongestInstalled) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.EmojiEvents, + contentDescription = "Longest installed", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Longest", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Install date + Column { + Text( + text = "Installed", + style = MaterialTheme.typography.labelSmall, + color = if (update.isCurrent) palette.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = update.installDate?.format(dateFormatter) ?: "Unknown", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (update.isCurrent) palette.onSurface else MaterialTheme.colorScheme.onSurface + ) + } + + // Duration + Column(horizontalAlignment = Alignment.End) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = if (update.isCurrent) palette.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Duration", + style = MaterialTheme.typography.labelSmall, + color = if (update.isCurrent) palette.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = formatDuration(update.durationDays), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (update.isCurrent) palette.onSurface else MaterialTheme.colorScheme.onSurface + ) + } + } + } + } +} + +private fun formatDuration(days: Long?): String { + if (days == null) return "Unknown" + + return when { + days == 0L -> "< 1 day" + days == 1L -> "1 day" + days < 30 -> "$days days" + days < 60 -> { + val weeks = days / 7 + if (weeks == 1L) "1 week" else "$weeks weeks" + } + days < 365 -> { + val months = days / 30 + if (months == 1L) "~1 month" else "~$months months" + } + else -> { + val years = days / 365 + val remainingMonths = (days % 365) / 30 + if (remainingMonths > 0) { + if (years == 1L) "~1 year $remainingMonths mo" else "~$years years $remainingMonths mo" + } else { + if (years == 1L) "~1 year" else "~$years years" + } + } + } +} diff --git a/app/src/main/java/com/matedroid/ui/screens/updates/SoftwareVersionsViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/updates/SoftwareVersionsViewModel.kt new file mode 100644 index 0000000..9b437a1 --- /dev/null +++ b/app/src/main/java/com/matedroid/ui/screens/updates/SoftwareVersionsViewModel.kt @@ -0,0 +1,164 @@ +package com.matedroid.ui.screens.updates + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.matedroid.data.api.models.UpdateData +import com.matedroid.data.repository.ApiResult +import com.matedroid.data.repository.TeslamateRepository +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 java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +data class SoftwareVersionsUiState( + val isLoading: Boolean = true, + val isRefreshing: Boolean = false, + val updates: List = emptyList(), + val longestInstalledId: Int? = null, + val error: String? = null, + val filterMonths: Int? = null // null = All time, 6 = Last 6 months, 12 = Last year +) + +data class SoftwareVersionItem( + val id: Int, + val version: String, + val installDate: LocalDateTime?, + val endDate: LocalDateTime?, + val durationDays: Long?, + val isCurrent: Boolean +) + +@HiltViewModel +class SoftwareVersionsViewModel @Inject constructor( + private val repository: TeslamateRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(SoftwareVersionsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var carId: Int? = null + private var allUpdates: List = emptyList() + + fun setCarId(id: Int) { + if (carId != id) { + carId = id + loadUpdates() + } + } + + fun setFilter(months: Int?) { + _uiState.update { it.copy(filterMonths = months) } + applyFilter(months) + } + + fun refresh() { + carId?.let { + _uiState.update { it.copy(isRefreshing = true) } + loadUpdates() + } + } + + fun clearError() { + _uiState.update { it.copy(error = null) } + } + + private fun loadUpdates() { + val id = carId ?: return + + viewModelScope.launch { + val state = _uiState.value + if (!state.isRefreshing) { + _uiState.update { it.copy(isLoading = true) } + } + + when (val result = repository.getUpdates(id)) { + is ApiResult.Success -> { + allUpdates = result.data + applyFilter(_uiState.value.filterMonths) + } + is ApiResult.Error -> { + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + error = result.message + ) + } + } + } + } + } + + private fun applyFilter(months: Int?) { + val cutoffDate = months?.let { + LocalDate.now().minusMonths(it.toLong()) + } + + val filteredUpdates = if (cutoffDate != null) { + allUpdates.filter { update -> + val startDate = parseDate(update.startDate) + startDate == null || startDate.toLocalDate() >= cutoffDate + } + } else { + allUpdates + } + + val items = filteredUpdates.mapIndexed { index, update -> + val startDate = parseDate(update.startDate) + val endDate = parseDate(update.endDate) + val isCurrent = index == 0 && endDate == null + + val durationDays = when { + startDate == null -> null + endDate != null -> Duration.between(startDate, endDate).toDays() + isCurrent -> Duration.between(startDate, LocalDateTime.now()).toDays() + else -> null + } + + SoftwareVersionItem( + id = update.id ?: 0, + version = update.version ?: "Unknown", + installDate = startDate, + endDate = endDate, + durationDays = durationDays, + isCurrent = isCurrent + ) + } + + // Find the version that stayed installed the longest (among displayed ones) + val longestId = items + .filter { it.durationDays != null } + .maxByOrNull { it.durationDays!! } + ?.id + + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + updates = items, + longestInstalledId = longestId, + error = null + ) + } + } + + private fun parseDate(dateStr: String?): LocalDateTime? { + if (dateStr == null) return null + return try { + LocalDateTime.parse(dateStr, DateTimeFormatter.ISO_DATE_TIME) + } catch (e: Exception) { + try { + LocalDateTime.parse(dateStr.replace(" ", "T"), DateTimeFormatter.ISO_DATE_TIME) + } catch (e2: Exception) { + null + } + } + } +}