feat(drives): add distance filter for drives (#8)

Add a new filter row in Drives screen to filter by drive length:
- Commute (< 10 km / 6 mi)
- Day trip (10-100 km / 6-60 mi)
- Road trip (> 100 km / 60 mi)

Filter labels automatically adapt to user's metric/imperial unit setting.

🤖 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-24 16:13:16 +01:00
committed by GitHub
parent 52b8162b13
commit 31103a60e3
3 changed files with 125 additions and 21 deletions
+1
View File
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Software Versions**: Tap the external link icon next to any version to view release notes on NotATeslaApp
- **Drives**: Filter drives by distance - Commute (< 10 km), Day trip (10-100 km), Road trip (> 100 km). Labels adapt to metric/imperial units.
### Changed
- **Mileage**: Round all distance values to whole numbers for cleaner display (Total, Avg/Year, year cards, month cards)
@@ -58,6 +58,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.matedroid.data.api.models.DriveData
import com.matedroid.data.api.models.Units
import com.matedroid.ui.components.BarChartData
import com.matedroid.ui.components.InteractiveBarChart
import com.matedroid.ui.theme.CarColorPalette
@@ -154,9 +155,12 @@ fun DrivesScreen(
chartData = uiState.chartData,
chartGranularity = uiState.chartGranularity,
summary = uiState.summary,
selectedFilter = selectedFilter,
selectedDateFilter = selectedFilter,
selectedDistanceFilter = uiState.distanceFilter,
units = uiState.units,
palette = palette,
onFilterSelected = { applyDateFilter(it) },
onDateFilterSelected = { applyDateFilter(it) },
onDistanceFilterSelected = { viewModel.setDistanceFilter(it) },
onDriveClick = onNavigateToDriveDetail
)
}
@@ -171,9 +175,12 @@ private fun DrivesContent(
chartData: List<DriveChartData>,
chartGranularity: DriveChartGranularity,
summary: DrivesSummary,
selectedFilter: DriveDateFilter,
selectedDateFilter: DriveDateFilter,
selectedDistanceFilter: DriveDistanceFilter,
units: Units?,
palette: CarColorPalette,
onFilterSelected: (DriveDateFilter) -> Unit,
onDateFilterSelected: (DriveDateFilter) -> Unit,
onDistanceFilterSelected: (DriveDistanceFilter) -> Unit,
onDriveClick: (driveId: Int) -> Unit
) {
LazyColumn(
@@ -183,9 +190,18 @@ private fun DrivesContent(
) {
item {
DateFilterChips(
selectedFilter = selectedFilter,
selectedFilter = selectedDateFilter,
palette = palette,
onFilterSelected = onFilterSelected
onFilterSelected = onDateFilterSelected
)
}
item {
DistanceFilterChips(
selectedFilter = selectedDistanceFilter,
units = units,
palette = palette,
onFilterSelected = onDistanceFilterSelected
)
}
@@ -266,6 +282,31 @@ private fun DateFilterChips(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DistanceFilterChips(
selectedFilter: DriveDistanceFilter,
units: Units?,
palette: CarColorPalette,
onFilterSelected: (DriveDistanceFilter) -> Unit
) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(DriveDistanceFilter.entries.toList()) { filter ->
FilterChip(
selected = filter == selectedFilter,
onClick = { onFilterSelected(filter) },
label = { Text(filter.getLabel(units)) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = palette.surface,
selectedLabelColor = palette.onSurface
)
)
}
}
}
@Composable
private fun SummaryCard(summary: DrivesSummary, palette: CarColorPalette) {
Card(
@@ -3,6 +3,7 @@ package com.matedroid.ui.screens.drives
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.matedroid.data.api.models.DriveData
import com.matedroid.data.api.models.Units
import com.matedroid.data.local.SettingsDataStore
import com.matedroid.data.repository.ApiResult
import com.matedroid.data.repository.TeslamateRepository
@@ -25,6 +26,26 @@ enum class DriveChartGranularity {
DAILY, WEEKLY, MONTHLY
}
enum class DriveDistanceFilter(
val maxDistanceKm: Double?,
val minDistanceKm: Double?
) {
ALL(null, null),
COMMUTE(10.0, null), // < 10 km / 6 mi
DAY_TRIP(100.0, 10.0), // 10-100 km / 6-60 mi
ROAD_TRIP(null, 100.0); // > 100 km / 60 mi
fun getLabel(units: Units?): String {
val isImperial = units?.isImperial == true
return when (this) {
ALL -> "All"
COMMUTE -> if (isImperial) "Commute (< 6 mi)" else "Commute (< 10 km)"
DAY_TRIP -> if (isImperial) "Day trip (6-60 mi)" else "Day trip (10-100 km)"
ROAD_TRIP -> if (isImperial) "Road trip (> 60 mi)" else "Road trip (> 100 km)"
}
}
}
data class DriveChartData(
val label: String,
val count: Int,
@@ -41,7 +62,9 @@ data class DrivesUiState(
val error: String? = null,
val startDate: LocalDate? = null,
val endDate: LocalDate? = null,
val summary: DrivesSummary = DrivesSummary()
val summary: DrivesSummary = DrivesSummary(),
val units: Units? = null,
val distanceFilter: DriveDistanceFilter = DriveDistanceFilter.ALL
)
data class DrivesSummary(
@@ -64,6 +87,7 @@ class DrivesViewModel @Inject constructor(
private var carId: Int? = null
private var showShortDrivesCharges: Boolean = false
private var allDrives: List<DriveData> = emptyList()
companion object {
private const val MIN_DURATION_MINUTES = 1
@@ -72,6 +96,7 @@ class DrivesViewModel @Inject constructor(
fun setCarId(id: Int) {
carId = id
loadUnits(id)
}
fun setDateFilter(startDate: LocalDate?, endDate: LocalDate?) {
@@ -84,6 +109,11 @@ class DrivesViewModel @Inject constructor(
loadDrives(null, null)
}
fun setDistanceFilter(filter: DriveDistanceFilter) {
_uiState.update { it.copy(distanceFilter = filter) }
applyFiltersAndUpdateState()
}
fun refresh() {
carId?.let {
_uiState.update { it.copy(isRefreshing = true) }
@@ -96,6 +126,17 @@ class DrivesViewModel @Inject constructor(
_uiState.update { it.copy(error = null) }
}
private fun loadUnits(carId: Int) {
viewModelScope.launch {
when (val result = repository.getCarStatus(carId)) {
is ApiResult.Success -> {
_uiState.update { it.copy(units = result.data.units) }
}
is ApiResult.Error -> { /* ignore, units will default to metric */ }
}
}
}
private fun loadDrives(startDate: LocalDate? = null, endDate: LocalDate? = null) {
val id = carId ?: return
@@ -114,32 +155,22 @@ class DrivesViewModel @Inject constructor(
when (val result = repository.getDrives(id, startDateStr, endDateStr)) {
is ApiResult.Success -> {
val allDrives = result.data
allDrives = result.data
// Calculate summary and chart from ALL drives (including short ones)
val summary = calculateSummary(allDrives)
val granularity = determineGranularity(startDate, endDate)
val chartData = calculateChartData(allDrives, granularity)
// Filter drives for display based on setting
// Hide drives under 1 minute OR with distance < 0.1 km
val displayedDrives = if (showShortDrivesCharges) {
allDrives
} else {
allDrives.filter { drive ->
(drive.durationMin ?: 0) >= MIN_DURATION_MINUTES &&
(drive.distance ?: 0.0) >= MIN_DISTANCE_KM
}
}
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
drives = displayedDrives,
chartData = chartData,
chartGranularity = granularity,
summary = summary,
error = null
)
}
applyFiltersAndUpdateState()
}
is ApiResult.Error -> {
_uiState.update {
@@ -154,6 +185,37 @@ class DrivesViewModel @Inject constructor(
}
}
private fun applyFiltersAndUpdateState() {
val state = _uiState.value
val distanceFilter = state.distanceFilter
// First apply short drives filter
var filteredDrives = if (showShortDrivesCharges) {
allDrives
} else {
allDrives.filter { drive ->
(drive.durationMin ?: 0) >= MIN_DURATION_MINUTES &&
(drive.distance ?: 0.0) >= MIN_DISTANCE_KM
}
}
// Then apply distance filter
filteredDrives = filteredDrives.filter { drive ->
val distance = drive.distance ?: 0.0
val minOk = distanceFilter.minDistanceKm?.let { distance >= it } ?: true
val maxOk = distanceFilter.maxDistanceKm?.let { distance < it } ?: true
minOk && maxOk
}
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
drives = filteredDrives
)
}
}
private fun determineGranularity(startDate: LocalDate?, endDate: LocalDate?): DriveChartGranularity {
if (startDate == null || endDate == null) return DriveChartGranularity.MONTHLY
val days = ChronoUnit.DAYS.between(startDate, endDate)