diff --git a/CHANGELOG.md b/CHANGELOG.md
index eeea6191..16650287 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- **Gasoline comparison** — in Settings, add an equivalent gasoline car's fuel economy (L/100 km or mpg) and pump price (per liter or per gallon). Cost Analytics then shows a neutral "vs gasoline" card comparing your Tesla charging cost for the selected range against what the same distance would have cost in gasoline, including the estimated volume used and the difference in absolute terms and percent. Distances from TeslaMate are never converted; only the ICE assumptions are converted when their unit doesn't match the length unit already in use.
+- **Utility-rate cost estimates** — set your home (AC) rate and, optionally, a DC/public rate per kWh in Settings. When TeslaMate has no price for a charging session, MateDroid now fills in the gap with your local rate so cost analytics and trip summaries stay useful. Recorded TeslaMate prices are always preferred (including free sessions); estimates are labelled honestly and never overwrite what was recorded. DC sessions use the DC rate when set, and fall back to the home rate otherwise so a single rate still works.
+- **Cost analytics screen** — a new "Costs" entry on the dashboard opens a focused view of your charging spend. Pick 7/30/90-day or all-time windows to see known cost, cost per 100 km/mi (only when every charge in range is priced), average cost per kWh, energy added, and a coverage summary that surfaces missing prices and free sessions honestly.
+- **Cost summary home-screen widget** — a read-only Glance widget that mirrors the Cost Analytics numbers on your home screen. Configurable per car with 7/30/90-day ranges, refreshes on each background sync, and never contacts the car or the API (all values come from the Room cache).
+- **Richer Trips summary** — the Trips list summary now includes energy used, average efficiency, energy charged, charging cost, and cost per 100 km/mi. Known costs stay visible when only some sessions are priced, with an honest coverage note; cost/100 only appears when every charge in range is priced.
+- **Trip highlights card** — a new card under the Trips summary surfaces standout trips in the current filter: most and least efficient, cheapest and priciest per 100 km/mi (only when every charge is priced), longest, biggest charging-overhead share, plus the total energy gap and average charging minutes per trip. Tap a highlight to jump straight to that trip.
+- **Battery health history** — the Battery screen now records a max-range snapshot each time it loads (once per day) and shows a trend chart with the last three months of data once enough snapshots exist.
+
### Changed
- **Snappier throughout** — smoother list scrolling, lighter chart rendering, the Stats and Trips screens do their heavy work off the UI thread, and the dashboard stops polling for status while it's off screen (less background battery use). No change to how anything looks.
- **Large cost totals now use a thousands separator** (e.g. "1,234.56") consistently across the stats, mileage and trip screens, matching how distances and energy are already shown.
diff --git a/README.md b/README.md
index 2f086645..f7f05435 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ A native Android application for viewing Tesla vehicle data from your self-hoste
- **Dashboard** - Real-time vehicle status at a glance with 3D car image matching your vehicle's color and wheels. Long tap it to change the picture.
- **Notifications** - Sentry events notification, tyres pressure alert and Live notification (Android 16+) of charging sessions.
-- **Widget** - Home/lock screen widget for easy and safe read-only access to your car status. No more trunks opened due to a misstap!
+- **Widgets** - Home/lock screen widgets for easy and safe read-only access to your car status (no more trunks opened due to a misstap!) and a Cost Summary widget showing your charging spend over the last 7, 30 or 90 days.
- **Stats for Nerds** - Tap car image for advanced statistics: records, extremes, AC/DC ratio and much more!
- **Charging History** - View all charging sessions with statistics and charts
- **Charge Details** - Interactive map and detailed power/voltage/temperature charts
diff --git a/app/schemas/com.matedroid.data.local.StatsDatabase/13.json b/app/schemas/com.matedroid.data.local.StatsDatabase/13.json
new file mode 100644
index 00000000..a0f81709
--- /dev/null
+++ b/app/schemas/com.matedroid.data.local.StatsDatabase/13.json
@@ -0,0 +1,1210 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 13,
+ "identityHash": "13ab2fa25e2d951e1113267e80e03ca7",
+ "entities": [
+ {
+ "tableName": "sync_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`carId` INTEGER NOT NULL, `lastDriveSyncAt` INTEGER NOT NULL, `lastChargeSyncAt` INTEGER NOT NULL, `lastDriveDetailId` INTEGER NOT NULL, `lastChargeDetailId` INTEGER NOT NULL, `detailSchemaVersion` INTEGER NOT NULL, `totalDrivesToProcess` INTEGER NOT NULL, `totalChargesToProcess` INTEGER NOT NULL, `drivesProcessed` INTEGER NOT NULL, `chargesProcessed` INTEGER NOT NULL, `summariesSynced` INTEGER NOT NULL, `detailsSynced` INTEGER NOT NULL, PRIMARY KEY(`carId`))",
+ "fields": [
+ {
+ "fieldPath": "carId",
+ "columnName": "carId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastDriveSyncAt",
+ "columnName": "lastDriveSyncAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastChargeSyncAt",
+ "columnName": "lastChargeSyncAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastDriveDetailId",
+ "columnName": "lastDriveDetailId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastChargeDetailId",
+ "columnName": "lastChargeDetailId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "detailSchemaVersion",
+ "columnName": "detailSchemaVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalDrivesToProcess",
+ "columnName": "totalDrivesToProcess",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalChargesToProcess",
+ "columnName": "totalChargesToProcess",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "drivesProcessed",
+ "columnName": "drivesProcessed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chargesProcessed",
+ "columnName": "chargesProcessed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "summariesSynced",
+ "columnName": "summariesSynced",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "detailsSynced",
+ "columnName": "detailsSynced",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "carId"
+ ]
+ }
+ },
+ {
+ "tableName": "drives_summary",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`driveId` INTEGER NOT NULL, `carId` INTEGER NOT NULL, `startDate` TEXT NOT NULL, `endDate` TEXT NOT NULL, `durationMin` INTEGER NOT NULL, `startAddress` TEXT NOT NULL, `endAddress` TEXT NOT NULL, `distance` REAL NOT NULL, `speedMax` INTEGER NOT NULL, `speedAvg` INTEGER NOT NULL, `powerMax` INTEGER NOT NULL, `powerMin` INTEGER NOT NULL, `startBatteryLevel` INTEGER NOT NULL, `endBatteryLevel` INTEGER NOT NULL, `outsideTempAvg` REAL, `insideTempAvg` REAL, `energyConsumed` REAL, `efficiency` REAL, PRIMARY KEY(`driveId`))",
+ "fields": [
+ {
+ "fieldPath": "driveId",
+ "columnName": "driveId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "carId",
+ "columnName": "carId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startDate",
+ "columnName": "startDate",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endDate",
+ "columnName": "endDate",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "durationMin",
+ "columnName": "durationMin",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startAddress",
+ "columnName": "startAddress",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endAddress",
+ "columnName": "endAddress",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "distance",
+ "columnName": "distance",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "speedMax",
+ "columnName": "speedMax",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "speedAvg",
+ "columnName": "speedAvg",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerMax",
+ "columnName": "powerMax",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerMin",
+ "columnName": "powerMin",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startBatteryLevel",
+ "columnName": "startBatteryLevel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endBatteryLevel",
+ "columnName": "endBatteryLevel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "outsideTempAvg",
+ "columnName": "outsideTempAvg",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "insideTempAvg",
+ "columnName": "insideTempAvg",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "energyConsumed",
+ "columnName": "energyConsumed",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "efficiency",
+ "columnName": "efficiency",
+ "affinity": "REAL"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "driveId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_drives_summary_carId",
+ "unique": false,
+ "columnNames": [
+ "carId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_drives_summary_carId` ON `${TABLE_NAME}` (`carId`)"
+ },
+ {
+ "name": "index_drives_summary_carId_startDate",
+ "unique": false,
+ "columnNames": [
+ "carId",
+ "startDate"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_drives_summary_carId_startDate` ON `${TABLE_NAME}` (`carId`, `startDate`)"
+ }
+ ]
+ },
+ {
+ "tableName": "charges_summary",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chargeId` INTEGER NOT NULL, `carId` INTEGER NOT NULL, `startDate` TEXT NOT NULL, `endDate` TEXT NOT NULL, `durationMin` INTEGER NOT NULL, `address` TEXT NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `energyAdded` REAL NOT NULL, `energyUsed` REAL, `cost` REAL, `startBatteryLevel` INTEGER NOT NULL, `endBatteryLevel` INTEGER NOT NULL, `outsideTempAvg` REAL, `odometer` REAL NOT NULL, PRIMARY KEY(`chargeId`))",
+ "fields": [
+ {
+ "fieldPath": "chargeId",
+ "columnName": "chargeId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "carId",
+ "columnName": "carId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startDate",
+ "columnName": "startDate",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endDate",
+ "columnName": "endDate",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "durationMin",
+ "columnName": "durationMin",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "address",
+ "columnName": "address",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "energyAdded",
+ "columnName": "energyAdded",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "energyUsed",
+ "columnName": "energyUsed",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "cost",
+ "columnName": "cost",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "startBatteryLevel",
+ "columnName": "startBatteryLevel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endBatteryLevel",
+ "columnName": "endBatteryLevel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "outsideTempAvg",
+ "columnName": "outsideTempAvg",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "odometer",
+ "columnName": "odometer",
+ "affinity": "REAL",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chargeId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_charges_summary_carId",
+ "unique": false,
+ "columnNames": [
+ "carId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_charges_summary_carId` ON `${TABLE_NAME}` (`carId`)"
+ },
+ {
+ "name": "index_charges_summary_carId_startDate",
+ "unique": false,
+ "columnNames": [
+ "carId",
+ "startDate"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_charges_summary_carId_startDate` ON `${TABLE_NAME}` (`carId`, `startDate`)"
+ }
+ ]
+ },
+ {
+ "tableName": "drive_detail_aggregates",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`driveId` INTEGER NOT NULL, `carId` INTEGER NOT NULL, `schemaVersion` INTEGER NOT NULL, `computedAt` INTEGER NOT NULL, `maxElevation` INTEGER, `minElevation` INTEGER, `startElevation` INTEGER, `endElevation` INTEGER, `elevationGain` INTEGER, `elevationLoss` INTEGER, `hasElevationData` INTEGER NOT NULL, `maxInsideTemp` REAL, `minInsideTemp` REAL, `maxOutsideTemp` REAL, `minOutsideTemp` REAL, `maxPower` INTEGER, `minPower` INTEGER, `climateOnPositions` INTEGER NOT NULL, `positionCount` INTEGER NOT NULL, `startLatitude` REAL, `startLongitude` REAL, `startCountryCode` TEXT, `startCountryName` TEXT, `startRegionName` TEXT, `startCity` TEXT, `endLatitude` REAL, `endLongitude` REAL, `extraJson` TEXT, PRIMARY KEY(`driveId`), FOREIGN KEY(`driveId`) REFERENCES `drives_summary`(`driveId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "driveId",
+ "columnName": "driveId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "carId",
+ "columnName": "carId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "schemaVersion",
+ "columnName": "schemaVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "computedAt",
+ "columnName": "computedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxElevation",
+ "columnName": "maxElevation",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "minElevation",
+ "columnName": "minElevation",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "startElevation",
+ "columnName": "startElevation",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "endElevation",
+ "columnName": "endElevation",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "elevationGain",
+ "columnName": "elevationGain",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "elevationLoss",
+ "columnName": "elevationLoss",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hasElevationData",
+ "columnName": "hasElevationData",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxInsideTemp",
+ "columnName": "maxInsideTemp",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "minInsideTemp",
+ "columnName": "minInsideTemp",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "maxOutsideTemp",
+ "columnName": "maxOutsideTemp",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "minOutsideTemp",
+ "columnName": "minOutsideTemp",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "maxPower",
+ "columnName": "maxPower",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "minPower",
+ "columnName": "minPower",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "climateOnPositions",
+ "columnName": "climateOnPositions",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "positionCount",
+ "columnName": "positionCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startLatitude",
+ "columnName": "startLatitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "startLongitude",
+ "columnName": "startLongitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "startCountryCode",
+ "columnName": "startCountryCode",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "startCountryName",
+ "columnName": "startCountryName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "startRegionName",
+ "columnName": "startRegionName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "startCity",
+ "columnName": "startCity",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "endLatitude",
+ "columnName": "endLatitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "endLongitude",
+ "columnName": "endLongitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "extraJson",
+ "columnName": "extraJson",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "driveId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_drive_detail_aggregates_carId",
+ "unique": false,
+ "columnNames": [
+ "carId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_drive_detail_aggregates_carId` ON `${TABLE_NAME}` (`carId`)"
+ },
+ {
+ "name": "index_drive_detail_aggregates_driveId",
+ "unique": false,
+ "columnNames": [
+ "driveId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_drive_detail_aggregates_driveId` ON `${TABLE_NAME}` (`driveId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "drives_summary",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "driveId"
+ ],
+ "referencedColumns": [
+ "driveId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "charge_detail_aggregates",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chargeId` INTEGER NOT NULL, `carId` INTEGER NOT NULL, `schemaVersion` INTEGER NOT NULL, `computedAt` INTEGER NOT NULL, `isFastCharger` INTEGER NOT NULL, `fastChargerBrand` TEXT, `connectorType` TEXT, `maxChargerPower` INTEGER, `maxChargerVoltage` INTEGER, `maxChargerCurrent` INTEGER, `chargerPhases` INTEGER, `maxOutsideTemp` REAL, `minOutsideTemp` REAL, `chargePointCount` INTEGER NOT NULL, `countryCode` TEXT, `countryName` TEXT, `regionName` TEXT, `city` TEXT, `extraJson` TEXT, PRIMARY KEY(`chargeId`), FOREIGN KEY(`chargeId`) REFERENCES `charges_summary`(`chargeId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "chargeId",
+ "columnName": "chargeId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "carId",
+ "columnName": "carId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "schemaVersion",
+ "columnName": "schemaVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "computedAt",
+ "columnName": "computedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFastCharger",
+ "columnName": "isFastCharger",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fastChargerBrand",
+ "columnName": "fastChargerBrand",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "connectorType",
+ "columnName": "connectorType",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "maxChargerPower",
+ "columnName": "maxChargerPower",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "maxChargerVoltage",
+ "columnName": "maxChargerVoltage",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "maxChargerCurrent",
+ "columnName": "maxChargerCurrent",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "chargerPhases",
+ "columnName": "chargerPhases",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "maxOutsideTemp",
+ "columnName": "maxOutsideTemp",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "minOutsideTemp",
+ "columnName": "minOutsideTemp",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "chargePointCount",
+ "columnName": "chargePointCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "countryCode",
+ "columnName": "countryCode",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "countryName",
+ "columnName": "countryName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "regionName",
+ "columnName": "regionName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "city",
+ "columnName": "city",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "extraJson",
+ "columnName": "extraJson",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chargeId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_charge_detail_aggregates_carId",
+ "unique": false,
+ "columnNames": [
+ "carId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_charge_detail_aggregates_carId` ON `${TABLE_NAME}` (`carId`)"
+ },
+ {
+ "name": "index_charge_detail_aggregates_chargeId",
+ "unique": false,
+ "columnNames": [
+ "chargeId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_charge_detail_aggregates_chargeId` ON `${TABLE_NAME}` (`chargeId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "charges_summary",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "chargeId"
+ ],
+ "referencedColumns": [
+ "chargeId"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "geocode_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gridLat` INTEGER NOT NULL, `gridLon` INTEGER NOT NULL, `countryCode` TEXT, `countryName` TEXT, `regionName` TEXT, `city` TEXT, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`gridLat`, `gridLon`))",
+ "fields": [
+ {
+ "fieldPath": "gridLat",
+ "columnName": "gridLat",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "gridLon",
+ "columnName": "gridLon",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "countryCode",
+ "columnName": "countryCode",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "countryName",
+ "columnName": "countryName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "regionName",
+ "columnName": "regionName",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "city",
+ "columnName": "city",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "cachedAt",
+ "columnName": "cachedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "gridLat",
+ "gridLon"
+ ]
+ }
+ },
+ {
+ "tableName": "geocode_queue",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gridLat` INTEGER NOT NULL, `gridLon` INTEGER NOT NULL, `carId` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `addedAt` INTEGER NOT NULL, `attempts` INTEGER NOT NULL, `lastAttemptAt` INTEGER, PRIMARY KEY(`gridLat`, `gridLon`))",
+ "fields": [
+ {
+ "fieldPath": "gridLat",
+ "columnName": "gridLat",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "gridLon",
+ "columnName": "gridLon",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "carId",
+ "columnName": "carId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "addedAt",
+ "columnName": "addedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "attempts",
+ "columnName": "attempts",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastAttemptAt",
+ "columnName": "lastAttemptAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "gridLat",
+ "gridLon"
+ ]
+ }
+ },
+ {
+ "tableName": "geocode_progress",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`carId` INTEGER NOT NULL, `totalLocations` INTEGER NOT NULL, `processedLocations` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, PRIMARY KEY(`carId`))",
+ "fields": [
+ {
+ "fieldPath": "carId",
+ "columnName": "carId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalLocations",
+ "columnName": "totalLocations",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "processedLocations",
+ "columnName": "processedLocations",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdatedAt",
+ "columnName": "lastUpdatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "carId"
+ ]
+ }
+ },
+ {
+ "tableName": "sentry_alert_log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `carId` INTEGER NOT NULL, `detectedAt` INTEGER NOT NULL, `sessionStartedAt` INTEGER NOT NULL, `latitude` REAL, `longitude` REAL, `address` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "carId",
+ "columnName": "carId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "detectedAt",
+ "columnName": "detectedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionStartedAt",
+ "columnName": "sessionStartedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "address",
+ "columnName": "address",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_sentry_alert_log_carId_detectedAt",
+ "unique": false,
+ "columnNames": [
+ "carId",
+ "detectedAt"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_sentry_alert_log_carId_detectedAt` ON `${TABLE_NAME}` (`carId`, `detectedAt`)"
+ }
+ ]
+ },
+ {
+ "tableName": "trip_route_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tripKey` TEXT NOT NULL, `segmentIndex` INTEGER NOT NULL, `segmentData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`tripKey`, `segmentIndex`))",
+ "fields": [
+ {
+ "fieldPath": "tripKey",
+ "columnName": "tripKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "segmentIndex",
+ "columnName": "segmentIndex",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "segmentData",
+ "columnName": "segmentData",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "tripKey",
+ "segmentIndex"
+ ]
+ }
+ },
+ {
+ "tableName": "trip_country_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tripKey` TEXT NOT NULL, `countries` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`tripKey`))",
+ "fields": [
+ {
+ "fieldPath": "tripKey",
+ "columnName": "tripKey",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "countries",
+ "columnName": "countries",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "tripKey"
+ ]
+ }
+ },
+ {
+ "tableName": "saved_trips",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `carId` INTEGER NOT NULL, `name` TEXT, `source` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "carId",
+ "columnName": "carId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_saved_trips_carId",
+ "unique": false,
+ "columnNames": [
+ "carId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_saved_trips_carId` ON `${TABLE_NAME}` (`carId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "saved_trip_legs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tripId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `legType` TEXT NOT NULL, `legId` INTEGER NOT NULL, PRIMARY KEY(`tripId`, `position`), FOREIGN KEY(`tripId`) REFERENCES `saved_trips`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "tripId",
+ "columnName": "tripId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "legType",
+ "columnName": "legType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "legId",
+ "columnName": "legId",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "tripId",
+ "position"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_saved_trip_legs_legType_legId",
+ "unique": false,
+ "columnNames": [
+ "legType",
+ "legId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_saved_trip_legs_legType_legId` ON `${TABLE_NAME}` (`legType`, `legId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "saved_trips",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "tripId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "saved_trip_consumed_fingerprints",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`savedTripId` INTEGER NOT NULL, `fingerprint` TEXT NOT NULL, PRIMARY KEY(`savedTripId`, `fingerprint`), FOREIGN KEY(`savedTripId`) REFERENCES `saved_trips`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "savedTripId",
+ "columnName": "savedTripId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fingerprint",
+ "columnName": "fingerprint",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "savedTripId",
+ "fingerprint"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_saved_trip_consumed_fingerprints_fingerprint",
+ "unique": false,
+ "columnNames": [
+ "fingerprint"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_saved_trip_consumed_fingerprints_fingerprint` ON `${TABLE_NAME}` (`fingerprint`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "saved_trips",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "savedTripId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "battery_health_snapshots",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `carId` INTEGER NOT NULL, `recordedAt` INTEGER NOT NULL, `maxRange` REAL, `currentRange` REAL, `maxCapacity` REAL, `currentCapacity` REAL, `ratedEfficiency` REAL, `batteryHealthPercentage` REAL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "carId",
+ "columnName": "carId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "recordedAt",
+ "columnName": "recordedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxRange",
+ "columnName": "maxRange",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "currentRange",
+ "columnName": "currentRange",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "maxCapacity",
+ "columnName": "maxCapacity",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "currentCapacity",
+ "columnName": "currentCapacity",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "ratedEfficiency",
+ "columnName": "ratedEfficiency",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "batteryHealthPercentage",
+ "columnName": "batteryHealthPercentage",
+ "affinity": "REAL"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_battery_health_snapshots_carId_recordedAt",
+ "unique": false,
+ "columnNames": [
+ "carId",
+ "recordedAt"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_battery_health_snapshots_carId_recordedAt` ON `${TABLE_NAME}` (`carId`, `recordedAt`)"
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '13ab2fa25e2d951e1113267e80e03ca7')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3043b903..2051067b 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..2abfd73d 100644
--- a/app/src/main/java/com/matedroid/MainActivity.kt
+++ b/app/src/main/java/com/matedroid/MainActivity.kt
@@ -25,7 +25,11 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
currentIntent = intent
- if (intent.hasExtra("EXTRA_CAR_ID")) {
+ // Skip-flag lets non-car widgets (trip / cost summaries) open the app
+ // without triggering an unrelated car-status refresh.
+ 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..983f6df7 100644
--- a/app/src/main/java/com/matedroid/data/local/SettingsDataStore.kt
+++ b/app/src/main/java/com/matedroid/data/local/SettingsDataStore.kt
@@ -54,7 +54,33 @@ data class AppSettings(
val currencyCode: String = "EUR",
val showShortDrivesCharges: Boolean = false,
val teslamateBaseUrl: String = "",
- val lastSelectedCarId: Int? = null
+ val unitOfLength: String = "km",
+ val lastSelectedCarId: Int? = null,
+ /**
+ * Home / AC utility rate (per kWh) in the user's chosen [currencyCode].
+ * Null when the user has not configured a rate; MateDroid then falls back
+ * to TeslaMate's recorded cost only.
+ */
+ val homeUtilityRatePerKwh: Double? = null,
+ /**
+ * Optional DC / public utility rate (per kWh). When null, DC estimates
+ * fall back to [homeUtilityRatePerKwh].
+ */
+ val dcUtilityRatePerKWh: Double? = null,
+ /**
+ * User assumption for an equivalent gasoline car's fuel economy.
+ * Interpreted in L/100 km when [iceFuelEconomyIsMpg] is false, US mpg otherwise.
+ * Null when unset — the gasoline comparison stays hidden in that case.
+ */
+ val iceFuelEconomyValue: Double? = null,
+ val iceFuelEconomyIsMpg: Boolean = false,
+ /**
+ * User assumption for the price of a unit of gasoline. Interpreted per
+ * liter when [iceFuelPriceIsPerGallon] is false, per US gallon otherwise.
+ * Stored in the same currency as [currencyCode].
+ */
+ val iceFuelPrice: Double? = null,
+ val iceFuelPriceIsPerGallon: Boolean = false,
) {
val isConfigured: Boolean
get() = serverUrl.isNotBlank()
@@ -76,9 +102,16 @@ 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")
+ private val homeUtilityRateKey = stringPreferencesKey("home_utility_rate_per_kwh")
+ private val dcUtilityRateKey = stringPreferencesKey("dc_utility_rate_per_kwh")
+ private val iceFuelEconomyValueKey = stringPreferencesKey("ice_fuel_economy_value")
+ private val iceFuelEconomyIsMpgKey = booleanPreferencesKey("ice_fuel_economy_is_mpg")
+ private val iceFuelPriceKey = stringPreferencesKey("ice_fuel_price")
+ private val iceFuelPriceIsPerGallonKey = booleanPreferencesKey("ice_fuel_price_is_per_gallon")
val notificationPermissionAsked: Flow = context.dataStore.data.map { preferences ->
preferences[notificationPermissionAskedKey] ?: false
@@ -95,7 +128,14 @@ class SettingsDataStore @Inject constructor(
currencyCode = preferences[currencyCodeKey] ?: "EUR",
showShortDrivesCharges = preferences[showShortDrivesChargesKey] ?: false,
teslamateBaseUrl = preferences[teslamateBaseUrlKey] ?: "",
- lastSelectedCarId = preferences[lastSelectedCarIdKey]
+ unitOfLength = preferences[unitOfLengthKey] ?: "km",
+ lastSelectedCarId = preferences[lastSelectedCarIdKey],
+ homeUtilityRatePerKwh = preferences[homeUtilityRateKey]?.toDoubleOrNull(),
+ dcUtilityRatePerKWh = preferences[dcUtilityRateKey]?.toDoubleOrNull(),
+ iceFuelEconomyValue = preferences[iceFuelEconomyValueKey]?.toDoubleOrNull(),
+ iceFuelEconomyIsMpg = preferences[iceFuelEconomyIsMpgKey] ?: false,
+ iceFuelPrice = preferences[iceFuelPriceKey]?.toDoubleOrNull(),
+ iceFuelPriceIsPerGallon = preferences[iceFuelPriceIsPerGallonKey] ?: false,
)
}
@@ -194,6 +234,17 @@ class SettingsDataStore @Inject constructor(
}
}
+ /**
+ * Cache the user's unit-of-length preference (km/mi) fetched from TeslaMate
+ * global settings. Consumed by widgets so they can format distances offline
+ * without hitting the API.
+ */
+ suspend fun saveUnitOfLength(unitOfLength: String) {
+ context.dataStore.edit { preferences ->
+ preferences[unitOfLengthKey] = unitOfLength
+ }
+ }
+
suspend fun saveLastSelectedCarId(carId: Int) {
context.dataStore.edit { preferences ->
preferences[lastSelectedCarIdKey] = carId
@@ -221,6 +272,83 @@ class SettingsDataStore @Inject constructor(
}
}
+ /**
+ * Save (or clear when null / non-positive) the home utility rate. Rates are
+ * stored as strings so an empty preference cleanly means "not set". Any
+ * non-finite or non-positive value is treated as "clear the rate".
+ */
+ suspend fun saveHomeUtilityRate(ratePerKwh: Double?) {
+ context.dataStore.edit { preferences ->
+ val valid = ratePerKwh?.takeIf { it.isFinite() && it > 0.0 }
+ if (valid == null) {
+ preferences.remove(homeUtilityRateKey)
+ } else {
+ preferences[homeUtilityRateKey] = valid.toString()
+ }
+ }
+ }
+
+ /**
+ * Save (or clear when null / non-positive) the optional DC utility rate.
+ */
+ suspend fun saveDcUtilityRate(ratePerKwh: Double?) {
+ context.dataStore.edit { preferences ->
+ val valid = ratePerKwh?.takeIf { it.isFinite() && it > 0.0 }
+ if (valid == null) {
+ preferences.remove(dcUtilityRateKey)
+ } else {
+ preferences[dcUtilityRateKey] = valid.toString()
+ }
+ }
+ }
+
+ /**
+ * Save (or clear when null / non-positive) the ICE fuel-economy assumption.
+ * [isMpg] records the unit the user typed the value in so we don't lose it
+ * on reload.
+ */
+ suspend fun saveIceFuelEconomy(value: Double?, isMpg: Boolean) {
+ context.dataStore.edit { preferences ->
+ val valid = value?.takeIf { it.isFinite() && it > 0.0 }
+ if (valid == null) {
+ preferences.remove(iceFuelEconomyValueKey)
+ } else {
+ preferences[iceFuelEconomyValueKey] = valid.toString()
+ }
+ preferences[iceFuelEconomyIsMpgKey] = isMpg
+ }
+ }
+
+ /**
+ * Save (or clear when null / non-positive) the ICE fuel-price assumption
+ * in the user's chosen currency. [isPerGallon] records the entered unit.
+ */
+ suspend fun saveIceFuelPrice(value: Double?, isPerGallon: Boolean) {
+ context.dataStore.edit { preferences ->
+ val valid = value?.takeIf { it.isFinite() && it > 0.0 }
+ if (valid == null) {
+ preferences.remove(iceFuelPriceKey)
+ } else {
+ preferences[iceFuelPriceKey] = valid.toString()
+ }
+ preferences[iceFuelPriceIsPerGallonKey] = isPerGallon
+ }
+ }
+
+ /** Update just the ICE fuel-economy unit (leaves the value untouched). */
+ suspend fun saveIceFuelEconomyUnit(isMpg: Boolean) {
+ context.dataStore.edit { preferences ->
+ preferences[iceFuelEconomyIsMpgKey] = isMpg
+ }
+ }
+
+ /** Update just the ICE fuel-price unit (leaves the value untouched). */
+ suspend fun saveIceFuelPriceUnit(isPerGallon: Boolean) {
+ context.dataStore.edit { preferences ->
+ preferences[iceFuelPriceIsPerGallonKey] = isPerGallon
+ }
+ }
+
suspend fun saveNotificationPermissionAsked() {
context.dataStore.edit { preferences ->
preferences[notificationPermissionAskedKey] = true
diff --git a/app/src/main/java/com/matedroid/data/local/StatsDatabase.kt b/app/src/main/java/com/matedroid/data/local/StatsDatabase.kt
index 89fdcdff..29954483 100644
--- a/app/src/main/java/com/matedroid/data/local/StatsDatabase.kt
+++ b/app/src/main/java/com/matedroid/data/local/StatsDatabase.kt
@@ -5,6 +5,7 @@ import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.matedroid.data.local.dao.AggregateDao
+import com.matedroid.data.local.dao.BatteryHealthSnapshotDao
import com.matedroid.data.local.dao.ChargeSummaryDao
import com.matedroid.data.local.dao.DriveSummaryDao
import com.matedroid.data.local.dao.GeocodeCacheDao
@@ -15,6 +16,7 @@ import com.matedroid.data.local.dao.SentryAlertLogDao
import com.matedroid.data.local.dao.SyncStateDao
import com.matedroid.data.local.dao.TripCountryCacheDao
import com.matedroid.data.local.dao.TripRouteCacheDao
+import com.matedroid.data.local.entity.BatteryHealthSnapshot
import com.matedroid.data.local.entity.ChargeDetailAggregate
import com.matedroid.data.local.entity.ChargeSummary
import com.matedroid.data.local.entity.DriveDetailAggregate
@@ -57,9 +59,10 @@ import com.matedroid.data.local.entity.TripRouteCache
TripCountryCache::class,
SavedTrip::class,
SavedTripLeg::class,
- SavedTripConsumedFingerprint::class
+ SavedTripConsumedFingerprint::class,
+ BatteryHealthSnapshot::class
],
- version = 12,
+ version = 13,
exportSchema = true
)
abstract class StatsDatabase : RoomDatabase() {
@@ -75,6 +78,7 @@ abstract class StatsDatabase : RoomDatabase() {
abstract fun tripRouteCacheDao(): TripRouteCacheDao
abstract fun tripCountryCacheDao(): TripCountryCacheDao
abstract fun savedTripDao(): SavedTripDao
+ abstract fun batteryHealthSnapshotDao(): BatteryHealthSnapshotDao
companion object {
const val DATABASE_NAME = "matedroid_stats.db"
@@ -317,6 +321,33 @@ abstract class StatsDatabase : RoomDatabase() {
}
}
- val ALL_MIGRATIONS = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
+ /**
+ * Migration from V12 to V13: Add battery health snapshot table for the trend chart on the
+ * Battery screen. Rows are appended once per day per car and pruned to the most recent
+ * [BatteryHealthSnapshotDao.MAX_ROWS_PER_CAR] entries.
+ */
+ val MIGRATION_12_13 = object : Migration(12, 13) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("""
+ CREATE TABLE IF NOT EXISTS battery_health_snapshots (
+ id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+ carId INTEGER NOT NULL,
+ recordedAt INTEGER NOT NULL,
+ maxRange REAL,
+ currentRange REAL,
+ maxCapacity REAL,
+ currentCapacity REAL,
+ ratedEfficiency REAL,
+ batteryHealthPercentage REAL
+ )
+ """)
+ db.execSQL("""
+ CREATE INDEX IF NOT EXISTS index_battery_health_snapshots_carId_recordedAt
+ ON battery_health_snapshots (carId, recordedAt)
+ """)
+ }
+ }
+
+ val ALL_MIGRATIONS = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13)
}
}
diff --git a/app/src/main/java/com/matedroid/data/local/dao/BatteryHealthSnapshotDao.kt b/app/src/main/java/com/matedroid/data/local/dao/BatteryHealthSnapshotDao.kt
new file mode 100644
index 00000000..2a48129b
--- /dev/null
+++ b/app/src/main/java/com/matedroid/data/local/dao/BatteryHealthSnapshotDao.kt
@@ -0,0 +1,63 @@
+package com.matedroid.data.local.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import com.matedroid.data.local.entity.BatteryHealthSnapshot
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface BatteryHealthSnapshotDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(snapshot: BatteryHealthSnapshot): Long
+
+ /** All snapshots for a car, oldest first — the chart wants chronological data. */
+ @Query("""
+ SELECT * FROM battery_health_snapshots
+ WHERE carId = :carId
+ ORDER BY recordedAt ASC
+ """)
+ fun observeAllForCar(carId: Int): Flow>
+
+ /** Latest snapshot for a car, or null if none. Used by the write path to decide whether the new sample is a duplicate worth skipping. */
+ @Query("""
+ SELECT * FROM battery_health_snapshots
+ WHERE carId = :carId
+ ORDER BY recordedAt DESC
+ LIMIT 1
+ """)
+ suspend fun getLatestForCar(carId: Int): BatteryHealthSnapshot?
+
+ @Query("SELECT COUNT(*) FROM battery_health_snapshots WHERE carId = :carId")
+ suspend fun countForCar(carId: Int): Int
+
+ /**
+ * Prune the car's history so at most [keep] most-recent rows remain.
+ * Called after inserts to bound storage (~90 rows ~= 3 months of daily samples).
+ */
+ @Query("""
+ DELETE FROM battery_health_snapshots
+ WHERE carId = :carId AND id NOT IN (
+ SELECT id FROM battery_health_snapshots
+ WHERE carId = :carId
+ ORDER BY recordedAt DESC
+ LIMIT :keep
+ )
+ """)
+ suspend fun pruneOldest(carId: Int, keep: Int)
+
+ /** Convenience wrapper: insert + prune in a single transaction. */
+ @Transaction
+ suspend fun insertAndPrune(snapshot: BatteryHealthSnapshot, keep: Int = MAX_ROWS_PER_CAR) {
+ insert(snapshot)
+ pruneOldest(snapshot.carId, keep)
+ }
+
+ companion object {
+ /** Retention cap per car — roughly 3 months of daily snapshots. */
+ const val MAX_ROWS_PER_CAR = 90
+ }
+}
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..19a60b52 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,44 @@ interface ChargeSummaryDao {
""")
suspend fun sumCostInRange(carId: Int, startDate: String, endDate: String): Double
+ // === Cost coverage counters (for CostAnalytics + trip/cost widgets) ===
+
+ // Number of charges in range with a non-null cost (includes free 0.0 sessions).
+ @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
+
+ // Number of charges in range whose cost was never recorded by TeslaMate.
+ @Query("""
+ SELECT COUNT(*) FROM charges_summary
+ WHERE carId = :carId
+ AND startDate >= :startDate AND startDate < :endDate
+ AND cost IS NULL
+ """)
+ suspend fun countMissingCostInRange(carId: Int, startDate: String, endDate: String): Int
+
+ // Priced-as-free charges (cost = 0). Handy for the cost analytics screen note.
+ @Query("""
+ SELECT COUNT(*) FROM charges_summary
+ WHERE carId = :carId
+ AND startDate >= :startDate AND startDate < :endDate
+ AND cost = 0
+ """)
+ suspend fun countZeroCostInRange(carId: Int, startDate: String, endDate: String): Int
+
+ // Every charge in a range for domain-side aggregation.
+ @Query("""
+ SELECT * FROM charges_summary
+ WHERE carId = :carId
+ AND startDate >= :startDate AND startDate < :endDate
+ ORDER BY startDate ASC
+ """)
+ suspend fun getInRange(carId: Int, startDate: String, endDate: String): List
+
// Average cost per kWh
@Query("""
SELECT COALESCE(SUM(cost) / NULLIF(SUM(energyAdded), 0), 0)
diff --git a/app/src/main/java/com/matedroid/data/local/entity/BatteryHealthSnapshot.kt b/app/src/main/java/com/matedroid/data/local/entity/BatteryHealthSnapshot.kt
new file mode 100644
index 00000000..6622b265
--- /dev/null
+++ b/app/src/main/java/com/matedroid/data/local/entity/BatteryHealthSnapshot.kt
@@ -0,0 +1,37 @@
+package com.matedroid.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+/**
+ * Time-series record of a car's battery health, taken from the TeslamateAPI
+ * `/battery_health` endpoint every time the Battery screen loads (with a
+ * per-day debounce so we don't accumulate near-identical rows).
+ *
+ * All numeric fields are stored raw as returned by the API — no unit
+ * conversion. When displayed, formatters attach the correct label based on
+ * the user's current unit preferences.
+ */
+@Entity(
+ tableName = "battery_health_snapshots",
+ indices = [Index(value = ["carId", "recordedAt"])]
+)
+data class BatteryHealthSnapshot(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val carId: Int,
+ /** Epoch milliseconds when the snapshot was captured on the device. */
+ val recordedAt: Long,
+ /** Max range at full charge (km or mi — as returned by the API). */
+ val maxRange: Double? = null,
+ /** Current range (km or mi). */
+ val currentRange: Double? = null,
+ /** Original usable capacity (kWh). */
+ val maxCapacity: Double? = null,
+ /** Current usable capacity after degradation (kWh). */
+ val currentCapacity: Double? = null,
+ /** Rated efficiency (Wh/km or Wh/mi). */
+ val ratedEfficiency: Double? = null,
+ /** Battery health percentage 0..100 as reported by the API. */
+ val batteryHealthPercentage: Double? = null,
+)
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..7fd11173 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.CostSummaryWidgetUpdateWorker
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@@ -129,11 +130,16 @@ class DataSyncWorker @AssistedInject constructor(
return if (hasNetworkError) {
log("Sync incomplete due to network errors, scheduling retry")
+ // Summaries may still have landed — refresh cost widget before retrying details.
+ CostSummaryWidgetUpdateWorker.scheduleImmediateUpdate(applicationContext)
Result.retry()
} else {
log("Sync complete for all cars")
// Schedule background geocoding for location data
scheduleGeocoding()
+ // Refresh the cost summary widget so it shows the freshest
+ // Room-cached numbers without waiting for the periodic worker.
+ CostSummaryWidgetUpdateWorker.scheduleImmediateUpdate(applicationContext)
Result.success()
}
} catch (e: Exception) {
diff --git a/app/src/main/java/com/matedroid/data/sync/SyncRepository.kt b/app/src/main/java/com/matedroid/data/sync/SyncRepository.kt
index 8c5a730e..52c6f094 100644
--- a/app/src/main/java/com/matedroid/data/sync/SyncRepository.kt
+++ b/app/src/main/java/com/matedroid/data/sync/SyncRepository.kt
@@ -10,6 +10,7 @@ import com.matedroid.data.api.models.ChargeData
import com.matedroid.data.api.models.ChargeDetail
import com.matedroid.data.api.models.DriveData
import com.matedroid.data.api.models.DriveDetail
+import com.matedroid.data.local.SettingsDataStore
import com.matedroid.data.local.dao.AggregateDao
import com.matedroid.data.local.dao.ChargeSummaryDao
import com.matedroid.data.local.dao.DriveSummaryDao
@@ -41,7 +42,8 @@ class SyncRepository @Inject constructor(
private val aggregateDao: AggregateDao,
private val syncManager: SyncManager,
private val logCollector: SyncLogCollector,
- private val geocodingRepository: GeocodingRepository
+ private val geocodingRepository: GeocodingRepository,
+ private val settingsDataStore: SettingsDataStore,
) {
companion object {
private const val TAG = "SyncRepository"
@@ -159,9 +161,24 @@ class SyncRepository @Inject constructor(
}
}
+ // Cache the unit system that these summaries were converted into, so
+ // offline widgets/screens label Room values honestly.
+ cacheUnitOfLengthFromGlobalSettings()
+
return true
}
+ private suspend fun cacheUnitOfLengthFromGlobalSettings() {
+ when (val result = teslamateRepository.getGlobalSettings()) {
+ is ApiResult.Success -> {
+ result.data.settings?.teslamateUnits?.unitOfLength
+ ?.takeIf { it == "km" || it == "mi" }
+ ?.let { settingsDataStore.saveUnitOfLength(it) }
+ }
+ is ApiResult.Error -> Unit
+ }
+ }
+
/**
* Sync drive details (Deep Stats - elevation, temperature extremes, country).
* Processes drives in parallel batches for improved performance.
diff --git a/app/src/main/java/com/matedroid/di/DatabaseModule.kt b/app/src/main/java/com/matedroid/di/DatabaseModule.kt
index cd390cb1..806dcd64 100644
--- a/app/src/main/java/com/matedroid/di/DatabaseModule.kt
+++ b/app/src/main/java/com/matedroid/di/DatabaseModule.kt
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.room.Room
import com.matedroid.data.local.StatsDatabase
import com.matedroid.data.local.dao.AggregateDao
+import com.matedroid.data.local.dao.BatteryHealthSnapshotDao
import com.matedroid.data.local.dao.ChargeSummaryDao
import com.matedroid.data.local.dao.DriveSummaryDao
import com.matedroid.data.local.dao.GeocodeCacheDao
@@ -105,4 +106,10 @@ object DatabaseModule {
fun provideSavedTripDao(database: StatsDatabase): SavedTripDao {
return database.savedTripDao()
}
+
+ @Provides
+ @Singleton
+ fun provideBatteryHealthSnapshotDao(database: StatsDatabase): BatteryHealthSnapshotDao {
+ return database.batteryHealthSnapshotDao()
+ }
}
diff --git a/app/src/main/java/com/matedroid/domain/CostAnalyticsCalculator.kt b/app/src/main/java/com/matedroid/domain/CostAnalyticsCalculator.kt
new file mode 100644
index 00000000..92fd8cbf
--- /dev/null
+++ b/app/src/main/java/com/matedroid/domain/CostAnalyticsCalculator.kt
@@ -0,0 +1,181 @@
+package com.matedroid.domain
+
+import com.matedroid.data.local.entity.ChargeSummary
+
+/**
+ * Cost breakdown metrics for a list of [ChargeSummary] rows over a date range.
+ *
+ * Reuses [TripCostCoverage] so screens and widgets can render coverage tags with
+ * the same vocabulary as the trip summary. Cost/100 distance is intentionally
+ * left null unless every charge in range has a numeric cost ("Complete") —
+ * either recorded by TeslaMate or estimated from the user's utility rates.
+ * Partial pricing is still exposed as a known total, but never as a per-distance
+ * rate.
+ */
+data class CostAnalyticsMetrics(
+ val chargeCount: Int = 0,
+ /**
+ * Charges with a TeslaMate-recorded cost (including 0.0 free sessions).
+ * Same meaning as [recordedChargeCount]; kept for backwards compatibility.
+ */
+ val pricedCount: Int = 0,
+ val zeroCostCount: Int = 0,
+ /**
+ * Charges still missing a numeric cost after attempting estimation
+ * (no recorded cost AND no utility rate applied).
+ */
+ val missingCostCount: Int = 0,
+ /** Sum of TeslaMate-recorded costs only. Null when none are recorded. */
+ val totalKnownCost: Double? = null,
+ val energyAddedTotal: Double = 0.0,
+ /** Energy of charges that contributed to [totalKnownCost] (recorded only). */
+ val energyAddedPriced: Double = 0.0,
+ /** Average $/kWh from recorded charges only (never from estimates). */
+ val avgCostPerKwh: Double? = null,
+ /** Uses [totalEffectiveCost] when coverage is Complete. Null otherwise. */
+ val costPer100Distance: Double? = null,
+ val totalDistance: Double = 0.0,
+ val costCoverage: TripCostCoverage = TripCostCoverage.None,
+ /**
+ * Sum of estimated costs (from utility rates) for charges that had no
+ * recorded cost. Null when no estimates were produced.
+ */
+ val totalEstimatedCost: Double? = null,
+ /** Sum of recorded + estimated costs. Null when neither is available. */
+ val totalEffectiveCost: Double? = null,
+ /** Number of charges whose cost came from a utility-rate estimate. */
+ val estimatedChargeCount: Int = 0,
+ /** Number of charges with a recorded TeslaMate cost (mirrors [pricedCount]). */
+ val recordedChargeCount: Int = 0,
+ /** True when at least one estimate contributed to [totalEffectiveCost]. */
+ val usesEstimates: Boolean = false,
+)
+
+object CostAnalyticsCalculator {
+
+ /**
+ * Backwards-compatible calculator: computes metrics using only TeslaMate's
+ * recorded costs. No utility-rate estimation is attempted.
+ *
+ * @param charges every charge in the selected range (priced or not).
+ * @param totalDistance driven distance in the same range, already in the
+ * user's unit system (km or mi); no conversion is applied.
+ */
+ fun calculate(
+ charges: List,
+ totalDistance: Double,
+ ): CostAnalyticsMetrics = calculate(
+ charges = charges,
+ totalDistance = totalDistance,
+ rates = UtilityRates(),
+ dcChargeIds = emptySet(),
+ )
+
+ /**
+ * Full calculator that optionally estimates missing costs from local
+ * utility [rates]. Charges whose id appears in [dcChargeIds] are considered
+ * DC/fast-charge for rate selection; all others are treated as AC/home.
+ *
+ * Estimates only run when the recorded cost is null AND a matching rate is
+ * configured (with DC falling back to the home rate). Estimates never
+ * overwrite a recorded cost, and never mutate the input rows.
+ */
+ fun calculate(
+ charges: List,
+ totalDistance: Double,
+ rates: UtilityRates,
+ dcChargeIds: Set,
+ ): CostAnalyticsMetrics {
+ if (charges.isEmpty()) {
+ return CostAnalyticsMetrics(totalDistance = totalDistance)
+ }
+
+ var recordedTotal = 0.0
+ var recordedEnergy = 0.0
+ var recordedCount = 0
+ var zeroCostCount = 0
+ var estimatedTotal = 0.0
+ var estimatedCount = 0
+ var missingCount = 0
+ val energyAddedTotal = charges.sumOf { it.energyAdded }
+
+ for (charge in charges) {
+ val isDc = charge.chargeId in dcChargeIds
+ val resolved = CostResolver.resolveChargeCost(
+ cost = charge.cost,
+ energyAdded = charge.energyAdded,
+ isDc = isDc,
+ rates = rates,
+ )
+ when (resolved.source) {
+ CostSource.Recorded -> {
+ val amount = resolved.amount ?: 0.0
+ recordedTotal += amount
+ recordedEnergy += charge.energyAdded
+ recordedCount++
+ if (amount == 0.0) zeroCostCount++
+ }
+ CostSource.Estimated -> {
+ estimatedTotal += resolved.amount ?: 0.0
+ estimatedCount++
+ }
+ CostSource.Missing -> {
+ missingCount++
+ }
+ }
+ }
+
+ val totalKnownCost = if (recordedCount == 0) null else recordedTotal
+ val totalEstimatedCost = if (estimatedCount == 0) null else estimatedTotal
+ // Effective cost = whichever of recorded + estimated is available.
+ val totalEffectiveCost = when {
+ recordedCount == 0 && estimatedCount == 0 -> null
+ recordedCount == 0 -> estimatedTotal
+ estimatedCount == 0 -> recordedTotal
+ else -> recordedTotal + estimatedTotal
+ }
+
+ // Coverage: Complete when every charge has a numeric cost (recorded OR
+ // estimated). Partial when at least one has any cost. None otherwise.
+ val anyCost = recordedCount + estimatedCount
+ val coverage = when {
+ missingCount == 0 && anyCost > 0 -> TripCostCoverage.Complete
+ anyCost == 0 -> TripCostCoverage.None
+ else -> TripCostCoverage.Partial
+ }
+
+ // Average $/kWh stays honest by using only the recorded numerator and
+ // the energy that fed it — estimates are excluded here on purpose so
+ // this figure reflects what TeslaMate actually saw.
+ val avgCostPerKwh = if (recordedCount > 0 && recordedEnergy > 0.0) {
+ recordedTotal / recordedEnergy
+ } else {
+ null
+ }
+
+ // Cost/100 distance is only honest when every charge in the range has
+ // a cost (recorded OR estimated) and we drove somewhere.
+ val costPer100Distance = totalEffectiveCost
+ ?.takeIf { coverage == TripCostCoverage.Complete && totalDistance > 0.0 }
+ ?.let { it * 100.0 / totalDistance }
+
+ return CostAnalyticsMetrics(
+ chargeCount = charges.size,
+ pricedCount = recordedCount,
+ zeroCostCount = zeroCostCount,
+ missingCostCount = missingCount,
+ totalKnownCost = totalKnownCost,
+ energyAddedTotal = energyAddedTotal,
+ energyAddedPriced = recordedEnergy,
+ avgCostPerKwh = avgCostPerKwh,
+ costPer100Distance = costPer100Distance,
+ totalDistance = totalDistance,
+ costCoverage = coverage,
+ totalEstimatedCost = totalEstimatedCost,
+ totalEffectiveCost = totalEffectiveCost,
+ estimatedChargeCount = estimatedCount,
+ recordedChargeCount = recordedCount,
+ usesEstimates = estimatedCount > 0,
+ )
+ }
+}
diff --git a/app/src/main/java/com/matedroid/domain/CostAnalyticsRepository.kt b/app/src/main/java/com/matedroid/domain/CostAnalyticsRepository.kt
new file mode 100644
index 00000000..a32da06d
--- /dev/null
+++ b/app/src/main/java/com/matedroid/domain/CostAnalyticsRepository.kt
@@ -0,0 +1,135 @@
+package com.matedroid.domain
+
+import com.matedroid.data.local.SettingsDataStore
+import com.matedroid.data.local.dao.AggregateDao
+import com.matedroid.data.local.dao.ChargeSummaryDao
+import com.matedroid.data.local.dao.DriveSummaryDao
+import com.matedroid.data.local.entity.ChargeSummary
+import com.matedroid.data.model.Currency
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Read-only bridge between the cost analytics UI/widget and the Room cache.
+ *
+ * All heavy lifting stays in [CostAnalyticsCalculator]; this class just picks
+ * the right rows out of the DAOs and augments them with the user's currency
+ * choice (from [SettingsDataStore]).
+ */
+@Singleton
+class CostAnalyticsRepository @Inject constructor(
+ private val chargeSummaryDao: ChargeSummaryDao,
+ private val driveSummaryDao: DriveSummaryDao,
+ private val aggregateDao: AggregateDao,
+ private val settingsDataStore: SettingsDataStore,
+) {
+
+ /**
+ * Load metrics for the given range, currency symbol, and the raw charge
+ * rows in case the caller wants to render per-charge detail later.
+ *
+ * @param carId Selected car id.
+ * @param range Half-open [startDate, endDate) window, or null for "all time".
+ * Dates use the TeslaMate API DateTime format ("yyyy-MM-dd'T'HH:mm:ss").
+ */
+ suspend fun loadMetrics(
+ carId: Int,
+ range: DateRange? = null,
+ ): CostAnalyticsSnapshot = withContext(Dispatchers.IO) {
+ val settings = settingsDataStore.settings.first()
+ val currency = Currency.findByCode(settings.currencyCode)
+ val rates = UtilityRates(
+ homePerKwh = settings.homeUtilityRatePerKwh,
+ dcPerKwh = settings.dcUtilityRatePerKWh,
+ )
+ val iceAssumptions = IceAssumptions(
+ economyValue = settings.iceFuelEconomyValue,
+ economyIsMpg = settings.iceFuelEconomyIsMpg,
+ fuelPrice = settings.iceFuelPrice,
+ fuelPriceIsPerGallon = settings.iceFuelPriceIsPerGallon,
+ )
+ val dcIds = try {
+ aggregateDao.getDcChargeIds(carId).toSet()
+ } catch (e: Exception) {
+ emptySet()
+ }
+
+ val (charges, distance) = if (range == null) {
+ val all = chargeSummaryDao.getAllForCar(carId)
+ val dist = driveSummaryDao.sumDistance(carId)
+ all to dist
+ } else {
+ val ch = chargeSummaryDao.getInRange(carId, range.startDate, range.endDate)
+ val dist = driveSummaryDao.sumDistanceInRange(carId, range.startDate, range.endDate)
+ ch to dist
+ }
+
+ val metrics = CostAnalyticsCalculator.calculate(
+ charges = charges,
+ totalDistance = distance,
+ rates = rates,
+ dcChargeIds = dcIds,
+ )
+ val distanceIsMiles = settings.unitOfLength == "mi"
+ val iceComparison = IceComparisonCalculator.compare(
+ distance = distance,
+ distanceIsMiles = distanceIsMiles,
+ teslaCost = metrics.totalEffectiveCost ?: metrics.totalKnownCost,
+ assumptions = iceAssumptions,
+ )
+
+ CostAnalyticsSnapshot(
+ metrics = metrics,
+ charges = charges,
+ currencySymbol = currency.symbol,
+ currencyCode = currency.code,
+ unitOfLength = settings.unitOfLength,
+ iceAssumptions = iceAssumptions,
+ iceComparison = iceComparison,
+ )
+ }
+
+ /** Persist TeslaMate's unit-of-length for offline widget/screen labeling. */
+ suspend fun cacheUnitOfLength(unitOfLength: String) {
+ if (unitOfLength == "km" || unitOfLength == "mi") {
+ settingsDataStore.saveUnitOfLength(unitOfLength)
+ }
+ }
+
+ data class DateRange(
+ val startDate: String,
+ val endDate: String,
+ ) {
+ companion object {
+ private val FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")
+
+ /**
+ * Half-open range covering the last [days] days (inclusive of today).
+ * Matches [com.matedroid.widget.TripSummaryWidgetDateRange] so the
+ * screen and widget agree on window semantics.
+ */
+ fun lastDays(days: Long, today: LocalDate = LocalDate.now()): DateRange {
+ require(days > 0) { "days must be positive" }
+ val start = today.minusDays(days - 1).atStartOfDay()
+ val end = today.plusDays(1).atStartOfDay()
+ return DateRange(start.format(FORMATTER), end.format(FORMATTER))
+ }
+ }
+ }
+}
+
+data class CostAnalyticsSnapshot(
+ val metrics: CostAnalyticsMetrics,
+ val charges: List,
+ val currencySymbol: String,
+ val currencyCode: String,
+ val unitOfLength: String,
+ val iceAssumptions: IceAssumptions = IceAssumptions(),
+ val iceComparison: IceComparisonResult? = null,
+)
diff --git a/app/src/main/java/com/matedroid/domain/IceComparisonCalculator.kt b/app/src/main/java/com/matedroid/domain/IceComparisonCalculator.kt
new file mode 100644
index 00000000..bcb36b54
--- /dev/null
+++ b/app/src/main/java/com/matedroid/domain/IceComparisonCalculator.kt
@@ -0,0 +1,153 @@
+package com.matedroid.domain
+
+/**
+ * User-configured assumptions for the ICE (gasoline) cost comparison.
+ *
+ * MateDroid stays neutral about which unit system the user prefers here: the
+ * economy figure can be entered in L/100 km or US mpg, and the fuel price can
+ * be per liter or per US gallon. [IceComparisonCalculator] converts between
+ * them internally when the assumptions don't match the length unit already
+ * used for TeslaMate distances (km vs. mi), so the distance value from Room
+ * is never converted.
+ *
+ * All fields are optional; the comparison is only surfaced when both a
+ * strictly-positive [economyValue] and a strictly-positive [fuelPrice] are
+ * available.
+ */
+data class IceAssumptions(
+ /** Fuel economy value; L/100 km when [economyIsMpg] is false, US mpg otherwise. */
+ val economyValue: Double? = null,
+ /** True when [economyValue] is US mpg; false when it is L/100 km. */
+ val economyIsMpg: Boolean = false,
+ /** Fuel price value; per liter when [fuelPriceIsPerGallon] is false, per US gallon otherwise. */
+ val fuelPrice: Double? = null,
+ /** True when [fuelPrice] is per US gallon; false when it is per liter. */
+ val fuelPriceIsPerGallon: Boolean = false,
+) {
+ /**
+ * True when both economy and price are set to strictly-positive, finite
+ * numbers. Anything else means the comparison should not be shown.
+ */
+ val isConfigured: Boolean
+ get() {
+ val e = economyValue
+ val p = fuelPrice
+ return e != null && e.isFinite() && e > 0.0 &&
+ p != null && p.isFinite() && p > 0.0
+ }
+}
+
+/**
+ * Neutral side-by-side result comparing MateDroid's Tesla charging cost to the
+ * equivalent gasoline cost implied by [IceAssumptions] over a given distance.
+ *
+ * Numbers are presented as-is: [savings] can be positive (gasoline more
+ * expensive) or negative (gasoline cheaper). Both amounts stay in the currency
+ * MateDroid already displays; no currency conversion is performed.
+ */
+data class IceComparisonResult(
+ /** Distance covered, in the unit already used by TeslaMate (km or mi). */
+ val distance: Double,
+ /** Tesla / EV cost for the same distance, or null when unknown. */
+ val teslaCost: Double?,
+ /** Estimated gasoline cost, or null when assumptions are missing / distance ≤ 0. */
+ val iceCost: Double?,
+ /** iceCost - teslaCost, or null when either side is null. */
+ val savings: Double?,
+ /** savings / iceCost * 100, or null when iceCost is null or ≤ 0. */
+ val savingsPercent: Double?,
+ /**
+ * Volume of gasoline the ICE car would have used for [distance].
+ * Expressed in the unit indicated by [usedImperialVolume]. Null when the
+ * gasoline cost couldn't be computed.
+ */
+ val iceVolumeUsed: Double?,
+ /** True when [iceVolumeUsed] is in US gallons; false when it is in liters. */
+ val usedImperialVolume: Boolean,
+)
+
+/**
+ * Pure math: given a distance already in the user's length unit (km when
+ * [distanceIsMiles] is false, mi when true), estimate what an equivalent
+ * gasoline car would have cost for the same drive.
+ *
+ * The distance is **never** converted: TeslaMate already gave it to us in the
+ * user's preferred unit. Only the ICE economy and fuel-price assumptions are
+ * converted when they were entered in the "other" unit system.
+ */
+object IceComparisonCalculator {
+
+ /** Liters per US gallon. */
+ private const val LITERS_PER_US_GALLON: Double = 3.785411784
+
+ /**
+ * mpg ↔ L/100 km conversion constant.
+ *
+ * `mpg = US_GAL_TO_L * KM_PER_MI * 100 / L_per_100km`
+ * = 3.785411784 * 1.609344 * 100 / 100 ≈ 235.214583
+ */
+ private const val MPG_L100_CONVERSION: Double = 235.214583
+
+ fun compare(
+ distance: Double,
+ distanceIsMiles: Boolean,
+ teslaCost: Double?,
+ assumptions: IceAssumptions,
+ ): IceComparisonResult {
+ if (!distance.isFinite() || distance <= 0.0 || !assumptions.isConfigured) {
+ return IceComparisonResult(
+ distance = distance,
+ teslaCost = teslaCost,
+ iceCost = null,
+ savings = null,
+ savingsPercent = null,
+ iceVolumeUsed = null,
+ usedImperialVolume = distanceIsMiles,
+ )
+ }
+
+ val economy = assumptions.economyValue!!
+ val price = assumptions.fuelPrice!!
+
+ val (volume, iceCost) = if (distanceIsMiles) {
+ val mpg = if (assumptions.economyIsMpg) {
+ economy
+ } else {
+ MPG_L100_CONVERSION / economy
+ }
+ val pricePerGallon = if (assumptions.fuelPriceIsPerGallon) {
+ price
+ } else {
+ price * LITERS_PER_US_GALLON
+ }
+ val gallons = distance / mpg
+ gallons to gallons * pricePerGallon
+ } else {
+ val lPer100 = if (assumptions.economyIsMpg) {
+ MPG_L100_CONVERSION / economy
+ } else {
+ economy
+ }
+ val pricePerLiter = if (assumptions.fuelPriceIsPerGallon) {
+ price / LITERS_PER_US_GALLON
+ } else {
+ price
+ }
+ val liters = distance * (lPer100 / 100.0)
+ liters to liters * pricePerLiter
+ }
+
+ val savings = if (teslaCost != null) iceCost - teslaCost else null
+ val savingsPercent = savings?.takeIf { iceCost > 0.0 }?.let { it / iceCost * 100.0 }
+
+ return IceComparisonResult(
+ distance = distance,
+ teslaCost = teslaCost,
+ iceCost = iceCost,
+ savings = savings,
+ savingsPercent = savingsPercent,
+ iceVolumeUsed = volume,
+ usedImperialVolume = distanceIsMiles,
+ )
+ }
+}
diff --git a/app/src/main/java/com/matedroid/domain/TripIntelligenceCalculator.kt b/app/src/main/java/com/matedroid/domain/TripIntelligenceCalculator.kt
new file mode 100644
index 00000000..46cc363c
--- /dev/null
+++ b/app/src/main/java/com/matedroid/domain/TripIntelligenceCalculator.kt
@@ -0,0 +1,90 @@
+package com.matedroid.domain
+
+import com.matedroid.domain.model.Trip
+
+/**
+ * Higher-order per-trip insights layered on top of [TripsSummaryCalculator].
+ *
+ * The summary answers "what did these trips add up to?"; this calculator answers
+ * "which trip stood out?" — most/least efficient, cheapest/most expensive per 100 dist,
+ * longest, biggest charging-overhead share, plus a couple of ensemble numbers
+ * (energy gap and average charging minutes per trip).
+ *
+ * Metrics are exposed as typed [Trip] references + raw [Double]s so callers can
+ * format with [com.matedroid.domain.model.UnitFormatter] rather than parsing pre-rendered strings.
+ * Following the same "honest cost" rule as the summary, the cost-per-100
+ * highlights only consider trips whose charges are all priced.
+ */
+data class TripIntelligence(
+ /** Lowest Wh/dist among trips with distance>0 and a defined efficiency. */
+ val mostEfficient: Trip? = null,
+ val leastEfficient: Trip? = null,
+ /** Lowest cost/100 dist among trips with [TripCostCoverage.Complete] and distance>0. */
+ val cheapestPer100: Trip? = null,
+ val mostExpensivePer100: Trip? = null,
+ /** Highest [Trip.totalDistance]. */
+ val longestDistance: Trip? = null,
+ /**
+ * Trip with the highest ratio of `sum(charge.durationMin) / trip.totalDurationMin`.
+ * Only trips with `totalDurationMin > 0` and at least one charge are considered.
+ */
+ val highestChargingOverhead: Trip? = null,
+ /** Ratio in `[0.0, 1.0]` corresponding to [highestChargingOverhead]. */
+ val highestChargingOverheadRatio: Double? = null,
+ /** `sum(charged) - sum(consumed)` across the filtered set. null when the set is empty. */
+ val energyGapKwh: Double? = null,
+ /** Mean charging minutes per trip across the filtered set. null when the set is empty. */
+ val avgChargingMinutesPerTrip: Double? = null,
+)
+
+object TripIntelligenceCalculator {
+
+ fun calculate(trips: List): TripIntelligence {
+ if (trips.isEmpty()) return TripIntelligence()
+
+ val efficiencyEligible = trips.filter { it.totalDistance > 0.0 && it.avgEfficiency != null }
+ val mostEfficient = efficiencyEligible.minByOrNull { it.avgEfficiency!! }
+ val leastEfficient = efficiencyEligible.maxByOrNull { it.avgEfficiency!! }
+
+ val fullyPriced = trips.filter { trip ->
+ trip.totalDistance > 0.0 &&
+ trip.charges.isNotEmpty() &&
+ trip.charges.all { it.cost != null } &&
+ trip.totalChargeCost != null
+ }
+ // Rank on cost/100dist so trips of different length compare fairly, matching
+ // the summary card's "Cost / 100 km" figure.
+ val cheapestPer100 = fullyPriced.minByOrNull { costPer100(it) }
+ val mostExpensivePer100 = fullyPriced.maxByOrNull { costPer100(it) }
+
+ val longestDistance = trips.maxByOrNull { it.totalDistance }
+
+ val overheadEligible = trips.filter { it.totalDurationMin > 0 && it.charges.isNotEmpty() }
+ val overheadRanked = overheadEligible.maxByOrNull { chargingOverheadRatio(it) }
+ val overheadRatio = overheadRanked?.let(::chargingOverheadRatio)
+
+ val energyGap = trips.sumOf { it.totalEnergyCharged } - trips.sumOf { it.totalEnergyConsumed }
+ val totalChargeMinutes = trips.sumOf { trip -> trip.charges.sumOf { it.durationMin } }
+ val avgChargingMinutesPerTrip = totalChargeMinutes.toDouble() / trips.size
+
+ return TripIntelligence(
+ mostEfficient = mostEfficient,
+ leastEfficient = leastEfficient,
+ cheapestPer100 = cheapestPer100,
+ mostExpensivePer100 = mostExpensivePer100,
+ longestDistance = longestDistance,
+ highestChargingOverhead = overheadRanked,
+ highestChargingOverheadRatio = overheadRatio,
+ energyGapKwh = energyGap,
+ avgChargingMinutesPerTrip = avgChargingMinutesPerTrip,
+ )
+ }
+
+ private fun costPer100(trip: Trip): Double =
+ trip.totalChargeCost!! * 100.0 / trip.totalDistance
+
+ private fun chargingOverheadRatio(trip: Trip): Double {
+ val chargeMinutes = trip.charges.sumOf { it.durationMin }
+ return chargeMinutes.toDouble() / trip.totalDurationMin.toDouble()
+ }
+}
diff --git a/app/src/main/java/com/matedroid/domain/TripsSummaryCalculator.kt b/app/src/main/java/com/matedroid/domain/TripsSummaryCalculator.kt
new file mode 100644
index 00000000..61dc2298
--- /dev/null
+++ b/app/src/main/java/com/matedroid/domain/TripsSummaryCalculator.kt
@@ -0,0 +1,129 @@
+package com.matedroid.domain
+
+import com.matedroid.domain.model.Trip
+
+enum class TripCostCoverage {
+ None,
+ Partial,
+ Complete,
+}
+
+data class TripsSummaryMetrics(
+ val tripCount: Int = 0,
+ val totalDistance: Double = 0.0,
+ val totalDrivingMinutes: Int = 0,
+ val totalEnergyConsumed: Double = 0.0,
+ val totalEnergyCharged: Double = 0.0,
+ val averageEfficiency: Double? = null,
+ /** Sum of TeslaMate-recorded charge costs only. */
+ val totalChargeCost: Double? = null,
+ /** Uses [totalEffectiveCost] when coverage is Complete. */
+ val costPer100Distance: Double? = null,
+ val pricedChargeCount: Int = 0,
+ val chargeCount: Int = 0,
+ val costCoverage: TripCostCoverage = TripCostCoverage.None,
+ /** Sum of estimated costs for null-cost charges. */
+ val totalEstimatedCost: Double? = null,
+ /** Sum of recorded + estimated costs. Null when neither is available. */
+ val totalEffectiveCost: Double? = null,
+ val estimatedChargeCount: Int = 0,
+ val recordedChargeCount: Int = 0,
+ val missingChargeCount: Int = 0,
+ /** True when at least one charge cost was estimated from utility rates. */
+ val usesEstimates: Boolean = false,
+)
+
+object TripsSummaryCalculator {
+
+ /**
+ * Backwards-compatible entry point: aggregates trips without applying any
+ * utility-rate estimation. Existing callers keep the recorded-only view.
+ */
+ fun calculate(trips: List): TripsSummaryMetrics =
+ calculate(trips = trips, rates = UtilityRates(), dcChargeIds = emptySet())
+
+ /**
+ * Aggregate a filtered list of trips into summary metrics, optionally
+ * estimating missing charge costs from local utility [rates]. Charges
+ * whose id is in [dcChargeIds] are treated as DC/fast for rate selection.
+ */
+ fun calculate(
+ trips: List,
+ rates: UtilityRates,
+ dcChargeIds: Set,
+ ): TripsSummaryMetrics {
+ val totalDistance = trips.sumOf { it.totalDistance }
+ val totalEnergyConsumed = trips.sumOf { it.totalEnergyConsumed }
+ val charges = trips.flatMap { it.charges }
+
+ var recordedTotal = 0.0
+ var recordedCount = 0
+ var estimatedTotal = 0.0
+ var estimatedCount = 0
+ var missingCount = 0
+
+ for (charge in charges) {
+ val isDc = charge.chargeId in dcChargeIds
+ val resolved = CostResolver.resolveChargeCost(
+ cost = charge.cost,
+ energyAdded = charge.energyAdded,
+ isDc = isDc,
+ rates = rates,
+ )
+ when (resolved.source) {
+ CostSource.Recorded -> {
+ recordedTotal += resolved.amount ?: 0.0
+ recordedCount++
+ }
+ CostSource.Estimated -> {
+ estimatedTotal += resolved.amount ?: 0.0
+ estimatedCount++
+ }
+ CostSource.Missing -> missingCount++
+ }
+ }
+
+ val totalChargeCost = if (recordedCount == 0) null else recordedTotal
+ val totalEstimatedCost = if (estimatedCount == 0) null else estimatedTotal
+ val totalEffectiveCost = when {
+ recordedCount == 0 && estimatedCount == 0 -> null
+ recordedCount == 0 -> estimatedTotal
+ estimatedCount == 0 -> recordedTotal
+ else -> recordedTotal + estimatedTotal
+ }
+
+ val anyCost = recordedCount + estimatedCount
+ val costCoverage = when {
+ charges.isEmpty() || anyCost == 0 -> TripCostCoverage.None
+ missingCount == 0 -> TripCostCoverage.Complete
+ else -> TripCostCoverage.Partial
+ }
+
+ return TripsSummaryMetrics(
+ tripCount = trips.size,
+ totalDistance = totalDistance,
+ totalDrivingMinutes = trips.sumOf { it.totalDrivingDurationMin },
+ totalEnergyConsumed = totalEnergyConsumed,
+ totalEnergyCharged = trips.sumOf { it.totalEnergyCharged },
+ averageEfficiency = totalDistance
+ .takeIf { it > 0.0 }
+ ?.let { totalEnergyConsumed * 1000.0 / it },
+ totalChargeCost = totalChargeCost,
+ // Cost / 100 distance is only honest when every charge in range has
+ // a cost (recorded or estimated). Known totals still surface under
+ // Partial; the rate stays unavailable.
+ costPer100Distance = totalEffectiveCost
+ ?.takeIf { costCoverage == TripCostCoverage.Complete && totalDistance > 0.0 }
+ ?.let { it * 100.0 / totalDistance },
+ pricedChargeCount = recordedCount,
+ chargeCount = charges.size,
+ costCoverage = costCoverage,
+ totalEstimatedCost = totalEstimatedCost,
+ totalEffectiveCost = totalEffectiveCost,
+ estimatedChargeCount = estimatedCount,
+ recordedChargeCount = recordedCount,
+ missingChargeCount = missingCount,
+ usesEstimates = estimatedCount > 0,
+ )
+ }
+}
diff --git a/app/src/main/java/com/matedroid/domain/UtilityRates.kt b/app/src/main/java/com/matedroid/domain/UtilityRates.kt
new file mode 100644
index 00000000..2ce5ccfe
--- /dev/null
+++ b/app/src/main/java/com/matedroid/domain/UtilityRates.kt
@@ -0,0 +1,108 @@
+package com.matedroid.domain
+
+/**
+ * User-configured local utility rates for charging cost estimation.
+ *
+ * MateDroid never converts units or currencies here: rates are stored and
+ * consumed in the same "$/kWh" numeric form the user typed in Settings, and
+ * paired with the currency they already picked for cost display.
+ *
+ * When TeslaMate has a recorded [Double] `cost` for a charge we always prefer
+ * that (including 0.0 free sessions). These rates are only used to estimate a
+ * cost when a charge is missing one.
+ */
+data class UtilityRates(
+ /** Home / AC rate in the user's chosen currency, per kWh. */
+ val homePerKwh: Double? = null,
+ /**
+ * Optional DC / public rate in the user's chosen currency, per kWh.
+ * When null, DC estimates fall back to [homePerKwh] so one rate still works.
+ */
+ val dcPerKwh: Double? = null,
+) {
+ /**
+ * Pick the rate that applies to a charge.
+ *
+ * If [isDc] is true and a DC rate is set, use it; otherwise fall back to
+ * the home rate. Returning null means no estimation should be attempted.
+ */
+ fun rateFor(isDc: Boolean): Double? =
+ if (isDc) (dcPerKwh ?: homePerKwh) else homePerKwh
+
+ /** True when at least one rate is set (any estimation is possible). */
+ val isConfigured: Boolean
+ get() = homePerKwh != null || dcPerKwh != null
+}
+
+/** Where a cost figure came from. Estimates are always labelled as such. */
+enum class CostSource {
+ /** From TeslaMate's stored charge.cost (may be 0.0 for a free session). */
+ Recorded,
+ /** Computed as `energy * rate` because TeslaMate had no cost. */
+ Estimated,
+ /** No recorded cost and no rate configured — cost is unknown. */
+ Missing,
+}
+
+/** Result of applying [CostResolver] to one charge or one drive. */
+data class ResolvedCost(
+ val amount: Double?,
+ val source: CostSource,
+)
+
+/**
+ * Central place that decides "what does this cost?" given an optional recorded
+ * value, an energy figure (in kWh), an AC/DC flag, and the user's [UtilityRates].
+ *
+ * Rules:
+ * 1. If [cost] is non-null → [CostSource.Recorded]. Never overridden, even 0.0.
+ * 2. Else if a rate applies (per [UtilityRates.rateFor]) and [energyAdded] is
+ * finite and non-negative → estimate = energy * rate, [CostSource.Estimated].
+ * 3. Else → [CostSource.Missing].
+ *
+ * The resolver never converts units. Callers must pass energy in kWh (which is
+ * what TeslaMate returns for both AC and DC charges).
+ */
+object CostResolver {
+
+ /**
+ * Resolve the cost for a single charge.
+ *
+ * @param cost recorded TeslaMate cost, or null when unknown.
+ * @param energyAdded kWh added by the charge (already in kWh from the API).
+ * @param isDc whether this charge is DC/fast — used to pick the rate.
+ * @param rates the user's configured local rates.
+ */
+ fun resolveChargeCost(
+ cost: Double?,
+ energyAdded: Double,
+ isDc: Boolean,
+ rates: UtilityRates,
+ ): ResolvedCost {
+ if (cost != null) return ResolvedCost(cost, CostSource.Recorded)
+ val rate = rates.rateFor(isDc) ?: return ResolvedCost(null, CostSource.Missing)
+ if (!energyAdded.isFinite() || energyAdded < 0.0) {
+ return ResolvedCost(null, CostSource.Missing)
+ }
+ return ResolvedCost(energyAdded * rate, CostSource.Estimated)
+ }
+
+ /**
+ * Estimate the electricity cost of a drive from its consumed energy using
+ * the user's home / AC rate (drives don't have a natural DC concept).
+ *
+ * Returns [CostSource.Missing] when the home rate is unset or the energy
+ * value is null / negative / not finite.
+ */
+ fun estimateDriveCost(
+ energyConsumed: Double?,
+ rates: UtilityRates,
+ ): ResolvedCost {
+ val energy = energyConsumed ?: return ResolvedCost(null, CostSource.Missing)
+ val rate = rates.homePerKwh ?: return ResolvedCost(null, CostSource.Missing)
+ if (!energy.isFinite() || energy < 0.0) {
+ return ResolvedCost(null, CostSource.Missing)
+ }
+ return ResolvedCost(energy * rate, CostSource.Estimated)
+ }
+}
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..d9d9a450 100644
--- a/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt
+++ b/app/src/main/java/com/matedroid/ui/navigation/NavGraph.kt
@@ -25,6 +25,7 @@ import com.matedroid.ui.screens.charges.ChargeDetailScreen
import com.matedroid.ui.screens.charges.ChargesScreen
import com.matedroid.ui.screens.charges.CompareChargesScreen
import com.matedroid.ui.screens.charges.CurrentChargeScreen
+import com.matedroid.ui.screens.costs.CostAnalyticsScreen
import com.matedroid.ui.screens.dashboard.DashboardScreen
import com.matedroid.ui.screens.demo.PalettePreviewScreen
import com.matedroid.ui.screens.drives.CompareDrivesScreen
@@ -122,6 +123,9 @@ sealed interface Screen {
@Serializable
data class SentryHistory(val carId: Int, val exteriorColor: String? = null) : Screen
+
+ @Serializable
+ data class CostAnalytics(val carId: Int, val exteriorColor: String? = null) : Screen
}
@Composable
@@ -179,6 +183,8 @@ fun NavGraph(
"mileage" -> Screen.Mileage(carId, exteriorColor)
"battery" -> Screen.Battery(carId, exteriorColor = exteriorColor)
"stats" -> Screen.Stats(carId, exteriorColor)
+ "costs" -> Screen.CostAnalytics(carId, exteriorColor)
+ "trips" -> Screen.Trips(carId, exteriorColor)
"countries_visited" -> Screen.CountriesVisited(carId, exteriorColor)
"updates" -> Screen.Updates(carId, exteriorColor)
"sentry_history" -> Screen.SentryHistory(carId, exteriorColor)
@@ -245,6 +251,9 @@ fun NavGraph(
},
onNavigateToTrips = { carId, exteriorColor ->
navController.navigate(Screen.Trips(carId, exteriorColor))
+ },
+ onNavigateToCosts = { carId, exteriorColor ->
+ navController.navigate(Screen.CostAnalytics(carId, exteriorColor))
}
)
}
@@ -511,5 +520,14 @@ fun NavGraph(
onNavigateBack = { navController.popBackStack() }
)
}
+
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+ CostAnalyticsScreen(
+ carId = route.carId,
+ exteriorColor = route.exteriorColor,
+ onNavigateBack = { navController.popBackStack() }
+ )
+ }
}
}
diff --git a/app/src/main/java/com/matedroid/ui/screens/battery/BatteryScreen.kt b/app/src/main/java/com/matedroid/ui/screens/battery/BatteryScreen.kt
index 85672c29..12c5f21a 100644
--- a/app/src/main/java/com/matedroid/ui/screens/battery/BatteryScreen.kt
+++ b/app/src/main/java/com/matedroid/ui/screens/battery/BatteryScreen.kt
@@ -62,10 +62,17 @@ import androidx.compose.ui.unit.dp
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.matedroid.R
+import com.matedroid.data.local.entity.BatteryHealthSnapshot
import com.matedroid.ui.components.MateDroidLoadingPlaceholder
+import com.matedroid.ui.components.OptimizedLineChart
import com.matedroid.ui.theme.CarColorPalette
import com.matedroid.ui.theme.CarColorPalettes
import com.matedroid.ui.theme.StatusSuccess
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+import kotlin.math.roundToInt
// Colors matching the reference screenshots
private val CapacityGreen = Color(0xFF4CAF50)
@@ -136,6 +143,7 @@ fun BatteryScreen(
if (stats != null) {
BatteryHealthContent(
stats = stats,
+ snapshots = uiState.snapshots,
units = uiState.units,
palette = palette,
onCardClick = { viewModel.showDetail() }
@@ -177,6 +185,7 @@ fun BatteryScreen(
@Composable
private fun BatteryHealthContent(
stats: BatteryStats,
+ snapshots: List,
units: com.matedroid.data.api.models.Units?,
palette: CarColorPalette,
onCardClick: () -> Unit
@@ -196,9 +205,112 @@ private fun BatteryHealthContent(
// Range Section
RangeCard(stats = stats, units = units, palette = palette, onClick = onCardClick)
+
+ // Historical max-range trend — needs at least two data points to draw a line.
+ val rangeSnapshots = snapshots.filter { it.maxRange != null }
+ if (rangeSnapshots.size >= 2) {
+ BatteryHistoryCard(
+ snapshots = rangeSnapshots,
+ units = units,
+ palette = palette,
+ )
+ }
}
}
+@Composable
+private fun BatteryHistoryCard(
+ snapshots: List,
+ units: com.matedroid.data.api.models.Units?,
+ palette: CarColorPalette,
+) {
+ val data = remember(snapshots) { snapshots.map { (it.maxRange ?: 0.0).toFloat() } }
+ val dateFormatter = remember { DateTimeFormatter.ofPattern("d MMM", Locale.getDefault()) }
+ val timeLabels = remember(snapshots) { buildFiveDateLabels(snapshots, dateFormatter) }
+ val fractionToTimeLabel: (Float) -> String = { fraction ->
+ val idx = (fraction * (snapshots.size - 1)).roundToInt().coerceIn(0, snapshots.lastIndex)
+ snapshots.getOrNull(idx)?.recordedAt?.let { formatSnapshotDate(it, dateFormatter) } ?: ""
+ }
+ val unitLabel = UnitFormatter.getDistanceUnit(units)
+ val subtitle = stringResource(
+ R.string.battery_history_range,
+ formatSnapshotDate(snapshots.first().recordedAt, dateFormatter),
+ formatSnapshotDate(snapshots.last().recordedAt, dateFormatter),
+ )
+
+ var showInfo by remember { mutableStateOf(false) }
+ if (showInfo) {
+ InfoDialog(
+ title = stringResource(R.string.battery_history_info_title),
+ message = stringResource(R.string.battery_history_info_message),
+ confirmText = stringResource(R.string.got_it),
+ onDismiss = { showInfo = false },
+ )
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = palette.surface),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = stringResource(R.string.battery_history_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = palette.onSurface,
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Icon(
+ imageVector = Icons.Filled.Info,
+ contentDescription = stringResource(R.string.info),
+ tint = palette.onSurfaceVariant,
+ modifier = Modifier
+ .size(16.dp)
+ .clickable { showInfo = true },
+ )
+ }
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ OptimizedLineChart(
+ data = data,
+ color = palette.accent,
+ unit = unitLabel,
+ timeLabels = timeLabels,
+ fractionToTimeLabel = fractionToTimeLabel,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+}
+
+/**
+ * Pick five snapshots evenly spaced across the range and format their dates for the X axis:
+ * start, 1st quarter, half, 3rd quarter, end.
+ */
+private fun buildFiveDateLabels(
+ snapshots: List,
+ formatter: DateTimeFormatter,
+): List {
+ if (snapshots.isEmpty()) return List(5) { "" }
+ val last = snapshots.lastIndex
+ val indices = listOf(0, last / 4, last / 2, last * 3 / 4, last)
+ return indices.map { i ->
+ val snap = snapshots.getOrNull(i.coerceIn(0, last)) ?: return@map ""
+ formatSnapshotDate(snap.recordedAt, formatter)
+ }
+}
+
+private fun formatSnapshotDate(epochMillis: Long, formatter: DateTimeFormatter): String =
+ Instant.ofEpochMilli(epochMillis)
+ .atZone(ZoneId.systemDefault())
+ .toLocalDate()
+ .format(formatter)
+
@Composable
private fun CapacityCard(stats: BatteryStats, units: com.matedroid.data.api.models.Units?, palette: CarColorPalette, onClick: () -> Unit) {
var showTooltip by remember { mutableStateOf(false) }
diff --git a/app/src/main/java/com/matedroid/ui/screens/battery/BatteryViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/battery/BatteryViewModel.kt
index f67609ec..08db8bad 100644
--- a/app/src/main/java/com/matedroid/ui/screens/battery/BatteryViewModel.kt
+++ b/app/src/main/java/com/matedroid/ui/screens/battery/BatteryViewModel.kt
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.matedroid.data.api.models.BatteryHealth
import com.matedroid.data.api.models.CarStatus
import com.matedroid.data.api.models.Units
+import com.matedroid.data.local.dao.BatteryHealthSnapshotDao
+import com.matedroid.data.local.entity.BatteryHealthSnapshot
import com.matedroid.data.repository.ApiResult
import com.matedroid.data.repository.TeslamateRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -12,8 +14,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
+import java.time.Instant
+import java.time.ZoneId
+import java.time.LocalDate
import javax.inject.Inject
+import kotlin.math.abs
data class BatteryUiState(
val isLoading: Boolean = true,
@@ -24,7 +32,12 @@ data class BatteryUiState(
val units: Units? = null,
val originalCapacity: Double = 82.0, // Default for Model 3 LR, could be fetched from car details
val ratedEfficiency: Double = 0.0,
- val showDetail: Boolean = false
+ val showDetail: Boolean = false,
+ /**
+ * Historical battery health snapshots for this car, oldest first, sourced from the
+ * local DB. Populated once [setCarId] runs; unaffected by refreshes.
+ */
+ val snapshots: List = emptyList(),
)
// Computed battery statistics
@@ -49,13 +62,15 @@ data class BatteryStats(
@HiltViewModel
class BatteryViewModel @Inject constructor(
- private val repository: TeslamateRepository
+ private val repository: TeslamateRepository,
+ private val snapshotDao: BatteryHealthSnapshotDao,
) : ViewModel() {
private val _uiState = MutableStateFlow(BatteryUiState())
val uiState: StateFlow = _uiState.asStateFlow()
private var carId: Int? = null
+ private var snapshotObserverJob: Job? = null
fun setCarId(id: Int, efficiency: Double? = null) {
if (carId != id) {
@@ -63,10 +78,20 @@ class BatteryViewModel @Inject constructor(
efficiency?.let { eff ->
_uiState.update { it.copy(ratedEfficiency = eff) }
}
+ observeSnapshots(id)
loadBatteryData()
}
}
+ private fun observeSnapshots(id: Int) {
+ snapshotObserverJob?.cancel()
+ snapshotObserverJob = viewModelScope.launch {
+ snapshotDao.observeAllForCar(id).collectLatest { rows ->
+ _uiState.update { it.copy(snapshots = rows) }
+ }
+ }
+ }
+
fun refresh() {
carId?.let {
_uiState.update { it.copy(isRefreshing = true) }
@@ -111,6 +136,7 @@ class BatteryViewModel @Inject constructor(
error = null
)
}
+ persistSnapshotIfWorthKeeping(id, healthResult.data)
}
healthResult is ApiResult.Error -> {
_uiState.update {
@@ -186,4 +212,43 @@ class BatteryViewModel @Inject constructor(
rangeAt100 = rangeAt100
)
}
+
+ /**
+ * Append a snapshot of [health] for [carId] iff the last stored sample is either from a
+ * previous local calendar day, or from today but with a noticeably different max range.
+ * The debounce keeps refreshes / multiple screen opens on the same day from swelling the
+ * table with near-duplicates.
+ */
+ private fun persistSnapshotIfWorthKeeping(carId: Int, health: BatteryHealth) {
+ viewModelScope.launch {
+ val last = snapshotDao.getLatestForCar(carId)
+ val now = System.currentTimeMillis()
+ val today = LocalDate.now(ZoneId.systemDefault())
+ val lastDay = last?.let {
+ Instant.ofEpochMilli(it.recordedAt).atZone(ZoneId.systemDefault()).toLocalDate()
+ }
+ val sameDay = lastDay == today
+ val nearlyIdenticalRange = last?.maxRange != null && health.maxRange != null &&
+ abs((last.maxRange ?: 0.0) - (health.maxRange ?: 0.0)) < MAX_RANGE_EPSILON
+ if (sameDay && nearlyIdenticalRange) return@launch
+
+ snapshotDao.insertAndPrune(
+ BatteryHealthSnapshot(
+ carId = carId,
+ recordedAt = now,
+ maxRange = health.maxRange,
+ currentRange = health.currentRange,
+ maxCapacity = health.maxCapacity,
+ currentCapacity = health.currentCapacity,
+ ratedEfficiency = health.ratedEfficiency,
+ batteryHealthPercentage = health.batteryHealthPercentage,
+ )
+ )
+ }
+ }
+
+ private companion object {
+ /** Two max-range readings closer than this (in km/mi — units are stored raw) count as the same value for debouncing. */
+ const val MAX_RANGE_EPSILON = 0.5
+ }
}
diff --git a/app/src/main/java/com/matedroid/ui/screens/costs/CostAnalyticsScreen.kt b/app/src/main/java/com/matedroid/ui/screens/costs/CostAnalyticsScreen.kt
new file mode 100644
index 00000000..f1c4443a
--- /dev/null
+++ b/app/src/main/java/com/matedroid/ui/screens/costs/CostAnalyticsScreen.kt
@@ -0,0 +1,623 @@
+package com.matedroid.ui.screens.costs
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+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.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.HelpOutline
+import androidx.compose.material.icons.filled.AttachMoney
+import androidx.compose.material.icons.filled.BatteryChargingFull
+import androidx.compose.material.icons.filled.ElectricBolt
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.LocalGasStation
+import androidx.compose.material.icons.filled.MoneyOff
+import androidx.compose.material.icons.filled.Paid
+import androidx.compose.material.icons.filled.Straighten
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.matedroid.R
+import com.matedroid.data.api.models.Units
+import com.matedroid.domain.CostAnalyticsMetrics
+import com.matedroid.domain.IceAssumptions
+import com.matedroid.domain.IceComparisonResult
+import com.matedroid.domain.TripCostCoverage
+import com.matedroid.domain.model.UnitFormatter
+import com.matedroid.ui.components.MateDroidLoadingPlaceholder
+import com.matedroid.ui.theme.CarColorPalette
+import com.matedroid.ui.theme.CarColorPalettes
+
+/**
+ * Cost Analytics screen. Uses only the local Room cache + user settings; there
+ * are no live API calls beyond the shared units-of-measure fetch performed by
+ * the view model.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CostAnalyticsScreen(
+ carId: Int,
+ exteriorColor: String? = null,
+ onNavigateBack: () -> Unit,
+ viewModel: CostAnalyticsViewModel = hiltViewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val isDarkTheme = isSystemInDarkTheme()
+ val palette = CarColorPalettes.forExteriorColor(exteriorColor, isDarkTheme)
+
+ LaunchedEffect(carId) { viewModel.setCarId(carId) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.cost_analytics_title)) },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ ),
+ )
+ },
+ ) { padding ->
+ when {
+ uiState.isLoading -> MateDroidLoadingPlaceholder(
+ color = palette.accent,
+ modifier = Modifier.padding(padding),
+ )
+ else -> CostAnalyticsContent(
+ metrics = uiState.metrics,
+ range = uiState.range,
+ units = uiState.units,
+ currencySymbol = uiState.currencySymbol,
+ iceAssumptions = uiState.iceAssumptions,
+ iceComparison = uiState.iceComparison,
+ palette = palette,
+ onRangeSelected = viewModel::setRange,
+ modifier = Modifier.padding(padding),
+ )
+ }
+ }
+}
+
+@Composable
+private fun CostAnalyticsContent(
+ metrics: CostAnalyticsMetrics,
+ range: CostAnalyticsRange,
+ units: Units?,
+ currencySymbol: String,
+ iceAssumptions: IceAssumptions,
+ iceComparison: IceComparisonResult?,
+ palette: CarColorPalette,
+ onRangeSelected: (CostAnalyticsRange) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ RangeChips(
+ selected = range,
+ palette = palette,
+ onSelected = onRangeSelected,
+ )
+ }
+ item {
+ HeadlineCard(
+ metrics = metrics,
+ units = units,
+ currencySymbol = currencySymbol,
+ palette = palette,
+ )
+ }
+ item {
+ CoverageCard(
+ metrics = metrics,
+ currencySymbol = currencySymbol,
+ palette = palette,
+ )
+ }
+ if (iceAssumptions.isConfigured &&
+ iceComparison != null &&
+ iceComparison.iceCost != null
+ ) {
+ item {
+ IceComparisonCard(
+ comparison = iceComparison,
+ units = units,
+ currencySymbol = currencySymbol,
+ palette = palette,
+ )
+ }
+ }
+ if (metrics.chargeCount == 0) {
+ item {
+ EmptyStateCard(palette = palette)
+ }
+ }
+ }
+}
+
+@Composable
+private fun IceComparisonCard(
+ comparison: IceComparisonResult,
+ units: Units?,
+ currencySymbol: String,
+ palette: CarColorPalette,
+) {
+ val unavailable = stringResource(R.string.cost_analytics_unavailable)
+ val distanceUnit = UnitFormatter.getDistanceUnit(units)
+ val teslaCost = comparison.teslaCost
+ ?.let { UnitFormatter.formatCost(it, currencySymbol) }
+ ?: unavailable
+ val iceCost = comparison.iceCost
+ ?.let { UnitFormatter.formatCost(it, currencySymbol) }
+ ?: unavailable
+ val savingsValue = comparison.savings
+ val savingsFormatted = savingsValue
+ ?.let { UnitFormatter.formatCost(it, currencySymbol) }
+ ?: unavailable
+ val savingsPercentFormatted = comparison.savingsPercent
+ ?.let { "%+,.1f%%".format(it) }
+ val volumeFormatted = comparison.iceVolumeUsed?.let { volume ->
+ val unitLabel = if (comparison.usedImperialVolume) {
+ stringResource(R.string.settings_ice_unit_gallon_short)
+ } else {
+ stringResource(R.string.settings_ice_unit_liter_short)
+ }
+ "%,.1f %s".format(volume, unitLabel)
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = palette.surface),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Filled.LocalGasStation,
+ contentDescription = null,
+ tint = palette.accent,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.ice_comparison_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = palette.onSurface,
+ )
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ SummaryItem(
+ icon = Icons.Filled.ElectricBolt,
+ label = stringResource(R.string.ice_comparison_tesla_label),
+ value = teslaCost,
+ palette = palette,
+ modifier = Modifier.weight(1f),
+ )
+ SummaryItem(
+ icon = Icons.Filled.LocalGasStation,
+ label = stringResource(R.string.ice_comparison_gasoline_label),
+ value = iceCost,
+ palette = palette,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ SummaryItem(
+ icon = Icons.Filled.Paid,
+ label = stringResource(R.string.ice_comparison_difference_label),
+ value = if (savingsPercentFormatted != null && savingsValue != null) {
+ "$savingsFormatted ($savingsPercentFormatted)"
+ } else {
+ savingsFormatted
+ },
+ palette = palette,
+ modifier = Modifier.weight(1f),
+ )
+ SummaryItem(
+ icon = Icons.Filled.Straighten,
+ label = stringResource(R.string.ice_comparison_distance_label, distanceUnit),
+ value = UnitFormatter.formatDistance(
+ comparison.distance,
+ units,
+ decimals = 0,
+ ),
+ palette = palette,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ if (volumeFormatted != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(
+ R.string.ice_comparison_gasoline_volume,
+ volumeFormatted,
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.onSurfaceVariant,
+ )
+ }
+ Spacer(modifier = Modifier.height(6.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Info,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = palette.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = stringResource(R.string.ice_comparison_assumptions_note),
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun RangeChips(
+ selected: CostAnalyticsRange,
+ palette: CarColorPalette,
+ onSelected: (CostAnalyticsRange) -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ CostAnalyticsRange.entries.forEach { option ->
+ FilterChip(
+ selected = option == selected,
+ onClick = { onSelected(option) },
+ label = { Text(stringResource(option.labelRes())) },
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = palette.accent.copy(alpha = 0.25f),
+ selectedLabelColor = palette.onSurface,
+ ),
+ )
+ }
+ }
+}
+
+@Composable
+private fun HeadlineCard(
+ metrics: CostAnalyticsMetrics,
+ units: Units?,
+ currencySymbol: String,
+ palette: CarColorPalette,
+) {
+ val unavailable = stringResource(R.string.cost_analytics_unavailable)
+ val distanceUnit = UnitFormatter.getDistanceUnit(units)
+
+ // Prefer the effective (recorded + estimated) total for headline display.
+ // Fall back to just the recorded value when no rate/estimation is active.
+ val headlineCost = (metrics.totalEffectiveCost ?: metrics.totalKnownCost)
+ ?.let { UnitFormatter.formatCost(it, currencySymbol) }
+ ?: unavailable
+ val costPer100 = metrics.costPer100Distance
+ ?.let { UnitFormatter.formatCost(it, currencySymbol) }
+ ?: unavailable
+ val avgPerKwh = metrics.avgCostPerKwh
+ ?.let { UnitFormatter.formatCost(it, currencySymbol, perKwh = true) }
+ ?: unavailable
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = palette.surface),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = stringResource(R.string.cost_analytics_headline_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = palette.onSurface,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ SummaryItem(
+ icon = Icons.Filled.Paid,
+ label = stringResource(
+ if (metrics.usesEstimates) {
+ R.string.cost_analytics_effective_cost
+ } else {
+ R.string.cost_analytics_known_cost
+ }
+ ),
+ value = headlineCost,
+ palette = palette,
+ modifier = Modifier.weight(1.2f),
+ )
+ SummaryItem(
+ icon = Icons.Filled.AttachMoney,
+ label = stringResource(
+ R.string.cost_analytics_cost_per_100,
+ distanceUnit,
+ ),
+ value = costPer100,
+ palette = palette,
+ modifier = Modifier.weight(0.8f),
+ )
+ }
+ if (metrics.usesEstimates) {
+ Spacer(modifier = Modifier.height(6.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Info,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = palette.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = stringResource(
+ R.string.cost_analytics_estimated_note,
+ metrics.estimatedChargeCount,
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.onSurfaceVariant,
+ )
+ }
+ if (metrics.recordedChargeCount > 0 && metrics.totalKnownCost != null) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(
+ R.string.cost_analytics_recorded_vs_estimated,
+ UnitFormatter.formatCost(metrics.totalKnownCost, currencySymbol),
+ metrics.recordedChargeCount,
+ UnitFormatter.formatCost(metrics.totalEstimatedCost ?: 0.0, currencySymbol),
+ metrics.estimatedChargeCount,
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.onSurfaceVariant,
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ SummaryItem(
+ icon = Icons.Filled.BatteryChargingFull,
+ label = stringResource(R.string.cost_analytics_energy_added),
+ value = UnitFormatter.formatEnergy(metrics.energyAddedTotal),
+ palette = palette,
+ modifier = Modifier.weight(1.2f),
+ )
+ SummaryItem(
+ icon = Icons.Filled.ElectricBolt,
+ label = stringResource(R.string.cost_analytics_avg_cost_per_kwh),
+ value = avgPerKwh,
+ palette = palette,
+ modifier = Modifier.weight(0.8f),
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ SummaryItem(
+ icon = Icons.Filled.Straighten,
+ label = stringResource(R.string.cost_analytics_distance),
+ value = UnitFormatter.formatDistance(
+ metrics.totalDistance,
+ units,
+ decimals = 0,
+ ),
+ palette = palette,
+ modifier = Modifier.weight(1.2f),
+ )
+ SummaryItem(
+ icon = Icons.Filled.ElectricBolt,
+ label = stringResource(R.string.cost_analytics_charges),
+ value = "%,d".format(metrics.chargeCount),
+ palette = palette,
+ modifier = Modifier.weight(0.8f),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun CoverageCard(
+ metrics: CostAnalyticsMetrics,
+ currencySymbol: String,
+ palette: CarColorPalette,
+) {
+ if (metrics.chargeCount == 0) return
+
+ val coverageLabel = when (metrics.costCoverage) {
+ TripCostCoverage.None -> stringResource(R.string.cost_analytics_coverage_none)
+ TripCostCoverage.Partial -> stringResource(
+ R.string.cost_analytics_coverage_partial,
+ metrics.recordedChargeCount + metrics.estimatedChargeCount,
+ metrics.chargeCount,
+ )
+ TripCostCoverage.Complete -> if (metrics.usesEstimates) {
+ stringResource(R.string.cost_analytics_coverage_complete_with_estimates)
+ } else {
+ stringResource(R.string.cost_analytics_coverage_complete)
+ }
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = palette.surface),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = stringResource(R.string.cost_analytics_coverage_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = palette.onSurface,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ SummaryItem(
+ icon = Icons.Filled.Info,
+ label = stringResource(R.string.cost_analytics_coverage_label),
+ value = coverageLabel,
+ palette = palette,
+ modifier = Modifier.weight(1.2f),
+ )
+ SummaryItem(
+ icon = Icons.AutoMirrored.Filled.HelpOutline,
+ label = stringResource(R.string.cost_analytics_missing),
+ value = "%,d".format(metrics.missingCostCount),
+ palette = palette,
+ modifier = Modifier.weight(0.8f),
+ )
+ }
+ if (metrics.zeroCostCount > 0) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ SummaryItem(
+ icon = Icons.Filled.MoneyOff,
+ label = stringResource(R.string.cost_analytics_free_sessions),
+ value = "%,d".format(metrics.zeroCostCount),
+ palette = palette,
+ modifier = Modifier.weight(1.2f),
+ )
+ Spacer(modifier = Modifier.weight(0.8f))
+ }
+ }
+ if (metrics.costCoverage != TripCostCoverage.Complete) {
+ Spacer(modifier = Modifier.height(10.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Info,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = palette.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = when (metrics.costCoverage) {
+ TripCostCoverage.None -> stringResource(R.string.cost_analytics_coverage_none_note)
+ TripCostCoverage.Partial -> stringResource(
+ R.string.cost_analytics_coverage_partial_note,
+ )
+ TripCostCoverage.Complete -> ""
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmptyStateCard(palette: CarColorPalette) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = palette.surface),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(R.string.cost_analytics_empty),
+ style = MaterialTheme.typography.bodyMedium,
+ color = palette.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
+
+@Composable
+private fun SummaryItem(
+ icon: ImageVector,
+ label: String,
+ value: String,
+ palette: CarColorPalette,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.padding(4.dp),
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = palette.accent,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Column {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.onSurfaceVariant,
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Bold,
+ color = palette.onSurface,
+ )
+ }
+ }
+}
+
+private fun CostAnalyticsRange.labelRes(): Int = when (this) {
+ CostAnalyticsRange.SevenDays -> R.string.cost_analytics_range_7_days
+ CostAnalyticsRange.ThirtyDays -> R.string.cost_analytics_range_30_days
+ CostAnalyticsRange.NinetyDays -> R.string.cost_analytics_range_90_days
+ CostAnalyticsRange.AllTime -> R.string.cost_analytics_range_all_time
+}
diff --git a/app/src/main/java/com/matedroid/ui/screens/costs/CostAnalyticsViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/costs/CostAnalyticsViewModel.kt
new file mode 100644
index 00000000..4a4a5829
--- /dev/null
+++ b/app/src/main/java/com/matedroid/ui/screens/costs/CostAnalyticsViewModel.kt
@@ -0,0 +1,114 @@
+package com.matedroid.ui.screens.costs
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.matedroid.data.api.models.Units
+import com.matedroid.data.model.Currency
+import com.matedroid.data.repository.ApiResult
+import com.matedroid.data.repository.TeslamateRepository
+import com.matedroid.domain.CostAnalyticsMetrics
+import com.matedroid.domain.CostAnalyticsRepository
+import com.matedroid.domain.IceAssumptions
+import com.matedroid.domain.IceComparisonResult
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Selectable ranges for the Cost Analytics screen. Values mirror the widget's
+ * options so users see consistent numbers across both surfaces.
+ */
+enum class CostAnalyticsRange(val days: Int?) {
+ SevenDays(7),
+ ThirtyDays(30),
+ NinetyDays(90),
+ AllTime(null);
+
+ companion object {
+ val DEFAULT = ThirtyDays
+ }
+}
+
+data class CostAnalyticsUiState(
+ val isLoading: Boolean = true,
+ val range: CostAnalyticsRange = CostAnalyticsRange.DEFAULT,
+ val metrics: CostAnalyticsMetrics = CostAnalyticsMetrics(),
+ val currencySymbol: String = Currency.DEFAULT.symbol,
+ val units: Units? = null,
+ val iceAssumptions: IceAssumptions = IceAssumptions(),
+ val iceComparison: IceComparisonResult? = null,
+ val error: String? = null,
+)
+
+@HiltViewModel
+class CostAnalyticsViewModel @Inject constructor(
+ private val costRepository: CostAnalyticsRepository,
+ private val teslamateRepository: TeslamateRepository,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(CostAnalyticsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var carId: Int? = null
+
+ fun setCarId(id: Int) {
+ if (carId == id) return
+ carId = id
+ loadUnits(id)
+ reload()
+ }
+
+ fun setRange(range: CostAnalyticsRange) {
+ if (_uiState.value.range == range) return
+ _uiState.update { it.copy(range = range) }
+ reload()
+ }
+
+ private fun loadUnits(carId: Int) {
+ viewModelScope.launch {
+ when (val result = teslamateRepository.getCarStatus(carId)) {
+ is ApiResult.Success -> {
+ // Cache only — Cost Analytics labels Room data with the synced unit.
+ result.data.units.unitOfLength
+ ?.takeIf { it == "km" || it == "mi" }
+ ?.let { costRepository.cacheUnitOfLength(it) }
+ }
+ is ApiResult.Error -> Unit
+ }
+ }
+ }
+
+ private fun reload() {
+ val id = carId ?: return
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+ try {
+ val days = _uiState.value.range.days
+ val range = days?.let { CostAnalyticsRepository.DateRange.lastDays(it.toLong()) }
+ val snapshot = costRepository.loadMetrics(id, range)
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ metrics = snapshot.metrics,
+ currencySymbol = snapshot.currencySymbol,
+ // Always label with the unit cached alongside Room summaries.
+ units = Units(unitOfLength = snapshot.unitOfLength),
+ iceAssumptions = snapshot.iceAssumptions,
+ iceComparison = snapshot.iceComparison,
+ )
+ }
+ } catch (e: Exception) {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = e.message ?: "Failed to load cost analytics",
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt
index d53d671d..9337a339 100644
--- a/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt
+++ b/app/src/main/java/com/matedroid/ui/screens/dashboard/DashboardScreen.kt
@@ -48,6 +48,7 @@ import androidx.compose.material.icons.filled.ElectricBolt
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.LocationOn
+import androidx.compose.material.icons.filled.Paid
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.Settings
@@ -196,6 +197,7 @@ fun DashboardScreen(
onNavigateToWhereWasI: (carId: Int, timestamp: String, exteriorColor: String?) -> Unit = { _, _, _ -> },
onNavigateToSentryHistory: (carId: Int, exteriorColor: String?) -> Unit = { _, _ -> },
onNavigateToTrips: (carId: Int, exteriorColor: String?) -> Unit = { _, _ -> },
+ onNavigateToCosts: (carId: Int, exteriorColor: String?) -> Unit = { _, _ -> },
viewModel: DashboardViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -406,6 +408,11 @@ fun DashboardScreen(
uiState.selectedCarId?.let { carId ->
onNavigateToTrips(carId, uiState.selectedCarExterior?.exteriorColor)
}
+ },
+ onNavigateToCosts = {
+ uiState.selectedCarId?.let { carId ->
+ onNavigateToCosts(carId, uiState.selectedCarExterior?.exteriorColor)
+ }
}
)
}
@@ -841,7 +848,8 @@ private fun DashboardContent(
onNavigateToCurrentCharge: () -> Unit = {},
onSaveCarImageOverride: (CarImageOverride?) -> Unit = {},
onNavigateToSentryHistory: () -> Unit = {},
- onNavigateToTrips: () -> Unit = {}
+ onNavigateToTrips: () -> Unit = {},
+ onNavigateToCosts: () -> Unit = {}
) {
val isDarkTheme = isSystemInDarkTheme()
val palette = CarColorPalettes.forExteriorColor(carExterior?.exteriorColor, isDarkTheme)
@@ -924,7 +932,8 @@ private fun DashboardContent(
onNavigateToDrives = onNavigateToDrives,
onNavigateToMileage = onNavigateToMileage,
onNavigateToUpdates = onNavigateToUpdates,
- onNavigateToTrips = onNavigateToTrips
+ onNavigateToTrips = onNavigateToTrips,
+ onNavigateToCosts = onNavigateToCosts
)
// Tyre pressure — its own card, shown when TPMS data is available
@@ -2257,7 +2266,8 @@ private fun VehicleInfoCard(
onNavigateToDrives: () -> Unit,
onNavigateToMileage: () -> Unit,
onNavigateToUpdates: () -> Unit,
- onNavigateToTrips: () -> Unit = {}
+ onNavigateToTrips: () -> Unit = {},
+ onNavigateToCosts: () -> Unit = {}
) {
Card(
modifier = Modifier.fillMaxWidth(),
@@ -2363,6 +2373,20 @@ private fun VehicleInfoCard(
modifier = Modifier.weight(1f)
)
}
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Cost analytics entry point. Sits under the Drives/Software strip so
+ // Costs stays visually adjacent to the other read-only insight surfaces
+ // (Stats + Mileage) rather than the live-status battery card.
+ NavButton(
+ title = stringResource(R.string.nav_costs),
+ value = stringResource(R.string.nav_costs_subtitle),
+ icon = Icons.Filled.Paid,
+ palette = palette,
+ onClick = onNavigateToCosts,
+ modifier = Modifier.fillMaxWidth()
+ )
}
}
}
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..7770ed1e 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
@@ -403,9 +403,17 @@ class DashboardViewModel @Inject constructor(
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('/'))
}
+ // Cache unit-of-length so home-screen widgets can format
+ // distances without talking to the API. Values other than
+ // "km"/"mi" (which TeslaMate might return in future versions)
+ // are ignored so we don't cache garbage.
+ 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/SettingsScreen.kt b/app/src/main/java/com/matedroid/ui/screens/settings/SettingsScreen.kt
index 0318b342..f3cd8c82 100644
--- a/app/src/main/java/com/matedroid/ui/screens/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/matedroid/ui/screens/settings/SettingsScreen.kt
@@ -36,6 +36,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -127,6 +128,12 @@ fun SettingsScreen(
onAcceptInvalidCertsChange = viewModel::updateAcceptInvalidCerts,
onCurrencyChange = viewModel::updateCurrency,
onShowShortDrivesChargesChange = viewModel::updateShowShortDrivesCharges,
+ onHomeUtilityRateChange = viewModel::updateHomeUtilityRate,
+ onDcUtilityRateChange = viewModel::updateDcUtilityRate,
+ onIceFuelEconomyChange = viewModel::updateIceFuelEconomy,
+ onIceFuelEconomyUnitChange = viewModel::updateIceFuelEconomyIsMpg,
+ onIceFuelPriceChange = viewModel::updateIceFuelPrice,
+ onIceFuelPriceUnitChange = viewModel::updateIceFuelPriceIsPerGallon,
onTestConnection = viewModel::testConnection,
onSave = { viewModel.saveSettings(onNavigateToDashboard) },
onPalettePreview = onNavigateToPalettePreview,
@@ -198,6 +205,12 @@ private fun SettingsContent(
onAcceptInvalidCertsChange: (Boolean) -> Unit,
onCurrencyChange: (String) -> Unit,
onShowShortDrivesChargesChange: (Boolean) -> Unit,
+ onHomeUtilityRateChange: (String) -> Unit = {},
+ onDcUtilityRateChange: (String) -> Unit = {},
+ onIceFuelEconomyChange: (String) -> Unit = {},
+ onIceFuelEconomyUnitChange: (Boolean) -> Unit = {},
+ onIceFuelPriceChange: (String) -> Unit = {},
+ onIceFuelPriceUnitChange: (Boolean) -> Unit = {},
onTestConnection: () -> Unit,
onSave: () -> Unit,
onPalettePreview: () -> Unit = {},
@@ -485,6 +498,132 @@ private fun SettingsContent(
Spacer(modifier = Modifier.height(16.dp))
+ // === Utility Rates (for cost estimation when TeslaMate has no price) ===
+ Text(
+ text = stringResource(R.string.settings_utility_rates_title),
+ style = MaterialTheme.typography.titleSmall
+ )
+ Text(
+ text = stringResource(R.string.settings_utility_rates_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = uiState.homeUtilityRateInput,
+ onValueChange = onHomeUtilityRateChange,
+ label = { Text(stringResource(R.string.settings_home_utility_rate_label)) },
+ placeholder = { Text(stringResource(R.string.settings_utility_rate_placeholder)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
+ enabled = !uiState.isTesting && !uiState.isSaving,
+ )
+ Text(
+ text = stringResource(R.string.settings_home_utility_rate_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = uiState.dcUtilityRateInput,
+ onValueChange = onDcUtilityRateChange,
+ label = { Text(stringResource(R.string.settings_dc_utility_rate_label)) },
+ placeholder = { Text(stringResource(R.string.settings_utility_rate_placeholder)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
+ enabled = !uiState.isTesting && !uiState.isSaving,
+ )
+ Text(
+ text = stringResource(R.string.settings_dc_utility_rate_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // === Gasoline comparison (optional; neutral "vs gasoline") ===
+ Text(
+ text = stringResource(R.string.settings_ice_section_title),
+ style = MaterialTheme.typography.titleSmall
+ )
+ Text(
+ text = stringResource(R.string.settings_ice_section_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = uiState.iceFuelEconomyInput,
+ onValueChange = onIceFuelEconomyChange,
+ label = { Text(stringResource(R.string.settings_ice_fuel_economy_label)) },
+ placeholder = {
+ Text(
+ if (uiState.iceFuelEconomyIsMpg) {
+ stringResource(R.string.settings_ice_fuel_economy_placeholder_mpg)
+ } else {
+ stringResource(R.string.settings_ice_fuel_economy_placeholder_l_per_100km)
+ }
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
+ enabled = !uiState.isTesting && !uiState.isSaving,
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ FilterChip(
+ selected = !uiState.iceFuelEconomyIsMpg,
+ onClick = { onIceFuelEconomyUnitChange(false) },
+ label = { Text(stringResource(R.string.settings_ice_unit_l_per_100km)) },
+ )
+ FilterChip(
+ selected = uiState.iceFuelEconomyIsMpg,
+ onClick = { onIceFuelEconomyUnitChange(true) },
+ label = { Text(stringResource(R.string.settings_ice_unit_mpg)) },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ OutlinedTextField(
+ value = uiState.iceFuelPriceInput,
+ onValueChange = onIceFuelPriceChange,
+ label = { Text(stringResource(R.string.settings_ice_fuel_price_label)) },
+ placeholder = { Text(stringResource(R.string.settings_ice_fuel_price_placeholder)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
+ enabled = !uiState.isTesting && !uiState.isSaving,
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ FilterChip(
+ selected = !uiState.iceFuelPriceIsPerGallon,
+ onClick = { onIceFuelPriceUnitChange(false) },
+ label = { Text(stringResource(R.string.settings_ice_unit_per_liter)) },
+ )
+ FilterChip(
+ selected = uiState.iceFuelPriceIsPerGallon,
+ onClick = { onIceFuelPriceUnitChange(true) },
+ label = { Text(stringResource(R.string.settings_ice_unit_per_gallon)) },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
// === Extra Settings (Collapsed by default) ===
HorizontalDivider()
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..b695ce99 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
@@ -42,6 +42,18 @@ data class SettingsUiState(
val acceptInvalidCerts: Boolean = false,
val currencyCode: String = "EUR",
val showShortDrivesCharges: Boolean = false,
+ /** Raw text entered by the user for the home/AC utility rate (per kWh). */
+ val homeUtilityRateInput: String = "",
+ /** Raw text entered by the user for the optional DC utility rate. */
+ val dcUtilityRateInput: String = "",
+ /** Raw text entered for the ICE (gasoline) fuel-economy assumption. */
+ val iceFuelEconomyInput: String = "",
+ /** True when the user is entering the fuel economy in mpg (else L/100 km). */
+ val iceFuelEconomyIsMpg: Boolean = false,
+ /** Raw text entered for the ICE (gasoline) fuel-price assumption. */
+ val iceFuelPriceInput: String = "",
+ /** True when the user is entering the fuel price per gallon (else per liter). */
+ val iceFuelPriceIsPerGallon: Boolean = false,
val isLoading: Boolean = true,
val isTesting: Boolean = false,
val isSaving: Boolean = false,
@@ -105,6 +117,12 @@ class SettingsViewModel @Inject constructor(
acceptInvalidCerts = settings.acceptInvalidCerts,
currencyCode = settings.currencyCode,
showShortDrivesCharges = settings.showShortDrivesCharges,
+ homeUtilityRateInput = settings.homeUtilityRatePerKwh?.let(::formatRate) ?: "",
+ dcUtilityRateInput = settings.dcUtilityRatePerKWh?.let(::formatRate) ?: "",
+ iceFuelEconomyInput = settings.iceFuelEconomyValue?.let(::formatRate) ?: "",
+ iceFuelEconomyIsMpg = settings.iceFuelEconomyIsMpg,
+ iceFuelPriceInput = settings.iceFuelPrice?.let(::formatRate) ?: "",
+ iceFuelPriceIsPerGallon = settings.iceFuelPriceIsPerGallon,
isLoading = false
)
}
@@ -180,6 +198,88 @@ class SettingsViewModel @Inject constructor(
}
}
+ /**
+ * Update the home / AC utility rate input. Only the raw text is stored in
+ * UI state; parsing to a Double happens on save. Blank / invalid values
+ * clear the persisted rate.
+ */
+ fun updateHomeUtilityRate(input: String) {
+ _uiState.value = _uiState.value.copy(homeUtilityRateInput = input)
+ viewModelScope.launch {
+ settingsDataStore.saveHomeUtilityRate(parseRate(input))
+ }
+ }
+
+ /**
+ * Update the optional DC utility rate input. Same rules as the home rate.
+ */
+ fun updateDcUtilityRate(input: String) {
+ _uiState.value = _uiState.value.copy(dcUtilityRateInput = input)
+ viewModelScope.launch {
+ settingsDataStore.saveDcUtilityRate(parseRate(input))
+ }
+ }
+
+ /**
+ * Update the ICE (gasoline) fuel-economy assumption text. Same parsing
+ * rules as the utility rates — blank or non-positive clears the value.
+ */
+ fun updateIceFuelEconomy(input: String) {
+ _uiState.value = _uiState.value.copy(iceFuelEconomyInput = input)
+ viewModelScope.launch {
+ settingsDataStore.saveIceFuelEconomy(
+ value = parseRate(input),
+ isMpg = _uiState.value.iceFuelEconomyIsMpg,
+ )
+ }
+ }
+
+ /** Toggle the ICE fuel-economy unit (mpg vs L/100 km) without altering the value. */
+ fun updateIceFuelEconomyIsMpg(isMpg: Boolean) {
+ _uiState.value = _uiState.value.copy(iceFuelEconomyIsMpg = isMpg)
+ viewModelScope.launch {
+ settingsDataStore.saveIceFuelEconomyUnit(isMpg)
+ }
+ }
+
+ /** Update the ICE fuel-price assumption text. */
+ fun updateIceFuelPrice(input: String) {
+ _uiState.value = _uiState.value.copy(iceFuelPriceInput = input)
+ viewModelScope.launch {
+ settingsDataStore.saveIceFuelPrice(
+ value = parseRate(input),
+ isPerGallon = _uiState.value.iceFuelPriceIsPerGallon,
+ )
+ }
+ }
+
+ /** Toggle the ICE fuel-price unit (per gallon vs per liter). */
+ fun updateIceFuelPriceIsPerGallon(isPerGallon: Boolean) {
+ _uiState.value = _uiState.value.copy(iceFuelPriceIsPerGallon = isPerGallon)
+ viewModelScope.launch {
+ settingsDataStore.saveIceFuelPriceUnit(isPerGallon)
+ }
+ }
+
+ /**
+ * Parse a locale-agnostic decimal rate ("0.15" or "0,15"). Returns null
+ * (clear rate) when blank, unparseable, or not strictly positive.
+ */
+ private fun parseRate(raw: String): Double? {
+ val cleaned = raw.trim().replace(',', '.')
+ if (cleaned.isEmpty()) return null
+ val value = cleaned.toDoubleOrNull() ?: return null
+ return value.takeIf { it.isFinite() && it > 0.0 }
+ }
+
+ /** Format a stored rate back to the text field without trailing zeros. */
+ private fun formatRate(value: Double): String {
+ if (value % 1.0 == 0.0) return value.toLong().toString()
+ // Force '.' as the decimal separator so the text round-trips through
+ // [parseRate] regardless of the device locale.
+ return "%.4f".format(java.util.Locale.ROOT, value).trimEnd('0').trimEnd('.')
+ }
+
fun testConnection() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isTesting = true, testResult = null, error = null)
@@ -259,9 +359,13 @@ class SettingsViewModel @Inject constructor(
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/ui/screens/trips/TripsScreen.kt b/app/src/main/java/com/matedroid/ui/screens/trips/TripsScreen.kt
index 8da02684..432f6cf4 100644
--- a/app/src/main/java/com/matedroid/ui/screens/trips/TripsScreen.kt
+++ b/app/src/main/java/com/matedroid/ui/screens/trips/TripsScreen.kt
@@ -24,18 +24,24 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.TrendingUp
import androidx.compose.material.icons.filled.Add
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.ui.graphics.Color
+import androidx.compose.material.icons.filled.AttachMoney
+import androidx.compose.material.icons.filled.BatteryChargingFull
+import androidx.compose.material.icons.filled.EnergySavingsLeaf
import androidx.compose.material.icons.filled.ElectricBolt
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Paid
import androidx.compose.material.icons.filled.Route
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Speed
+import androidx.compose.material.icons.filled.Straighten
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -56,6 +62,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -66,6 +73,9 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.matedroid.R
import com.matedroid.data.api.models.Units
import com.matedroid.domain.isSignificant
+import com.matedroid.domain.TripCostCoverage
+import com.matedroid.domain.TripIntelligence
+import com.matedroid.domain.TripsSummaryMetrics
import com.matedroid.domain.model.Trip
import com.matedroid.domain.model.UnitFormatter
import com.matedroid.ui.components.DateRangePickerDialog
@@ -81,6 +91,7 @@ import com.matedroid.util.formatMedium
import com.matedroid.util.parseIsoDateTime
import java.time.LocalDate
import java.util.Locale
+import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -204,9 +215,8 @@ fun TripsScreen(
Box(modifier = Modifier.padding(padding)) {
TripsContent(
trips = uiState.trips,
- totalDistance = uiState.totalDistance,
- totalDrivingMin = uiState.totalDrivingMin,
- totalEnergyCharged = uiState.totalEnergyCharged,
+ summary = uiState.summary,
+ intelligence = uiState.intelligence,
availableYears = uiState.availableYears,
selectedYear = uiState.selectedYear,
isCustomDateFilter = uiState.isCustomDateFilter,
@@ -215,6 +225,7 @@ fun TripsScreen(
onYearSelected = { viewModel.setYear(it) },
onCustomRangeSelected = { start, end -> viewModel.setCustomDateRange(start, end) },
units = uiState.units,
+ currencySymbol = uiState.currencySymbol,
palette = palette,
dcChargeIds = uiState.dcChargeIds,
showShortDrivesCharges = uiState.showShortDrivesCharges,
@@ -232,9 +243,8 @@ fun TripsScreen(
@Composable
private fun TripsContent(
trips: List,
- totalDistance: Double,
- totalDrivingMin: Int,
- totalEnergyCharged: Double,
+ summary: TripsSummaryMetrics,
+ intelligence: TripIntelligence,
availableYears: List,
selectedYear: Int?,
isCustomDateFilter: Boolean,
@@ -243,14 +253,19 @@ private fun TripsContent(
onYearSelected: (Int?) -> Unit,
onCustomRangeSelected: (LocalDate, LocalDate) -> Unit,
units: Units?,
+ currencySymbol: String,
palette: CarColorPalette,
dcChargeIds: Set,
showShortDrivesCharges: Boolean,
onTripClick: (Trip) -> Unit
) {
val listState = rememberLazyListState()
- // Header items in render order: year chips (conditional), summary, section header.
- val headerCount = 2 + (if (availableYears.size > 1) 1 else 0)
+ // Whether the highlights card renders at all — one row must exist beyond the summary/trip-count.
+ val showIntelligence = intelligence.hasAnyHighlight()
+ // Header items in render order: year chips (conditional), summary, intelligence (conditional), section header.
+ val headerCount = 2 +
+ (if (availableYears.size > 1) 1 else 0) +
+ (if (showIntelligence) 1 else 0)
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
@@ -278,15 +293,26 @@ private fun TripsContent(
// Summary card
item {
SummaryCard(
- tripCount = trips.size,
- totalDistance = totalDistance,
- totalDrivingMin = totalDrivingMin,
- totalEnergyCharged = totalEnergyCharged,
+ summary = summary,
units = units,
+ currencySymbol = currencySymbol,
palette = palette
)
}
+ // Trip intelligence card — only when we've got at least one highlight for the current filter.
+ if (showIntelligence) {
+ item {
+ IntelligenceCard(
+ intelligence = intelligence,
+ units = units,
+ currencySymbol = currencySymbol,
+ palette = palette,
+ onTripClick = onTripClick,
+ )
+ }
+ }
+
// Section header
item {
Spacer(modifier = Modifier.height(8.dp))
@@ -337,13 +363,22 @@ private fun TripsContent(
@Composable
private fun SummaryCard(
- tripCount: Int,
- totalDistance: Double,
- totalDrivingMin: Int,
- totalEnergyCharged: Double,
+ summary: TripsSummaryMetrics,
units: Units?,
+ currencySymbol: String,
palette: CarColorPalette
) {
+ val unavailable = "—"
+ val efficiency = summary.averageEfficiency?.let {
+ UnitFormatter.formatEfficiency(it, units, decimals = 0)
+ } ?: unavailable
+ val chargeCost = summary.totalChargeCost?.let {
+ UnitFormatter.formatCost(it, currencySymbol)
+ } ?: unavailable
+ val costPerDistance = summary.costPer100Distance?.let {
+ UnitFormatter.formatCost(it, currencySymbol)
+ } ?: unavailable
+
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = palette.surface)
@@ -360,14 +395,14 @@ private fun SummaryCard(
SummaryItem(
icon = Icons.Filled.Route,
label = stringResource(R.string.trips_title),
- value = "%,d".format(tripCount),
+ value = "%,d".format(summary.tripCount),
palette = palette,
modifier = Modifier.weight(1.2f)
)
SummaryItem(
- icon = Icons.Filled.Speed,
+ icon = Icons.Filled.Straighten,
label = stringResource(R.string.total_distance),
- value = UnitFormatter.formatDistance(totalDistance, units, decimals = 0),
+ value = UnitFormatter.formatDistance(summary.totalDistance, units, decimals = 0),
palette = palette,
modifier = Modifier.weight(0.8f)
)
@@ -377,19 +412,299 @@ private fun SummaryCard(
SummaryItem(
icon = Icons.Filled.Schedule,
label = stringResource(R.string.trip_driving_time),
- value = formatDuration(LocalContext.current.resources, totalDrivingMin),
+ value = formatDuration(
+ LocalContext.current.resources,
+ summary.totalDrivingMinutes,
+ ),
palette = palette,
modifier = Modifier.weight(1.2f)
)
SummaryItem(
icon = Icons.Filled.ElectricBolt,
+ label = stringResource(R.string.stats_energy_used),
+ value = UnitFormatter.formatEnergy(summary.totalEnergyConsumed),
+ palette = palette,
+ modifier = Modifier.weight(0.8f)
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ SummaryItem(
+ icon = Icons.Filled.EnergySavingsLeaf,
+ label = stringResource(R.string.stats_avg_efficiency),
+ value = efficiency,
+ palette = palette,
+ modifier = Modifier.weight(1.2f)
+ )
+ SummaryItem(
+ icon = Icons.Filled.BatteryChargingFull,
label = stringResource(R.string.trip_energy_charged),
- value = "%,.0f kWh".format(totalEnergyCharged),
+ value = UnitFormatter.formatEnergy(summary.totalEnergyCharged),
+ palette = palette,
+ modifier = Modifier.weight(0.8f)
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ SummaryItem(
+ icon = Icons.Filled.Paid,
+ label = stringResource(R.string.trip_charge_cost),
+ value = chargeCost,
+ palette = palette,
+ modifier = Modifier.weight(1.2f)
+ )
+ SummaryItem(
+ icon = Icons.Filled.AttachMoney,
+ label = stringResource(
+ R.string.trip_cost_per_100,
+ UnitFormatter.getDistanceUnit(units),
+ ),
+ value = costPerDistance,
palette = palette,
modifier = Modifier.weight(0.8f)
)
}
+ when (summary.costCoverage) {
+ TripCostCoverage.Partial -> {
+ Spacer(modifier = Modifier.height(10.dp))
+ CostCoverageNote(
+ text = stringResource(
+ R.string.trip_cost_coverage_partial,
+ summary.pricedChargeCount,
+ summary.chargeCount,
+ ),
+ palette = palette,
+ )
+ }
+ TripCostCoverage.None -> {
+ if (summary.chargeCount > 0) {
+ Spacer(modifier = Modifier.height(10.dp))
+ CostCoverageNote(
+ text = stringResource(R.string.trip_cost_coverage_none),
+ palette = palette,
+ )
+ }
+ }
+ TripCostCoverage.Complete -> Unit
+ }
+ }
+ }
+}
+
+/** True when the card has at least one Trip-backed row worth surfacing. */
+private fun TripIntelligence.hasAnyHighlight(): Boolean =
+ mostEfficient != null ||
+ leastEfficient != null ||
+ cheapestPer100 != null ||
+ mostExpensivePer100 != null ||
+ longestDistance != null ||
+ highestChargingOverhead != null
+
+@Composable
+private fun IntelligenceCard(
+ intelligence: TripIntelligence,
+ units: Units?,
+ currencySymbol: String,
+ palette: CarColorPalette,
+ onTripClick: (Trip) -> Unit,
+) {
+ val distanceUnit = UnitFormatter.getDistanceUnit(units)
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = palette.surface)
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = stringResource(R.string.trip_intelligence_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = palette.onSurface
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+
+ intelligence.mostEfficient?.let { trip ->
+ IntelligenceRow(
+ icon = Icons.Filled.EnergySavingsLeaf,
+ label = stringResource(R.string.trip_intelligence_most_efficient),
+ value = trip.avgEfficiency?.let {
+ UnitFormatter.formatEfficiency(it, units, decimals = 0)
+ } ?: "—",
+ tripLabel = trip.displayName(),
+ palette = palette,
+ onClick = { onTripClick(trip) },
+ )
+ }
+ intelligence.leastEfficient?.let { trip ->
+ if (trip !== intelligence.mostEfficient) {
+ IntelligenceRow(
+ icon = Icons.AutoMirrored.Filled.TrendingUp,
+ label = stringResource(R.string.trip_intelligence_least_efficient),
+ value = trip.avgEfficiency?.let {
+ UnitFormatter.formatEfficiency(it, units, decimals = 0)
+ } ?: "—",
+ tripLabel = trip.displayName(),
+ palette = palette,
+ onClick = { onTripClick(trip) },
+ )
+ }
+ }
+ intelligence.cheapestPer100?.let { trip ->
+ val per100 = trip.totalChargeCost?.let {
+ it * 100.0 / trip.totalDistance
+ }
+ IntelligenceRow(
+ icon = Icons.Filled.Paid,
+ label = stringResource(R.string.trip_intelligence_cheapest_per_100, distanceUnit),
+ value = per100?.let { UnitFormatter.formatCost(it, currencySymbol) } ?: "—",
+ tripLabel = trip.displayName(),
+ palette = palette,
+ onClick = { onTripClick(trip) },
+ )
+ }
+ intelligence.mostExpensivePer100?.let { trip ->
+ if (trip !== intelligence.cheapestPer100) {
+ val per100 = trip.totalChargeCost?.let {
+ it * 100.0 / trip.totalDistance
+ }
+ IntelligenceRow(
+ icon = Icons.Filled.AttachMoney,
+ label = stringResource(R.string.trip_intelligence_pricey_per_100, distanceUnit),
+ value = per100?.let { UnitFormatter.formatCost(it, currencySymbol) } ?: "—",
+ tripLabel = trip.displayName(),
+ palette = palette,
+ onClick = { onTripClick(trip) },
+ )
+ }
+ }
+ intelligence.longestDistance?.let { trip ->
+ IntelligenceRow(
+ icon = Icons.Filled.Straighten,
+ label = stringResource(R.string.trip_intelligence_longest),
+ value = UnitFormatter.formatDistance(trip.totalDistance, units, decimals = 0),
+ tripLabel = trip.displayName(),
+ palette = palette,
+ onClick = { onTripClick(trip) },
+ )
+ }
+ intelligence.highestChargingOverhead?.let { trip ->
+ val ratio = intelligence.highestChargingOverheadRatio ?: 0.0
+ IntelligenceRow(
+ icon = Icons.Filled.Schedule,
+ label = stringResource(R.string.trip_intelligence_charge_overhead),
+ value = stringResource(
+ R.string.trip_intelligence_percent,
+ (ratio * 100.0).roundToInt(),
+ ),
+ tripLabel = trip.displayName(),
+ palette = palette,
+ onClick = { onTripClick(trip) },
+ )
+ }
+ intelligence.energyGapKwh?.let { gap ->
+ IntelligenceRow(
+ icon = Icons.Filled.ElectricBolt,
+ label = stringResource(R.string.trip_intelligence_energy_gap),
+ value = "%+.1f kWh".format(gap),
+ tripLabel = null,
+ palette = palette,
+ onClick = null,
+ )
+ }
+ intelligence.avgChargingMinutesPerTrip?.let { avg ->
+ if (avg > 0.0) {
+ IntelligenceRow(
+ icon = Icons.Filled.BatteryChargingFull,
+ label = stringResource(R.string.trip_intelligence_avg_charge_time),
+ value = formatDuration(
+ LocalContext.current.resources,
+ avg.roundToInt(),
+ ),
+ tripLabel = null,
+ palette = palette,
+ onClick = null,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun IntelligenceRow(
+ icon: ImageVector,
+ label: String,
+ value: String,
+ tripLabel: String?,
+ palette: CarColorPalette,
+ onClick: (() -> Unit)?,
+) {
+ val rowModifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 6.dp)
+ .let { base ->
+ if (onClick != null) base.clickable(onClick = onClick) else base
+ }
+
+ Row(
+ modifier = rowModifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = palette.accent,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(modifier = Modifier.width(10.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = palette.onSurfaceVariant,
+ )
+ if (!tripLabel.isNullOrBlank()) {
+ Text(
+ text = tripLabel,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = palette.onSurface,
+ maxLines = 1,
+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
+ )
+ }
}
+ Spacer(modifier = Modifier.width(10.dp))
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Bold,
+ color = palette.onSurface,
+ )
+ }
+}
+
+@Composable
+private fun CostCoverageNote(
+ text: String,
+ palette: CarColorPalette,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Info,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = palette.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall,
+ color = palette.onSurfaceVariant,
+ )
}
}
@@ -663,4 +978,3 @@ internal fun Trip.displayName(): String {
* 1w–~1mo → "Xw Yd"
* ≥30d → "Xmo Yw"
*/
-
diff --git a/app/src/main/java/com/matedroid/ui/screens/trips/TripsViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/trips/TripsViewModel.kt
index 524de941..4f6a9d88 100644
--- a/app/src/main/java/com/matedroid/ui/screens/trips/TripsViewModel.kt
+++ b/app/src/main/java/com/matedroid/ui/screens/trips/TripsViewModel.kt
@@ -5,10 +5,17 @@ import androidx.lifecycle.viewModelScope
import com.matedroid.data.api.models.Units
import com.matedroid.data.local.SettingsDataStore
import com.matedroid.data.local.dao.AggregateDao
+import com.matedroid.data.model.Currency
import com.matedroid.data.repository.ApiResult
import com.matedroid.data.repository.TeslamateRepository
+import com.matedroid.domain.TripIntelligence
+import com.matedroid.domain.TripIntelligenceCalculator
import com.matedroid.domain.TripRepository
+import com.matedroid.domain.TripsSummaryCalculator
+import com.matedroid.domain.TripsSummaryMetrics
+import com.matedroid.domain.UtilityRates
import com.matedroid.domain.model.Trip
+import com.matedroid.util.parseIsoDate
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -16,24 +23,24 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import com.matedroid.util.parseIsoDate
import java.time.LocalDate
import javax.inject.Inject
data class TripsUiState(
val isLoading: Boolean = true,
val trips: List = emptyList(),
- val totalDistance: Double = 0.0,
- val totalDrivingMin: Int = 0,
- val totalEnergyCharged: Double = 0.0,
+ val summary: TripsSummaryMetrics = TripsSummaryMetrics(),
+ val intelligence: TripIntelligence = TripIntelligence(),
val availableYears: List = emptyList(),
val selectedYear: Int? = null,
val isCustomDateFilter: Boolean = false,
val customStartDate: LocalDate? = null,
val customEndDate: LocalDate? = null,
val units: Units? = null,
+ val currencySymbol: String = Currency.DEFAULT.symbol,
val dcChargeIds: Set = emptySet(),
- val showShortDrivesCharges: Boolean = false
+ val showShortDrivesCharges: Boolean = false,
+ val utilityRates: UtilityRates = UtilityRates(),
)
@HiltViewModel
@@ -66,6 +73,9 @@ class TripsViewModel @Inject constructor(
aggregateDao.getDcChargeIds(id).toSet()
} catch (e: Exception) { emptySet() }
_uiState.update { it.copy(dcChargeIds = ids) }
+ // DC ids feed rate selection for utility-based cost estimation;
+ // re-run the current filter so the summary reflects the DC set.
+ applyFilter()
}
}
@@ -119,8 +129,19 @@ class TripsViewModel @Inject constructor(
private fun loadTrips(carId: Int) {
viewModelScope.launch {
// Read on each load so toggling the setting is reflected when the screen reloads.
- val showShort = settingsDataStore.showShortDrivesCharges.first()
- _uiState.update { it.copy(showShortDrivesCharges = showShort) }
+ val settings = settingsDataStore.settings.first()
+ val currencySymbol = Currency.findByCode(settings.currencyCode).symbol
+ val rates = UtilityRates(
+ homePerKwh = settings.homeUtilityRatePerKwh,
+ dcPerKwh = settings.dcUtilityRatePerKWh,
+ )
+ _uiState.update {
+ it.copy(
+ showShortDrivesCharges = settings.showShortDrivesCharges,
+ currencySymbol = currencySymbol,
+ utilityRates = rates,
+ )
+ }
allTrips = tripRepository.getTrips(carId)
@@ -149,9 +170,12 @@ class TripsViewModel @Inject constructor(
_uiState.update {
it.copy(
trips = filtered,
- totalDistance = filtered.sumOf { t -> t.totalDistance },
- totalDrivingMin = filtered.sumOf { t -> t.totalDrivingDurationMin },
- totalEnergyCharged = filtered.sumOf { t -> t.totalEnergyCharged }
+ summary = TripsSummaryCalculator.calculate(
+ trips = filtered,
+ rates = state.utilityRates,
+ dcChargeIds = state.dcChargeIds,
+ ),
+ intelligence = TripIntelligenceCalculator.calculate(filtered),
)
}
}
diff --git a/app/src/main/java/com/matedroid/widget/CostSummaryWidget.kt b/app/src/main/java/com/matedroid/widget/CostSummaryWidget.kt
new file mode 100644
index 00000000..477548d3
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/CostSummaryWidget.kt
@@ -0,0 +1,445 @@
+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 COST_WIDGET_BACKGROUND = Color(0xFF101413)
+private val COST_WIDGET_PANEL = Color(0xFF18201E)
+private val COST_WIDGET_ACCENT = Color(0xFF58D68D)
+private val COST_WIDGET_SECONDARY = Color(0xFFFFC857)
+private val COST_WIDGET_TEXT = Color.White
+private val COST_WIDGET_MUTED = Color(0xB3FFFFFF)
+
+/**
+ * Read-only home screen widget that surfaces charging-cost intelligence.
+ *
+ * All database work happens in [CostSummaryWidgetUpdateWorker]; rendering only
+ * reads preformatted values from Glance preferences. The widget never talks to
+ * TeslaMate or the car — it works purely off the Room cache built by the app.
+ */
+class CostSummaryWidget : GlanceAppWidget() {
+
+ companion object {
+ val CAR_ID_KEY = intPreferencesKey("cost_summary_car_id")
+ val HAS_DATA_KEY = booleanPreferencesKey("cost_summary_has_data")
+ val HAS_CHARGES_KEY = booleanPreferencesKey("cost_summary_has_charges")
+ val CAR_NAME_KEY = stringPreferencesKey("cost_summary_car_name")
+ val RANGE_DAYS_KEY = intPreferencesKey("cost_summary_range_days")
+ val PERIOD_LABEL_KEY = stringPreferencesKey("cost_summary_period_label")
+ val COST_VALUE_KEY = stringPreferencesKey("cost_summary_cost_value")
+ val COST_PER_DISTANCE_VALUE_KEY = stringPreferencesKey("cost_summary_cost_per_distance_value")
+ val AVG_COST_PER_KWH_VALUE_KEY = stringPreferencesKey("cost_summary_avg_cost_per_kwh_value")
+ val ENERGY_VALUE_KEY = stringPreferencesKey("cost_summary_energy_value")
+ val DISTANCE_UNIT_KEY = stringPreferencesKey("cost_summary_distance_unit")
+ val COVERAGE_KEY = stringPreferencesKey("cost_summary_coverage")
+ val CHARGES_WITH_COST_KEY = intPreferencesKey("cost_summary_charges_with_cost")
+ val CHARGE_COUNT_KEY = intPreferencesKey("cost_summary_charge_count")
+ val UPDATED_VALUE_KEY = stringPreferencesKey("cost_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 openCostsIntent = Intent(context, MainActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ .putExtra("EXTRA_NAVIGATE_TO", "costs")
+ .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(COST_WIDGET_BACKGROUND))
+ .clickable(actionStartActivity(openCostsIntent))
+ ) {
+ 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(COST_WIDGET_BACKGROUND))
+ .padding(12.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = text,
+ style = TextStyle(
+ color = ColorProvider(COST_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.cost_widget_period_last_30_days)
+ val hasCharges = prefs[HAS_CHARGES_KEY] ?: false
+ val cost = prefs[COST_VALUE_KEY].orEmpty()
+ val costPerDistance = prefs[COST_PER_DISTANCE_VALUE_KEY].orEmpty()
+ val avgPerKwh = prefs[AVG_COST_PER_KWH_VALUE_KEY].orEmpty()
+ val energy = prefs[ENERGY_VALUE_KEY].orEmpty()
+ val distanceUnit = prefs[DISTANCE_UNIT_KEY].orEmpty()
+ val coverage = CostSummaryWidgetCoverage.values().firstOrNull {
+ it.name == prefs[COVERAGE_KEY]
+ } ?: CostSummaryWidgetCoverage.None
+ val chargesWithCost = prefs[CHARGES_WITH_COST_KEY] ?: 0
+ val chargeCount = prefs[CHARGE_COUNT_KEY] ?: 0
+ 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(COST_WIDGET_TEXT),
+ fontSize = if (compact) 12.sp else 14.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ maxLines = 1,
+ )
+ }
+ Text(
+ text = periodLabel,
+ style = TextStyle(
+ color = ColorProvider(COST_WIDGET_MUTED),
+ fontSize = if (compact) 10.sp else 11.sp,
+ ),
+ maxLines = 1,
+ )
+ }
+ if (!compact && updated.isNotBlank()) {
+ Text(
+ text = updated,
+ style = TextStyle(
+ color = ColorProvider(COST_WIDGET_MUTED),
+ fontSize = 10.sp,
+ ),
+ maxLines = 1,
+ )
+ }
+ }
+
+ Spacer(modifier = GlanceModifier.height(if (compact) 4.dp else 8.dp))
+
+ if (!hasCharges) {
+ Box(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .defaultWeight()
+ .background(ColorProvider(COST_WIDGET_PANEL))
+ .cornerRadius(8.dp)
+ .padding(12.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = context.getString(R.string.cost_widget_empty),
+ style = TextStyle(
+ color = ColorProvider(COST_WIDGET_MUTED),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ maxLines = 2,
+ )
+ }
+ } else {
+ // Hero row: total known cost + coverage badge.
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ Column(modifier = GlanceModifier.defaultWeight()) {
+ Text(
+ text = cost,
+ style = TextStyle(
+ color = ColorProvider(COST_WIDGET_ACCENT),
+ fontSize = if (compact) 22.sp else 30.sp,
+ fontWeight = FontWeight.Bold,
+ ),
+ maxLines = 1,
+ )
+ Text(
+ text = coverageLabel(
+ context = context,
+ coverage = coverage,
+ chargesWithCost = chargesWithCost,
+ chargeCount = chargeCount,
+ ),
+ style = TextStyle(
+ color = ColorProvider(COST_WIDGET_MUTED),
+ fontSize = 10.sp,
+ ),
+ maxLines = 1,
+ )
+ }
+ }
+
+ if (compact) {
+ Spacer(modifier = GlanceModifier.defaultWeight())
+ CompactFooter(
+ costPerDistance = costPerDistance,
+ avgPerKwh = avgPerKwh,
+ coverage = coverage,
+ )
+ } else {
+ Spacer(modifier = GlanceModifier.defaultWeight())
+
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ MetricTile(
+ label = context.getString(
+ R.string.cost_widget_cost_per_distance,
+ distanceUnit,
+ ),
+ value = if (coverage == CostSummaryWidgetCoverage.Complete) {
+ costPerDistance
+ } else {
+ context.getString(R.string.cost_widget_no_value)
+ },
+ accent = COST_WIDGET_ACCENT,
+ modifier = GlanceModifier.defaultWeight(),
+ )
+ Spacer(modifier = GlanceModifier.width(8.dp))
+ MetricTile(
+ label = context.getString(R.string.cost_widget_avg_cost_per_kwh),
+ value = avgPerKwh,
+ accent = COST_WIDGET_SECONDARY,
+ modifier = GlanceModifier.defaultWeight(),
+ )
+ }
+
+ Spacer(modifier = GlanceModifier.height(7.dp))
+
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ MetricTile(
+ label = context.getString(R.string.cost_widget_energy),
+ value = energy,
+ accent = COST_WIDGET_SECONDARY,
+ modifier = GlanceModifier.defaultWeight(),
+ )
+ Spacer(modifier = GlanceModifier.width(8.dp))
+ MetricTile(
+ label = context.getString(R.string.cost_widget_charges),
+ value = "%,d".format(chargeCount),
+ accent = COST_WIDGET_TEXT,
+ modifier = GlanceModifier.defaultWeight(),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ private fun coverageLabel(
+ context: Context,
+ coverage: CostSummaryWidgetCoverage,
+ chargesWithCost: Int,
+ chargeCount: Int,
+ ): String = when (coverage) {
+ CostSummaryWidgetCoverage.None -> context.getString(R.string.cost_widget_no_charges)
+ CostSummaryWidgetCoverage.Missing -> context.getString(R.string.cost_widget_cost_none_priced)
+ CostSummaryWidgetCoverage.Partial -> context.getString(
+ R.string.cost_widget_cost_partially_priced,
+ chargesWithCost,
+ chargeCount,
+ )
+ CostSummaryWidgetCoverage.Complete -> context.getString(R.string.cost_widget_cost_all_priced)
+ }
+
+ @Composable
+ private fun CompactFooter(
+ costPerDistance: String,
+ avgPerKwh: String,
+ coverage: CostSummaryWidgetCoverage,
+ ) {
+ val context = LocalContext.current
+ // Compact mode has room for one primary rate. Prefer the honest cost/100
+ // when complete, otherwise fall back to the per-kWh price which is still
+ // truthful under partial coverage (it excludes unpriced charges).
+ val primary = when (coverage) {
+ CostSummaryWidgetCoverage.Complete -> context.getString(
+ R.string.cost_widget_compact_cost_per_distance,
+ costPerDistance,
+ )
+ CostSummaryWidgetCoverage.Partial,
+ CostSummaryWidgetCoverage.Missing,
+ CostSummaryWidgetCoverage.None -> context.getString(
+ R.string.cost_widget_compact_cost_per_distance_unavailable,
+ )
+ }
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = primary,
+ style = TextStyle(
+ color = ColorProvider(COST_WIDGET_TEXT),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ maxLines = 1,
+ )
+ Spacer(modifier = GlanceModifier.defaultWeight())
+ Text(
+ text = context.getString(R.string.cost_widget_compact_avg_cost_per_kwh, avgPerKwh),
+ style = TextStyle(
+ color = ColorProvider(COST_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(COST_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(COST_WIDGET_MUTED),
+ fontSize = 9.sp,
+ ),
+ maxLines = 1,
+ )
+ }
+ }
+
+ suspend fun updateWidget(
+ context: Context,
+ glanceId: GlanceId,
+ data: CostSummaryWidgetDisplayData,
+ ) {
+ updateAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) { prefs ->
+ prefs.toMutablePreferences().apply {
+ this[CAR_ID_KEY] = data.carId
+ this[HAS_DATA_KEY] = true
+ this[HAS_CHARGES_KEY] = data.hasCharges
+ this[CAR_NAME_KEY] = data.carName
+ this[PERIOD_LABEL_KEY] = data.periodLabel
+ this[COST_VALUE_KEY] = data.costValue
+ this[COST_PER_DISTANCE_VALUE_KEY] = data.costPerDistanceValue
+ this[AVG_COST_PER_KWH_VALUE_KEY] = data.avgCostPerKwhValue
+ this[ENERGY_VALUE_KEY] = data.energyValue
+ this[DISTANCE_UNIT_KEY] = data.distanceUnit
+ this[COVERAGE_KEY] = data.costCoverage.name
+ this[CHARGES_WITH_COST_KEY] = data.chargesWithCost
+ this[CHARGE_COUNT_KEY] = data.chargeCount
+ this[UPDATED_VALUE_KEY] = data.updatedValue
+ }
+ }
+ update(context, glanceId)
+ }
+}
diff --git a/app/src/main/java/com/matedroid/widget/CostSummaryWidgetConfigActivity.kt b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetConfigActivity.kt
new file mode 100644
index 00000000..8e26c986
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetConfigActivity.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 CostSummaryWidgetConfigActivity : 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(CostSummaryWidgetRange.DEFAULT) }
+ var selectedCarId by remember { mutableStateOf(null) }
+
+ LaunchedEffect(Unit) {
+ val glanceId = GlanceAppWidgetManager(this@CostSummaryWidgetConfigActivity)
+ .getGlanceIdBy(appWidgetId)
+ val prefs = getAppWidgetState(
+ this@CostSummaryWidgetConfigActivity,
+ PreferencesGlanceStateDefinition,
+ glanceId,
+ )
+ selectedRange = CostSummaryWidgetRange.fromDays(
+ prefs[CostSummaryWidget.RANGE_DAYS_KEY]
+ ?: CostSummaryWidgetRange.DEFAULT.days
+ )
+ selectedCarId = prefs[CostSummaryWidget.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: CostSummaryWidgetRange,
+ selectedCarId: Int?,
+ onRangeSelected: (CostSummaryWidgetRange) -> 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.cost_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,
+ ) {
+ CostSummaryWidgetRange.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: CostSummaryWidgetRange,
+ ) {
+ lifecycleScope.launch {
+ val glanceId = GlanceAppWidgetManager(this@CostSummaryWidgetConfigActivity)
+ .getGlanceIdBy(appWidgetId)
+
+ updateAppWidgetState(
+ this@CostSummaryWidgetConfigActivity,
+ PreferencesGlanceStateDefinition,
+ glanceId,
+ ) { prefs ->
+ prefs.toMutablePreferences().apply {
+ this[CostSummaryWidget.CAR_ID_KEY] = car.carId
+ this[CostSummaryWidget.CAR_NAME_KEY] = car.displayName
+ this[CostSummaryWidget.RANGE_DAYS_KEY] = range.days
+ this[CostSummaryWidget.PERIOD_LABEL_KEY] = getString(range.labelRes)
+ this[CostSummaryWidget.HAS_DATA_KEY] = false
+ }
+ }
+
+ CostSummaryWidget().update(this@CostSummaryWidgetConfigActivity, glanceId)
+ CostSummaryWidgetUpdateWorker.scheduleImmediateUpdate(this@CostSummaryWidgetConfigActivity)
+
+ setResult(RESULT_OK, Intent().apply {
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ })
+ finish()
+ }
+ }
+}
diff --git a/app/src/main/java/com/matedroid/widget/CostSummaryWidgetDateRange.kt b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetDateRange.kt
new file mode 100644
index 00000000..fbe22435
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetDateRange.kt
@@ -0,0 +1,35 @@
+package com.matedroid.widget
+
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+/**
+ * Date-range helper for the Cost Summary widget. Kept intentionally simple —
+ * the widget only needs a half-open [start, end) window covering the last
+ * [days] days, aligned to local midnight. Format matches TeslaMate's DateTime
+ * format so it can be handed straight to Room queries expecting an ISO string.
+ */
+object CostSummaryWidgetDateRange {
+ private val API_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")
+
+ fun lastDays(
+ today: LocalDate = LocalDate.now(),
+ days: Long = CostSummaryWidgetRange.DEFAULT.days.toLong(),
+ ): CostSummaryWidgetDateRangeValues {
+ require(days > 0) { "days must be positive" }
+ val start = today.minusDays(days - 1).atStartOfDay()
+ val end = today.plusDays(1).atStartOfDay()
+ return CostSummaryWidgetDateRangeValues(
+ startDate = start.toApiDateString(),
+ endDate = end.toApiDateString(),
+ )
+ }
+
+ private fun LocalDateTime.toApiDateString(): String = format(API_DATE_FORMATTER)
+}
+
+data class CostSummaryWidgetDateRangeValues(
+ val startDate: String,
+ val endDate: String,
+)
diff --git a/app/src/main/java/com/matedroid/widget/CostSummaryWidgetDisplayData.kt b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetDisplayData.kt
new file mode 100644
index 00000000..0c5fdca5
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetDisplayData.kt
@@ -0,0 +1,43 @@
+package com.matedroid.widget
+
+/**
+ * Persisted display snapshot for the read-only cost summary widget.
+ *
+ * The widget composable reads only these preformatted values from Glance
+ * preferences. All database access and formatting live in
+ * [CostSummaryWidgetUpdateWorker] + [CostSummaryWidgetFormatter].
+ */
+data class CostSummaryWidgetDisplayData(
+ val carId: Int,
+ val carName: String,
+ val periodLabel: String,
+ val hasCharges: Boolean,
+ val costValue: String,
+ val costPerDistanceValue: String,
+ val avgCostPerKwhValue: String,
+ val energyValue: String,
+ val distanceUnit: String,
+ val costCoverage: CostSummaryWidgetCoverage,
+ val chargesWithCost: Int,
+ val chargeCount: Int,
+ val updatedValue: String,
+)
+
+/**
+ * Coverage bucket for a widget snapshot — same semantics as
+ * [com.matedroid.domain.TripCostCoverage] but kept local so the widget can be
+ * built without pulling the domain enum through Glance preferences.
+ */
+enum class CostSummaryWidgetCoverage {
+ /** No charges in the range at all. */
+ None,
+
+ /** Some charges recorded, but none carry a cost yet. */
+ Missing,
+
+ /** At least one but not every charge has a cost. Rate is unavailable. */
+ Partial,
+
+ /** Every charge in the range has a cost — cost/distance is honest. */
+ Complete,
+}
diff --git a/app/src/main/java/com/matedroid/widget/CostSummaryWidgetFormatter.kt b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetFormatter.kt
new file mode 100644
index 00000000..b9e0078c
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetFormatter.kt
@@ -0,0 +1,68 @@
+package com.matedroid.widget
+
+import com.matedroid.data.api.models.Units
+import com.matedroid.domain.CostAnalyticsMetrics
+import com.matedroid.domain.TripCostCoverage
+import com.matedroid.domain.model.UnitFormatter
+
+/**
+ * Turn a [CostAnalyticsMetrics] into a preformatted
+ * [CostSummaryWidgetDisplayData] snapshot for the Cost Summary widget.
+ *
+ * Cost/100 distance is intentionally left as [noValue] unless every charge in
+ * the range is priced. The widget UI can then honestly show a partial coverage
+ * badge without inventing a rate that would mislead the user.
+ */
+object CostSummaryWidgetFormatter {
+
+ fun format(
+ carId: Int,
+ carName: String,
+ periodLabel: String,
+ metrics: CostAnalyticsMetrics,
+ currencySymbol: String,
+ noValue: String,
+ updatedValue: String,
+ units: Units?,
+ ): CostSummaryWidgetDisplayData {
+ val hasCharges = metrics.chargeCount > 0
+
+ val coverage = when (metrics.costCoverage) {
+ TripCostCoverage.None -> if (metrics.chargeCount == 0) {
+ CostSummaryWidgetCoverage.None
+ } else {
+ CostSummaryWidgetCoverage.Missing
+ }
+ TripCostCoverage.Partial -> CostSummaryWidgetCoverage.Partial
+ TripCostCoverage.Complete -> CostSummaryWidgetCoverage.Complete
+ }
+
+ val costValue = metrics.totalKnownCost
+ ?.let { UnitFormatter.formatCost(it, currencySymbol) }
+ ?: noValue
+
+ val costPerDistanceValue = metrics.costPer100Distance
+ ?.let { UnitFormatter.formatCost(it, currencySymbol) }
+ ?: noValue
+
+ val avgCostPerKwhValue = metrics.avgCostPerKwh
+ ?.let { UnitFormatter.formatCost(it, currencySymbol, perKwh = true) }
+ ?: noValue
+
+ return CostSummaryWidgetDisplayData(
+ carId = carId,
+ carName = carName,
+ periodLabel = periodLabel,
+ hasCharges = hasCharges,
+ costValue = costValue,
+ costPerDistanceValue = costPerDistanceValue,
+ avgCostPerKwhValue = avgCostPerKwhValue,
+ energyValue = UnitFormatter.formatEnergy(metrics.energyAddedTotal),
+ distanceUnit = UnitFormatter.getDistanceUnit(units),
+ costCoverage = coverage,
+ chargesWithCost = metrics.pricedCount,
+ chargeCount = metrics.chargeCount,
+ updatedValue = updatedValue,
+ )
+ }
+}
diff --git a/app/src/main/java/com/matedroid/widget/CostSummaryWidgetRange.kt b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetRange.kt
new file mode 100644
index 00000000..5a19f4ec
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetRange.kt
@@ -0,0 +1,25 @@
+package com.matedroid.widget
+
+import androidx.annotation.StringRes
+import com.matedroid.R
+
+/**
+ * User-selectable window for the Cost Summary widget. Mirrors the on-screen
+ * ranges in [com.matedroid.ui.screens.costs.CostAnalyticsRange] so numbers stay
+ * consistent between the widget and the Cost Analytics screen.
+ */
+enum class CostSummaryWidgetRange(
+ val days: Int,
+ @StringRes val labelRes: Int,
+) {
+ SevenDays(7, R.string.cost_widget_period_last_7_days),
+ ThirtyDays(30, R.string.cost_widget_period_last_30_days),
+ NinetyDays(90, R.string.cost_widget_period_last_90_days);
+
+ companion object {
+ val DEFAULT = ThirtyDays
+
+ fun fromDays(days: Int): CostSummaryWidgetRange =
+ values().firstOrNull { it.days == days } ?: DEFAULT
+ }
+}
diff --git a/app/src/main/java/com/matedroid/widget/CostSummaryWidgetReceiver.kt b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetReceiver.kt
new file mode 100644
index 00000000..183dc31e
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetReceiver.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 CostSummaryWidgetReceiver : GlanceAppWidgetReceiver() {
+
+ override val glanceAppWidget: GlanceAppWidget = CostSummaryWidget()
+
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ CostSummaryWidgetUpdateWorker.scheduleWork(context)
+ }
+
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ CostSummaryWidgetUpdateWorker.cancelWork(context)
+ }
+
+ override fun onAppWidgetOptionsChanged(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int,
+ newOptions: Bundle,
+ ) {
+ super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
+ CostSummaryWidgetUpdateWorker.scheduleImmediateUpdate(context)
+ }
+}
diff --git a/app/src/main/java/com/matedroid/widget/CostSummaryWidgetUpdateWorker.kt b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetUpdateWorker.kt
new file mode 100644
index 00000000..82cae5f8
--- /dev/null
+++ b/app/src/main/java/com/matedroid/widget/CostSummaryWidgetUpdateWorker.kt
@@ -0,0 +1,168 @@
+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.AggregateDao
+import com.matedroid.data.local.dao.ChargeSummaryDao
+import com.matedroid.data.local.dao.DriveSummaryDao
+import com.matedroid.data.model.Currency
+import com.matedroid.domain.CostAnalyticsCalculator
+import com.matedroid.domain.UtilityRates
+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
+
+/**
+ * Background worker that keeps the [CostSummaryWidget] snapshots up to date.
+ *
+ * The worker owns all DB access and formatting. Widget composables only read
+ * the preformatted values written into Glance preferences.
+ */
+@HiltWorker
+class CostSummaryWidgetUpdateWorker @AssistedInject constructor(
+ @Assisted private val appContext: Context,
+ @Assisted workerParams: WorkerParameters,
+ private val driveSummaryDao: DriveSummaryDao,
+ private val chargeSummaryDao: ChargeSummaryDao,
+ private val aggregateDao: AggregateDao,
+ private val settingsDataStore: SettingsDataStore,
+) : CoroutineWorker(appContext, workerParams) {
+
+ companion object {
+ private const val TAG = "CostSummaryWidgetWorker"
+ const val WORK_NAME = "cost_summary_widget_update"
+ const val PERIODIC_WORK_NAME = "cost_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 cost 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 cost 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 cost summary widget update work")
+ }
+ }
+
+ override suspend fun doWork(): Result {
+ Log.d(TAG, "Starting cost summary widget update")
+
+ val manager = GlanceAppWidgetManager(appContext)
+ val glanceIds = manager.getGlanceIds(CostSummaryWidget::class.java)
+
+ if (glanceIds.isEmpty()) {
+ Log.d(TAG, "No active cost summary widgets, skipping update")
+ return Result.success()
+ }
+
+ val settings = settingsDataStore.settings.firstOrNull()
+ val currency = Currency.findByCode(settings?.currencyCode ?: Currency.DEFAULT.code)
+ val rates = UtilityRates(
+ homePerKwh = settings?.homeUtilityRatePerKwh,
+ dcPerKwh = settings?.dcUtilityRatePerKWh,
+ )
+ // Use only the cached unit that was stored with the last summary sync so Room
+ // values and labels stay aligned (never live-fetch settings here).
+ val units = Units(
+ unitOfLength = settings?.unitOfLength?.takeIf { it == "km" || it == "mi" } ?: "km"
+ )
+
+ for (glanceId in glanceIds) {
+ try {
+ val prefs = getAppWidgetState(appContext, PreferencesGlanceStateDefinition, glanceId)
+ val carId = prefs[CostSummaryWidget.CAR_ID_KEY] ?: continue
+ val carName = prefs[CostSummaryWidget.CAR_NAME_KEY]
+ ?: appContext.getString(R.string.app_name)
+ val range = CostSummaryWidgetRange.fromDays(
+ prefs[CostSummaryWidget.RANGE_DAYS_KEY] ?: CostSummaryWidgetRange.DEFAULT.days
+ )
+ val rangeValues = CostSummaryWidgetDateRange.lastDays(days = range.days.toLong())
+
+ val charges = chargeSummaryDao.getInRange(
+ carId,
+ rangeValues.startDate,
+ rangeValues.endDate,
+ )
+ val distance = driveSummaryDao.sumDistanceInRange(
+ carId,
+ rangeValues.startDate,
+ rangeValues.endDate,
+ )
+ val dcIds = try {
+ aggregateDao.getDcChargeIds(carId).toSet()
+ } catch (e: Exception) {
+ emptySet()
+ }
+ val metrics = CostAnalyticsCalculator.calculate(
+ charges = charges,
+ totalDistance = distance,
+ rates = rates,
+ dcChargeIds = dcIds,
+ )
+
+ val displayData = CostSummaryWidgetFormatter.format(
+ carId = carId,
+ carName = carName,
+ periodLabel = appContext.getString(range.labelRes),
+ metrics = metrics,
+ currencySymbol = currency.symbol,
+ noValue = appContext.getString(R.string.cost_widget_no_value),
+ updatedValue = appContext.getString(
+ R.string.cost_widget_updated,
+ DateFormat.getTimeInstance(DateFormat.SHORT).format(Date()),
+ ),
+ units = units,
+ )
+ CostSummaryWidget().updateWidget(appContext, glanceId, displayData)
+ Log.d(TAG, "Updated cost summary widget for car $carId")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error updating cost summary widget $glanceId", e)
+ }
+ }
+
+ return Result.success()
+ }
+}
diff --git a/app/src/main/res/layout/cost_summary_widget_preview.xml b/app/src/main/res/layout/cost_summary_widget_preview.xml
new file mode 100644
index 00000000..c36b3663
--- /dev/null
+++ b/app/src/main/res/layout/cost_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..a4d0f670 100644
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -181,6 +181,8 @@
Programari
+ Costos
+ Desglossament del cost de càrrega
DE
@@ -243,6 +245,38 @@
Moneda per als costos
+ Tarifes elèctriques
+ S\'utilitzen per estimar el cost de càrrega quan TeslaMate no té preu. Introdueix la teva tarifa local per kWh en la moneda seleccionada a dalt.
+ Tarifa domèstica (per kWh)
+ S\'aplica a sessions AC/domèstiques i a les estimacions de cost de conducció.
+ Tarifa DC / pública (per kWh, opcional)
+ S\'aplica a sessions de càrrega ràpida. Si es deixa en blanc, les sessions DC utilitzen la tarifa domèstica.
+ p. ex. 0,15
+
+
+ Comparació amb la gasolina
+ Compara el cost de càrrega amb el d\'un cotxe de gasolina equivalent. Introdueix el consum i el preu del combustible al sortidor.
+ Consum de combustible
+ p. ex. 7,0
+ p. ex. 32
+ Preu del combustible
+ p. ex. 1,75
+ L/100 km
+ mpg
+ per litre
+ per galó
+ L
+ gal
+
+
+ Comparació amb la gasolina
+ Tesla (aquest rang)
+ Equivalent en gasolina
+ Diferència
+ Distància (%1$s)
+ Gasolina consumida amb aquest consum: %1$s
+ Basat en els teus supòsits de consum i preu del combustible.
+
Selecciona moneda
@@ -511,6 +545,13 @@
La degradació de la bateria s\'estima basant-se en dades de Teslamate.\n\n• Capacitat (%): Salut restant de la bateria comparada amb nova\n• Pèrdua (kWh): Capacitat energètica perduda per degradació\n• Pèrdua (%): Percentatge de la capacitat original perduda\n\nNota: Això és una estimació. Tesla no proporciona dades oficials de degradació.
+
+ Autonomia màxima al llarg del temps
+ Historial d\'autonomia màxima
+ MateDroid enregistra una captura de l\'autonomia màxima cada cop que obres aquesta pantalla (com a màxim una al dia). El gràfic mostra com ha canviat des que utilitzes l\'app.\n\nL\'autonomia varia amb la temperatura i l\'estil de conducció — pren les baixades curtes com a soroll, no com a degradació.
+ %1$s – %2$s
+ Torna passat un altre dia — calen almenys dues captures per traçar la tendència.
+
Autonomia
Autonomia màx (nova)
Autonomia màx (ara)
@@ -1100,10 +1141,27 @@
Temps de conducció
Energia carregada
Cost de càrrega
+ Els costos inclouen %1$d de %2$d sessions de càrrega
+
+ No hi ha costos de càrrega registrats per a aquests viatges
Consum
Cost / 100 %1$s
+
+
+ Destacats dels viatges
+ Més eficient
+ Menys eficient
+ Més barat / 100 %1$s
+ Més car / 100 %1$s
+ Viatge més llarg
+ Més temps carregant
+ Balanç d\'energia
+ Càrrega mitjana / viatge
+ %1$d%%
+ Obre el viatge
+
Trajecte %1$d
Càrrega %1$d
@@ -1279,4 +1337,56 @@
- %d càrrega comparable a prop
- %d càrregues comparables a prop
+
+
+ Anàlisi de costos
+ —
+ Resum de costos
+ Cost conegut
+ Cost estimat
+ Inclou %1$d sessió/ons estimada/es a partir de la teva tarifa elèctrica.
+ Registrat %1$s (%2$d) · Estimat %3$s (%4$d)
+ Tot amb preu (amb estimacions)
+ Cost / 100 %1$s
+ Energia afegida
+ Cost mitjà / kWh
+ Distància
+ Càrregues
+ Cobertura de costos
+ Cobertura
+ Sense preu
+ %1$d de %2$d amb preu
+ Totes amb preu
+ Afegeix el cost a les càrregues a TeslaMate per veure el cost / distància i el cost mitjà / kWh.
+ El cost / distància es manté ocult fins que totes les càrregues de l\'interval tinguin preu. Els totals reflecteixen només les sessions amb preu.
+ Cost mancant
+ Sessions gratis
+ Cap càrrega en aquest interval.
+ 7d
+ 30d
+ 90d
+ Sempre
+
+
+ Resum de costos
+ Cost de càrrega, cobertura i energia per interval
+ Darrers 7 dies
+ Darrers 30 dies
+ Darrers 90 dies
+ Interval del resum
+ Cap càrrega en aquest interval
+ Energia
+ Càrregues
+ Cost/100 %1$s
+ Mitjà/kWh
+ N/D
+ Cap càrrega
+ Cost · cap preu
+ Cost · %1$d/%2$d amb preu
+ Cost · complet
+ %1$s /100
+ Taxa N/D
+ %1$s /kWh
+ Actualitzat %1$s
+ Desa l\'estri
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 491f89e9..dd716ec9 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -181,6 +181,8 @@
Software
+ Kosten
+ Ladekostenübersicht
FL
@@ -243,6 +245,38 @@
Währung für Kosten
+ Stromtarife
+ Werden verwendet, um Ladekosten zu schätzen, wenn TeslaMate keinen Preis hinterlegt hat. Gib deinen lokalen Tarif pro kWh in der oben gewählten Währung ein.
+ Haustarif (pro kWh)
+ Wird auf AC/Zuhause-Ladevorgänge und auf Kostenschätzungen für Fahrten angewendet.
+ DC-/öffentlicher Tarif (pro kWh, optional)
+ Wird auf Schnellladevorgänge angewendet. Bleibt das Feld leer, wird für DC-Sitzungen der Haustarif genutzt.
+ z. B. 0,15
+
+
+ Benzin-Vergleich
+ Vergleiche die Ladekosten mit einem gleichwertigen Benziner. Gib den Verbrauch und den Kraftstoffpreis an der Zapfsäule ein.
+ Kraftstoffverbrauch
+ z. B. 7,0
+ z. B. 32
+ Kraftstoffpreis
+ z. B. 1,75
+ L/100 km
+ mpg
+ pro Liter
+ pro Gallone
+ L
+ gal
+
+
+ Vergleich mit Benzin
+ Tesla (dieser Zeitraum)
+ Benzin-Äquivalent
+ Differenz
+ Strecke (%1$s)
+ Benzinverbrauch bei diesem Wert: %1$s
+ Basierend auf deinen Annahmen zu Verbrauch und Kraftstoffpreis.
+
Währung auswählen
@@ -511,6 +545,13 @@
Geschätzte Degradation
Die Batteriedegradation wird auf Basis von Teslamate-Daten geschätzt.\n\n• Kapazität (%): Verbleibende Batteriegesundheit im Vergleich zu neu\n• Verlust (kWh): Durch Degradation verlorene Energiekapazität\n• Verlust (%): Prozentsatz der ursprünglichen Kapazität, der verloren ging\n\nHinweis: Dies ist eine Schätzung. Tesla stellt keine offiziellen Degradationsdaten bereit.
+
+ Max. Reichweite im Zeitverlauf
+ Verlauf der maximalen Reichweite
+ MateDroid speichert jedes Mal einen Schnappschuss der maximalen Reichweite, wenn du diesen Bildschirm öffnest (höchstens einmal am Tag). Das Diagramm zeigt, wie sich der Wert seit der ersten Nutzung entwickelt hat.\n\nDie Reichweite schwankt mit Temperatur und Fahrweise — kurzzeitige Rückgänge sind Rauschen, keine Degradation.
+ %1$s – %2$s
+ Komm nach einem weiteren Tag zurück — für eine Kurve braucht es mindestens zwei Schnappschüsse.
+
Reichweite
Max. Reichweite (neu)
@@ -1128,10 +1169,27 @@
Geladene Energie
Ladekosten
+ Kosten enthalten %1$d von %2$d Ladevorgängen
+
+ Für diese Fahrten sind keine Ladekosten erfasst
Verbrauch
Kosten / 100 %1$s
+
+
+ Fahrt-Highlights
+ Effizienteste
+ Am wenigsten effizient
+ Günstigste / 100 %1$s
+ Teuerste / 100 %1$s
+ Längste Fahrt
+ Größter Ladeanteil
+ Energiebilanz
+ Ladezeit / Fahrt
+ %1$d%%
+ Fahrt öffnen
+
Fahrt %1$d
@@ -1394,4 +1452,56 @@
- %d vergleichbare Ladung in der Nähe
- %d vergleichbare Ladungen in der Nähe
-
\ No newline at end of file
+
+
+ Kostenanalyse
+ —
+ Kostenübersicht
+ Bekannte Kosten
+ Geschätzte Kosten
+ Enthält %1$d geschätzte Sitzung(en) aus deinem Stromtarif.
+ Erfasst %1$s (%2$d) · Geschätzt %3$s (%4$d)
+ Alle mit Preis (mit Schätzungen)
+ Kosten / 100 %1$s
+ Geladene Energie
+ Ø Kosten / kWh
+ Strecke
+ Ladungen
+ Kostenerfassung
+ Erfassung
+ Keine Preise
+ %1$d von %2$d bepreist
+ Alle bepreist
+ Trage in TeslaMate Kosten für die Ladungen ein, um Kosten / Strecke und Ø Kosten / kWh anzuzeigen.
+ Kosten / Strecke bleiben ausgeblendet, solange nicht alle Ladungen im Zeitraum bepreist sind. Summen zeigen nur bepreiste Sitzungen.
+ Fehlende Preise
+ Gratis-Sitzungen
+ Keine Ladungen in diesem Zeitraum.
+ 7T
+ 30T
+ 90T
+ Gesamt
+
+
+ Kostenübersicht
+ Ladekosten, Erfassung und Energie nach Zeitraum
+ Letzte 7 Tage
+ Letzte 30 Tage
+ Letzte 90 Tage
+ Zeitraum
+ Keine Ladungen in diesem Zeitraum
+ Energie
+ Ladungen
+ Kosten/100 %1$s
+ Ø/kWh
+ k. A.
+ Keine Ladungen
+ Kosten · keine Preise
+ Kosten · %1$d/%2$d bepreist
+ Kosten · vollständig
+ %1$s /100
+ Rate k. A.
+ %1$s /kWh
+ Aktualisiert %1$s
+ Widget speichern
+
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 1a73b6fc..b68008fd 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -181,6 +181,8 @@
Software
+ Costes
+ Desglose del coste de carga
DI
@@ -243,6 +245,38 @@
Moneda para costes
+ Tarifas eléctricas
+ Se usan para estimar el coste de carga cuando TeslaMate no tiene un precio. Introduce tu tarifa local por kWh en la moneda seleccionada arriba.
+ Tarifa doméstica (por kWh)
+ Se aplica a sesiones AC/domésticas y a las estimaciones de coste de conducción.
+ Tarifa DC / pública (por kWh, opcional)
+ Se aplica a sesiones de carga rápida. Si se deja en blanco, las sesiones DC usan la tarifa doméstica.
+ p. ej. 0,15
+
+
+ Comparación con gasolina
+ Compara el coste de carga con el de un coche de gasolina equivalente. Introduce el consumo y el precio del combustible en el surtidor.
+ Consumo de combustible
+ p. ej. 7,0
+ p. ej. 32
+ Precio del combustible
+ p. ej. 1,75
+ L/100 km
+ mpg
+ por litro
+ por galón
+ L
+ gal
+
+
+ Comparación con gasolina
+ Tesla (este rango)
+ Equivalente en gasolina
+ Diferencia
+ Distancia (%1$s)
+ Gasolina consumida con este consumo: %1$s
+ Basado en tus supuestos de consumo y precio del combustible.
+
Seleccionar moneda
@@ -511,6 +545,13 @@
La degradación de la batería se estima basándose en datos de Teslamate.\n\n• Capacidad (%): Salud restante de la batería comparada con nueva\n• Pérdida (kWh): Capacidad energética perdida por degradación\n• Pérdida (%): Porcentaje de la capacidad original perdida\n\n\nNota: Esta es una estimación. Tesla no proporciona datos oficiales de degradación.
+
+ Autonomía máxima con el tiempo
+ Historial de autonomía máxima
+ MateDroid guarda una instantánea de la autonomía máxima de tu batería cada vez que abres esta pantalla (una al día como máximo). El gráfico muestra cómo ha cambiado desde que empezaste a usar la app.\n\nLa autonomía varía con la temperatura y la forma de conducir — considera las caídas puntuales como ruido, no degradación.
+ %1$s – %2$s
+ Vuelve tras otro día — hacen falta al menos dos instantáneas para dibujar la tendencia.
+
Autonomía
Autonomía máx (nueva)
Autonomía máx (ahora)
@@ -1100,10 +1141,27 @@
Tiempo de conducción
Energía cargada
Coste de carga
+ Los costes incluyen %1$d de %2$d sesiones de carga
+
+ No hay costes de carga registrados para estos viajes
Consumo
Coste / 100 %1$s
+
+
+ Puntos destacados de los viajes
+ Más eficiente
+ Menos eficiente
+ Más barato / 100 %1$s
+ Más caro / 100 %1$s
+ Viaje más largo
+ Más tiempo cargando
+ Balance de energía
+ Carga media / viaje
+ %1$d%%
+ Abrir viaje
+
Trayecto %1$d
Carga %1$d
@@ -1279,4 +1337,56 @@
- %d carga comparable cerca
- %d cargas comparables cerca
+
+
+ Análisis de costes
+ —
+ Resumen de costes
+ Coste conocido
+ Coste estimado
+ Incluye %1$d sesión(es) estimada(s) a partir de tu tarifa eléctrica.
+ Registrado %1$s (%2$d) · Estimado %3$s (%4$d)
+ Todos con precio (con estimaciones)
+ Coste / 100 %1$s
+ Energía añadida
+ Coste medio / kWh
+ Distancia
+ Cargas
+ Cobertura de costes
+ Cobertura
+ Sin precio
+ %1$d de %2$d con precio
+ Todas con precio
+ Añade el coste a las cargas en TeslaMate para ver el coste / distancia y el coste medio / kWh.
+ El coste / distancia permanece oculto hasta que todas las cargas del intervalo tengan precio. Los totales reflejan solo las sesiones con precio.
+ Coste faltante
+ Sesiones gratis
+ No hay cargas en este intervalo.
+ 7d
+ 30d
+ 90d
+ Todo
+
+
+ Resumen de costes
+ Coste de carga, cobertura y energía por intervalo
+ Últimos 7 días
+ Últimos 30 días
+ Últimos 90 días
+ Intervalo del resumen
+ Sin cargas en este intervalo
+ Energía
+ Cargas
+ Coste/100 %1$s
+ Medio/kWh
+ N/D
+ Sin cargas
+ Coste · sin precio
+ Coste · %1$d/%2$d con precio
+ Coste · completo
+ %1$s /100
+ Tasa N/D
+ %1$s /kWh
+ Actualizado %1$s
+ Guardar widget
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 511cf585..008e38f1 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -181,6 +181,8 @@
Software
+ Costi
+ Analisi dei costi di ricarica
AS
@@ -245,6 +247,39 @@
Valuta per i costi
Seleziona valuta
+
+ Tariffe elettriche
+ Usate per stimare il costo di ricarica quando TeslaMate non ha un prezzo. Inserisci la tua tariffa locale per kWh nella valuta selezionata sopra.
+ Tariffa domestica (per kWh)
+ Applicata alle ricariche AC/domestiche e alle stime dei costi di guida.
+ Tariffa DC / pubblica (per kWh, opzionale)
+ Applicata alle ricariche rapide. Se lasciata vuota, le sessioni DC usano la tariffa domestica.
+ es. 0,15
+
+
+ Confronto con la benzina
+ Confronta il costo di ricarica con quello di un\'auto a benzina equivalente. Inserisci il consumo e il prezzo del carburante al distributore.
+ Consumo carburante
+ es. 7,0
+ es. 32
+ Prezzo carburante
+ es. 1,75
+ L/100 km
+ mpg
+ al litro
+ al gallone
+ L
+ gal
+
+
+ Confronto benzina
+ Tesla (in questo intervallo)
+ Equivalente a benzina
+ Differenza
+ Distanza (%1$s)
+ Benzina consumata con questo consumo: %1$s
+ Basato sulle tue ipotesi di consumo e prezzo del carburante.
+
Impostazioni extra
@@ -511,6 +546,13 @@
Il degrado della batteria è stimato in base ai dati di Teslamate.\n\n• Capacità (%): Salute residua della batteria rispetto al nuovo\n• Perdita (kWh): Capacità energetica persa per degrado\n• Perdita (%): Percentuale della capacità originale persa\n\nNota: Questa è una stima. Tesla non fornisce dati ufficiali sul degrado.
+
+ Autonomia massima nel tempo
+ Storico autonomia massima
+ MateDroid registra un\'istantanea dell\'autonomia massima ogni volta che apri questa schermata (al massimo una al giorno). Il grafico mostra come è cambiata da quando usi l\'app.\n\nL\'autonomia varia con temperatura e stile di guida — considera i cali di breve termine come rumore, non degrado.
+ %1$s – %2$s
+ Torna tra un giorno — servono almeno due istantanee per tracciare un andamento.
+
Autonomia
Autonomia max (nuova)
Autonomia max (ora)
@@ -1100,10 +1142,27 @@
Tempo di guida
Energia ricaricata
Costo ricarica
+ I costi includono %1$d di %2$d sessioni di ricarica
+
+ Nessun costo di ricarica registrato per questi viaggi
Consumo
Costo / 100 %1$s
+
+
+ Momenti chiave dei viaggi
+ Più efficiente
+ Meno efficiente
+ Più economico / 100 %1$s
+ Più caro / 100 %1$s
+ Viaggio più lungo
+ Più tempo in ricarica
+ Bilancio energetico
+ Ricarica media / viaggio
+ %1$d%%
+ Apri il viaggio
+
Tratta %1$d
Ricarica %1$d
@@ -1279,4 +1338,56 @@
- %d ricarica confrontabile nelle vicinanze
- %d ricariche confrontabili nelle vicinanze
+
+
+ Analisi costi
+ —
+ Panoramica costi
+ Costo noto
+ Costo stimato
+ Include %1$d sessione/i stimata/e dalla tua tariffa elettrica.
+ Registrato %1$s (%2$d) · Stimato %3$s (%4$d)
+ Tutti prezzati (stime incluse)
+ Costo / 100 %1$s
+ Energia aggiunta
+ Costo medio / kWh
+ Distanza
+ Ricariche
+ Copertura costi
+ Copertura
+ Nessun prezzo
+ %1$d di %2$d con prezzo
+ Tutte con prezzo
+ Aggiungi il costo alle ricariche in TeslaMate per vedere il costo / distanza e il costo medio / kWh.
+ Il costo / distanza resta nascosto finché tutte le ricariche dell\'intervallo hanno un prezzo. I totali riflettono solo le sessioni con prezzo.
+ Prezzo mancante
+ Sessioni gratis
+ Nessuna ricarica in questo intervallo.
+ 7g
+ 30g
+ 90g
+ Sempre
+
+
+ Riepilogo costi
+ Costi di ricarica, copertura ed energia per intervallo
+ Ultimi 7 giorni
+ Ultimi 30 giorni
+ Ultimi 90 giorni
+ Intervallo riepilogo
+ Nessuna ricarica in questo intervallo
+ Energia
+ Ricariche
+ Costo/100 %1$s
+ Media/kWh
+ N/D
+ Nessuna ricarica
+ Costo · nessun prezzo
+ Costo · %1$d/%2$d con prezzo
+ Costo · completo
+ %1$s /100
+ Tasso N/D
+ %1$s /kWh
+ Aggiornato %1$s
+ Salva widget
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index ee7ed110..141e05d6 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -181,6 +181,8 @@
软件
+ 费用
+ 充电费用明细
左前
@@ -243,6 +245,38 @@
费用货币
+ 电价
+ 在 TeslaMate 未设置价格时,用于估算充电费用。请按上方所选货币输入本地每 kWh 电价。
+ 家用电价(每 kWh)
+ 适用于交流/家用充电,以及行驶费用估算。
+ 直流 / 公共电价(每 kWh,可选)
+ 适用于快充。若留空,直流充电将使用家用电价。
+ 例如 0.15
+
+
+ 与汽油车对比
+ 将充电成本与同等汽油车对比。请填写其油耗与加油站油价。
+ 油耗
+ 例如 7.0
+ 例如 32
+ 油价
+ 例如 1.75
+ L/100 km
+ mpg
+ 每升
+ 每加仑
+ L
+ gal
+
+
+ 与汽油对比
+ Tesla(此区间)
+ 汽油等效成本
+ 差额
+ 距离(%1$s)
+ 按此油耗消耗的汽油:%1$s
+ 基于您设定的油耗与油价假设。
+
选择货币
@@ -510,6 +544,13 @@
预估衰减
电池衰减根据 TeslaMate 数据估算。\n\n• 容量 (%):与新电池相比的剩余电池健康度\n• 损失 (kWh):因衰减而损失的能量容量\n• 损失 (%):原始容量的损失百分比\n\n注意:这是一个估算值。Tesla 不提供官方衰减数据。
+
+ 最大续航变化
+ 最大续航历史
+ 每次你打开此界面,MateDroid 会记录一次电池最大续航快照(每天最多一次)。图表显示自开始使用应用以来的变化。\n\n续航会随温度和驾驶风格波动 — 短期下降通常是噪声,而非衰减。
+ %1$s – %2$s
+ 再过一天回来 — 至少需要两次快照才能绘制趋势。
+
续航
最大续航(出厂)
@@ -1101,10 +1142,27 @@
驾驶时间
充入能量
充电费用
+ 费用包含 %1$d/%2$d 次充电记录
+
+ 这些行程没有记录充电费用
能耗
费用 / 100 %1$s
+
+
+ 行程亮点
+ 最省电
+ 最耗电
+ 最便宜 / 100 %1$s
+ 最贵 / 100 %1$s
+ 最长行程
+ 充电占比最高
+ 能量差
+ 平均充电时长 / 行程
+ %1$d%%
+ 打开行程
+
行驶 %1$d
充电 %1$d
@@ -1301,4 +1359,56 @@
- 附近 %d 次可比充电
+
+
+ 费用分析
+ —
+ 费用概览
+ 已知费用
+ 估算费用
+ 包含根据你的电价估算的 %1$d 次充电。
+ 已记录 %1$s(%2$d)· 已估算 %3$s(%4$d)
+ 全部有价(含估算)
+ 每 100 %1$s 费用
+ 已充能量
+ 每 kWh 均价
+ 里程
+ 充电次数
+ 费用覆盖
+ 覆盖
+ 均未计价
+ %1$d / %2$d 已计价
+ 全部已计价
+ 在 TeslaMate 中为充电添加费用后,即可查看每单位里程费用及每 kWh 均价。
+ 仅当范围内所有充电都已计价时才显示每单位里程费用。合计仅反映已计价的会话。
+ 缺少费用
+ 免费会话
+ 此范围内暂无充电。
+ 7 天
+ 30 天
+ 90 天
+ 全部
+
+
+ 费用摘要
+ 按范围显示充电费用、覆盖和能耗
+ 最近 7 天
+ 最近 30 天
+ 最近 90 天
+ 摘要范围
+ 此范围内没有充电
+ 能耗
+ 充电次数
+ 每 100 %1$s 费用
+ 均价/kWh
+ 暂无
+ 无充电
+ 费用 · 均未计价
+ 费用 · %1$d/%2$d 已计价
+ 费用 · 全部已计价
+ %1$s /100
+ 费率暂无
+ %1$s /kWh
+ 已更新 %1$s
+ 保存小组件
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ab9a97dd..d0c49a4e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -184,6 +184,10 @@
Software
+
+ Costs
+
+ Charging cost breakdown
FL
@@ -248,6 +252,44 @@
Currency for costs
Select currency
+
+ Utility rates
+ Used to estimate charging cost when TeslaMate has no price set. Enter your local rate per kWh in the currency selected above.
+ Home rate (per kWh)
+ Applied to AC / home charging sessions and to drive-cost estimates.
+ DC / public rate (per kWh, optional)
+ Applied to fast-charging sessions. If left blank, DC sessions use the home rate.
+ e.g. 0.15
+
+
+ Gasoline comparison
+ Compare charging cost to an equivalent gasoline car. Enter its fuel economy and the price you\'d pay at the pump.
+ Fuel economy
+ e.g. 7.0
+ e.g. 32
+ Fuel price
+ e.g. 1.75
+
+ L/100 km
+ mpg
+
+ per liter
+ per gallon
+
+ L
+ gal
+
+
+ vs gasoline
+ Tesla (this range)
+ Gasoline equivalent
+ Difference
+
+ Distance (%1$s)
+
+ Gasoline used at this economy: %1$s
+ Based on your fuel economy and fuel price assumptions.
+
Extra settings
@@ -514,6 +556,18 @@
Estimated Degradation
Battery degradation is estimated based on Teslamate data.\n\n• Capacity (%): Remaining battery health compared to new\n• Loss (kWh): Energy capacity lost due to degradation\n• Loss (%): Percentage of original capacity lost\n\nNote: This is an estimate. Tesla does not provide official degradation data.
+
+
+ Max range over time
+
+ Max range history
+
+ MateDroid records a snapshot of your battery\'s max range each time you open this screen (at most once per day). The chart shows how that figure has changed since you started using the app.\n\nRange fluctuates with temperature and driving style — treat short-term dips as noise, not degradation.
+
+ %1$s – %2$s
+
+ Come back after another day — you need at least two snapshots to plot a trend.
+
Range
Max Range (new)
@@ -1131,10 +1185,39 @@
Energy charged
Charge cost
+
+ Costs include %1$d of %2$d charging sessions
+
+ No charging costs recorded for these trips
Consumption
Cost / 100 %1$s
+
+
+
+ Trip highlights
+
+ Most efficient
+
+ Least efficient
+
+ Cheapest / 100 %1$s
+
+ Priciest / 100 %1$s
+
+ Longest trip
+
+ Most charging overhead
+
+ Energy gap
+
+ Avg charging / trip
+
+ %1$d%%
+
+ Open trip
+
Drive %1$d
@@ -1413,4 +1496,99 @@
- %d comparable charge nearby
- %d comparable charges nearby
+
+
+
+ Cost analytics
+
+ —
+
+ Cost overview
+
+ Known cost
+
+ Estimated cost
+
+ Includes %1$d estimated session(s) from your utility rate.
+
+ Recorded %1$s (%2$d) · Estimated %3$s (%4$d)
+
+ All priced (estimates used)
+
+ Cost / 100 %1$s
+
+ Energy added
+
+ Avg cost / kWh
+
+ Distance
+
+ Charges
+
+ Cost coverage
+
+ Coverage
+
+ Not priced
+
+ %1$d of %2$d priced
+
+ All priced
+
+ Add cost to charges in TeslaMate to see cost / distance and average cost / kWh.
+
+ Cost / distance stays hidden until every charge in the range is priced. Totals reflect only the priced sessions.
+
+ Missing cost
+
+ Free sessions
+
+ No charges in this range yet.
+
+ 7d
+ 30d
+ 90d
+ All time
+
+
+
+ Cost summary
+
+ Charging cost, coverage, and energy by range
+
+ Last 7 days
+ Last 30 days
+ Last 90 days
+
+ Summary range
+
+ No charges in this range
+
+ Energy
+
+ Charges
+
+ Cost/100 %1$s
+
+ Avg/kWh
+
+ N/A
+
+ No charges
+
+ Cost · none priced
+
+ Cost · %1$d/%2$d priced
+
+ Cost · all priced
+
+ %1$s /100
+
+ Rate N/A
+
+ %1$s /kWh
+
+ Updated %1$s
+
+ Save widget
diff --git a/app/src/main/res/xml/cost_summary_widget_info.xml b/app/src/main/res/xml/cost_summary_widget_info.xml
new file mode 100644
index 00000000..903feb12
--- /dev/null
+++ b/app/src/main/res/xml/cost_summary_widget_info.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/app/src/test/java/com/matedroid/domain/CostAnalyticsCalculatorTest.kt b/app/src/test/java/com/matedroid/domain/CostAnalyticsCalculatorTest.kt
new file mode 100644
index 00000000..fe3f8516
--- /dev/null
+++ b/app/src/test/java/com/matedroid/domain/CostAnalyticsCalculatorTest.kt
@@ -0,0 +1,249 @@
+package com.matedroid.domain
+
+import com.matedroid.data.local.entity.ChargeSummary
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class CostAnalyticsCalculatorTest {
+
+ @Test
+ fun `empty charge list yields empty metrics`() {
+ val metrics = CostAnalyticsCalculator.calculate(emptyList(), totalDistance = 0.0)
+ assertEquals(0, metrics.chargeCount)
+ assertEquals(TripCostCoverage.None, metrics.costCoverage)
+ assertNull(metrics.totalKnownCost)
+ assertNull(metrics.costPer100Distance)
+ assertNull(metrics.avgCostPerKwh)
+ }
+
+ @Test
+ fun `all charges priced yields complete coverage and cost per 100`() {
+ val metrics = CostAnalyticsCalculator.calculate(
+ charges = listOf(
+ charge(id = 1, energy = 30.0, cost = 12.0),
+ charge(id = 2, energy = 20.0, cost = 8.0),
+ ),
+ totalDistance = 500.0,
+ )
+
+ assertEquals(2, metrics.chargeCount)
+ assertEquals(2, metrics.pricedCount)
+ assertEquals(0, metrics.missingCostCount)
+ assertEquals(0, metrics.zeroCostCount)
+ assertEquals(20.0, metrics.totalKnownCost ?: 0.0, 0.001)
+ assertEquals(50.0, metrics.energyAddedTotal, 0.001)
+ assertEquals(50.0, metrics.energyAddedPriced, 0.001)
+ assertEquals(0.4, metrics.avgCostPerKwh ?: 0.0, 0.001)
+ assertEquals(4.0, metrics.costPer100Distance ?: 0.0, 0.001)
+ assertEquals(TripCostCoverage.Complete, metrics.costCoverage)
+ }
+
+ @Test
+ fun `partial coverage exposes total but withholds cost per 100`() {
+ val metrics = CostAnalyticsCalculator.calculate(
+ charges = listOf(
+ charge(id = 1, energy = 30.0, cost = 15.0),
+ charge(id = 2, energy = 20.0, cost = null),
+ ),
+ totalDistance = 400.0,
+ )
+
+ assertEquals(TripCostCoverage.Partial, metrics.costCoverage)
+ assertEquals(2, metrics.chargeCount)
+ assertEquals(1, metrics.pricedCount)
+ assertEquals(1, metrics.missingCostCount)
+ assertEquals(15.0, metrics.totalKnownCost ?: 0.0, 0.001)
+ // avg cost/kWh uses only priced energy so it stays honest even with gaps.
+ assertEquals(0.5, metrics.avgCostPerKwh ?: 0.0, 0.001)
+ assertNull(metrics.costPer100Distance)
+ }
+
+ @Test
+ fun `no priced charges yields missing coverage`() {
+ val metrics = CostAnalyticsCalculator.calculate(
+ charges = listOf(
+ charge(id = 1, energy = 10.0, cost = null),
+ charge(id = 2, energy = 25.0, cost = null),
+ ),
+ totalDistance = 300.0,
+ )
+
+ assertEquals(TripCostCoverage.None, metrics.costCoverage)
+ assertEquals(0, metrics.pricedCount)
+ assertEquals(2, metrics.missingCostCount)
+ assertNull(metrics.totalKnownCost)
+ assertNull(metrics.avgCostPerKwh)
+ assertNull(metrics.costPer100Distance)
+ }
+
+ @Test
+ fun `zero-cost charges count as priced and contribute to complete coverage`() {
+ val metrics = CostAnalyticsCalculator.calculate(
+ charges = listOf(
+ charge(id = 1, energy = 20.0, cost = 0.0),
+ charge(id = 2, energy = 15.0, cost = 5.0),
+ ),
+ totalDistance = 200.0,
+ )
+
+ assertEquals(TripCostCoverage.Complete, metrics.costCoverage)
+ assertEquals(2, metrics.pricedCount)
+ assertEquals(1, metrics.zeroCostCount)
+ assertEquals(0, metrics.missingCostCount)
+ assertEquals(5.0, metrics.totalKnownCost ?: -1.0, 0.001)
+ // Average includes free-charging kWh in the denominator: 5 / 35 ≈ 0.143.
+ assertEquals(5.0 / 35.0, metrics.avgCostPerKwh ?: 0.0, 0.001)
+ assertEquals(2.5, metrics.costPer100Distance ?: 0.0, 0.001)
+ }
+
+ @Test
+ fun `all charges free still count as complete but avg cost is zero`() {
+ val metrics = CostAnalyticsCalculator.calculate(
+ charges = listOf(charge(id = 1, energy = 20.0, cost = 0.0)),
+ totalDistance = 150.0,
+ )
+
+ assertEquals(TripCostCoverage.Complete, metrics.costCoverage)
+ assertEquals(0.0, metrics.totalKnownCost ?: -1.0, 0.001)
+ assertEquals(0.0, metrics.avgCostPerKwh ?: -1.0, 0.001)
+ assertEquals(0.0, metrics.costPer100Distance ?: -1.0, 0.001)
+ }
+
+ @Test
+ fun `no distance driven yields null cost per 100 even with complete coverage`() {
+ val metrics = CostAnalyticsCalculator.calculate(
+ charges = listOf(charge(id = 1, energy = 10.0, cost = 4.0)),
+ totalDistance = 0.0,
+ )
+
+ assertEquals(TripCostCoverage.Complete, metrics.costCoverage)
+ assertNull(metrics.costPer100Distance)
+ assertEquals(4.0, metrics.totalKnownCost ?: 0.0, 0.001)
+ }
+
+ // ============= Utility-rate estimation =============
+
+ @Test
+ fun `mixed recorded and estimated charges reach complete with usesEstimates true`() {
+ val metrics = CostAnalyticsCalculator.calculate(
+ charges = listOf(
+ charge(id = 1, energy = 30.0, cost = 15.0), // recorded
+ charge(id = 2, energy = 20.0, cost = null), // AC, estimated at 0.10
+ ),
+ totalDistance = 500.0,
+ rates = UtilityRates(homePerKwh = 0.10),
+ dcChargeIds = emptySet(),
+ )
+
+ assertEquals(TripCostCoverage.Complete, metrics.costCoverage)
+ assertEquals(true, metrics.usesEstimates)
+ assertEquals(1, metrics.recordedChargeCount)
+ assertEquals(1, metrics.estimatedChargeCount)
+ assertEquals(0, metrics.missingCostCount)
+ assertEquals(15.0, metrics.totalKnownCost ?: 0.0, 0.001)
+ assertEquals(2.0, metrics.totalEstimatedCost ?: 0.0, 0.001)
+ assertEquals(17.0, metrics.totalEffectiveCost ?: 0.0, 0.001)
+ // Cost/100 uses the effective total: 17 * 100 / 500 = 3.4
+ assertEquals(3.4, metrics.costPer100Distance ?: 0.0, 0.001)
+ // avg cost/kWh still comes from recorded only: 15 / 30 = 0.5
+ assertEquals(0.5, metrics.avgCostPerKwh ?: 0.0, 0.001)
+ }
+
+ @Test
+ fun `dc charges use dc rate over home rate`() {
+ val metrics = CostAnalyticsCalculator.calculate(
+ charges = listOf(
+ charge(id = 1, energy = 30.0, cost = null), // AC → 0.10
+ charge(id = 2, energy = 50.0, cost = null), // DC → 0.40
+ ),
+ totalDistance = 400.0,
+ rates = UtilityRates(homePerKwh = 0.10, dcPerKwh = 0.40),
+ dcChargeIds = setOf(2),
+ )
+
+ assertEquals(TripCostCoverage.Complete, metrics.costCoverage)
+ assertEquals(true, metrics.usesEstimates)
+ assertEquals(0, metrics.recordedChargeCount)
+ assertEquals(2, metrics.estimatedChargeCount)
+ assertNull(metrics.totalKnownCost)
+ // 30 * 0.10 + 50 * 0.40 = 3 + 20 = 23
+ assertEquals(23.0, metrics.totalEstimatedCost ?: 0.0, 0.001)
+ assertEquals(23.0, metrics.totalEffectiveCost ?: 0.0, 0.001)
+ // avg cost/kWh needs recorded data; here there is none.
+ assertNull(metrics.avgCostPerKwh)
+ // Cost/100 = 23 * 100 / 400 = 5.75
+ assertEquals(5.75, metrics.costPer100Distance ?: 0.0, 0.001)
+ }
+
+ @Test
+ fun `dc charge with no dc rate falls back to home rate`() {
+ val metrics = CostAnalyticsCalculator.calculate(
+ charges = listOf(charge(id = 1, energy = 25.0, cost = null)),
+ totalDistance = 200.0,
+ rates = UtilityRates(homePerKwh = 0.20),
+ dcChargeIds = setOf(1),
+ )
+
+ assertEquals(TripCostCoverage.Complete, metrics.costCoverage)
+ assertEquals(5.0, metrics.totalEffectiveCost ?: 0.0, 0.001)
+ assertEquals(1, metrics.estimatedChargeCount)
+ }
+
+ @Test
+ fun `no rate configured leaves null-cost charges as missing`() {
+ val metrics = CostAnalyticsCalculator.calculate(
+ charges = listOf(
+ charge(id = 1, energy = 10.0, cost = 4.0),
+ charge(id = 2, energy = 20.0, cost = null),
+ ),
+ totalDistance = 100.0,
+ rates = UtilityRates(),
+ dcChargeIds = emptySet(),
+ )
+
+ // Behaviour must match the recorded-only overload.
+ assertEquals(TripCostCoverage.Partial, metrics.costCoverage)
+ assertEquals(false, metrics.usesEstimates)
+ assertEquals(1, metrics.missingCostCount)
+ assertNull(metrics.totalEstimatedCost)
+ assertEquals(4.0, metrics.totalEffectiveCost ?: 0.0, 0.001)
+ // Partial coverage still hides cost/100.
+ assertNull(metrics.costPer100Distance)
+ }
+
+ @Test
+ fun `recorded zero cost is preserved even when a rate is configured`() {
+ val metrics = CostAnalyticsCalculator.calculate(
+ charges = listOf(charge(id = 1, energy = 20.0, cost = 0.0)),
+ totalDistance = 100.0,
+ rates = UtilityRates(homePerKwh = 0.30),
+ dcChargeIds = emptySet(),
+ )
+
+ // The free session must not be overwritten by an estimate.
+ assertEquals(false, metrics.usesEstimates)
+ assertEquals(0.0, metrics.totalEffectiveCost ?: -1.0, 0.001)
+ assertEquals(1, metrics.recordedChargeCount)
+ assertEquals(0, metrics.estimatedChargeCount)
+ }
+
+ private fun charge(id: Int, energy: Double, cost: Double?): ChargeSummary =
+ ChargeSummary(
+ chargeId = id,
+ carId = 1,
+ startDate = "2026-07-01T10:00:00",
+ endDate = "2026-07-01T10:30:00",
+ durationMin = 30,
+ address = "Charger $id",
+ latitude = 0.0,
+ longitude = 0.0,
+ energyAdded = energy,
+ energyUsed = null,
+ cost = cost,
+ startBatteryLevel = 30,
+ endBatteryLevel = 80,
+ outsideTempAvg = 20.0,
+ odometer = 10_000.0,
+ )
+}
diff --git a/app/src/test/java/com/matedroid/domain/CostResolverTest.kt b/app/src/test/java/com/matedroid/domain/CostResolverTest.kt
new file mode 100644
index 00000000..6b52c9e2
--- /dev/null
+++ b/app/src/test/java/com/matedroid/domain/CostResolverTest.kt
@@ -0,0 +1,179 @@
+package com.matedroid.domain
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class CostResolverTest {
+
+ // ---------------- resolveChargeCost ----------------
+
+ @Test
+ fun `recorded cost is always preferred over estimation`() {
+ val result = CostResolver.resolveChargeCost(
+ cost = 12.5,
+ energyAdded = 50.0,
+ isDc = false,
+ rates = UtilityRates(homePerKwh = 0.20),
+ )
+ assertEquals(CostSource.Recorded, result.source)
+ assertEquals(12.5, result.amount ?: 0.0, 0.0001)
+ }
+
+ @Test
+ fun `zero cost is recorded not missing`() {
+ val result = CostResolver.resolveChargeCost(
+ cost = 0.0,
+ energyAdded = 30.0,
+ isDc = true,
+ rates = UtilityRates(homePerKwh = 0.20, dcPerKwh = 0.40),
+ )
+ // Recorded 0.0 (a free session) must survive; estimation must not overwrite.
+ assertEquals(CostSource.Recorded, result.source)
+ assertEquals(0.0, result.amount ?: -1.0, 0.0001)
+ }
+
+ @Test
+ fun `null cost with home rate yields ac estimate`() {
+ val result = CostResolver.resolveChargeCost(
+ cost = null,
+ energyAdded = 40.0,
+ isDc = false,
+ rates = UtilityRates(homePerKwh = 0.15),
+ )
+ assertEquals(CostSource.Estimated, result.source)
+ assertEquals(6.0, result.amount ?: 0.0, 0.0001)
+ }
+
+ @Test
+ fun `null cost with dc rate yields dc estimate`() {
+ val result = CostResolver.resolveChargeCost(
+ cost = null,
+ energyAdded = 20.0,
+ isDc = true,
+ rates = UtilityRates(homePerKwh = 0.10, dcPerKwh = 0.50),
+ )
+ assertEquals(CostSource.Estimated, result.source)
+ assertEquals(10.0, result.amount ?: 0.0, 0.0001)
+ }
+
+ @Test
+ fun `dc estimate falls back to home rate when dc rate is missing`() {
+ val result = CostResolver.resolveChargeCost(
+ cost = null,
+ energyAdded = 25.0,
+ isDc = true,
+ rates = UtilityRates(homePerKwh = 0.20),
+ )
+ assertEquals(CostSource.Estimated, result.source)
+ assertEquals(5.0, result.amount ?: 0.0, 0.0001)
+ }
+
+ @Test
+ fun `null cost with no rates configured yields missing`() {
+ val result = CostResolver.resolveChargeCost(
+ cost = null,
+ energyAdded = 30.0,
+ isDc = true,
+ rates = UtilityRates(),
+ )
+ assertEquals(CostSource.Missing, result.source)
+ assertNull(result.amount)
+ }
+
+ @Test
+ fun `ac charge with only dc rate configured yields missing`() {
+ // AC never falls back to DC; only DC falls back to home.
+ val result = CostResolver.resolveChargeCost(
+ cost = null,
+ energyAdded = 30.0,
+ isDc = false,
+ rates = UtilityRates(dcPerKwh = 0.40),
+ )
+ assertEquals(CostSource.Missing, result.source)
+ assertNull(result.amount)
+ }
+
+ @Test
+ fun `negative or non finite energy is treated as missing`() {
+ val rates = UtilityRates(homePerKwh = 0.20)
+ assertEquals(
+ CostSource.Missing,
+ CostResolver.resolveChargeCost(null, -1.0, false, rates).source,
+ )
+ assertEquals(
+ CostSource.Missing,
+ CostResolver.resolveChargeCost(null, Double.NaN, false, rates).source,
+ )
+ assertEquals(
+ CostSource.Missing,
+ CostResolver.resolveChargeCost(null, Double.POSITIVE_INFINITY, false, rates).source,
+ )
+ }
+
+ @Test
+ fun `zero energy estimate is zero not missing`() {
+ val result = CostResolver.resolveChargeCost(
+ cost = null,
+ energyAdded = 0.0,
+ isDc = false,
+ rates = UtilityRates(homePerKwh = 0.20),
+ )
+ assertEquals(CostSource.Estimated, result.source)
+ assertEquals(0.0, result.amount ?: -1.0, 0.0001)
+ }
+
+ // ---------------- estimateDriveCost ----------------
+
+ @Test
+ fun `drive cost uses home rate`() {
+ val result = CostResolver.estimateDriveCost(
+ energyConsumed = 12.0,
+ rates = UtilityRates(homePerKwh = 0.25, dcPerKwh = 0.60),
+ )
+ assertEquals(CostSource.Estimated, result.source)
+ assertEquals(3.0, result.amount ?: 0.0, 0.0001)
+ }
+
+ @Test
+ fun `drive cost is missing without home rate even if dc is set`() {
+ val result = CostResolver.estimateDriveCost(
+ energyConsumed = 12.0,
+ rates = UtilityRates(dcPerKwh = 0.60),
+ )
+ assertEquals(CostSource.Missing, result.source)
+ assertNull(result.amount)
+ }
+
+ @Test
+ fun `drive cost is missing when energy is null or negative`() {
+ val rates = UtilityRates(homePerKwh = 0.25)
+ assertEquals(CostSource.Missing, CostResolver.estimateDriveCost(null, rates).source)
+ assertEquals(CostSource.Missing, CostResolver.estimateDriveCost(-1.0, rates).source)
+ }
+
+ // ---------------- UtilityRates ----------------
+
+ @Test
+ fun `utility rates isConfigured reflects any set rate`() {
+ assertEquals(false, UtilityRates().isConfigured)
+ assertEquals(true, UtilityRates(homePerKwh = 0.1).isConfigured)
+ assertEquals(true, UtilityRates(dcPerKwh = 0.4).isConfigured)
+ assertEquals(true, UtilityRates(homePerKwh = 0.1, dcPerKwh = 0.4).isConfigured)
+ }
+
+ @Test
+ fun `rateFor picks dc then falls back to home when dc missing`() {
+ val both = UtilityRates(homePerKwh = 0.1, dcPerKwh = 0.4)
+ assertEquals(0.1, both.rateFor(isDc = false) ?: 0.0, 0.0001)
+ assertEquals(0.4, both.rateFor(isDc = true) ?: 0.0, 0.0001)
+
+ val homeOnly = UtilityRates(homePerKwh = 0.1)
+ assertEquals(0.1, homeOnly.rateFor(isDc = true) ?: 0.0, 0.0001)
+ assertEquals(0.1, homeOnly.rateFor(isDc = false) ?: 0.0, 0.0001)
+
+ val dcOnly = UtilityRates(dcPerKwh = 0.4)
+ assertEquals(0.4, dcOnly.rateFor(isDc = true) ?: 0.0, 0.0001)
+ assertNull(dcOnly.rateFor(isDc = false))
+ }
+}
diff --git a/app/src/test/java/com/matedroid/domain/IceComparisonCalculatorTest.kt b/app/src/test/java/com/matedroid/domain/IceComparisonCalculatorTest.kt
new file mode 100644
index 00000000..00b8e1ac
--- /dev/null
+++ b/app/src/test/java/com/matedroid/domain/IceComparisonCalculatorTest.kt
@@ -0,0 +1,266 @@
+package com.matedroid.domain
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class IceComparisonCalculatorTest {
+
+ // ============== isConfigured ==============
+
+ @Test
+ fun `isConfigured is false when values are null`() {
+ assertFalse(IceAssumptions().isConfigured)
+ assertFalse(IceAssumptions(economyValue = 6.0).isConfigured)
+ assertFalse(IceAssumptions(fuelPrice = 1.8).isConfigured)
+ }
+
+ @Test
+ fun `isConfigured is false when values are not strictly positive`() {
+ assertFalse(IceAssumptions(economyValue = 0.0, fuelPrice = 1.8).isConfigured)
+ assertFalse(IceAssumptions(economyValue = -1.0, fuelPrice = 1.8).isConfigured)
+ assertFalse(IceAssumptions(economyValue = 6.0, fuelPrice = 0.0).isConfigured)
+ assertFalse(IceAssumptions(economyValue = 6.0, fuelPrice = -0.1).isConfigured)
+ assertFalse(
+ IceAssumptions(
+ economyValue = Double.POSITIVE_INFINITY,
+ fuelPrice = 1.8,
+ ).isConfigured
+ )
+ assertFalse(
+ IceAssumptions(
+ economyValue = 6.0,
+ fuelPrice = Double.NaN,
+ ).isConfigured
+ )
+ }
+
+ @Test
+ fun `isConfigured is true when both values are strictly positive`() {
+ assertTrue(
+ IceAssumptions(
+ economyValue = 6.5,
+ economyIsMpg = false,
+ fuelPrice = 1.75,
+ fuelPriceIsPerGallon = false,
+ ).isConfigured
+ )
+ }
+
+ // ============== Metric path (L/100km + $/L, distance in km) ==============
+
+ @Test
+ fun `metric path uses liters and price per liter directly`() {
+ val assumptions = IceAssumptions(
+ economyValue = 7.0,
+ economyIsMpg = false,
+ fuelPrice = 1.80,
+ fuelPriceIsPerGallon = false,
+ )
+ val result = IceComparisonCalculator.compare(
+ distance = 500.0,
+ distanceIsMiles = false,
+ teslaCost = 25.0,
+ assumptions = assumptions,
+ )
+
+ // 500 km * (7 / 100) = 35 L; 35 * 1.80 = 63.0
+ assertEquals(35.0, result.iceVolumeUsed ?: 0.0, 0.0001)
+ assertEquals(63.0, result.iceCost ?: 0.0, 0.0001)
+ assertEquals(38.0, result.savings ?: 0.0, 0.0001)
+ assertEquals(38.0 / 63.0 * 100.0, result.savingsPercent ?: 0.0, 0.0001)
+ assertFalse(result.usedImperialVolume)
+ }
+
+ // ============== Imperial path (mpg + $/gal, distance in mi) ==============
+
+ @Test
+ fun `imperial path uses gallons and price per gallon directly`() {
+ val assumptions = IceAssumptions(
+ economyValue = 30.0,
+ economyIsMpg = true,
+ fuelPrice = 3.60,
+ fuelPriceIsPerGallon = true,
+ )
+ val result = IceComparisonCalculator.compare(
+ distance = 300.0,
+ distanceIsMiles = true,
+ teslaCost = 12.0,
+ assumptions = assumptions,
+ )
+
+ // 300 mi / 30 mpg = 10 gal; 10 * 3.60 = 36.0
+ assertEquals(10.0, result.iceVolumeUsed ?: 0.0, 0.0001)
+ assertEquals(36.0, result.iceCost ?: 0.0, 0.0001)
+ assertEquals(24.0, result.savings ?: 0.0, 0.0001)
+ assertEquals(24.0 / 36.0 * 100.0, result.savingsPercent ?: 0.0, 0.0001)
+ assertTrue(result.usedImperialVolume)
+ }
+
+ // ============== Mixed unit: assumptions in mpg / $ per gallon, distance km ==============
+
+ @Test
+ fun `mpg economy is converted to L per 100 km when distance is km`() {
+ // 30 mpg ≈ 7.8405 L/100 km. For 100 km: liters ≈ 7.8405.
+ // $3.60 / gal ≈ 0.9510 / L. Cost ≈ 7.8405 * 0.9510 ≈ 7.4562
+ val assumptions = IceAssumptions(
+ economyValue = 30.0,
+ economyIsMpg = true,
+ fuelPrice = 3.60,
+ fuelPriceIsPerGallon = true,
+ )
+ val result = IceComparisonCalculator.compare(
+ distance = 100.0,
+ distanceIsMiles = false,
+ teslaCost = null,
+ assumptions = assumptions,
+ )
+
+ val expectedLiters = 235.214583 / 30.0 // ≈ 7.8405
+ val expectedPricePerLiter = 3.60 / 3.785411784
+ val expectedCost = expectedLiters * expectedPricePerLiter
+
+ assertEquals(expectedLiters, result.iceVolumeUsed ?: 0.0, 0.0001)
+ assertEquals(expectedCost, result.iceCost ?: 0.0, 0.0001)
+ assertFalse(result.usedImperialVolume)
+ assertNull(result.savings)
+ assertNull(result.savingsPercent)
+ }
+
+ // ============== Mixed unit: assumptions in L/100km / $ per liter, distance mi ==============
+
+ @Test
+ fun `L per 100 km economy is converted to mpg when distance is miles`() {
+ // 7.8405 L/100km ≈ 30 mpg. 200 mi at 30 mpg = 6.6667 gal.
+ // $1.80/L * 3.7854 = $6.8137/gal. Cost ≈ 6.6667 * 6.8137 ≈ 45.42
+ val assumptions = IceAssumptions(
+ economyValue = 235.214583 / 30.0, // exact "30 mpg" in L/100km
+ economyIsMpg = false,
+ fuelPrice = 1.80,
+ fuelPriceIsPerGallon = false,
+ )
+ val result = IceComparisonCalculator.compare(
+ distance = 200.0,
+ distanceIsMiles = true,
+ teslaCost = 20.0,
+ assumptions = assumptions,
+ )
+
+ val expectedGallons = 200.0 / 30.0
+ val expectedPricePerGallon = 1.80 * 3.785411784
+ val expectedCost = expectedGallons * expectedPricePerGallon
+
+ assertEquals(expectedGallons, result.iceVolumeUsed ?: 0.0, 0.0001)
+ assertEquals(expectedCost, result.iceCost ?: 0.0, 0.0001)
+ assertTrue(result.usedImperialVolume)
+ assertEquals(expectedCost - 20.0, result.savings ?: 0.0, 0.0001)
+ }
+
+ // ============== Missing / edge cases ==============
+
+ @Test
+ fun `missing assumptions produce null ice cost but preserve tesla cost`() {
+ val result = IceComparisonCalculator.compare(
+ distance = 500.0,
+ distanceIsMiles = false,
+ teslaCost = 25.0,
+ assumptions = IceAssumptions(),
+ )
+ assertNull(result.iceCost)
+ assertNull(result.savings)
+ assertNull(result.savingsPercent)
+ assertNull(result.iceVolumeUsed)
+ assertEquals(25.0, result.teslaCost ?: 0.0, 0.0001)
+ assertEquals(500.0, result.distance, 0.0001)
+ }
+
+ @Test
+ fun `zero distance produces null ice cost`() {
+ val result = IceComparisonCalculator.compare(
+ distance = 0.0,
+ distanceIsMiles = false,
+ teslaCost = 0.0,
+ assumptions = IceAssumptions(
+ economyValue = 7.0,
+ fuelPrice = 1.8,
+ ),
+ )
+ assertNull(result.iceCost)
+ assertNull(result.iceVolumeUsed)
+ assertNull(result.savings)
+ assertNull(result.savingsPercent)
+ }
+
+ @Test
+ fun `negative distance is treated as no comparison`() {
+ val result = IceComparisonCalculator.compare(
+ distance = -1.0,
+ distanceIsMiles = true,
+ teslaCost = null,
+ assumptions = IceAssumptions(
+ economyValue = 30.0,
+ economyIsMpg = true,
+ fuelPrice = 3.6,
+ fuelPriceIsPerGallon = true,
+ ),
+ )
+ assertNull(result.iceCost)
+ }
+
+ @Test
+ fun `tesla cost null yields null savings but ice cost is still computed`() {
+ val result = IceComparisonCalculator.compare(
+ distance = 100.0,
+ distanceIsMiles = false,
+ teslaCost = null,
+ assumptions = IceAssumptions(
+ economyValue = 6.0,
+ fuelPrice = 1.5,
+ ),
+ )
+ // 100 * 6/100 = 6 L; 6 * 1.5 = 9.0
+ assertEquals(9.0, result.iceCost ?: 0.0, 0.0001)
+ assertNull(result.savings)
+ assertNull(result.savingsPercent)
+ assertNull(result.teslaCost)
+ }
+
+ @Test
+ fun `negative savings when tesla costs more than gasoline`() {
+ val result = IceComparisonCalculator.compare(
+ distance = 100.0,
+ distanceIsMiles = false,
+ teslaCost = 50.0,
+ assumptions = IceAssumptions(
+ economyValue = 6.0,
+ fuelPrice = 1.5,
+ ),
+ )
+ // ICE = 9.0, Tesla = 50.0 → savings = -41.0
+ assertEquals(-41.0, result.savings ?: 0.0, 0.0001)
+ assertEquals(-41.0 / 9.0 * 100.0, result.savingsPercent ?: 0.0, 0.0001)
+ }
+
+ @Test
+ fun `metric round trip for a common European assumption`() {
+ // 6.5 L/100km, €1.75/L, 1234 km: liters = 80.21, cost = 140.3675
+ val result = IceComparisonCalculator.compare(
+ distance = 1234.0,
+ distanceIsMiles = false,
+ teslaCost = 61.7,
+ assumptions = IceAssumptions(
+ economyValue = 6.5,
+ economyIsMpg = false,
+ fuelPrice = 1.75,
+ fuelPriceIsPerGallon = false,
+ ),
+ )
+ val expectedLiters = 1234.0 * 6.5 / 100.0
+ val expectedCost = expectedLiters * 1.75
+ assertEquals(expectedLiters, result.iceVolumeUsed ?: 0.0, 0.0001)
+ assertEquals(expectedCost, result.iceCost ?: 0.0, 0.0001)
+ assertEquals(expectedCost - 61.7, result.savings ?: 0.0, 0.0001)
+ }
+}
diff --git a/app/src/test/java/com/matedroid/domain/TripIntelligenceCalculatorTest.kt b/app/src/test/java/com/matedroid/domain/TripIntelligenceCalculatorTest.kt
new file mode 100644
index 00000000..bd6d1f0d
--- /dev/null
+++ b/app/src/test/java/com/matedroid/domain/TripIntelligenceCalculatorTest.kt
@@ -0,0 +1,196 @@
+package com.matedroid.domain
+
+import com.matedroid.data.local.entity.ChargeSummary
+import com.matedroid.data.local.entity.DriveSummary
+import com.matedroid.domain.model.Trip
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class TripIntelligenceCalculatorTest {
+
+ @Test
+ fun `empty trips yield empty intelligence`() {
+ val result = TripIntelligenceCalculator.calculate(emptyList())
+ assertNull(result.mostEfficient)
+ assertNull(result.leastEfficient)
+ assertNull(result.cheapestPer100)
+ assertNull(result.mostExpensivePer100)
+ assertNull(result.longestDistance)
+ assertNull(result.highestChargingOverhead)
+ assertNull(result.highestChargingOverheadRatio)
+ assertNull(result.energyGapKwh)
+ assertNull(result.avgChargingMinutesPerTrip)
+ }
+
+ @Test
+ fun `picks lowest and highest efficiency trips`() {
+ val efficient = trip(id = 1, distance = 400.0, energyConsumed = 40.0, chargeCosts = listOf(10.0))
+ val greedy = trip(id = 2, distance = 200.0, energyConsumed = 40.0, chargeCosts = listOf(10.0))
+
+ val result = TripIntelligenceCalculator.calculate(listOf(efficient, greedy))
+
+ assertEquals(efficient.startDate, result.mostEfficient?.startDate)
+ assertEquals(greedy.startDate, result.leastEfficient?.startDate)
+ }
+
+ @Test
+ fun `cheapest per 100 ignores trips with any unpriced charge`() {
+ val fullyPricedCheap = trip(
+ id = 1,
+ distance = 400.0,
+ energyConsumed = 60.0,
+ chargeCosts = listOf(8.0, 4.0),
+ )
+ val fullyPricedExpensive = trip(
+ id = 2,
+ distance = 200.0,
+ energyConsumed = 30.0,
+ chargeCosts = listOf(20.0),
+ )
+ val partial = trip(
+ id = 3,
+ distance = 500.0,
+ energyConsumed = 80.0,
+ chargeCosts = listOf(1.0, null),
+ )
+
+ val result = TripIntelligenceCalculator.calculate(
+ listOf(fullyPricedCheap, fullyPricedExpensive, partial)
+ )
+
+ assertEquals(fullyPricedCheap.startDate, result.cheapestPer100?.startDate)
+ assertEquals(fullyPricedExpensive.startDate, result.mostExpensivePer100?.startDate)
+ }
+
+ @Test
+ fun `cheapest per 100 nulls out when no trip is fully priced`() {
+ val partial = trip(id = 1, distance = 500.0, energyConsumed = 60.0, chargeCosts = listOf(null))
+ val result = TripIntelligenceCalculator.calculate(listOf(partial))
+
+ assertNull(result.cheapestPer100)
+ assertNull(result.mostExpensivePer100)
+ }
+
+ @Test
+ fun `longest distance and charging overhead leader picked correctly`() {
+ val short = trip(
+ id = 1,
+ distance = 100.0,
+ energyConsumed = 20.0,
+ chargeCosts = listOf(5.0),
+ drivingMinutes = 60,
+ chargeMinutes = 60,
+ )
+ val long = trip(
+ id = 2,
+ distance = 900.0,
+ energyConsumed = 140.0,
+ chargeCosts = listOf(30.0),
+ drivingMinutes = 480,
+ chargeMinutes = 30,
+ )
+
+ val result = TripIntelligenceCalculator.calculate(listOf(short, long))
+
+ assertEquals(long.startDate, result.longestDistance?.startDate)
+ assertEquals(short.startDate, result.highestChargingOverhead?.startDate)
+ val ratio = result.highestChargingOverheadRatio ?: 0.0
+ assertTrue("Expected ratio > 0.2 but was $ratio", ratio > 0.2)
+ }
+
+ @Test
+ fun `energy gap and average charging minutes reflect the whole set`() {
+ val a = trip(
+ id = 1,
+ distance = 300.0,
+ energyConsumed = 50.0,
+ chargeCosts = listOf(10.0),
+ chargeMinutes = 40,
+ )
+ val b = trip(
+ id = 2,
+ distance = 200.0,
+ energyConsumed = 30.0,
+ chargeCosts = listOf(6.0),
+ chargeMinutes = 20,
+ )
+
+ val result = TripIntelligenceCalculator.calculate(listOf(a, b))
+
+ val expectedGap = (a.totalEnergyCharged + b.totalEnergyCharged) -
+ (a.totalEnergyConsumed + b.totalEnergyConsumed)
+ assertEquals(expectedGap, result.energyGapKwh ?: 0.0, 0.001)
+ assertEquals(30.0, result.avgChargingMinutesPerTrip ?: 0.0, 0.001)
+ }
+
+ @Test
+ fun `trips without charges are ignored for charging overhead but count elsewhere`() {
+ val noCharges = trip(
+ id = 1,
+ distance = 250.0,
+ energyConsumed = 40.0,
+ chargeCosts = emptyList(),
+ )
+
+ val result = TripIntelligenceCalculator.calculate(listOf(noCharges))
+
+ assertNotNull(result.longestDistance)
+ assertNull(result.highestChargingOverhead)
+ assertNull(result.highestChargingOverheadRatio)
+ assertEquals(0.0, result.avgChargingMinutesPerTrip ?: -1.0, 0.001)
+ }
+
+ private fun trip(
+ id: Int,
+ distance: Double,
+ energyConsumed: Double,
+ chargeCosts: List,
+ drivingMinutes: Int = 240,
+ chargeMinutes: Int = 30,
+ ): Trip {
+ val drive = DriveSummary(
+ driveId = id,
+ carId = 1,
+ startDate = "2026-07-0${id}T08:00:00",
+ endDate = "2026-07-0${id}T12:00:00",
+ durationMin = drivingMinutes,
+ startAddress = "Start",
+ endAddress = "End",
+ distance = distance,
+ speedMax = 120,
+ speedAvg = 80,
+ powerMax = 150,
+ powerMin = -50,
+ startBatteryLevel = 90,
+ endBatteryLevel = 20,
+ outsideTempAvg = 20.0,
+ insideTempAvg = 21.0,
+ energyConsumed = energyConsumed,
+ efficiency = if (distance > 0) energyConsumed * 1000.0 / distance else null,
+ )
+ val charges = chargeCosts.mapIndexed { index, cost ->
+ ChargeSummary(
+ chargeId = id * 10 + index,
+ carId = 1,
+ startDate = "2026-07-0${id}T12:10:00",
+ endDate = "2026-07-0${id}T12:40:00",
+ durationMin = chargeMinutes,
+ address = "Charger",
+ latitude = 0.0,
+ longitude = 0.0,
+ energyAdded = 30.0,
+ energyUsed = null,
+ cost = cost,
+ startBatteryLevel = 20,
+ endBatteryLevel = 70,
+ outsideTempAvg = 20.0,
+ odometer = 10_000.0,
+ )
+ }
+
+ return TripAggregator.buildTrip(listOf(drive), charges)!!
+ }
+}
diff --git a/app/src/test/java/com/matedroid/domain/TripsSummaryCalculatorTest.kt b/app/src/test/java/com/matedroid/domain/TripsSummaryCalculatorTest.kt
new file mode 100644
index 00000000..9fbdb070
--- /dev/null
+++ b/app/src/test/java/com/matedroid/domain/TripsSummaryCalculatorTest.kt
@@ -0,0 +1,184 @@
+package com.matedroid.domain
+
+import com.matedroid.data.local.entity.ChargeSummary
+import com.matedroid.data.local.entity.DriveSummary
+import com.matedroid.domain.model.Trip
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class TripsSummaryCalculatorTest {
+
+ @Test
+ fun `calculate aggregates trip energy efficiency and complete costs`() {
+ val summary = TripsSummaryCalculator.calculate(
+ listOf(
+ trip(id = 1, distance = 300.0, energyConsumed = 45.0, chargeCosts = listOf(12.0)),
+ trip(id = 2, distance = 200.0, energyConsumed = 35.0, chargeCosts = listOf(8.0)),
+ )
+ )
+
+ assertEquals(2, summary.tripCount)
+ assertEquals(500.0, summary.totalDistance, 0.001)
+ assertEquals(480, summary.totalDrivingMinutes)
+ assertEquals(80.0, summary.totalEnergyConsumed, 0.001)
+ assertEquals(60.0, summary.totalEnergyCharged, 0.001)
+ assertEquals(160.0, summary.averageEfficiency ?: 0.0, 0.001)
+ assertEquals(20.0, summary.totalChargeCost ?: 0.0, 0.001)
+ assertEquals(4.0, summary.costPer100Distance ?: 0.0, 0.001)
+ assertEquals(2, summary.pricedChargeCount)
+ assertEquals(2, summary.chargeCount)
+ assertEquals(TripCostCoverage.Complete, summary.costCoverage)
+ }
+
+ @Test
+ fun `calculate exposes partial cost coverage without inventing a complete rate`() {
+ val summary = TripsSummaryCalculator.calculate(
+ listOf(
+ trip(
+ id = 1,
+ distance = 400.0,
+ energyConsumed = 64.0,
+ chargeCosts = listOf(10.0, null),
+ )
+ )
+ )
+
+ assertEquals(10.0, summary.totalChargeCost ?: 0.0, 0.001)
+ assertNull(summary.costPer100Distance)
+ assertEquals(1, summary.pricedChargeCount)
+ assertEquals(2, summary.chargeCount)
+ assertEquals(TripCostCoverage.Partial, summary.costCoverage)
+ }
+
+ @Test
+ fun `calculate leaves costs unavailable when no charge has a price`() {
+ val summary = TripsSummaryCalculator.calculate(
+ listOf(
+ trip(id = 1, distance = 300.0, energyConsumed = 45.0, chargeCosts = listOf(null))
+ )
+ )
+
+ assertNull(summary.totalChargeCost)
+ assertNull(summary.costPer100Distance)
+ assertEquals(TripCostCoverage.None, summary.costCoverage)
+ }
+
+ @Test
+ fun `calculate treats zero-cost charges as priced and complete`() {
+ val summary = TripsSummaryCalculator.calculate(
+ listOf(
+ trip(id = 1, distance = 200.0, energyConsumed = 30.0, chargeCosts = listOf(0.0))
+ )
+ )
+
+ assertEquals(0.0, summary.totalChargeCost ?: -1.0, 0.001)
+ assertEquals(0.0, summary.costPer100Distance ?: -1.0, 0.001)
+ assertEquals(1, summary.pricedChargeCount)
+ assertEquals(1, summary.chargeCount)
+ assertEquals(TripCostCoverage.Complete, summary.costCoverage)
+ }
+
+ @Test
+ fun `calculate returns empty metrics for an empty range`() {
+ assertEquals(TripsSummaryMetrics(), TripsSummaryCalculator.calculate(emptyList()))
+ }
+
+ @Test
+ fun `estimated costs fill in null-cost charges when rates are configured`() {
+ val summary = TripsSummaryCalculator.calculate(
+ trips = listOf(
+ trip(
+ id = 1,
+ distance = 400.0,
+ energyConsumed = 60.0,
+ chargeCosts = listOf(10.0, null),
+ )
+ ),
+ rates = UtilityRates(homePerKwh = 0.20),
+ dcChargeIds = emptySet(),
+ )
+
+ assertEquals(TripCostCoverage.Complete, summary.costCoverage)
+ assertEquals(true, summary.usesEstimates)
+ assertEquals(10.0, summary.totalChargeCost ?: 0.0, 0.001)
+ // 30 kWh * 0.20 = 6.0 estimated
+ assertEquals(6.0, summary.totalEstimatedCost ?: 0.0, 0.001)
+ assertEquals(16.0, summary.totalEffectiveCost ?: 0.0, 0.001)
+ // Cost/100 uses effective total: 16 * 100 / 400 = 4.0
+ assertEquals(4.0, summary.costPer100Distance ?: 0.0, 0.001)
+ assertEquals(1, summary.estimatedChargeCount)
+ assertEquals(1, summary.recordedChargeCount)
+ assertEquals(0, summary.missingChargeCount)
+ }
+
+ @Test
+ fun `dc charge ids drive dc rate selection`() {
+ val summary = TripsSummaryCalculator.calculate(
+ trips = listOf(
+ trip(
+ id = 1,
+ distance = 300.0,
+ energyConsumed = 45.0,
+ chargeCosts = listOf(null),
+ )
+ ),
+ rates = UtilityRates(homePerKwh = 0.10, dcPerKwh = 0.50),
+ // trip helper builds chargeId = id*10 + index → 10 here.
+ dcChargeIds = setOf(10),
+ )
+
+ // 30 kWh added * 0.50 DC rate = 15.0
+ assertEquals(15.0, summary.totalEffectiveCost ?: 0.0, 0.001)
+ assertEquals(1, summary.estimatedChargeCount)
+ }
+
+ private fun trip(
+ id: Int,
+ distance: Double,
+ energyConsumed: Double,
+ chargeCosts: List,
+ ): Trip {
+ val drive = DriveSummary(
+ driveId = id,
+ carId = 1,
+ startDate = "2026-07-0${id}T08:00:00",
+ endDate = "2026-07-0${id}T12:00:00",
+ durationMin = 240,
+ startAddress = "Start",
+ endAddress = "End",
+ distance = distance,
+ speedMax = 120,
+ speedAvg = 80,
+ powerMax = 150,
+ powerMin = -50,
+ startBatteryLevel = 90,
+ endBatteryLevel = 20,
+ outsideTempAvg = 20.0,
+ insideTempAvg = 21.0,
+ energyConsumed = energyConsumed,
+ efficiency = energyConsumed * 1000.0 / distance,
+ )
+ val charges = chargeCosts.mapIndexed { index, cost ->
+ ChargeSummary(
+ chargeId = id * 10 + index,
+ carId = 1,
+ startDate = "2026-07-0${id}T12:10:00",
+ endDate = "2026-07-0${id}T12:40:00",
+ durationMin = 30,
+ address = "Charger",
+ latitude = 0.0,
+ longitude = 0.0,
+ energyAdded = 30.0,
+ energyUsed = null,
+ cost = cost,
+ startBatteryLevel = 20,
+ endBatteryLevel = 70,
+ outsideTempAvg = 20.0,
+ odometer = 10_000.0,
+ )
+ }
+
+ return TripAggregator.buildTrip(listOf(drive), charges)!!
+ }
+}
diff --git a/app/src/test/java/com/matedroid/widget/CostSummaryWidgetDateRangeTest.kt b/app/src/test/java/com/matedroid/widget/CostSummaryWidgetDateRangeTest.kt
new file mode 100644
index 00000000..2c281f0e
--- /dev/null
+++ b/app/src/test/java/com/matedroid/widget/CostSummaryWidgetDateRangeTest.kt
@@ -0,0 +1,32 @@
+package com.matedroid.widget
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.time.LocalDate
+
+class CostSummaryWidgetDateRangeTest {
+
+ @Test
+ fun `last 7 days covers today through six days back`() {
+ val today = LocalDate.of(2026, 7, 18)
+ val range = CostSummaryWidgetDateRange.lastDays(today = today, days = 7)
+
+ assertTrue(range.startDate.startsWith("2026-07-12T00:00:00"))
+ assertTrue(range.endDate.startsWith("2026-07-19T00:00:00"))
+ }
+
+ @Test
+ fun `last 30 days matches default range`() {
+ val today = LocalDate.of(2026, 7, 18)
+ val range = CostSummaryWidgetDateRange.lastDays(today = today, days = 30)
+
+ assertEquals("2026-06-19T00:00:00", range.startDate)
+ assertEquals("2026-07-19T00:00:00", range.endDate)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `zero days is rejected`() {
+ CostSummaryWidgetDateRange.lastDays(days = 0)
+ }
+}
diff --git a/app/src/test/java/com/matedroid/widget/CostSummaryWidgetFormatterTest.kt b/app/src/test/java/com/matedroid/widget/CostSummaryWidgetFormatterTest.kt
new file mode 100644
index 00000000..8bc42c8b
--- /dev/null
+++ b/app/src/test/java/com/matedroid/widget/CostSummaryWidgetFormatterTest.kt
@@ -0,0 +1,133 @@
+package com.matedroid.widget
+
+import com.matedroid.data.api.models.Units
+import com.matedroid.domain.CostAnalyticsMetrics
+import com.matedroid.domain.TripCostCoverage
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CostSummaryWidgetFormatterTest {
+
+ private val units = Units(unitOfLength = "km")
+ private val symbol = "€"
+ private val noValue = "N/A"
+
+ @Test
+ fun `complete coverage exposes cost per distance`() {
+ val metrics = CostAnalyticsMetrics(
+ chargeCount = 3,
+ pricedCount = 3,
+ totalKnownCost = 30.0,
+ energyAddedTotal = 60.0,
+ energyAddedPriced = 60.0,
+ avgCostPerKwh = 0.5,
+ costPer100Distance = 6.0,
+ totalDistance = 500.0,
+ costCoverage = TripCostCoverage.Complete,
+ )
+
+ val data = CostSummaryWidgetFormatter.format(
+ carId = 1,
+ carName = "Model 3",
+ periodLabel = "Last 30 days",
+ metrics = metrics,
+ currencySymbol = symbol,
+ noValue = noValue,
+ updatedValue = "Updated 10:00",
+ units = units,
+ )
+
+ assertEquals(CostSummaryWidgetCoverage.Complete, data.costCoverage)
+ assertTrue("cost value should be populated", data.costValue != noValue)
+ assertTrue(
+ "cost per distance should be populated when complete",
+ data.costPerDistanceValue != noValue,
+ )
+ assertTrue(data.avgCostPerKwhValue != noValue)
+ assertEquals(3, data.chargeCount)
+ assertEquals(3, data.chargesWithCost)
+ }
+
+ @Test
+ fun `partial coverage withholds cost per distance but keeps total`() {
+ val metrics = CostAnalyticsMetrics(
+ chargeCount = 3,
+ pricedCount = 2,
+ missingCostCount = 1,
+ totalKnownCost = 20.0,
+ energyAddedTotal = 60.0,
+ energyAddedPriced = 40.0,
+ avgCostPerKwh = 0.5,
+ costPer100Distance = null,
+ totalDistance = 500.0,
+ costCoverage = TripCostCoverage.Partial,
+ )
+
+ val data = CostSummaryWidgetFormatter.format(
+ carId = 1,
+ carName = "Model 3",
+ periodLabel = "Last 30 days",
+ metrics = metrics,
+ currencySymbol = symbol,
+ noValue = noValue,
+ updatedValue = "Updated 10:00",
+ units = units,
+ )
+
+ assertEquals(CostSummaryWidgetCoverage.Partial, data.costCoverage)
+ assertTrue("total cost should still surface", data.costValue != noValue)
+ assertEquals(noValue, data.costPerDistanceValue)
+ assertTrue("avg cost/kWh comes from priced energy only", data.avgCostPerKwhValue != noValue)
+ }
+
+ @Test
+ fun `no priced charges surfaces missing coverage and no totals`() {
+ val metrics = CostAnalyticsMetrics(
+ chargeCount = 2,
+ pricedCount = 0,
+ missingCostCount = 2,
+ totalKnownCost = null,
+ energyAddedTotal = 30.0,
+ energyAddedPriced = 0.0,
+ avgCostPerKwh = null,
+ costPer100Distance = null,
+ totalDistance = 200.0,
+ costCoverage = TripCostCoverage.None,
+ )
+
+ val data = CostSummaryWidgetFormatter.format(
+ carId = 1,
+ carName = "Model 3",
+ periodLabel = "Last 30 days",
+ metrics = metrics,
+ currencySymbol = symbol,
+ noValue = noValue,
+ updatedValue = "Updated 10:00",
+ units = units,
+ )
+
+ assertEquals(CostSummaryWidgetCoverage.Missing, data.costCoverage)
+ assertEquals(noValue, data.costValue)
+ assertEquals(noValue, data.costPerDistanceValue)
+ assertEquals(noValue, data.avgCostPerKwhValue)
+ assertTrue("charges still counted", data.hasCharges)
+ }
+
+ @Test
+ fun `zero charges reports none coverage and hasCharges false`() {
+ val data = CostSummaryWidgetFormatter.format(
+ carId = 1,
+ carName = "Model 3",
+ periodLabel = "Last 30 days",
+ metrics = CostAnalyticsMetrics(),
+ currencySymbol = symbol,
+ noValue = noValue,
+ updatedValue = "Updated 10:00",
+ units = units,
+ )
+
+ assertEquals(CostSummaryWidgetCoverage.None, data.costCoverage)
+ assertEquals(false, data.hasCharges)
+ }
+}
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index ebc05ef4..57c66638 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -400,6 +400,23 @@ Or use Android Studio:
2. Wait for Gradle sync
3. Click Run (green play button)
+### Home-screen widgets
+
+MateDroid ships read-only Glance widgets that never send vehicle commands:
+
+- **Car status** (`CarWidget`) — live-ish status snapshot refreshed by its update worker
+- **Cost summary** (`CostSummaryWidget`) — known charging cost, cost/100 (only when every charge in range is priced), coverage labels, and energy for 7/30/90-day windows. Values come from Room + `SettingsDataStore` only; `DataSyncWorker` triggers an immediate refresh after a successful sync.
+
+Widget formatting must use `UnitFormatter` for labels only — TeslaMateApi already converts numeric values to the user's unit system.
+
+### Room analytics cache
+
+`StatsDatabase` (currently version 13) stores drive/charge summaries plus:
+
+- **Battery health snapshots** (`BatteryHealthSnapshot`) — one max-range sample per car per day when the Battery screen loads, pruned to ~90 rows, used for the Battery history chart
+
+Cost and trip intelligence calculators (`CostAnalyticsCalculator`, `TripsSummaryCalculator`, `TripIntelligenceCalculator`) are pure functions over cached data so widgets and screens stay honest about partial pricing.
+
## Configuration
On first launch, you'll be prompted to configure your TeslamateApi connection: