From 2a28aeb490eaf3ba0077cb5b2b6ce7026069ac35 Mon Sep 17 00:00:00 2001 From: Raymond Kelly Date: Wed, 8 Jul 2026 20:48:23 -0500 Subject: [PATCH 1/5] feat: add trip summary widget --- app/src/main/AndroidManifest.xml | 22 + .../main/java/com/matedroid/MainActivity.kt | 4 +- .../matedroid/data/local/SettingsDataStore.kt | 9 + .../data/local/dao/ChargeSummaryDao.kt | 8 + .../com/matedroid/data/sync/DataSyncWorker.kt | 2 + .../com/matedroid/ui/navigation/NavGraph.kt | 1 + .../screens/dashboard/DashboardViewModel.kt | 8 +- .../ui/screens/settings/SettingsViewModel.kt | 8 +- .../com/matedroid/widget/TripSummaryWidget.kt | 415 ++++++++++++++++++ .../widget/TripSummaryWidgetConfigActivity.kt | 204 +++++++++ .../widget/TripSummaryWidgetDateRange.kt | 19 + .../widget/TripSummaryWidgetDisplayData.kt | 22 + .../widget/TripSummaryWidgetFormatter.kt | 59 +++ .../widget/TripSummaryWidgetReceiver.kt | 32 ++ .../widget/TripSummaryWidgetUpdateWorker.kt | 154 +++++++ .../layout/trip_summary_widget_preview.xml | 27 ++ app/src/main/res/values-ca/strings.xml | 10 + app/src/main/res/values-de/strings.xml | 12 +- app/src/main/res/values-es/strings.xml | 10 + app/src/main/res/values-it/strings.xml | 10 + app/src/main/res/values-zh/strings.xml | 10 + app/src/main/res/values/strings.xml | 30 ++ .../main/res/xml/trip_summary_widget_info.xml | 13 + .../dashboard/DashboardViewModelTest.kt | 22 + .../screens/settings/SettingsViewModelTest.kt | 25 ++ .../widget/TripSummaryWidgetDateRangeTest.kt | 27 ++ .../widget/TripSummaryWidgetFormatterTest.kt | 132 ++++++ 27 files changed, 1289 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt create mode 100644 app/src/main/java/com/matedroid/widget/TripSummaryWidgetConfigActivity.kt create mode 100644 app/src/main/java/com/matedroid/widget/TripSummaryWidgetDateRange.kt create mode 100644 app/src/main/java/com/matedroid/widget/TripSummaryWidgetDisplayData.kt create mode 100644 app/src/main/java/com/matedroid/widget/TripSummaryWidgetFormatter.kt create mode 100644 app/src/main/java/com/matedroid/widget/TripSummaryWidgetReceiver.kt create mode 100644 app/src/main/java/com/matedroid/widget/TripSummaryWidgetUpdateWorker.kt create mode 100644 app/src/main/res/layout/trip_summary_widget_preview.xml create mode 100644 app/src/main/res/xml/trip_summary_widget_info.xml create mode 100644 app/src/test/java/com/matedroid/widget/TripSummaryWidgetDateRangeTest.kt create mode 100644 app/src/test/java/com/matedroid/widget/TripSummaryWidgetFormatterTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3043b903..992aa575 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -87,6 +87,18 @@ android:resource="@xml/car_widget_info" /> + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/matedroid/MainActivity.kt b/app/src/main/java/com/matedroid/MainActivity.kt index d016785d..c3e6ee5b 100644 --- a/app/src/main/java/com/matedroid/MainActivity.kt +++ b/app/src/main/java/com/matedroid/MainActivity.kt @@ -25,7 +25,9 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) currentIntent = intent - if (intent.hasExtra("EXTRA_CAR_ID")) { + if (intent.hasExtra("EXTRA_CAR_ID") && + !intent.getBooleanExtra("EXTRA_SKIP_CAR_WIDGET_UPDATE", false) + ) { CarWidgetUpdateWorker.scheduleImmediateUpdate(this) } enableEdgeToEdge() diff --git a/app/src/main/java/com/matedroid/data/local/SettingsDataStore.kt b/app/src/main/java/com/matedroid/data/local/SettingsDataStore.kt index 76a3ad6a..457e96e0 100644 --- a/app/src/main/java/com/matedroid/data/local/SettingsDataStore.kt +++ b/app/src/main/java/com/matedroid/data/local/SettingsDataStore.kt @@ -54,6 +54,7 @@ data class AppSettings( val currencyCode: String = "EUR", val showShortDrivesCharges: Boolean = false, val teslamateBaseUrl: String = "", + val unitOfLength: String = "km", val lastSelectedCarId: Int? = null ) { val isConfigured: Boolean @@ -76,6 +77,7 @@ class SettingsDataStore @Inject constructor( private val currencyCodeKey = stringPreferencesKey("currency_code") private val showShortDrivesChargesKey = booleanPreferencesKey("show_short_drives_charges") private val teslamateBaseUrlKey = stringPreferencesKey("teslamate_base_url") + private val unitOfLengthKey = stringPreferencesKey("unit_of_length") private val lastSelectedCarIdKey = intPreferencesKey("last_selected_car_id") private val carImageOverridesKey = stringPreferencesKey("car_image_overrides") private val notificationPermissionAskedKey = booleanPreferencesKey("notification_permission_asked") @@ -95,6 +97,7 @@ class SettingsDataStore @Inject constructor( currencyCode = preferences[currencyCodeKey] ?: "EUR", showShortDrivesCharges = preferences[showShortDrivesChargesKey] ?: false, teslamateBaseUrl = preferences[teslamateBaseUrlKey] ?: "", + unitOfLength = preferences[unitOfLengthKey] ?: "km", lastSelectedCarId = preferences[lastSelectedCarIdKey] ) } @@ -194,6 +197,12 @@ class SettingsDataStore @Inject constructor( } } + suspend fun saveUnitOfLength(unitOfLength: String) { + context.dataStore.edit { preferences -> + preferences[unitOfLengthKey] = unitOfLength + } + } + suspend fun saveLastSelectedCarId(carId: Int) { context.dataStore.edit { preferences -> preferences[lastSelectedCarIdKey] = carId diff --git a/app/src/main/java/com/matedroid/data/local/dao/ChargeSummaryDao.kt b/app/src/main/java/com/matedroid/data/local/dao/ChargeSummaryDao.kt index 0fb4334e..ad108897 100644 --- a/app/src/main/java/com/matedroid/data/local/dao/ChargeSummaryDao.kt +++ b/app/src/main/java/com/matedroid/data/local/dao/ChargeSummaryDao.kt @@ -62,6 +62,14 @@ interface ChargeSummaryDao { """) suspend fun sumCostInRange(carId: Int, startDate: String, endDate: String): Double + @Query(""" + SELECT COUNT(*) FROM charges_summary + WHERE carId = :carId + AND startDate >= :startDate AND startDate < :endDate + AND cost IS NOT NULL + """) + suspend fun countWithCostInRange(carId: Int, startDate: String, endDate: String): Int + // Average cost per kWh @Query(""" SELECT COALESCE(SUM(cost) / NULLIF(SUM(energyAdded), 0), 0) diff --git a/app/src/main/java/com/matedroid/data/sync/DataSyncWorker.kt b/app/src/main/java/com/matedroid/data/sync/DataSyncWorker.kt index 6896d606..fa950bf9 100644 --- a/app/src/main/java/com/matedroid/data/sync/DataSyncWorker.kt +++ b/app/src/main/java/com/matedroid/data/sync/DataSyncWorker.kt @@ -17,6 +17,7 @@ import java.util.concurrent.TimeUnit import com.matedroid.R import com.matedroid.data.repository.ApiResult import com.matedroid.data.repository.TeslamateRepository +import com.matedroid.widget.TripSummaryWidgetUpdateWorker import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -134,6 +135,7 @@ class DataSyncWorker @AssistedInject constructor( log("Sync complete for all cars") // Schedule background geocoding for location data scheduleGeocoding() + TripSummaryWidgetUpdateWorker.scheduleImmediateUpdate(applicationContext) Result.success() } } catch (e: Exception) { 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 3f936db5..6b15afdd 100644 --- a/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt @@ -179,6 +179,7 @@ fun NavGraph( "mileage" -> Screen.Mileage(carId, exteriorColor) "battery" -> Screen.Battery(carId, exteriorColor = exteriorColor) "stats" -> Screen.Stats(carId, exteriorColor) + "trips" -> Screen.Trips(carId, exteriorColor) "countries_visited" -> Screen.CountriesVisited(carId, exteriorColor) "updates" -> Screen.Updates(carId, exteriorColor) "sentry_history" -> Screen.SentryHistory(carId, exteriorColor) diff --git a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardViewModel.kt index f3fc8925..19dbfa1a 100644 --- a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardViewModel.kt @@ -396,16 +396,20 @@ class DashboardViewModel @Inject constructor( } /** - * Fetches global settings from the API and caches the base_url. + * Fetches global settings from the API and caches settings used offline. * This runs silently - failures don't affect the user experience. */ private fun fetchAndCacheGlobalSettings() { viewModelScope.launch { when (val result = repository.getGlobalSettings()) { is ApiResult.Success -> { - result.data.settings?.teslamateUrls?.baseUrl?.let { url -> + val globalSettings = result.data.settings + globalSettings?.teslamateUrls?.baseUrl?.let { url -> settingsDataStore.saveTeslamateBaseUrl(url.trimEnd('/')) } + globalSettings?.teslamateUnits?.unitOfLength + ?.takeIf { it == "km" || it == "mi" } + ?.let { settingsDataStore.saveUnitOfLength(it) } } is ApiResult.Error -> { // Silent fail - this is optional functionality diff --git a/app/src/main/java/com/matedroid/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/settings/SettingsViewModel.kt index 680f8637..7b8f6e5e 100644 --- a/app/src/main/java/com/matedroid/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/matedroid/ui/screens/settings/SettingsViewModel.kt @@ -253,15 +253,19 @@ class SettingsViewModel @Inject constructor( } /** - * Fetches global settings from the API and caches the base_url. + * Fetches global settings from the API and caches settings used offline. * This runs silently - failures don't affect the user experience. */ private suspend fun fetchAndCacheGlobalSettings() { when (val result = repository.getGlobalSettings()) { is ApiResult.Success -> { - result.data.settings?.teslamateUrls?.baseUrl?.let { url -> + val globalSettings = result.data.settings + globalSettings?.teslamateUrls?.baseUrl?.let { url -> settingsDataStore.saveTeslamateBaseUrl(url.trimEnd('/')) } + globalSettings?.teslamateUnits?.unitOfLength + ?.takeIf { it == "km" || it == "mi" } + ?.let { settingsDataStore.saveUnitOfLength(it) } } is ApiResult.Error -> { // Silent fail - this is optional functionality diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt new file mode 100644 index 00000000..d38f2d1d --- /dev/null +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt @@ -0,0 +1,415 @@ +package com.matedroid.widget + +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.appWidgetBackground +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.state.GlanceStateDefinition +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import com.matedroid.MainActivity +import com.matedroid.R + +private val TRIP_WIDGET_BACKGROUND = Color(0xFF101413) +private val TRIP_WIDGET_PANEL = Color(0xFF18201E) +private val TRIP_WIDGET_ACCENT = Color(0xFF58D68D) +private val TRIP_WIDGET_SECONDARY = Color(0xFFFFC857) +private val TRIP_WIDGET_TEXT = Color.White +private val TRIP_WIDGET_MUTED = Color(0xB3FFFFFF) + +/** + * Read-only home screen widget focused on recent driving and charging summaries. + * + * All database work happens in [TripSummaryWidgetUpdateWorker]. Rendering uses + * preformatted values from Glance preferences so the widget itself never talks + * to TeslaMate or the car. + */ +class TripSummaryWidget : GlanceAppWidget() { + + companion object { + val CAR_ID_KEY = intPreferencesKey("trip_summary_car_id") + val HAS_DATA_KEY = booleanPreferencesKey("trip_summary_has_data") + val HAS_DRIVES_KEY = booleanPreferencesKey("trip_summary_has_drives") + val CAR_NAME_KEY = stringPreferencesKey("trip_summary_car_name") + val DISTANCE_VALUE_KEY = stringPreferencesKey("trip_summary_distance_value") + val DRIVE_COUNT_VALUE_KEY = stringPreferencesKey("trip_summary_drive_count_value") + val DRIVING_DAYS_VALUE_KEY = stringPreferencesKey("trip_summary_driving_days_value") + val ENERGY_VALUE_KEY = stringPreferencesKey("trip_summary_energy_value") + val EFFICIENCY_VALUE_KEY = stringPreferencesKey("trip_summary_efficiency_value") + val COST_VALUE_KEY = stringPreferencesKey("trip_summary_cost_value") + val COST_PER_DISTANCE_VALUE_KEY = stringPreferencesKey("trip_summary_cost_per_distance_value") + val DISTANCE_UNIT_KEY = stringPreferencesKey("trip_summary_distance_unit") + val UPDATED_VALUE_KEY = stringPreferencesKey("trip_summary_updated_value") + } + + override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition + + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { WidgetContent() } + } + + @Composable + internal fun WidgetContent() { + val prefs = currentState() + val carId = prefs[CAR_ID_KEY] + val hasData = prefs[HAS_DATA_KEY] ?: false + val context = LocalContext.current + + GlanceTheme { + val cornerMod = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + GlanceModifier.cornerRadius(android.R.dimen.system_app_widget_background_radius) + } else { + GlanceModifier + } + val openTripsIntent = Intent(context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra("EXTRA_NAVIGATE_TO", "trips") + .putExtra("EXTRA_SKIP_CAR_WIDGET_UPDATE", true) + .apply { + if (carId != null) putExtra("EXTRA_CAR_ID", carId) + } + + Box( + modifier = GlanceModifier + .fillMaxSize() + .appWidgetBackground() + .then(cornerMod) + .background(ColorProvider(TRIP_WIDGET_BACKGROUND)) + .clickable(actionStartActivity(openTripsIntent)) + ) { + when { + carId == null -> CenteredMessage(context.getString(R.string.widget_error_configure)) + !hasData -> CenteredMessage(context.getString(R.string.widget_loading)) + else -> SummaryContent(prefs) + } + } + } + } + + @Composable + private fun CenteredMessage(text: String) { + Box( + modifier = GlanceModifier + .fillMaxSize() + .background(ColorProvider(TRIP_WIDGET_BACKGROUND)) + .padding(12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = TextStyle( + color = ColorProvider(TRIP_WIDGET_MUTED), + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 2 + ) + } + } + + @Composable + private fun SummaryContent(prefs: Preferences) { + val context = LocalContext.current + val size = LocalSize.current + val compact = size.height.value < 115f || size.width.value < 190f + + val carName = prefs[CAR_NAME_KEY].orEmpty() + val hasDrives = prefs[HAS_DRIVES_KEY] ?: false + val distance = prefs[DISTANCE_VALUE_KEY].orEmpty() + val drives = prefs[DRIVE_COUNT_VALUE_KEY].orEmpty() + val drivingDays = prefs[DRIVING_DAYS_VALUE_KEY].orEmpty() + val energy = prefs[ENERGY_VALUE_KEY].orEmpty() + val efficiency = prefs[EFFICIENCY_VALUE_KEY].orEmpty() + val cost = prefs[COST_VALUE_KEY].orEmpty() + val costPerDistance = prefs[COST_PER_DISTANCE_VALUE_KEY].orEmpty() + val distanceUnit = prefs[DISTANCE_UNIT_KEY].orEmpty() + val updated = prefs[UPDATED_VALUE_KEY].orEmpty() + + Column( + modifier = GlanceModifier + .fillMaxSize() + .padding( + horizontal = if (compact) 10.dp else 14.dp, + vertical = if (compact) 8.dp else 12.dp + ) + ) { + Row( + modifier = GlanceModifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = GlanceModifier.defaultWeight()) { + if (carName.isNotBlank()) { + Text( + text = carName, + style = TextStyle( + color = ColorProvider(TRIP_WIDGET_TEXT), + fontSize = if (compact) 12.sp else 14.sp, + fontWeight = FontWeight.Bold + ), + maxLines = 1 + ) + } + Text( + text = context.getString(R.string.trip_widget_period_last_30_days), + style = TextStyle( + color = ColorProvider(TRIP_WIDGET_MUTED), + fontSize = if (compact) 10.sp else 11.sp + ), + maxLines = 1 + ) + } + if (!compact && updated.isNotBlank()) { + Text( + text = updated, + style = TextStyle( + color = ColorProvider(TRIP_WIDGET_MUTED), + fontSize = 10.sp + ), + maxLines = 1 + ) + } + } + + Spacer(modifier = GlanceModifier.height(if (compact) 4.dp else 8.dp)) + + if (!hasDrives) { + Box( + modifier = GlanceModifier + .fillMaxWidth() + .defaultWeight() + .background(ColorProvider(TRIP_WIDGET_PANEL)) + .cornerRadius(8.dp) + .padding(12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = context.getString(R.string.trip_widget_empty), + style = TextStyle( + color = ColorProvider(TRIP_WIDGET_MUTED), + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 2 + ) + } + } else { + Row( + modifier = GlanceModifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom + ) { + Column(modifier = GlanceModifier.defaultWeight()) { + Text( + text = distance, + style = TextStyle( + color = ColorProvider(TRIP_WIDGET_ACCENT), + fontSize = if (compact) 22.sp else 30.sp, + fontWeight = FontWeight.Bold + ), + maxLines = 1 + ) + Text( + text = context.getString(R.string.distance), + style = TextStyle( + color = ColorProvider(TRIP_WIDGET_MUTED), + fontSize = 10.sp + ), + maxLines = 1 + ) + } + Spacer(modifier = GlanceModifier.width(8.dp)) + Column( + horizontalAlignment = Alignment.End + ) { + Text( + text = drives, + style = TextStyle( + color = ColorProvider(TRIP_WIDGET_TEXT), + fontSize = if (compact) 18.sp else 22.sp, + fontWeight = FontWeight.Bold + ), + maxLines = 1 + ) + Text( + text = context.getString(R.string.nav_drives), + style = TextStyle( + color = ColorProvider(TRIP_WIDGET_MUTED), + fontSize = 10.sp + ), + maxLines = 1 + ) + } + } + + if (compact) { + Spacer(modifier = GlanceModifier.defaultWeight()) + CompactFooter(cost = cost, efficiency = efficiency) + } else { + Spacer(modifier = GlanceModifier.defaultWeight()) + + Row(modifier = GlanceModifier.fillMaxWidth()) { + MetricTile( + label = context.getString(R.string.trip_widget_energy), + value = energy, + accent = TRIP_WIDGET_SECONDARY, + modifier = GlanceModifier.defaultWeight() + ) + Spacer(modifier = GlanceModifier.width(8.dp)) + MetricTile( + label = context.getString(R.string.cost), + value = cost, + accent = TRIP_WIDGET_ACCENT, + modifier = GlanceModifier.defaultWeight() + ) + } + + Spacer(modifier = GlanceModifier.height(7.dp)) + + Row(modifier = GlanceModifier.fillMaxWidth()) { + MetricTile( + label = context.getString(R.string.trip_widget_efficiency), + value = efficiency, + accent = TRIP_WIDGET_ACCENT, + modifier = GlanceModifier.defaultWeight() + ) + Spacer(modifier = GlanceModifier.width(8.dp)) + MetricTile( + label = context.getString(R.string.trip_widget_cost_per_distance, distanceUnit), + value = costPerDistance, + accent = TRIP_WIDGET_SECONDARY, + modifier = GlanceModifier.defaultWeight() + ) + Spacer(modifier = GlanceModifier.width(8.dp)) + MetricTile( + label = context.getString(R.string.trip_widget_driving_days), + value = drivingDays, + accent = TRIP_WIDGET_TEXT, + modifier = GlanceModifier.defaultWeight() + ) + } + } + } + } + } + + @Composable + private fun CompactFooter(cost: String, efficiency: String) { + Row( + modifier = GlanceModifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = cost, + style = TextStyle( + color = ColorProvider(TRIP_WIDGET_TEXT), + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 1 + ) + Spacer(modifier = GlanceModifier.defaultWeight()) + Text( + text = efficiency, + style = TextStyle( + color = ColorProvider(TRIP_WIDGET_MUTED), + fontSize = 12.sp + ), + maxLines = 1 + ) + } + } + + @Composable + private fun MetricTile( + label: String, + value: String, + accent: Color, + modifier: GlanceModifier = GlanceModifier + ) { + Column( + modifier = modifier + .background(ColorProvider(TRIP_WIDGET_PANEL)) + .cornerRadius(8.dp) + .padding(horizontal = 8.dp, vertical = 7.dp) + ) { + Text( + text = value, + style = TextStyle( + color = ColorProvider(accent), + fontSize = 13.sp, + fontWeight = FontWeight.Bold + ), + maxLines = 1 + ) + Text( + text = label, + style = TextStyle( + color = ColorProvider(TRIP_WIDGET_MUTED), + fontSize = 9.sp + ), + maxLines = 1 + ) + } + } + + suspend fun updateWidget( + context: Context, + glanceId: GlanceId, + data: TripSummaryWidgetDisplayData + ) { + updateAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) { prefs -> + prefs.toMutablePreferences().apply { + this[CAR_ID_KEY] = data.carId + this[HAS_DATA_KEY] = true + this[HAS_DRIVES_KEY] = data.hasDrives + this[CAR_NAME_KEY] = data.carName + this[DISTANCE_VALUE_KEY] = data.distanceValue + this[DRIVE_COUNT_VALUE_KEY] = data.driveCountValue + this[DRIVING_DAYS_VALUE_KEY] = data.drivingDaysValue + this[ENERGY_VALUE_KEY] = data.energyValue + this[EFFICIENCY_VALUE_KEY] = data.efficiencyValue + this[COST_VALUE_KEY] = data.costValue + this[COST_PER_DISTANCE_VALUE_KEY] = data.costPerDistanceValue + this[DISTANCE_UNIT_KEY] = data.distanceUnit + this[UPDATED_VALUE_KEY] = data.updatedValue + } + } + update(context, glanceId) + } +} diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetConfigActivity.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetConfigActivity.kt new file mode 100644 index 00000000..0edac417 --- /dev/null +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetConfigActivity.kt @@ -0,0 +1,204 @@ +package com.matedroid.widget + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.lifecycle.lifecycleScope +import com.matedroid.R +import com.matedroid.data.api.models.CarData +import com.matedroid.data.repository.ApiResult +import com.matedroid.data.repository.TeslamateRepository +import com.matedroid.ui.theme.MateDroidTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class TripSummaryWidgetConfigActivity : ComponentActivity() { + + @Inject + lateinit var teslamateRepository: TeslamateRepository + + private sealed interface ScreenState { + data object Loading : ScreenState + data class Picker(val cars: List) : ScreenState + data class Error(val message: String) : ScreenState + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setResult(RESULT_CANCELED) + + val appWidgetId = intent.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + setContent { + MateDroidTheme { + var screenState by remember { mutableStateOf(ScreenState.Loading) } + + LaunchedEffect(Unit) { + when (val result = teslamateRepository.getCars()) { + is ApiResult.Success -> { + val cars = result.data + when { + cars.isEmpty() -> screenState = ScreenState.Error( + getString(R.string.no_vehicles_found) + ) + cars.size == 1 -> confirmSelection(appWidgetId, cars.first()) + else -> screenState = ScreenState.Picker(cars) + } + } + is ApiResult.Error -> screenState = ScreenState.Error(result.message) + } + } + + when (val s = screenState) { + is ScreenState.Loading -> LoadingScreen() + is ScreenState.Picker -> PickerScreen(s.cars) { car -> + confirmSelection(appWidgetId, car) + } + is ScreenState.Error -> ErrorScreen(s.message) + } + } + } + } + + @Composable + private fun LoadingScreen() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + } + } + + @Composable + private fun ErrorScreen(message: String) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.error_loading_data), + style = MaterialTheme.typography.bodyLarge + ) + Spacer(Modifier.height(8.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + @Composable + @OptIn(ExperimentalMaterial3Api::class) + private fun PickerScreen(cars: List, onCarSelected: (CarData) -> Unit) { + Scaffold( + topBar = { + TopAppBar(title = { Text(stringResource(R.string.widget_select_car_title)) }) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(cars) { car -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onCarSelected(car) } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = car.displayName, + style = MaterialTheme.typography.titleMedium + ) + } + } + } + } + } + } + + private fun confirmSelection(appWidgetId: Int, car: CarData) { + lifecycleScope.launch { + val glanceId = GlanceAppWidgetManager(this@TripSummaryWidgetConfigActivity) + .getGlanceIdBy(appWidgetId) + + updateAppWidgetState( + this@TripSummaryWidgetConfigActivity, + PreferencesGlanceStateDefinition, + glanceId + ) { prefs -> + prefs.toMutablePreferences().apply { + this[TripSummaryWidget.CAR_ID_KEY] = car.carId + this[TripSummaryWidget.CAR_NAME_KEY] = car.displayName + this[TripSummaryWidget.HAS_DATA_KEY] = false + } + } + + TripSummaryWidget().update(this@TripSummaryWidgetConfigActivity, glanceId) + TripSummaryWidgetUpdateWorker.scheduleImmediateUpdate(this@TripSummaryWidgetConfigActivity) + + setResult(RESULT_OK, Intent().apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + }) + finish() + } + } +} diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDateRange.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDateRange.kt new file mode 100644 index 00000000..ef5523b0 --- /dev/null +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDateRange.kt @@ -0,0 +1,19 @@ +package com.matedroid.widget + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +object TripSummaryWidgetDateRange { + private val API_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") + + fun lastDays(today: LocalDate = LocalDate.now(), days: Long = 30): Pair { + require(days > 0) { "days must be positive" } + + val start = today.minusDays(days - 1).atStartOfDay() + val end = today.plusDays(1).atStartOfDay() + return start.toApiDateString() to end.toApiDateString() + } + + private fun LocalDateTime.toApiDateString(): String = format(API_DATE_FORMATTER) +} diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDisplayData.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDisplayData.kt new file mode 100644 index 00000000..5fb9144e --- /dev/null +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDisplayData.kt @@ -0,0 +1,22 @@ +package com.matedroid.widget + +/** + * Persisted display snapshot for the read-only trip summary widget. + * + * The widget renderer reads only these preformatted values from Glance + * preferences. The worker owns all database access and formatting. + */ +data class TripSummaryWidgetDisplayData( + val carId: Int, + val carName: String, + val hasDrives: Boolean, + val distanceValue: String, + val driveCountValue: String, + val drivingDaysValue: String, + val energyValue: String, + val efficiencyValue: String, + val costValue: String, + val costPerDistanceValue: String, + val distanceUnit: String, + val updatedValue: String, +) diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetFormatter.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetFormatter.kt new file mode 100644 index 00000000..21777c41 --- /dev/null +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetFormatter.kt @@ -0,0 +1,59 @@ +package com.matedroid.widget + +import com.matedroid.data.api.models.Units +import com.matedroid.domain.model.UnitFormatter + +data class TripSummaryWidgetMetrics( + val driveCount: Int, + val drivingDays: Int, + val totalDistance: Double, + val totalEnergy: Double, + val avgEfficiency: Double, + val totalCost: Double, + val chargesWithCost: Int, +) + +object TripSummaryWidgetFormatter { + + fun format( + carId: Int, + carName: String, + metrics: TripSummaryWidgetMetrics, + currencySymbol: String, + noValue: String, + updatedValue: String, + units: Units? = null, + ): TripSummaryWidgetDisplayData { + val hasCostData = metrics.chargesWithCost > 0 + val costValue = if (hasCostData) { + UnitFormatter.formatCost(metrics.totalCost, currencySymbol) + } else { + noValue + } + val costPerDistance = if (hasCostData && metrics.totalDistance > 0) { + UnitFormatter.formatCost((metrics.totalCost / metrics.totalDistance) * 100, currencySymbol) + } else { + noValue + } + val efficiencyValue = if (metrics.avgEfficiency > 0) { + UnitFormatter.formatEfficiency(metrics.avgEfficiency, units = units, decimals = 0) + } else { + noValue + } + + return TripSummaryWidgetDisplayData( + carId = carId, + carName = carName, + hasDrives = metrics.driveCount > 0, + distanceValue = UnitFormatter.formatDistance(metrics.totalDistance, units = units, decimals = 0), + driveCountValue = "%,d".format(metrics.driveCount), + drivingDaysValue = "%,d".format(metrics.drivingDays), + energyValue = UnitFormatter.formatEnergy(metrics.totalEnergy), + efficiencyValue = efficiencyValue, + costValue = costValue, + costPerDistanceValue = costPerDistance, + distanceUnit = UnitFormatter.getDistanceUnit(units), + updatedValue = updatedValue, + ) + } +} diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetReceiver.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetReceiver.kt new file mode 100644 index 00000000..169f3b41 --- /dev/null +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetReceiver.kt @@ -0,0 +1,32 @@ +package com.matedroid.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.os.Bundle +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +class TripSummaryWidgetReceiver : GlanceAppWidgetReceiver() { + + override val glanceAppWidget: GlanceAppWidget = TripSummaryWidget() + + override fun onEnabled(context: Context) { + super.onEnabled(context) + TripSummaryWidgetUpdateWorker.scheduleWork(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + TripSummaryWidgetUpdateWorker.cancelWork(context) + } + + override fun onAppWidgetOptionsChanged( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + newOptions: Bundle + ) { + super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions) + TripSummaryWidgetUpdateWorker.scheduleImmediateUpdate(context) + } +} diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetUpdateWorker.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetUpdateWorker.kt new file mode 100644 index 00000000..a922f3a8 --- /dev/null +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetUpdateWorker.kt @@ -0,0 +1,154 @@ +package com.matedroid.widget + +import android.content.Context +import android.util.Log +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.matedroid.R +import com.matedroid.data.api.models.Units +import com.matedroid.data.local.SettingsDataStore +import com.matedroid.data.local.dao.ChargeSummaryDao +import com.matedroid.data.local.dao.DriveSummaryDao +import com.matedroid.data.model.Currency +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.firstOrNull +import java.text.DateFormat +import java.util.Date +import java.util.concurrent.TimeUnit + +@HiltWorker +class TripSummaryWidgetUpdateWorker @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted workerParams: WorkerParameters, + private val driveSummaryDao: DriveSummaryDao, + private val chargeSummaryDao: ChargeSummaryDao, + private val settingsDataStore: SettingsDataStore, +) : CoroutineWorker(appContext, workerParams) { + + companion object { + private const val TAG = "TripSummaryWidgetWorker" + const val WORK_NAME = "trip_summary_widget_update" + const val PERIODIC_WORK_NAME = "trip_summary_widget_update_periodic" + private const val PERIODIC_INTERVAL_HOURS = 6L + private const val RANGE_DAYS = 30L + + fun scheduleImmediateUpdate(context: Context) { + val request = OneTimeWorkRequestBuilder() + .addTag(TAG) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.REPLACE, + request + ) + Log.d(TAG, "Scheduled immediate trip summary widget update") + } + + fun scheduleWork(context: Context) { + val periodicRequest = PeriodicWorkRequestBuilder( + PERIODIC_INTERVAL_HOURS, + TimeUnit.HOURS + ) + .addTag("$TAG-periodic") + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + periodicRequest + ) + Log.d(TAG, "Scheduled trip summary widget update every ${PERIODIC_INTERVAL_HOURS}h") + } + + fun cancelWork(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + WorkManager.getInstance(context).cancelUniqueWork(PERIODIC_WORK_NAME) + Log.d(TAG, "Cancelled trip summary widget update work") + } + } + + override suspend fun doWork(): Result { + Log.d(TAG, "Starting trip summary widget update") + + val manager = GlanceAppWidgetManager(appContext) + val glanceIds = manager.getGlanceIds(TripSummaryWidget::class.java) + + if (glanceIds.isEmpty()) { + Log.d(TAG, "No active trip summary widgets, skipping update") + return Result.success() + } + + val settings = settingsDataStore.settings.firstOrNull() + val currency = Currency.findByCode(settings?.currencyCode ?: Currency.DEFAULT.code) + val units = Units(unitOfLength = settings?.unitOfLength ?: "km") + val (startDate, endDate) = TripSummaryWidgetDateRange.lastDays(days = RANGE_DAYS) + + for (glanceId in glanceIds) { + try { + val prefs = getAppWidgetState(appContext, PreferencesGlanceStateDefinition, glanceId) + val carId = prefs[TripSummaryWidget.CAR_ID_KEY] ?: continue + val carName = prefs[TripSummaryWidget.CAR_NAME_KEY] + ?: appContext.getString(R.string.app_name) + + val displayData = buildDisplayData( + carId = carId, + carName = carName, + currencySymbol = currency.symbol, + units = units, + startDate = startDate, + endDate = endDate + ) + TripSummaryWidget().updateWidget(appContext, glanceId, displayData) + Log.d(TAG, "Updated trip summary widget for car $carId") + } catch (e: Exception) { + Log.e(TAG, "Error updating trip summary widget $glanceId", e) + } + } + + return Result.success() + } + + private suspend fun buildDisplayData( + carId: Int, + carName: String, + currencySymbol: String, + units: Units, + startDate: String, + endDate: String, + ): TripSummaryWidgetDisplayData { + val metrics = TripSummaryWidgetMetrics( + driveCount = driveSummaryDao.countInRange(carId, startDate, endDate), + drivingDays = driveSummaryDao.countDrivingDaysInRange(carId, startDate, endDate), + totalDistance = driveSummaryDao.sumDistanceInRange(carId, startDate, endDate), + totalEnergy = driveSummaryDao.sumEnergyConsumedInRange(carId, startDate, endDate), + avgEfficiency = driveSummaryDao.avgEfficiencyInRange(carId, startDate, endDate), + totalCost = chargeSummaryDao.sumCostInRange(carId, startDate, endDate), + chargesWithCost = chargeSummaryDao.countWithCostInRange(carId, startDate, endDate), + ) + val noValue = appContext.getString(R.string.trip_widget_no_value) + return TripSummaryWidgetFormatter.format( + carId = carId, + carName = carName, + metrics = metrics, + currencySymbol = currencySymbol, + noValue = noValue, + updatedValue = appContext.getString( + R.string.trip_widget_updated, + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date()) + ), + units = units, + ) + } + +} diff --git a/app/src/main/res/layout/trip_summary_widget_preview.xml b/app/src/main/res/layout/trip_summary_widget_preview.xml new file mode 100644 index 00000000..4c90b2d5 --- /dev/null +++ b/app/src/main/res/layout/trip_summary_widget_preview.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index e234d5d6..4993606c 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1198,6 +1198,16 @@ Estat de bateria del teu Tesla + Resum de viatges + Els darrers 30 dies de trajectes, energia i costos de càrrega + Darrers 30 dies + Cap trajecte en els darrers 30 dies + Dies + Energia + Eficiència + Cost/100 %1$s + N/D + Actualitzat %1$s Selecciona un vehicle per a l\'estri Carregant… Toca per configurar diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 491f89e9..86b547d9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1286,6 +1286,16 @@ Batteriestatus für deinen Tesla + Trip-Zusammenfassung + Die letzten 30 Tage mit Fahrten, Energie und Ladekosten + Letzte 30 Tage + Keine Fahrten in den letzten 30 Tagen + Tage + Energie + Effizienz + Kosten/100 %1$s + k. A. + Aktualisiert %1$s Fahrzeug für das Widget auswählen @@ -1394,4 +1404,4 @@ %d vergleichbare Ladung in der Nähe %d vergleichbare Ladungen in der Nähe - \ No newline at end of file + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 1a73b6fc..3ef52b68 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1198,6 +1198,16 @@ Estado de batería de tu Tesla + Resumen de viajes + Últimos 30 días de recorridos, energía y costes de carga + Últimos 30 días + No hay recorridos en los últimos 30 días + Días + Energía + Eficiencia + Coste/100 %1$s + N/D + Actualizado %1$s Selecciona un vehículo para el widget Cargando… Toca para configurar diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 511cf585..0feb86e8 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1198,6 +1198,16 @@ Stato batteria per la tua Tesla + Riepilogo viaggi + Ultimi 30 giorni di percorsi, energia e costi di ricarica + Ultimi 30 giorni + Nessun percorso negli ultimi 30 giorni + Giorni + Energia + Efficienza + Costo/100 %1$s + N/D + Aggiornato %1$s Seleziona un veicolo per il widget Caricamento… Tocca per configurare diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index ee7ed110..b0526166 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1201,6 +1201,16 @@ Tesla 电池状态小组件 + 行程摘要 + 最近 30 天的行驶、能耗和充电成本 + 最近 30 天 + 最近 30 天没有行驶记录 + 天数 + 能耗 + 效率 + 费用/100 %1$s + 暂无 + 已更新 %1$s 为小组件选择一辆车 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab9a97dd..76f2c7b5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1292,6 +1292,36 @@ Battery status for your Tesla + + Trip summary + + + Last 30 days of drives, energy, and charging costs + + + Last 30 days + + + No drives in the last 30 days + + + Days + + + Energy + + + Efficiency + + + Cost/100 %1$s + + + N/A + + + Updated %1$s + Select a vehicle for the widget diff --git a/app/src/main/res/xml/trip_summary_widget_info.xml b/app/src/main/res/xml/trip_summary_widget_info.xml new file mode 100644 index 00000000..68c3767c --- /dev/null +++ b/app/src/main/res/xml/trip_summary_widget_info.xml @@ -0,0 +1,13 @@ + + diff --git a/app/src/test/java/com/matedroid/ui/screens/dashboard/DashboardViewModelTest.kt b/app/src/test/java/com/matedroid/ui/screens/dashboard/DashboardViewModelTest.kt index 5cd099b0..ef0084d3 100644 --- a/app/src/test/java/com/matedroid/ui/screens/dashboard/DashboardViewModelTest.kt +++ b/app/src/test/java/com/matedroid/ui/screens/dashboard/DashboardViewModelTest.kt @@ -9,6 +9,7 @@ import com.matedroid.data.api.models.ChargingDetails import com.matedroid.data.api.models.GlobalSettings import com.matedroid.data.api.models.GlobalSettingsData import com.matedroid.data.api.models.TeslamateUrls +import com.matedroid.data.api.models.TeslamateUnits import com.matedroid.data.api.models.Units import com.matedroid.data.local.AppSettings import com.matedroid.data.local.SettingsDataStore @@ -133,6 +134,27 @@ class DashboardViewModelTest { assertEquals(testStatus, viewModel!!.uiState.value.carStatus) } + @Test + fun `loadCars caches unit of length from global settings`() = runTest { + coEvery { repository.getCars() } returns ApiResult.Success(listOf(testCar)) + coEvery { repository.getCarStatus(1) } returns ApiResult.Success(testStatusWithUnits) + coEvery { repository.getGlobalSettings() } returns ApiResult.Success( + GlobalSettingsData( + settings = GlobalSettings( + teslamateUrls = TeslamateUrls(baseUrl = "https://teslamate.example.com"), + teslamateUnits = TeslamateUnits(unitOfLength = "mi") + ) + ) + ) + coEvery { settingsDataStore.saveUnitOfLength(any()) } returns Unit + + viewModel = createViewModel() + runCurrent() + cancelViewModelCoroutines() + + coVerify { settingsDataStore.saveUnitOfLength("mi") } + } + @Test fun `loadCars shows error when api fails`() = runTest { coEvery { repository.getCars() } returns ApiResult.Error("Network error") diff --git a/app/src/test/java/com/matedroid/ui/screens/settings/SettingsViewModelTest.kt b/app/src/test/java/com/matedroid/ui/screens/settings/SettingsViewModelTest.kt index f7685529..6181489a 100644 --- a/app/src/test/java/com/matedroid/ui/screens/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/matedroid/ui/screens/settings/SettingsViewModelTest.kt @@ -5,6 +5,7 @@ import androidx.work.WorkManager import com.matedroid.data.api.models.GlobalSettings import com.matedroid.data.api.models.GlobalSettingsData import com.matedroid.data.api.models.TeslamateUrls +import com.matedroid.data.api.models.TeslamateUnits import com.matedroid.data.local.AppSettings import com.matedroid.data.local.SettingsDataStore import com.matedroid.data.repository.ApiResult @@ -179,6 +180,30 @@ class SettingsViewModelTest { assertNull(result.secondaryResult) // No secondary URL configured } + @Test + fun `testConnection caches unit of length from global settings`() = runTest { + coEvery { repository.testConnection(any(), any()) } returns ApiResult.Success(Unit) + coEvery { repository.getGlobalSettings() } returns ApiResult.Success( + GlobalSettingsData( + settings = GlobalSettings( + teslamateUrls = TeslamateUrls(baseUrl = "https://teslamate.example.com"), + teslamateUnits = TeslamateUnits(unitOfLength = "mi") + ) + ) + ) + coEvery { settingsDataStore.saveTeslamateBaseUrl(any()) } returns Unit + coEvery { settingsDataStore.saveUnitOfLength(any()) } returns Unit + + viewModel = createViewModel() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.updateServerUrl("https://valid.com") + viewModel.testConnection() + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { settingsDataStore.saveUnitOfLength("mi") } + } + @Test fun `testConnection tests both servers when secondary is configured`() = runTest { coEvery { repository.testConnection("https://primary.com", any()) } returns ApiResult.Success(Unit) diff --git a/app/src/test/java/com/matedroid/widget/TripSummaryWidgetDateRangeTest.kt b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetDateRangeTest.kt new file mode 100644 index 00000000..ec0a2c22 --- /dev/null +++ b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetDateRangeTest.kt @@ -0,0 +1,27 @@ +package com.matedroid.widget + +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class TripSummaryWidgetDateRangeTest { + + @Test + fun lastDaysIncludesTodayAndPreviousDays() { + val (startDate, endDate) = TripSummaryWidgetDateRange.lastDays( + today = LocalDate.of(2026, 7, 8), + days = 30 + ) + + assertEquals("2026-06-09T00:00:00", startDate) + assertEquals("2026-07-09T00:00:00", endDate) + } + + @Test + fun lastDaysRejectsNonPositiveRanges() { + assertThrows(IllegalArgumentException::class.java) { + TripSummaryWidgetDateRange.lastDays(days = 0) + } + } +} diff --git a/app/src/test/java/com/matedroid/widget/TripSummaryWidgetFormatterTest.kt b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetFormatterTest.kt new file mode 100644 index 00000000..507ef312 --- /dev/null +++ b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetFormatterTest.kt @@ -0,0 +1,132 @@ +package com.matedroid.widget + +import com.matedroid.data.api.models.Units +import java.util.Locale +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class TripSummaryWidgetFormatterTest { + + private lateinit var previousLocale: Locale + + @Before + fun setUp() { + previousLocale = Locale.getDefault() + Locale.setDefault(Locale.US) + } + + @After + fun tearDown() { + Locale.setDefault(previousLocale) + } + + @Test + fun formatShowsNoValueWhenNoChargeCostWasRecorded() { + val data = TripSummaryWidgetFormatter.format( + carId = 7, + carName = "Model Y", + metrics = TripSummaryWidgetMetrics( + driveCount = 4, + drivingDays = 3, + totalDistance = 300.0, + totalEnergy = 48.0, + avgEfficiency = 160.0, + totalCost = 0.0, + chargesWithCost = 0, + ), + currencySymbol = "\$", + noValue = "N/A", + updatedValue = "Updated 9:00", + ) + + assertTrue(data.hasDrives) + assertEquals("300 km", data.distanceValue) + assertEquals("4", data.driveCountValue) + assertEquals("3", data.drivingDaysValue) + assertEquals("48 kWh", data.energyValue) + assertEquals("160 Wh/km", data.efficiencyValue) + assertEquals("N/A", data.costValue) + assertEquals("N/A", data.costPerDistanceValue) + assertEquals("km", data.distanceUnit) + assertEquals("Updated 9:00", data.updatedValue) + } + + @Test + fun formatShowsZeroCostWhenAZeroCostChargeWasRecorded() { + val data = TripSummaryWidgetFormatter.format( + carId = 7, + carName = "Model Y", + metrics = TripSummaryWidgetMetrics( + driveCount = 3, + drivingDays = 2, + totalDistance = 250.0, + totalEnergy = 40.0, + avgEfficiency = 160.0, + totalCost = 0.0, + chargesWithCost = 1, + ), + currencySymbol = "\$", + noValue = "N/A", + updatedValue = "Updated 9:00", + ) + + assertEquals("0.00 \$", data.costValue) + assertEquals("0.00 \$", data.costPerDistanceValue) + } + + @Test + fun formatUsesImperialLabelsWhenUnitsAreImperial() { + val data = TripSummaryWidgetFormatter.format( + carId = 7, + carName = "Model 3", + metrics = TripSummaryWidgetMetrics( + driveCount = 2, + drivingDays = 2, + totalDistance = 120.0, + totalEnergy = 30.0, + avgEfficiency = 250.0, + totalCost = 12.0, + chargesWithCost = 1, + ), + currencySymbol = "\$", + noValue = "N/A", + updatedValue = "Updated 9:00", + units = Units(unitOfLength = "mi"), + ) + + assertEquals("120 mi", data.distanceValue) + assertEquals("250 Wh/mi", data.efficiencyValue) + assertEquals("10.00 \$", data.costPerDistanceValue) + assertEquals("mi", data.distanceUnit) + } + + @Test + fun formatMarksEmptyDriveRange() { + val data = TripSummaryWidgetFormatter.format( + carId = 7, + carName = "Model S", + metrics = TripSummaryWidgetMetrics( + driveCount = 0, + drivingDays = 0, + totalDistance = 0.0, + totalEnergy = 0.0, + avgEfficiency = 0.0, + totalCost = 0.0, + chargesWithCost = 0, + ), + currencySymbol = "\$", + noValue = "N/A", + updatedValue = "Updated 9:00", + ) + + assertFalse(data.hasDrives) + assertEquals("0 km", data.distanceValue) + assertEquals("0", data.drivingDaysValue) + assertEquals("N/A", data.efficiencyValue) + assertEquals("N/A", data.costValue) + } +} From 2809e1dffcbc50b2d4b5db7e42598541c19af686 Mon Sep 17 00:00:00 2001 From: Raymond Kelly Date: Wed, 8 Jul 2026 22:40:04 -0500 Subject: [PATCH 2/5] fix: label compact trip widget metrics --- app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt | 5 +++-- app/src/main/res/values-ca/strings.xml | 2 ++ app/src/main/res/values-de/strings.xml | 2 ++ app/src/main/res/values-es/strings.xml | 2 ++ app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-zh/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 6 ++++++ 7 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt index d38f2d1d..d0003be7 100644 --- a/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt @@ -330,12 +330,13 @@ class TripSummaryWidget : GlanceAppWidget() { @Composable private fun CompactFooter(cost: String, efficiency: String) { + val context = LocalContext.current Row( modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text( - text = cost, + text = context.getString(R.string.trip_widget_compact_cost, cost), style = TextStyle( color = ColorProvider(TRIP_WIDGET_TEXT), fontSize = 12.sp, @@ -345,7 +346,7 @@ class TripSummaryWidget : GlanceAppWidget() { ) Spacer(modifier = GlanceModifier.defaultWeight()) Text( - text = efficiency, + text = context.getString(R.string.trip_widget_compact_efficiency, efficiency), style = TextStyle( color = ColorProvider(TRIP_WIDGET_MUTED), fontSize = 12.sp diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 4993606c..0d61d49a 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1206,6 +1206,8 @@ Energia Eficiència Cost/100 %1$s + Cost %1$s + Ef. %1$s N/D Actualitzat %1$s Selecciona un vehicle per a l\'estri diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 86b547d9..48d82fd9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1294,6 +1294,8 @@ Energie Effizienz Kosten/100 %1$s + Kosten %1$s + Eff. %1$s k. A. Aktualisiert %1$s diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3ef52b68..b7572cb2 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1206,6 +1206,8 @@ Energía Eficiencia Coste/100 %1$s + Coste %1$s + Ef. %1$s N/D Actualizado %1$s Selecciona un vehículo para el widget diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0feb86e8..f7111ed5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1206,6 +1206,8 @@ Energia Efficienza Costo/100 %1$s + Costo %1$s + Eff. %1$s N/D Aggiornato %1$s Seleziona un veicolo per il widget diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index b0526166..e401dab2 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1209,6 +1209,8 @@ 能耗 效率 费用/100 %1$s + 费用 %1$s + 效率 %1$s 暂无 已更新 %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 76f2c7b5..72059c1c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1316,6 +1316,12 @@ Cost/100 %1$s + + Cost %1$s + + + Eff %1$s + N/A From 6678f0ffb4b6981b030097ff092ac2048b2146c0 Mon Sep 17 00:00:00 2001 From: Raymond Kelly Date: Wed, 8 Jul 2026 22:42:41 -0500 Subject: [PATCH 3/5] chore: add trip widget preview layout --- app/src/main/res/xml/trip_summary_widget_info.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/xml/trip_summary_widget_info.xml b/app/src/main/res/xml/trip_summary_widget_info.xml index 68c3767c..6063480f 100644 --- a/app/src/main/res/xml/trip_summary_widget_info.xml +++ b/app/src/main/res/xml/trip_summary_widget_info.xml @@ -10,4 +10,5 @@ android:updatePeriodMillis="0" android:configure="com.matedroid.widget.TripSummaryWidgetConfigActivity" android:description="@string/trip_widget_description" + android:previewLayout="@layout/trip_summary_widget_preview" android:initialLayout="@layout/trip_summary_widget_preview" /> From da23f284869411308f4fb274913b5e9275578b2e Mon Sep 17 00:00:00 2001 From: Raymond Kelly Date: Thu, 9 Jul 2026 19:55:17 -0500 Subject: [PATCH 4/5] feat: add trip widget range selector --- .../com/matedroid/widget/TripSummaryWidget.kt | 7 ++- .../widget/TripSummaryWidgetConfigActivity.kt | 54 ++++++++++++++++--- .../widget/TripSummaryWidgetDisplayData.kt | 1 + .../widget/TripSummaryWidgetFormatter.kt | 2 + .../widget/TripSummaryWidgetRange.kt | 21 ++++++++ .../widget/TripSummaryWidgetUpdateWorker.kt | 9 +++- app/src/main/res/values-ca/strings.xml | 7 ++- app/src/main/res/values-de/strings.xml | 7 ++- app/src/main/res/values-es/strings.xml | 7 ++- app/src/main/res/values-it/strings.xml | 7 ++- app/src/main/res/values-zh/strings.xml | 7 ++- app/src/main/res/values/strings.xml | 9 +++- .../widget/TripSummaryWidgetDateRangeTest.kt | 27 ++++++++++ .../widget/TripSummaryWidgetFormatterTest.kt | 5 ++ 14 files changed, 149 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/matedroid/widget/TripSummaryWidgetRange.kt diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt index d0003be7..fccf01a9 100644 --- a/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt @@ -66,6 +66,8 @@ class TripSummaryWidget : GlanceAppWidget() { val HAS_DATA_KEY = booleanPreferencesKey("trip_summary_has_data") val HAS_DRIVES_KEY = booleanPreferencesKey("trip_summary_has_drives") val CAR_NAME_KEY = stringPreferencesKey("trip_summary_car_name") + val RANGE_DAYS_KEY = intPreferencesKey("trip_summary_range_days") + val PERIOD_LABEL_KEY = stringPreferencesKey("trip_summary_period_label") val DISTANCE_VALUE_KEY = stringPreferencesKey("trip_summary_distance_value") val DRIVE_COUNT_VALUE_KEY = stringPreferencesKey("trip_summary_drive_count_value") val DRIVING_DAYS_VALUE_KEY = stringPreferencesKey("trip_summary_driving_days_value") @@ -151,6 +153,8 @@ class TripSummaryWidget : GlanceAppWidget() { val compact = size.height.value < 115f || size.width.value < 190f val carName = prefs[CAR_NAME_KEY].orEmpty() + val periodLabel = prefs[PERIOD_LABEL_KEY] + ?: context.getString(R.string.trip_widget_period_last_30_days) val hasDrives = prefs[HAS_DRIVES_KEY] ?: false val distance = prefs[DISTANCE_VALUE_KEY].orEmpty() val drives = prefs[DRIVE_COUNT_VALUE_KEY].orEmpty() @@ -187,7 +191,7 @@ class TripSummaryWidget : GlanceAppWidget() { ) } Text( - text = context.getString(R.string.trip_widget_period_last_30_days), + text = periodLabel, style = TextStyle( color = ColorProvider(TRIP_WIDGET_MUTED), fontSize = if (compact) 10.sp else 11.sp @@ -400,6 +404,7 @@ class TripSummaryWidget : GlanceAppWidget() { this[HAS_DATA_KEY] = true this[HAS_DRIVES_KEY] = data.hasDrives this[CAR_NAME_KEY] = data.carName + this[PERIOD_LABEL_KEY] = data.periodLabel this[DISTANCE_VALUE_KEY] = data.distanceValue this[DRIVE_COUNT_VALUE_KEY] = data.driveCountValue this[DRIVING_DAYS_VALUE_KEY] = data.drivingDaysValue diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetConfigActivity.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetConfigActivity.kt index 0edac417..5ccf37ca 100644 --- a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetConfigActivity.kt +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetConfigActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -76,6 +77,7 @@ class TripSummaryWidgetConfigActivity : ComponentActivity() { setContent { MateDroidTheme { var screenState by remember { mutableStateOf(ScreenState.Loading) } + var selectedRange by remember { mutableStateOf(TripSummaryWidgetRange.DEFAULT) } LaunchedEffect(Unit) { when (val result = teslamateRepository.getCars()) { @@ -85,7 +87,6 @@ class TripSummaryWidgetConfigActivity : ComponentActivity() { cars.isEmpty() -> screenState = ScreenState.Error( getString(R.string.no_vehicles_found) ) - cars.size == 1 -> confirmSelection(appWidgetId, cars.first()) else -> screenState = ScreenState.Picker(cars) } } @@ -95,9 +96,12 @@ class TripSummaryWidgetConfigActivity : ComponentActivity() { when (val s = screenState) { is ScreenState.Loading -> LoadingScreen() - is ScreenState.Picker -> PickerScreen(s.cars) { car -> - confirmSelection(appWidgetId, car) - } + is ScreenState.Picker -> PickerScreen( + cars = s.cars, + selectedRange = selectedRange, + onRangeSelected = { selectedRange = it }, + onCarSelected = { car -> confirmSelection(appWidgetId, car, selectedRange) } + ) is ScreenState.Error -> ErrorScreen(s.message) } } @@ -139,7 +143,12 @@ class TripSummaryWidgetConfigActivity : ComponentActivity() { @Composable @OptIn(ExperimentalMaterial3Api::class) - private fun PickerScreen(cars: List, onCarSelected: (CarData) -> Unit) { + private fun PickerScreen( + cars: List, + selectedRange: TripSummaryWidgetRange, + onRangeSelected: (TripSummaryWidgetRange) -> Unit, + onCarSelected: (CarData) -> Unit, + ) { Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.widget_select_car_title)) }) @@ -152,6 +161,33 @@ class TripSummaryWidgetConfigActivity : ComponentActivity() { .padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) { + Text( + text = stringResource(R.string.trip_widget_range_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TripSummaryWidgetRange.values().forEach { range -> + FilterChip( + selected = selectedRange == range, + onClick = { onRangeSelected(range) }, + label = { Text(stringResource(range.labelRes)) } + ) + } + } + } + } + items(cars) { car -> Card( modifier = Modifier @@ -175,7 +211,11 @@ class TripSummaryWidgetConfigActivity : ComponentActivity() { } } - private fun confirmSelection(appWidgetId: Int, car: CarData) { + private fun confirmSelection( + appWidgetId: Int, + car: CarData, + range: TripSummaryWidgetRange, + ) { lifecycleScope.launch { val glanceId = GlanceAppWidgetManager(this@TripSummaryWidgetConfigActivity) .getGlanceIdBy(appWidgetId) @@ -188,6 +228,8 @@ class TripSummaryWidgetConfigActivity : ComponentActivity() { prefs.toMutablePreferences().apply { this[TripSummaryWidget.CAR_ID_KEY] = car.carId this[TripSummaryWidget.CAR_NAME_KEY] = car.displayName + this[TripSummaryWidget.RANGE_DAYS_KEY] = range.days + this[TripSummaryWidget.PERIOD_LABEL_KEY] = getString(range.labelRes) this[TripSummaryWidget.HAS_DATA_KEY] = false } } diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDisplayData.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDisplayData.kt index 5fb9144e..1937b9ab 100644 --- a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDisplayData.kt +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDisplayData.kt @@ -9,6 +9,7 @@ package com.matedroid.widget data class TripSummaryWidgetDisplayData( val carId: Int, val carName: String, + val periodLabel: String, val hasDrives: Boolean, val distanceValue: String, val driveCountValue: String, diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetFormatter.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetFormatter.kt index 21777c41..11f5913f 100644 --- a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetFormatter.kt +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetFormatter.kt @@ -18,6 +18,7 @@ object TripSummaryWidgetFormatter { fun format( carId: Int, carName: String, + periodLabel: String, metrics: TripSummaryWidgetMetrics, currencySymbol: String, noValue: String, @@ -44,6 +45,7 @@ object TripSummaryWidgetFormatter { return TripSummaryWidgetDisplayData( carId = carId, carName = carName, + periodLabel = periodLabel, hasDrives = metrics.driveCount > 0, distanceValue = UnitFormatter.formatDistance(metrics.totalDistance, units = units, decimals = 0), driveCountValue = "%,d".format(metrics.driveCount), diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetRange.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetRange.kt new file mode 100644 index 00000000..26a77005 --- /dev/null +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetRange.kt @@ -0,0 +1,21 @@ +package com.matedroid.widget + +import androidx.annotation.StringRes +import com.matedroid.R + +enum class TripSummaryWidgetRange( + val days: Int, + @StringRes val labelRes: Int, +) { + SevenDays(7, R.string.trip_widget_period_last_7_days), + ThirtyDays(30, R.string.trip_widget_period_last_30_days), + NinetyDays(90, R.string.trip_widget_period_last_90_days); + + companion object { + val DEFAULT = ThirtyDays + + fun fromDays(days: Int): TripSummaryWidgetRange { + return values().firstOrNull { it.days == days } ?: DEFAULT + } + } +} diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetUpdateWorker.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetUpdateWorker.kt index a922f3a8..29c7e381 100644 --- a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetUpdateWorker.kt +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetUpdateWorker.kt @@ -40,7 +40,6 @@ class TripSummaryWidgetUpdateWorker @AssistedInject constructor( const val WORK_NAME = "trip_summary_widget_update" const val PERIODIC_WORK_NAME = "trip_summary_widget_update_periodic" private const val PERIODIC_INTERVAL_HOURS = 6L - private const val RANGE_DAYS = 30L fun scheduleImmediateUpdate(context: Context) { val request = OneTimeWorkRequestBuilder() @@ -92,7 +91,6 @@ class TripSummaryWidgetUpdateWorker @AssistedInject constructor( val settings = settingsDataStore.settings.firstOrNull() val currency = Currency.findByCode(settings?.currencyCode ?: Currency.DEFAULT.code) val units = Units(unitOfLength = settings?.unitOfLength ?: "km") - val (startDate, endDate) = TripSummaryWidgetDateRange.lastDays(days = RANGE_DAYS) for (glanceId in glanceIds) { try { @@ -100,10 +98,15 @@ class TripSummaryWidgetUpdateWorker @AssistedInject constructor( val carId = prefs[TripSummaryWidget.CAR_ID_KEY] ?: continue val carName = prefs[TripSummaryWidget.CAR_NAME_KEY] ?: appContext.getString(R.string.app_name) + val range = TripSummaryWidgetRange.fromDays( + prefs[TripSummaryWidget.RANGE_DAYS_KEY] ?: TripSummaryWidgetRange.DEFAULT.days + ) + val (startDate, endDate) = TripSummaryWidgetDateRange.lastDays(days = range.days.toLong()) val displayData = buildDisplayData( carId = carId, carName = carName, + periodLabel = appContext.getString(range.labelRes), currencySymbol = currency.symbol, units = units, startDate = startDate, @@ -122,6 +125,7 @@ class TripSummaryWidgetUpdateWorker @AssistedInject constructor( private suspend fun buildDisplayData( carId: Int, carName: String, + periodLabel: String, currencySymbol: String, units: Units, startDate: String, @@ -140,6 +144,7 @@ class TripSummaryWidgetUpdateWorker @AssistedInject constructor( return TripSummaryWidgetFormatter.format( carId = carId, carName = carName, + periodLabel = periodLabel, metrics = metrics, currencySymbol = currencySymbol, noValue = noValue, diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 0d61d49a..7a4d04c2 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1199,9 +1199,12 @@ Estat de bateria del teu Tesla Resum de viatges - Els darrers 30 dies de trajectes, energia i costos de càrrega + Trajectes, energia i costos de càrrega per interval + Darrers 7 dies Darrers 30 dies - Cap trajecte en els darrers 30 dies + Darrers 90 dies + Interval del resum + Cap trajecte en aquest interval Dies Energia Eficiència diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 48d82fd9..fd674d7a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1287,9 +1287,12 @@ Batteriestatus für deinen Tesla Trip-Zusammenfassung - Die letzten 30 Tage mit Fahrten, Energie und Ladekosten + Fahrten, Energie und Ladekosten nach Zeitraum + Letzte 7 Tage Letzte 30 Tage - Keine Fahrten in den letzten 30 Tagen + Letzte 90 Tage + Zusammenfassungszeitraum + Keine Fahrten in diesem Zeitraum Tage Energie Effizienz diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b7572cb2..079a94a4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1199,9 +1199,12 @@ Estado de batería de tu Tesla Resumen de viajes - Últimos 30 días de recorridos, energía y costes de carga + Recorridos, energía y costes de carga por intervalo + Últimos 7 días Últimos 30 días - No hay recorridos en los últimos 30 días + Últimos 90 días + Intervalo del resumen + No hay recorridos en este intervalo Días Energía Eficiencia diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f7111ed5..248a9f6b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1199,9 +1199,12 @@ Stato batteria per la tua Tesla Riepilogo viaggi - Ultimi 30 giorni di percorsi, energia e costi di ricarica + Percorsi, energia e costi di ricarica per intervallo + Ultimi 7 giorni Ultimi 30 giorni - Nessun percorso negli ultimi 30 giorni + Ultimi 90 giorni + Intervallo riepilogo + Nessun percorso in questo intervallo Giorni Energia Efficienza diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index e401dab2..ff81f5ed 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1202,9 +1202,12 @@ Tesla 电池状态小组件 行程摘要 - 最近 30 天的行驶、能耗和充电成本 + 按范围显示行驶、能耗和充电成本 + 最近 7 天 最近 30 天 - 最近 30 天没有行驶记录 + 最近 90 天 + 摘要范围 + 此范围内没有行驶记录 天数 能耗 效率 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 72059c1c..1dc8801d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1296,13 +1296,18 @@ Trip summary - Last 30 days of drives, energy, and charging costs + Drives, energy, and charging costs by range + Last 7 days Last 30 days + Last 90 days + + + Summary range - No drives in the last 30 days + No drives in this range Days diff --git a/app/src/test/java/com/matedroid/widget/TripSummaryWidgetDateRangeTest.kt b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetDateRangeTest.kt index ec0a2c22..66763189 100644 --- a/app/src/test/java/com/matedroid/widget/TripSummaryWidgetDateRangeTest.kt +++ b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetDateRangeTest.kt @@ -18,6 +18,33 @@ class TripSummaryWidgetDateRangeTest { assertEquals("2026-07-09T00:00:00", endDate) } + @Test + fun lastDaysSupportsShortAndLongWidgetRanges() { + val today = LocalDate.of(2026, 7, 8) + + val (sevenDayStart, sevenDayEnd) = TripSummaryWidgetDateRange.lastDays( + today = today, + days = 7 + ) + val (ninetyDayStart, ninetyDayEnd) = TripSummaryWidgetDateRange.lastDays( + today = today, + days = 90 + ) + + assertEquals("2026-07-02T00:00:00", sevenDayStart) + assertEquals("2026-07-09T00:00:00", sevenDayEnd) + assertEquals("2026-04-10T00:00:00", ninetyDayStart) + assertEquals("2026-07-09T00:00:00", ninetyDayEnd) + } + + @Test + fun rangeFallsBackToDefaultWhenStoredPreferenceIsUnsupported() { + assertEquals(TripSummaryWidgetRange.SevenDays, TripSummaryWidgetRange.fromDays(7)) + assertEquals(TripSummaryWidgetRange.ThirtyDays, TripSummaryWidgetRange.fromDays(30)) + assertEquals(TripSummaryWidgetRange.NinetyDays, TripSummaryWidgetRange.fromDays(90)) + assertEquals(TripSummaryWidgetRange.DEFAULT, TripSummaryWidgetRange.fromDays(42)) + } + @Test fun lastDaysRejectsNonPositiveRanges() { assertThrows(IllegalArgumentException::class.java) { diff --git a/app/src/test/java/com/matedroid/widget/TripSummaryWidgetFormatterTest.kt b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetFormatterTest.kt index 507ef312..9e8afb28 100644 --- a/app/src/test/java/com/matedroid/widget/TripSummaryWidgetFormatterTest.kt +++ b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetFormatterTest.kt @@ -29,6 +29,7 @@ class TripSummaryWidgetFormatterTest { val data = TripSummaryWidgetFormatter.format( carId = 7, carName = "Model Y", + periodLabel = "Last 30 days", metrics = TripSummaryWidgetMetrics( driveCount = 4, drivingDays = 3, @@ -44,6 +45,7 @@ class TripSummaryWidgetFormatterTest { ) assertTrue(data.hasDrives) + assertEquals("Last 30 days", data.periodLabel) assertEquals("300 km", data.distanceValue) assertEquals("4", data.driveCountValue) assertEquals("3", data.drivingDaysValue) @@ -60,6 +62,7 @@ class TripSummaryWidgetFormatterTest { val data = TripSummaryWidgetFormatter.format( carId = 7, carName = "Model Y", + periodLabel = "Last 7 days", metrics = TripSummaryWidgetMetrics( driveCount = 3, drivingDays = 2, @@ -83,6 +86,7 @@ class TripSummaryWidgetFormatterTest { val data = TripSummaryWidgetFormatter.format( carId = 7, carName = "Model 3", + periodLabel = "Last 90 days", metrics = TripSummaryWidgetMetrics( driveCount = 2, drivingDays = 2, @@ -109,6 +113,7 @@ class TripSummaryWidgetFormatterTest { val data = TripSummaryWidgetFormatter.format( carId = 7, carName = "Model S", + periodLabel = "Last 30 days", metrics = TripSummaryWidgetMetrics( driveCount = 0, drivingDays = 0, From c73a06794720320c131e22d830e671ddfdc65767 Mon Sep 17 00:00:00 2001 From: Raymond Kelly Date: Thu, 9 Jul 2026 21:58:13 -0500 Subject: [PATCH 5/5] feat: add trip widget comparisons --- .../com/matedroid/widget/TripSummaryWidget.kt | 64 ++++++++++++++++++- .../widget/TripSummaryWidgetConfigActivity.kt | 62 ++++++++++++++++-- .../widget/TripSummaryWidgetDateRange.kt | 24 +++++++ .../widget/TripSummaryWidgetDisplayData.kt | 20 ++++++ .../widget/TripSummaryWidgetFormatter.kt | 32 ++++++++++ .../widget/TripSummaryWidgetUpdateWorker.kt | 30 +++++---- app/src/main/res/values-ca/strings.xml | 8 +++ app/src/main/res/values-de/strings.xml | 8 +++ app/src/main/res/values-es/strings.xml | 8 +++ app/src/main/res/values-it/strings.xml | 8 +++ app/src/main/res/values-zh/strings.xml | 8 +++ app/src/main/res/values/strings.xml | 12 ++++ .../main/res/xml/trip_summary_widget_info.xml | 1 + .../widget/TripSummaryWidgetDateRangeTest.kt | 13 ++++ .../widget/TripSummaryWidgetFormatterTest.kt | 44 +++++++++++++ 15 files changed, 324 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt index fccf01a9..8ea22816 100644 --- a/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt @@ -69,11 +69,16 @@ class TripSummaryWidget : GlanceAppWidget() { val RANGE_DAYS_KEY = intPreferencesKey("trip_summary_range_days") val PERIOD_LABEL_KEY = stringPreferencesKey("trip_summary_period_label") val DISTANCE_VALUE_KEY = stringPreferencesKey("trip_summary_distance_value") + val DISTANCE_TREND_KEY = stringPreferencesKey("trip_summary_distance_trend") + val DISTANCE_TREND_PERCENT_KEY = intPreferencesKey("trip_summary_distance_trend_percent") val DRIVE_COUNT_VALUE_KEY = stringPreferencesKey("trip_summary_drive_count_value") val DRIVING_DAYS_VALUE_KEY = stringPreferencesKey("trip_summary_driving_days_value") val ENERGY_VALUE_KEY = stringPreferencesKey("trip_summary_energy_value") val EFFICIENCY_VALUE_KEY = stringPreferencesKey("trip_summary_efficiency_value") val COST_VALUE_KEY = stringPreferencesKey("trip_summary_cost_value") + val COST_COVERAGE_KEY = stringPreferencesKey("trip_summary_cost_coverage") + val CHARGES_WITH_COST_KEY = intPreferencesKey("trip_summary_charges_with_cost") + val CHARGE_COUNT_KEY = intPreferencesKey("trip_summary_charge_count") val COST_PER_DISTANCE_VALUE_KEY = stringPreferencesKey("trip_summary_cost_per_distance_value") val DISTANCE_UNIT_KEY = stringPreferencesKey("trip_summary_distance_unit") val UPDATED_VALUE_KEY = stringPreferencesKey("trip_summary_updated_value") @@ -157,11 +162,20 @@ class TripSummaryWidget : GlanceAppWidget() { ?: context.getString(R.string.trip_widget_period_last_30_days) val hasDrives = prefs[HAS_DRIVES_KEY] ?: false val distance = prefs[DISTANCE_VALUE_KEY].orEmpty() + val distanceTrend = TripSummaryWidgetTrend.values().firstOrNull { + it.name == prefs[DISTANCE_TREND_KEY] + } ?: TripSummaryWidgetTrend.None + val distanceTrendPercent = prefs[DISTANCE_TREND_PERCENT_KEY] ?: 0 val drives = prefs[DRIVE_COUNT_VALUE_KEY].orEmpty() val drivingDays = prefs[DRIVING_DAYS_VALUE_KEY].orEmpty() val energy = prefs[ENERGY_VALUE_KEY].orEmpty() val efficiency = prefs[EFFICIENCY_VALUE_KEY].orEmpty() val cost = prefs[COST_VALUE_KEY].orEmpty() + val costCoverage = TripSummaryWidgetCostCoverage.values().firstOrNull { + it.name == prefs[COST_COVERAGE_KEY] + } ?: TripSummaryWidgetCostCoverage.None + val chargesWithCost = prefs[CHARGES_WITH_COST_KEY] ?: 0 + val chargeCount = prefs[CHARGE_COUNT_KEY] ?: 0 val costPerDistance = prefs[COST_PER_DISTANCE_VALUE_KEY].orEmpty() val distanceUnit = prefs[DISTANCE_UNIT_KEY].orEmpty() val updated = prefs[UPDATED_VALUE_KEY].orEmpty() @@ -249,7 +263,15 @@ class TripSummaryWidget : GlanceAppWidget() { maxLines = 1 ) Text( - text = context.getString(R.string.distance), + text = if (compact) { + context.getString(R.string.distance) + } else { + distanceTrendLabel( + context = context, + trend = distanceTrend, + percent = distanceTrendPercent, + ) + }, style = TextStyle( color = ColorProvider(TRIP_WIDGET_MUTED), fontSize = 10.sp @@ -296,7 +318,12 @@ class TripSummaryWidget : GlanceAppWidget() { ) Spacer(modifier = GlanceModifier.width(8.dp)) MetricTile( - label = context.getString(R.string.cost), + label = costCoverageLabel( + context = context, + coverage = costCoverage, + chargesWithCost = chargesWithCost, + chargeCount = chargeCount, + ), value = cost, accent = TRIP_WIDGET_ACCENT, modifier = GlanceModifier.defaultWeight() @@ -332,6 +359,34 @@ class TripSummaryWidget : GlanceAppWidget() { } } + private fun distanceTrendLabel( + context: Context, + trend: TripSummaryWidgetTrend, + percent: Int, + ): String = when (trend) { + TripSummaryWidgetTrend.None -> context.getString(R.string.distance) + TripSummaryWidgetTrend.New -> context.getString(R.string.trip_widget_distance_trend_new) + TripSummaryWidgetTrend.Same -> context.getString(R.string.trip_widget_distance_trend_same) + TripSummaryWidgetTrend.Up -> context.getString(R.string.trip_widget_distance_trend_more, percent) + TripSummaryWidgetTrend.Down -> context.getString(R.string.trip_widget_distance_trend_less, percent) + } + + private fun costCoverageLabel( + context: Context, + coverage: TripSummaryWidgetCostCoverage, + chargesWithCost: Int, + chargeCount: Int, + ): String = when (coverage) { + TripSummaryWidgetCostCoverage.None -> context.getString(R.string.cost) + TripSummaryWidgetCostCoverage.Missing -> context.getString(R.string.trip_widget_cost_none_priced) + TripSummaryWidgetCostCoverage.Partial -> context.getString( + R.string.trip_widget_cost_partially_priced, + chargesWithCost, + chargeCount, + ) + TripSummaryWidgetCostCoverage.Complete -> context.getString(R.string.trip_widget_cost_all_priced) + } + @Composable private fun CompactFooter(cost: String, efficiency: String) { val context = LocalContext.current @@ -406,11 +461,16 @@ class TripSummaryWidget : GlanceAppWidget() { this[CAR_NAME_KEY] = data.carName this[PERIOD_LABEL_KEY] = data.periodLabel this[DISTANCE_VALUE_KEY] = data.distanceValue + this[DISTANCE_TREND_KEY] = data.distanceTrend.name + this[DISTANCE_TREND_PERCENT_KEY] = data.distanceTrendPercent this[DRIVE_COUNT_VALUE_KEY] = data.driveCountValue this[DRIVING_DAYS_VALUE_KEY] = data.drivingDaysValue this[ENERGY_VALUE_KEY] = data.energyValue this[EFFICIENCY_VALUE_KEY] = data.efficiencyValue this[COST_VALUE_KEY] = data.costValue + this[COST_COVERAGE_KEY] = data.costCoverage.name + this[CHARGES_WITH_COST_KEY] = data.chargesWithCost + this[CHARGE_COUNT_KEY] = data.chargeCount this[COST_PER_DISTANCE_VALUE_KEY] = data.costPerDistanceValue this[DISTANCE_UNIT_KEY] = data.distanceUnit this[UPDATED_VALUE_KEY] = data.updatedValue diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetConfigActivity.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetConfigActivity.kt index 5ccf37ca..bdbd41e9 100644 --- a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetConfigActivity.kt +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetConfigActivity.kt @@ -16,11 +16,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button 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.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -35,6 +38,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.lifecycle.lifecycleScope @@ -78,8 +82,22 @@ class TripSummaryWidgetConfigActivity : ComponentActivity() { MateDroidTheme { var screenState by remember { mutableStateOf(ScreenState.Loading) } var selectedRange by remember { mutableStateOf(TripSummaryWidgetRange.DEFAULT) } + var selectedCarId by remember { mutableStateOf(null) } LaunchedEffect(Unit) { + val glanceId = GlanceAppWidgetManager(this@TripSummaryWidgetConfigActivity) + .getGlanceIdBy(appWidgetId) + val prefs = getAppWidgetState( + this@TripSummaryWidgetConfigActivity, + PreferencesGlanceStateDefinition, + glanceId, + ) + selectedRange = TripSummaryWidgetRange.fromDays( + prefs[TripSummaryWidget.RANGE_DAYS_KEY] + ?: TripSummaryWidgetRange.DEFAULT.days + ) + selectedCarId = prefs[TripSummaryWidget.CAR_ID_KEY] + when (val result = teslamateRepository.getCars()) { is ApiResult.Success -> { val cars = result.data @@ -87,7 +105,12 @@ class TripSummaryWidgetConfigActivity : ComponentActivity() { cars.isEmpty() -> screenState = ScreenState.Error( getString(R.string.no_vehicles_found) ) - else -> screenState = ScreenState.Picker(cars) + else -> { + if (selectedCarId == null && cars.size == 1) { + selectedCarId = cars.single().carId + } + screenState = ScreenState.Picker(cars) + } } } is ApiResult.Error -> screenState = ScreenState.Error(result.message) @@ -99,8 +122,14 @@ class TripSummaryWidgetConfigActivity : ComponentActivity() { is ScreenState.Picker -> PickerScreen( cars = s.cars, selectedRange = selectedRange, + selectedCarId = selectedCarId, onRangeSelected = { selectedRange = it }, - onCarSelected = { car -> confirmSelection(appWidgetId, car, selectedRange) } + onCarSelected = { selectedCarId = it.carId }, + onConfirm = { + s.cars.firstOrNull { it.carId == selectedCarId }?.let { car -> + confirmSelection(appWidgetId, car, selectedRange) + } + }, ) is ScreenState.Error -> ErrorScreen(s.message) } @@ -146,12 +175,25 @@ class TripSummaryWidgetConfigActivity : ComponentActivity() { private fun PickerScreen( cars: List, selectedRange: TripSummaryWidgetRange, + selectedCarId: Int?, onRangeSelected: (TripSummaryWidgetRange) -> Unit, onCarSelected: (CarData) -> Unit, + onConfirm: () -> Unit, ) { Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.widget_select_car_title)) }) + }, + bottomBar = { + Button( + onClick = onConfirm, + enabled = selectedCarId != null, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text(stringResource(R.string.widget_save)) + } } ) { padding -> LazyColumn( @@ -192,7 +234,14 @@ class TripSummaryWidgetConfigActivity : ComponentActivity() { Card( modifier = Modifier .fillMaxWidth() - .clickable { onCarSelected(car) } + .clickable { onCarSelected(car) }, + colors = CardDefaults.cardColors( + containerColor = if (selectedCarId == car.carId) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ), ) { Row( modifier = Modifier @@ -202,7 +251,12 @@ class TripSummaryWidgetConfigActivity : ComponentActivity() { ) { Text( text = car.displayName, - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + RadioButton( + selected = selectedCarId == car.carId, + onClick = null, ) } } diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDateRange.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDateRange.kt index ef5523b0..a6c2b999 100644 --- a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDateRange.kt +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDateRange.kt @@ -15,5 +15,29 @@ object TripSummaryWidgetDateRange { return start.toApiDateString() to end.toApiDateString() } + fun currentAndPreviousDays( + today: LocalDate = LocalDate.now(), + days: Long = 30, + ): TripSummaryWidgetDateRanges { + require(days > 0) { "days must be positive" } + + val currentStart = today.minusDays(days - 1).atStartOfDay() + val currentEnd = today.plusDays(1).atStartOfDay() + val previousStart = currentStart.minusDays(days) + return TripSummaryWidgetDateRanges( + currentStart = currentStart.toApiDateString(), + currentEnd = currentEnd.toApiDateString(), + previousStart = previousStart.toApiDateString(), + previousEnd = currentStart.toApiDateString(), + ) + } + private fun LocalDateTime.toApiDateString(): String = format(API_DATE_FORMATTER) } + +data class TripSummaryWidgetDateRanges( + val currentStart: String, + val currentEnd: String, + val previousStart: String, + val previousEnd: String, +) diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDisplayData.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDisplayData.kt index 1937b9ab..15fd4e24 100644 --- a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDisplayData.kt +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDisplayData.kt @@ -12,12 +12,32 @@ data class TripSummaryWidgetDisplayData( val periodLabel: String, val hasDrives: Boolean, val distanceValue: String, + val distanceTrend: TripSummaryWidgetTrend, + val distanceTrendPercent: Int, val driveCountValue: String, val drivingDaysValue: String, val energyValue: String, val efficiencyValue: String, val costValue: String, + val costCoverage: TripSummaryWidgetCostCoverage, + val chargesWithCost: Int, + val chargeCount: Int, val costPerDistanceValue: String, val distanceUnit: String, val updatedValue: String, ) + +enum class TripSummaryWidgetTrend { + None, + New, + Same, + Up, + Down, +} + +enum class TripSummaryWidgetCostCoverage { + None, + Missing, + Partial, + Complete, +} diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetFormatter.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetFormatter.kt index 11f5913f..39ab8fe3 100644 --- a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetFormatter.kt +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetFormatter.kt @@ -2,6 +2,8 @@ package com.matedroid.widget import com.matedroid.data.api.models.Units import com.matedroid.domain.model.UnitFormatter +import kotlin.math.abs +import kotlin.math.roundToInt data class TripSummaryWidgetMetrics( val driveCount: Int, @@ -11,6 +13,8 @@ data class TripSummaryWidgetMetrics( val avgEfficiency: Double, val totalCost: Double, val chargesWithCost: Int, + val chargeCount: Int, + val previousDistance: Double, ) object TripSummaryWidgetFormatter { @@ -41,6 +45,29 @@ object TripSummaryWidgetFormatter { } else { noValue } + val distanceTrend = when { + metrics.driveCount <= 0 -> TripSummaryWidgetTrend.None + metrics.previousDistance <= 0 -> TripSummaryWidgetTrend.New + else -> { + val percent = ((metrics.totalDistance / metrics.previousDistance) - 1.0) * 100.0 + when (percent.roundToInt()) { + 0 -> TripSummaryWidgetTrend.Same + in 1..Int.MAX_VALUE -> TripSummaryWidgetTrend.Up + else -> TripSummaryWidgetTrend.Down + } + } + } + val distanceTrendPercent = if (metrics.previousDistance > 0) { + abs((((metrics.totalDistance / metrics.previousDistance) - 1.0) * 100.0).roundToInt()) + } else { + 0 + } + val costCoverage = when { + metrics.chargeCount <= 0 -> TripSummaryWidgetCostCoverage.None + metrics.chargesWithCost <= 0 -> TripSummaryWidgetCostCoverage.Missing + metrics.chargesWithCost < metrics.chargeCount -> TripSummaryWidgetCostCoverage.Partial + else -> TripSummaryWidgetCostCoverage.Complete + } return TripSummaryWidgetDisplayData( carId = carId, @@ -48,11 +75,16 @@ object TripSummaryWidgetFormatter { periodLabel = periodLabel, hasDrives = metrics.driveCount > 0, distanceValue = UnitFormatter.formatDistance(metrics.totalDistance, units = units, decimals = 0), + distanceTrend = distanceTrend, + distanceTrendPercent = distanceTrendPercent, driveCountValue = "%,d".format(metrics.driveCount), drivingDaysValue = "%,d".format(metrics.drivingDays), energyValue = UnitFormatter.formatEnergy(metrics.totalEnergy), efficiencyValue = efficiencyValue, costValue = costValue, + costCoverage = costCoverage, + chargesWithCost = metrics.chargesWithCost, + chargeCount = metrics.chargeCount, costPerDistanceValue = costPerDistance, distanceUnit = UnitFormatter.getDistanceUnit(units), updatedValue = updatedValue, diff --git a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetUpdateWorker.kt b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetUpdateWorker.kt index 29c7e381..aa225392 100644 --- a/app/src/main/java/com/matedroid/widget/TripSummaryWidgetUpdateWorker.kt +++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetUpdateWorker.kt @@ -101,7 +101,9 @@ class TripSummaryWidgetUpdateWorker @AssistedInject constructor( val range = TripSummaryWidgetRange.fromDays( prefs[TripSummaryWidget.RANGE_DAYS_KEY] ?: TripSummaryWidgetRange.DEFAULT.days ) - val (startDate, endDate) = TripSummaryWidgetDateRange.lastDays(days = range.days.toLong()) + val ranges = TripSummaryWidgetDateRange.currentAndPreviousDays( + days = range.days.toLong() + ) val displayData = buildDisplayData( carId = carId, @@ -109,8 +111,7 @@ class TripSummaryWidgetUpdateWorker @AssistedInject constructor( periodLabel = appContext.getString(range.labelRes), currencySymbol = currency.symbol, units = units, - startDate = startDate, - endDate = endDate + ranges = ranges, ) TripSummaryWidget().updateWidget(appContext, glanceId, displayData) Log.d(TAG, "Updated trip summary widget for car $carId") @@ -128,17 +129,22 @@ class TripSummaryWidgetUpdateWorker @AssistedInject constructor( periodLabel: String, currencySymbol: String, units: Units, - startDate: String, - endDate: String, + ranges: TripSummaryWidgetDateRanges, ): TripSummaryWidgetDisplayData { val metrics = TripSummaryWidgetMetrics( - driveCount = driveSummaryDao.countInRange(carId, startDate, endDate), - drivingDays = driveSummaryDao.countDrivingDaysInRange(carId, startDate, endDate), - totalDistance = driveSummaryDao.sumDistanceInRange(carId, startDate, endDate), - totalEnergy = driveSummaryDao.sumEnergyConsumedInRange(carId, startDate, endDate), - avgEfficiency = driveSummaryDao.avgEfficiencyInRange(carId, startDate, endDate), - totalCost = chargeSummaryDao.sumCostInRange(carId, startDate, endDate), - chargesWithCost = chargeSummaryDao.countWithCostInRange(carId, startDate, endDate), + driveCount = driveSummaryDao.countInRange(carId, ranges.currentStart, ranges.currentEnd), + drivingDays = driveSummaryDao.countDrivingDaysInRange(carId, ranges.currentStart, ranges.currentEnd), + totalDistance = driveSummaryDao.sumDistanceInRange(carId, ranges.currentStart, ranges.currentEnd), + totalEnergy = driveSummaryDao.sumEnergyConsumedInRange(carId, ranges.currentStart, ranges.currentEnd), + avgEfficiency = driveSummaryDao.avgEfficiencyInRange(carId, ranges.currentStart, ranges.currentEnd), + totalCost = chargeSummaryDao.sumCostInRange(carId, ranges.currentStart, ranges.currentEnd), + chargesWithCost = chargeSummaryDao.countWithCostInRange(carId, ranges.currentStart, ranges.currentEnd), + chargeCount = chargeSummaryDao.countInRange(carId, ranges.currentStart, ranges.currentEnd), + previousDistance = driveSummaryDao.sumDistanceInRange( + carId, + ranges.previousStart, + ranges.previousEnd, + ), ) val noValue = appContext.getString(R.string.trip_widget_no_value) return TripSummaryWidgetFormatter.format( diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 7a4d04c2..3e6d266a 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1208,12 +1208,20 @@ Dies Energia Eficiència + Distància · nova + Distància · sense canvis + Distància · %1$d%% més + Distància · %1$d%% menys + Cost · cap preu + Cost · %1$d/%2$d amb preu + Cost · complet Cost/100 %1$s Cost %1$s Ef. %1$s N/D Actualitzat %1$s Selecciona un vehicle per a l\'estri + Desa l\'estri Carregant… Toca per configurar diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index fd674d7a..65c01c32 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1296,6 +1296,13 @@ Tage Energie Effizienz + Strecke · neu + Strecke · unverändert + Strecke · %1$d%% mehr + Strecke · %1$d%% weniger + Kosten · keine Preise + Kosten · %1$d/%2$d bepreist + Kosten · vollständig Kosten/100 %1$s Kosten %1$s Eff. %1$s @@ -1304,6 +1311,7 @@ Fahrzeug für das Widget auswählen + Widget speichern Wird geladen… diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 079a94a4..ddf232ac 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1208,12 +1208,20 @@ Días Energía Eficiencia + Distancia · nueva + Distancia · sin cambios + Distancia · %1$d%% más + Distancia · %1$d%% menos + Coste · sin precios + Coste · %1$d/%2$d con precio + Coste · completo Coste/100 %1$s Coste %1$s Ef. %1$s N/D Actualizado %1$s Selecciona un vehículo para el widget + Guardar widget Cargando… Toca para configurar diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 248a9f6b..56751d44 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1208,12 +1208,20 @@ Giorni Energia Efficienza + Distanza · nuova + Distanza · invariata + Distanza · %1$d%% in più + Distanza · %1$d%% in meno + Costo · nessun prezzo + Costo · %1$d/%2$d con prezzo + Costo · completo Costo/100 %1$s Costo %1$s Eff. %1$s N/D Aggiornato %1$s Seleziona un veicolo per il widget + Salva widget Caricamento… Tocca per configurare diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index ff81f5ed..a21fceb2 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1211,6 +1211,13 @@ 天数 能耗 效率 + 里程 · 本期新增 + 里程 · 与上期相同 + 里程 · 增加 %1$d%% + 里程 · 减少 %1$d%% + 费用 · 均未计价 + 费用 · %1$d/%2$d 已计价 + 费用 · 全部已计价 费用/100 %1$s 费用 %1$s 效率 %1$s @@ -1219,6 +1226,7 @@ 为小组件选择一辆车 + 保存小组件 加载中… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1dc8801d..88137be7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1318,6 +1318,17 @@ Efficiency + + Distance · new this period + Distance · unchanged + Distance · %1$d%% more + Distance · %1$d%% less + + + Cost · none priced + Cost · %1$d/%2$d priced + Cost · all priced + Cost/100 %1$s @@ -1335,6 +1346,7 @@ Select a vehicle for the widget + Save widget Loading… diff --git a/app/src/main/res/xml/trip_summary_widget_info.xml b/app/src/main/res/xml/trip_summary_widget_info.xml index 6063480f..55c4fe5f 100644 --- a/app/src/main/res/xml/trip_summary_widget_info.xml +++ b/app/src/main/res/xml/trip_summary_widget_info.xml @@ -9,6 +9,7 @@ android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0" android:configure="com.matedroid.widget.TripSummaryWidgetConfigActivity" + android:widgetFeatures="reconfigurable" android:description="@string/trip_widget_description" android:previewLayout="@layout/trip_summary_widget_preview" android:initialLayout="@layout/trip_summary_widget_preview" /> diff --git a/app/src/test/java/com/matedroid/widget/TripSummaryWidgetDateRangeTest.kt b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetDateRangeTest.kt index 66763189..ad6d37c7 100644 --- a/app/src/test/java/com/matedroid/widget/TripSummaryWidgetDateRangeTest.kt +++ b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetDateRangeTest.kt @@ -37,6 +37,19 @@ class TripSummaryWidgetDateRangeTest { assertEquals("2026-07-09T00:00:00", ninetyDayEnd) } + @Test + fun comparisonRangeImmediatelyPrecedesCurrentRange() { + val ranges = TripSummaryWidgetDateRange.currentAndPreviousDays( + today = LocalDate.of(2026, 7, 8), + days = 30, + ) + + assertEquals("2026-06-09T00:00:00", ranges.currentStart) + assertEquals("2026-07-09T00:00:00", ranges.currentEnd) + assertEquals("2026-05-10T00:00:00", ranges.previousStart) + assertEquals("2026-06-09T00:00:00", ranges.previousEnd) + } + @Test fun rangeFallsBackToDefaultWhenStoredPreferenceIsUnsupported() { assertEquals(TripSummaryWidgetRange.SevenDays, TripSummaryWidgetRange.fromDays(7)) diff --git a/app/src/test/java/com/matedroid/widget/TripSummaryWidgetFormatterTest.kt b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetFormatterTest.kt index 9e8afb28..055489a1 100644 --- a/app/src/test/java/com/matedroid/widget/TripSummaryWidgetFormatterTest.kt +++ b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetFormatterTest.kt @@ -38,6 +38,8 @@ class TripSummaryWidgetFormatterTest { avgEfficiency = 160.0, totalCost = 0.0, chargesWithCost = 0, + chargeCount = 2, + previousDistance = 250.0, ), currencySymbol = "\$", noValue = "N/A", @@ -53,6 +55,9 @@ class TripSummaryWidgetFormatterTest { assertEquals("160 Wh/km", data.efficiencyValue) assertEquals("N/A", data.costValue) assertEquals("N/A", data.costPerDistanceValue) + assertEquals(TripSummaryWidgetTrend.Up, data.distanceTrend) + assertEquals(20, data.distanceTrendPercent) + assertEquals(TripSummaryWidgetCostCoverage.Missing, data.costCoverage) assertEquals("km", data.distanceUnit) assertEquals("Updated 9:00", data.updatedValue) } @@ -71,6 +76,8 @@ class TripSummaryWidgetFormatterTest { avgEfficiency = 160.0, totalCost = 0.0, chargesWithCost = 1, + chargeCount = 1, + previousDistance = 250.0, ), currencySymbol = "\$", noValue = "N/A", @@ -79,6 +86,8 @@ class TripSummaryWidgetFormatterTest { assertEquals("0.00 \$", data.costValue) assertEquals("0.00 \$", data.costPerDistanceValue) + assertEquals(TripSummaryWidgetTrend.Same, data.distanceTrend) + assertEquals(TripSummaryWidgetCostCoverage.Complete, data.costCoverage) } @Test @@ -95,6 +104,8 @@ class TripSummaryWidgetFormatterTest { avgEfficiency = 250.0, totalCost = 12.0, chargesWithCost = 1, + chargeCount = 2, + previousDistance = 150.0, ), currencySymbol = "\$", noValue = "N/A", @@ -106,6 +117,9 @@ class TripSummaryWidgetFormatterTest { assertEquals("250 Wh/mi", data.efficiencyValue) assertEquals("10.00 \$", data.costPerDistanceValue) assertEquals("mi", data.distanceUnit) + assertEquals(TripSummaryWidgetTrend.Down, data.distanceTrend) + assertEquals(20, data.distanceTrendPercent) + assertEquals(TripSummaryWidgetCostCoverage.Partial, data.costCoverage) } @Test @@ -122,6 +136,8 @@ class TripSummaryWidgetFormatterTest { avgEfficiency = 0.0, totalCost = 0.0, chargesWithCost = 0, + chargeCount = 0, + previousDistance = 200.0, ), currencySymbol = "\$", noValue = "N/A", @@ -133,5 +149,33 @@ class TripSummaryWidgetFormatterTest { assertEquals("0", data.drivingDaysValue) assertEquals("N/A", data.efficiencyValue) assertEquals("N/A", data.costValue) + assertEquals(TripSummaryWidgetTrend.None, data.distanceTrend) + assertEquals(TripSummaryWidgetCostCoverage.None, data.costCoverage) + } + + @Test + fun formatMarksDistanceAsNewWhenPreviousRangeWasEmpty() { + val data = TripSummaryWidgetFormatter.format( + carId = 7, + carName = "Model 3", + periodLabel = "Last 7 days", + metrics = TripSummaryWidgetMetrics( + driveCount = 1, + drivingDays = 1, + totalDistance = 42.0, + totalEnergy = 7.0, + avgEfficiency = 167.0, + totalCost = 0.0, + chargesWithCost = 0, + chargeCount = 0, + previousDistance = 0.0, + ), + currencySymbol = "\$", + noValue = "N/A", + updatedValue = "Updated 9:00", + ) + + assertEquals(TripSummaryWidgetTrend.New, data.distanceTrend) + assertEquals(0, data.distanceTrendPercent) } }