mirror of
https://github.com/vide/matedroid.git
synced 2026-01-20 00:03:17 +08:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user