import 'dart:io'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; import 'package:uuid/uuid.dart'; import '../models/fuel_entry.dart'; import '../models/vehicle.dart'; import 'db_schema.dart'; const dbFileName = 'fuel_tax_tracker.db'; /// OCR-scanned VINs are a fixed alphanumeric charset, but manual entry in /// the vehicle form doesn't enforce that — so anything outside /// `[A-Za-z0-9_-]` is replaced before a VIN is used as a local directory /// name or cloud folder name, to keep it a safe single path segment (no /// `/`, `..`, etc.). String sanitizedPathSegment(String value) => value.trim().replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_'); /// `yyyy.mm` for the given (local) date — the subfolder a receipt is filed /// under within its vehicle's folder, e.g. `2026.08`. Based on the fuel /// entry's purchase date ([FuelEntry.date]), not when the photo happened to /// be taken or synced. String monthFolderName(DateTime date) => '${date.year.toString().padLeft(4, '0')}.${date.month.toString().padLeft(2, '0')}'; /// Name of the *local* on-device staging folder for not-yet-uploaded /// receipt photos — distinct from (though coincidentally the same string /// as) `receiptsFolderName` in `cloud/cloud_storage_provider.dart`, which /// names the corresponding subfolder inside the shared cloud app folder. const localReceiptsFolderName = 'receipts'; /// Owns the local SQLite database (vehicles + fuel_entries) and the /// `receipts/` folder of not-yet-uploaded receipt photos, both under /// `ApplicationDocumentsDirectory/FuelTaxTracker`. /// /// Every row carries `updated_at` (for newest-wins merging), `deleted_at` /// (a soft-delete tombstone — see [Vehicle.deletedAt]/[FuelEntry.deletedAt] /// for why deletes aren't real `DELETE`s), and `dirty` (local-only: "not /// yet pushed to the cloud"). `dirty` is intentionally not exposed on the /// domain model classes — it's sync bookkeeping the UI layer never needs to /// know about; only [CloudSyncService] reads/clears it. class DatabaseService { late Directory _rootDirectory; late Database _db; Directory get rootDirectory => _rootDirectory; Directory get receiptsDirectory => Directory(p.join(_rootDirectory.path, localReceiptsFolderName)); /// Raw handle for [CloudSyncService], which needs to run ATTACH-based /// merge SQL and VACUUM INTO snapshots that go beyond simple CRUD. Database get rawDb => _db; String get databasePath => _db.path; Future init() async { final docsDir = await getApplicationDocumentsDirectory(); _rootDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker')); await _rootDirectory.create(recursive: true); await receiptsDirectory.create(recursive: true); final dbPath = p.join(_rootDirectory.path, dbFileName); _db = await openDatabase( dbPath, version: 4, onCreate: _onCreate, onUpgrade: _onUpgrade, ); } Future _onCreate(Database db, int version) async { await db.execute(createVehiclesTableSql); await db.execute(createFuelEntriesTableSql); await db.execute(createFuelEntriesIndexSql); } /// Versions 2/3 (VIN as primary key, then dropping license plate) were /// rebuilt fresh on upgrade rather than migrated, since at the time that /// only affected local, not-yet-synced test data. Version 4 (VIN becomes /// editable, with a new hidden `id` taking over as the actual primary /// key/merge key) is the first schema change made after real usage had /// likely accumulated, so this one preserves existing rows instead of /// dropping them: each vehicle gets a freshly generated `id`, and /// `fuel_entries.vehicle_id` is repointed from the old `vehicle_vin` via /// that mapping. Future _onUpgrade(Database db, int oldVersion, int newVersion) async { if (oldVersion < 4) { await _migrateToVehicleIdSchema(db); } } Future _migrateToVehicleIdSchema(Database db) async { const uuid = Uuid(); final oldVehicles = await db.query('vehicles'); final vinToId = {}; await db.execute('ALTER TABLE vehicles RENAME TO vehicles_old'); await db.execute(createVehiclesTableSql); for (final row in oldVehicles) { final vin = row['vin'] as String; // An already-upgraded-then-reverted-then-upgraded-again edge case // could in theory produce duplicate vin rows pre-migration; keep the // first id assigned per vin so fuel_entries below has a single, // unambiguous target. final id = vinToId.putIfAbsent(vin, uuid.v4); await db.insert('vehicles', { 'id': id, 'vin': vin, 'nickname': row['nickname'], 'updated_at': row['updated_at'], 'deleted_at': row['deleted_at'], // Force a re-push under the new schema — the shape of what's on // the cloud (if anything's been synced yet) still reflects the old // schema and needs to be overwritten with this one. 'dirty': 1, }); } await db.execute('DROP TABLE vehicles_old'); final oldFuelEntries = await db.query('fuel_entries'); await db.execute('ALTER TABLE fuel_entries RENAME TO fuel_entries_old'); await db.execute(createFuelEntriesTableSql); await db.execute(createFuelEntriesIndexSql); for (final row in oldFuelEntries) { final vehicleId = vinToId[row['vehicle_vin'] as String]; // No matching vehicle row (shouldn't normally happen) — drop rather // than insert a fuel entry with a dangling reference. if (vehicleId == null) continue; await db.insert('fuel_entries', { 'id': row['id'], 'vehicle_id': vehicleId, 'date': row['date'], 'gallons': row['gallons'], 'price_per_gallon': row['price_per_gallon'], 'total_cost': row['total_cost'], 'receipt_image_path': row['receipt_image_path'], 'receipt_drive_file_id': row['receipt_drive_file_id'], 'updated_at': row['updated_at'], 'deleted_at': row['deleted_at'], 'dirty': 1, }); } await db.execute('DROP TABLE fuel_entries_old'); } Future> getVehicles() async { final rows = await _db.query('vehicles', where: 'deleted_at IS NULL'); return rows.map(Vehicle.fromMap).toList(); } Future> getFuelEntries() async { final rows = await _db.query('fuel_entries', where: 'deleted_at IS NULL'); return rows.map(FuelEntry.fromMap).toList(); } /// True if an active (non-deleted) vehicle other than [excludeId] already /// has this VIN. VIN must stay unique even though it's editable, so /// callers adding a new vehicle (no [excludeId]) or changing an existing /// one's VIN (passing its own [id][Vehicle.id] as [excludeId], so it /// doesn't collide with itself) should check this first. Future vinExists(String vin, {String? excludeId}) async { final where = StringBuffer('vin = ? AND deleted_at IS NULL'); final whereArgs = [vin]; if (excludeId != null) { where.write(' AND id != ?'); whereArgs.add(excludeId); } final rows = await _db.query( 'vehicles', where: where.toString(), whereArgs: whereArgs, limit: 1, ); return rows.isNotEmpty; } /// Inserts or fully overwrites a vehicle row and marks it dirty (pending /// push to Drive). Future saveVehicle(Vehicle vehicle) async { final map = Map.from(vehicle.toMap())..['dirty'] = 1; await _db.insert('vehicles', map, conflictAlgorithm: ConflictAlgorithm.replace); } Future saveFuelEntry(FuelEntry entry) async { final map = Map.from(entry.toMap())..['dirty'] = 1; await _db.insert('fuel_entries', map, conflictAlgorithm: ConflictAlgorithm.replace); } Future softDeleteVehicle(String id, DateTime deletedAt) async { await _db.update( 'vehicles', { 'deleted_at': deletedAt.millisecondsSinceEpoch, 'updated_at': deletedAt.millisecondsSinceEpoch, 'dirty': 1, }, where: 'id = ?', whereArgs: [id], ); } Future softDeleteFuelEntry(String id, DateTime deletedAt) async { await _db.update( 'fuel_entries', { 'deleted_at': deletedAt.millisecondsSinceEpoch, 'updated_at': deletedAt.millisecondsSinceEpoch, 'dirty': 1, }, where: 'id = ?', whereArgs: [id], ); } /// Soft-deletes every active fuel entry for [vehicleId] (cascade for a /// vehicle deletion) and returns their local receipt image paths, if /// any, so the caller can clean those files up too. Future> softDeleteFuelEntriesForVehicle( String vehicleId, DateTime deletedAt, ) async { final rows = await _db.query( 'fuel_entries', where: 'vehicle_id = ? AND deleted_at IS NULL', whereArgs: [vehicleId], ); final localPaths = rows.map((r) => r['receipt_image_path'] as String?).whereType().toList(); await _db.update( 'fuel_entries', { 'deleted_at': deletedAt.millisecondsSinceEpoch, 'updated_at': deletedAt.millisecondsSinceEpoch, 'dirty': 1, }, where: 'vehicle_id = ? AND deleted_at IS NULL', whereArgs: [vehicleId], ); return localPaths; } /// Stores under `receipts///`, mirroring the per-vehicle, /// per-month folder structure used on the cloud side (see /// [CloudSyncService]'s `_receiptFolderFor`), so a receipt's local /// staging path already shows which vehicle and month it belongs to. Future storeReceiptImage( File sourceImage, String fuelEntryId, String vin, DateTime date, ) async { final ext = p.extension(sourceImage.path); final entryDir = Directory(p.join( receiptsDirectory.path, sanitizedPathSegment(vin), monthFolderName(date), )); await entryDir.create(recursive: true); final destPath = p.join(entryDir.path, '$fuelEntryId$ext'); final copied = await sourceImage.copy(destPath); return copied.path; } /// Soft-deletes every active vehicle and fuel entry — as if the user had /// deleted each one individually (see [softDeleteVehicle]/ /// [softDeleteFuelEntry] for why these aren't real `DELETE`s: the /// tombstones are what let the deletion propagate to other synced devices /// on the next sync, rather than the rows just being silently re-imported /// from the cloud copy). Also empties the local receipts folder outright /// rather than deleting file-by-file, to catch any orphaned file a /// tracked path might have missed. Future purgeAllData(DateTime deletedAt) async { final ts = deletedAt.millisecondsSinceEpoch; await _db.update( 'fuel_entries', {'deleted_at': ts, 'updated_at': ts, 'dirty': 1}, where: 'deleted_at IS NULL', ); await _db.update( 'vehicles', {'deleted_at': ts, 'updated_at': ts, 'dirty': 1}, where: 'deleted_at IS NULL', ); if (await receiptsDirectory.exists()) { await receiptsDirectory.delete(recursive: true); } await receiptsDirectory.create(recursive: true); } /// Soft-deletes every active fuel entry (across all vehicles) whose /// [FuelEntry.date] falls within [start]–[end] inclusive, and returns /// their local receipt image paths so the caller can clean those files up /// too. Vehicles are left untouched — a date range only makes sense /// against the fuel entries logged against them, not the vehicles /// themselves. Future> purgeFuelEntriesInRange( DateTime start, DateTime end, DateTime deletedAt, ) async { final rangeStart = DateTime(start.year, start.month, start.day).millisecondsSinceEpoch; final rangeEndExclusive = DateTime(end.year, end.month, end.day).add(const Duration(days: 1)).millisecondsSinceEpoch; final ts = deletedAt.millisecondsSinceEpoch; final rows = await _db.query( 'fuel_entries', where: 'deleted_at IS NULL AND date >= ? AND date < ?', whereArgs: [rangeStart, rangeEndExclusive], ); final localPaths = rows.map((r) => r['receipt_image_path'] as String?).whereType().toList(); await _db.update( 'fuel_entries', {'deleted_at': ts, 'updated_at': ts, 'dirty': 1}, where: 'deleted_at IS NULL AND date >= ? AND date < ?', whereArgs: [rangeStart, rangeEndExclusive], ); return localPaths; } Future deleteReceiptImageFile(String? path) async { if (path == null) return; final file = File(path); if (await file.exists()) { await file.delete(); } } }