diff --git a/CHANGELOG.md b/CHANGELOG.md index eeea6191..75369792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **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..a40452c6 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,19 @@ 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, ) { val isConfigured: Boolean get() = serverUrl.isNotBlank() @@ -76,9 +88,12 @@ 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") val notificationPermissionAsked: Flow = context.dataStore.data.map { preferences -> preferences[notificationPermissionAskedKey] ?: false @@ -95,7 +110,10 @@ 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(), ) } @@ -194,6 +212,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 +250,36 @@ 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() + } + } + } + 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..b037e38a --- /dev/null +++ b/app/src/main/java/com/matedroid/domain/CostAnalyticsRepository.kt @@ -0,0 +1,117 @@ +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 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, + ) + CostAnalyticsSnapshot( + metrics = metrics, + charges = charges, + currencySymbol = currency.symbol, + currencyCode = currency.code, + unitOfLength = settings.unitOfLength, + ) + } + + /** 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, +) 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..8bea254c --- /dev/null +++ b/app/src/main/java/com/matedroid/ui/screens/costs/CostAnalyticsScreen.kt @@ -0,0 +1,478 @@ +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.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.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, + palette = palette, + onRangeSelected = viewModel::setRange, + modifier = Modifier.padding(padding), + ) + } + } +} + +@Composable +private fun CostAnalyticsContent( + metrics: CostAnalyticsMetrics, + range: CostAnalyticsRange, + units: Units?, + currencySymbol: String, + 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 (metrics.chargeCount == 0) { + item { + EmptyStateCard(palette = palette) + } + } + } +} + +@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..6ce4462d --- /dev/null +++ b/app/src/main/java/com/matedroid/ui/screens/costs/CostAnalyticsViewModel.kt @@ -0,0 +1,108 @@ +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 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 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), + ) + } + } 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..b65b33e4 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 @@ -127,6 +127,8 @@ fun SettingsScreen( onAcceptInvalidCertsChange = viewModel::updateAcceptInvalidCerts, onCurrencyChange = viewModel::updateCurrency, onShowShortDrivesChargesChange = viewModel::updateShowShortDrivesCharges, + onHomeUtilityRateChange = viewModel::updateHomeUtilityRate, + onDcUtilityRateChange = viewModel::updateDcUtilityRate, onTestConnection = viewModel::testConnection, onSave = { viewModel.saveSettings(onNavigateToDashboard) }, onPalettePreview = onNavigateToPalettePreview, @@ -198,6 +200,8 @@ private fun SettingsContent( onAcceptInvalidCertsChange: (Boolean) -> Unit, onCurrencyChange: (String) -> Unit, onShowShortDrivesChargesChange: (Boolean) -> Unit, + onHomeUtilityRateChange: (String) -> Unit = {}, + onDcUtilityRateChange: (String) -> Unit = {}, onTestConnection: () -> Unit, onSave: () -> Unit, onPalettePreview: () -> Unit = {}, @@ -485,6 +489,58 @@ 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(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..4cc02f6e 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,10 @@ 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 = "", val isLoading: Boolean = true, val isTesting: Boolean = false, val isSaving: Boolean = false, @@ -105,6 +109,8 @@ class SettingsViewModel @Inject constructor( acceptInvalidCerts = settings.acceptInvalidCerts, currencyCode = settings.currencyCode, showShortDrivesCharges = settings.showShortDrivesCharges, + homeUtilityRateInput = settings.homeUtilityRatePerKwh?.let(::formatRate) ?: "", + dcUtilityRateInput = settings.dcUtilityRatePerKWh?.let(::formatRate) ?: "", isLoading = false ) } @@ -180,6 +186,47 @@ 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)) + } + } + + /** + * 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 +306,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..3e903489 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,13 @@ 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 Selecciona moneda @@ -511,6 +520,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 +1116,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 +1312,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..9c175b46 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,13 @@ 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 Kosten­schä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 Währung auswählen @@ -511,6 +520,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 +1144,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 +1427,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..8a0960ae 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,13 @@ 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 Seleccionar moneda @@ -511,6 +520,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 +1116,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 +1312,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..e5bef8a1 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,15 @@ 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 + Impostazioni extra @@ -511,6 +522,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 +1118,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 +1314,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..6e537042 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,13 @@ 费用货币 + 电价 + 在 TeslaMate 未设置价格时,用于估算充电费用。请按上方所选货币输入本地每 kWh 电价。 + 家用电价(每 kWh) + 适用于交流/家用充电,以及行驶费用估算。 + 直流 / 公共电价(每 kWh,可选) + 适用于快充。若留空,直流充电将使用家用电价。 + 例如 0.15 选择货币 @@ -510,6 +519,13 @@ 预估衰减 电池衰减根据 TeslaMate 数据估算。\n\n• 容量 (%):与新电池相比的剩余电池健康度\n• 损失 (kWh):因衰减而损失的能量容量\n• 损失 (%):原始容量的损失百分比\n\n注意:这是一个估算值。Tesla 不提供官方衰减数据。 + + 最大续航变化 + 最大续航历史 + 每次你打开此界面,MateDroid 会记录一次电池最大续航快照(每天最多一次)。图表显示自开始使用应用以来的变化。\n\n续航会随温度和驾驶风格波动 — 短期下降通常是噪声,而非衰减。 + %1$s – %2$s + 再过一天回来 — 至少需要两次快照才能绘制趋势。 + 续航 最大续航(出厂) @@ -1101,10 +1117,27 @@ 驾驶时间 充入能量 充电费用 + 费用包含 %1$d/%2$d 次充电记录 + + 这些行程没有记录充电费用 能耗 费用 / 100 %1$s + + + 行程亮点 + 最省电 + 最耗电 + 最便宜 / 100 %1$s + 最贵 / 100 %1$s + 最长行程 + 充电占比最高 + 能量差 + 平均充电时长 / 行程 + %1$d%% + 打开行程 + 行驶 %1$d 充电 %1$d @@ -1301,4 +1334,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..13561360 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,15 @@ 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 + Extra settings @@ -514,6 +527,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 +1156,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 +1467,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/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: