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) + } +}