feat: add Software Versions screen with update history

- 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 <noreply@anthropic.com>
This commit is contained in:
Davide Ferrari
2025-12-21 12:04:08 +01:00
co-authored by Claude Opus 4.5
parent 203bacf8dd
commit ea80b16623
4 changed files with 617 additions and 11 deletions
@@ -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() }
@@ -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
)
}
}
@@ -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<SoftwareVersionItem>,
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"
}
}
}
}
@@ -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<SoftwareVersionItem> = 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<SoftwareVersionsUiState> = _uiState.asStateFlow()
private var carId: Int? = null
private var allUpdates: List<UpdateData> = 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
}
}
}
}