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..8ea22816
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidget.kt
@@ -0,0 +1,481 @@
+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 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")
+ }
+
+ 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 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 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()
+
+ 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 = periodLabel,
+ 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 = 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
+ ),
+ 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 = costCoverageLabel(
+ context = context,
+ coverage = costCoverage,
+ chargesWithCost = chargesWithCost,
+ chargeCount = chargeCount,
+ ),
+ 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()
+ )
+ }
+ }
+ }
+ }
+ }
+
+ 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
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = context.getString(R.string.trip_widget_compact_cost, cost),
+ style = TextStyle(
+ color = ColorProvider(TRIP_WIDGET_TEXT),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium
+ ),
+ maxLines = 1
+ )
+ Spacer(modifier = GlanceModifier.defaultWeight())
+ Text(
+ text = context.getString(R.string.trip_widget_compact_efficiency, 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[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
+ }
+ }
+ 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..bdbd41e9
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetConfigActivity.kt
@@ -0,0 +1,300 @@
+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.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
+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.getAppWidgetState
+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) }
+ 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
+ when {
+ cars.isEmpty() -> screenState = ScreenState.Error(
+ getString(R.string.no_vehicles_found)
+ )
+ else -> {
+ if (selectedCarId == null && cars.size == 1) {
+ selectedCarId = cars.single().carId
+ }
+ screenState = ScreenState.Picker(cars)
+ }
+ }
+ }
+ is ApiResult.Error -> screenState = ScreenState.Error(result.message)
+ }
+ }
+
+ when (val s = screenState) {
+ is ScreenState.Loading -> LoadingScreen()
+ is ScreenState.Picker -> PickerScreen(
+ cars = s.cars,
+ selectedRange = selectedRange,
+ selectedCarId = selectedCarId,
+ onRangeSelected = { selectedRange = it },
+ onCarSelected = { selectedCarId = it.carId },
+ onConfirm = {
+ s.cars.firstOrNull { it.carId == selectedCarId }?.let { car ->
+ confirmSelection(appWidgetId, car, selectedRange)
+ }
+ },
+ )
+ 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,
+ 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(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .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
+ .fillMaxWidth()
+ .clickable { onCarSelected(car) },
+ colors = CardDefaults.cardColors(
+ containerColor = if (selectedCarId == car.carId) {
+ MaterialTheme.colorScheme.primaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ }
+ ),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = car.displayName,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.weight(1f),
+ )
+ RadioButton(
+ selected = selectedCarId == car.carId,
+ onClick = null,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun confirmSelection(
+ appWidgetId: Int,
+ car: CarData,
+ range: TripSummaryWidgetRange,
+ ) {
+ 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.RANGE_DAYS_KEY] = range.days
+ this[TripSummaryWidget.PERIOD_LABEL_KEY] = getString(range.labelRes)
+ 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..a6c2b999
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDateRange.kt
@@ -0,0 +1,43 @@
+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()
+ }
+
+ 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
new file mode 100644
index 00000000..15fd4e24
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetDisplayData.kt
@@ -0,0 +1,43 @@
+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 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
new file mode 100644
index 00000000..39ab8fe3
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetFormatter.kt
@@ -0,0 +1,93 @@
+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,
+ val drivingDays: Int,
+ val totalDistance: Double,
+ val totalEnergy: Double,
+ val avgEfficiency: Double,
+ val totalCost: Double,
+ val chargesWithCost: Int,
+ val chargeCount: Int,
+ val previousDistance: Double,
+)
+
+object TripSummaryWidgetFormatter {
+
+ fun format(
+ carId: Int,
+ carName: String,
+ periodLabel: 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
+ }
+ 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,
+ carName = carName,
+ 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/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/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..aa225392
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/TripSummaryWidgetUpdateWorker.kt
@@ -0,0 +1,165 @@
+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
+
+ 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")
+
+ 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 range = TripSummaryWidgetRange.fromDays(
+ prefs[TripSummaryWidget.RANGE_DAYS_KEY] ?: TripSummaryWidgetRange.DEFAULT.days
+ )
+ val ranges = TripSummaryWidgetDateRange.currentAndPreviousDays(
+ days = range.days.toLong()
+ )
+
+ val displayData = buildDisplayData(
+ carId = carId,
+ carName = carName,
+ periodLabel = appContext.getString(range.labelRes),
+ currencySymbol = currency.symbol,
+ units = units,
+ ranges = ranges,
+ )
+ 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,
+ periodLabel: String,
+ currencySymbol: String,
+ units: Units,
+ ranges: TripSummaryWidgetDateRanges,
+ ): TripSummaryWidgetDisplayData {
+ val metrics = TripSummaryWidgetMetrics(
+ 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(
+ carId = carId,
+ carName = carName,
+ periodLabel = periodLabel,
+ 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..3e6d266a 100644
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -1198,7 +1198,30 @@
Estat de bateria del teu Tesla
+ Resum de viatges
+ Trajectes, energia i costos de càrrega per interval
+ Darrers 7 dies
+ Darrers 30 dies
+ Darrers 90 dies
+ Interval del resum
+ Cap trajecte en aquest interval
+ 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 491f89e9..65c01c32 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -1286,9 +1286,32 @@
Batteriestatus für deinen Tesla
+ Trip-Zusammenfassung
+ Fahrten, Energie und Ladekosten nach Zeitraum
+ Letzte 7 Tage
+ Letzte 30 Tage
+ Letzte 90 Tage
+ Zusammenfassungszeitraum
+ Keine Fahrten in diesem Zeitraum
+ 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
+ k. A.
+ Aktualisiert %1$s
Fahrzeug für das Widget auswählen
+ Widget speichern
Wird geladen…
@@ -1394,4 +1417,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..ddf232ac 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -1198,7 +1198,30 @@
Estado de batería de tu Tesla
+ Resumen de viajes
+ Recorridos, energía y costes de carga por intervalo
+ Últimos 7 días
+ Últimos 30 días
+ Últimos 90 días
+ Intervalo del resumen
+ No hay recorridos en este intervalo
+ 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 511cf585..56751d44 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -1198,7 +1198,30 @@
Stato batteria per la tua Tesla
+ Riepilogo viaggi
+ Percorsi, energia e costi di ricarica per intervallo
+ Ultimi 7 giorni
+ Ultimi 30 giorni
+ Ultimi 90 giorni
+ Intervallo riepilogo
+ Nessun percorso in questo intervallo
+ 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 ee7ed110..a21fceb2 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -1201,9 +1201,32 @@
Tesla 电池状态小组件
+ 行程摘要
+ 按范围显示行驶、能耗和充电成本
+ 最近 7 天
+ 最近 30 天
+ 最近 90 天
+ 摘要范围
+ 此范围内没有行驶记录
+ 天数
+ 能耗
+ 效率
+ 里程 · 本期新增
+ 里程 · 与上期相同
+ 里程 · 增加 %1$d%%
+ 里程 · 减少 %1$d%%
+ 费用 · 均未计价
+ 费用 · %1$d/%2$d 已计价
+ 费用 · 全部已计价
+ 费用/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 ab9a97dd..88137be7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1292,8 +1292,61 @@
Battery status for your Tesla
+
+ Trip summary
+
+
+ Drives, energy, and charging costs by range
+
+
+ Last 7 days
+ Last 30 days
+ Last 90 days
+
+
+ Summary range
+
+
+ No drives in this range
+
+
+ Days
+
+
+ Energy
+
+
+ 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
+
+
+ Cost %1$s
+
+
+ Eff %1$s
+
+
+ N/A
+
+
+ Updated %1$s
+
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
new file mode 100644
index 00000000..55c4fe5f
--- /dev/null
+++ b/app/src/main/res/xml/trip_summary_widget_info.xml
@@ -0,0 +1,15 @@
+
+
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..ad6d37c7
--- /dev/null
+++ b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetDateRangeTest.kt
@@ -0,0 +1,67 @@
+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 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 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))
+ 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) {
+ 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..055489a1
--- /dev/null
+++ b/app/src/test/java/com/matedroid/widget/TripSummaryWidgetFormatterTest.kt
@@ -0,0 +1,181 @@
+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",
+ periodLabel = "Last 30 days",
+ metrics = TripSummaryWidgetMetrics(
+ driveCount = 4,
+ drivingDays = 3,
+ totalDistance = 300.0,
+ totalEnergy = 48.0,
+ avgEfficiency = 160.0,
+ totalCost = 0.0,
+ chargesWithCost = 0,
+ chargeCount = 2,
+ previousDistance = 250.0,
+ ),
+ currencySymbol = "\$",
+ noValue = "N/A",
+ updatedValue = "Updated 9:00",
+ )
+
+ assertTrue(data.hasDrives)
+ assertEquals("Last 30 days", data.periodLabel)
+ 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(TripSummaryWidgetTrend.Up, data.distanceTrend)
+ assertEquals(20, data.distanceTrendPercent)
+ assertEquals(TripSummaryWidgetCostCoverage.Missing, data.costCoverage)
+ assertEquals("km", data.distanceUnit)
+ assertEquals("Updated 9:00", data.updatedValue)
+ }
+
+ @Test
+ fun formatShowsZeroCostWhenAZeroCostChargeWasRecorded() {
+ val data = TripSummaryWidgetFormatter.format(
+ carId = 7,
+ carName = "Model Y",
+ periodLabel = "Last 7 days",
+ metrics = TripSummaryWidgetMetrics(
+ driveCount = 3,
+ drivingDays = 2,
+ totalDistance = 250.0,
+ totalEnergy = 40.0,
+ avgEfficiency = 160.0,
+ totalCost = 0.0,
+ chargesWithCost = 1,
+ chargeCount = 1,
+ previousDistance = 250.0,
+ ),
+ currencySymbol = "\$",
+ noValue = "N/A",
+ updatedValue = "Updated 9:00",
+ )
+
+ assertEquals("0.00 \$", data.costValue)
+ assertEquals("0.00 \$", data.costPerDistanceValue)
+ assertEquals(TripSummaryWidgetTrend.Same, data.distanceTrend)
+ assertEquals(TripSummaryWidgetCostCoverage.Complete, data.costCoverage)
+ }
+
+ @Test
+ fun formatUsesImperialLabelsWhenUnitsAreImperial() {
+ val data = TripSummaryWidgetFormatter.format(
+ carId = 7,
+ carName = "Model 3",
+ periodLabel = "Last 90 days",
+ metrics = TripSummaryWidgetMetrics(
+ driveCount = 2,
+ drivingDays = 2,
+ totalDistance = 120.0,
+ totalEnergy = 30.0,
+ avgEfficiency = 250.0,
+ totalCost = 12.0,
+ chargesWithCost = 1,
+ chargeCount = 2,
+ previousDistance = 150.0,
+ ),
+ 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)
+ assertEquals(TripSummaryWidgetTrend.Down, data.distanceTrend)
+ assertEquals(20, data.distanceTrendPercent)
+ assertEquals(TripSummaryWidgetCostCoverage.Partial, data.costCoverage)
+ }
+
+ @Test
+ fun formatMarksEmptyDriveRange() {
+ val data = TripSummaryWidgetFormatter.format(
+ carId = 7,
+ carName = "Model S",
+ periodLabel = "Last 30 days",
+ metrics = TripSummaryWidgetMetrics(
+ driveCount = 0,
+ drivingDays = 0,
+ totalDistance = 0.0,
+ totalEnergy = 0.0,
+ avgEfficiency = 0.0,
+ totalCost = 0.0,
+ chargesWithCost = 0,
+ chargeCount = 0,
+ previousDistance = 200.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)
+ 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)
+ }
+}