import 'dart:io'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; import '../models/fuel_entry.dart'; import '../models/vehicle.dart'; import 'db_schema.dart'; const dbFileName = 'fuel_tax_tracker.db'; const receiptsFolderName = '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 Drive"). `dirty` is intentionally not exposed on the /// domain model classes — it's sync bookkeeping the UI layer never needs to /// know about; only [DriveSyncService] reads/clears it. class DatabaseService { late Directory _rootDirectory; late Database _db; Directory get rootDirectory => _rootDirectory; Directory get receiptsDirectory => Directory(p.join(_rootDirectory.path, receiptsFolderName)); /// Raw handle for [DriveSyncService], 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: 1, onCreate: _onCreate); } Future _onCreate(Database db, int version) async { await db.execute(createVehiclesTableSql); await db.execute(createFuelEntriesTableSql); await db.execute(createFuelEntriesIndexSql); } 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(); } /// 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; } Future storeReceiptImage(File sourceImage, String fuelEntryId) async { final ext = p.extension(sourceImage.path); final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext'); final copied = await sourceImage.copy(destPath); return copied.path; } Future deleteReceiptImageFile(String? path) async { if (path == null) return; final file = File(path); if (await file.exists()) { await file.delete(); } } }