MO-Fuel-Tax-Back/lib/services/database_service.dart
2026-08-08 16:55:40 -05:00

146 lines
4.8 KiB
Dart

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<void> 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<void> _onCreate(Database db, int version) async {
await db.execute(createVehiclesTableSql);
await db.execute(createFuelEntriesTableSql);
await db.execute(createFuelEntriesIndexSql);
}
Future<List<Vehicle>> getVehicles() async {
final rows = await _db.query('vehicles', where: 'deleted_at IS NULL');
return rows.map(Vehicle.fromMap).toList();
}
Future<List<FuelEntry>> 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<void> saveVehicle(Vehicle vehicle) async {
final map = Map<String, Object?>.from(vehicle.toMap())..['dirty'] = 1;
await _db.insert('vehicles', map, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> saveFuelEntry(FuelEntry entry) async {
final map = Map<String, Object?>.from(entry.toMap())..['dirty'] = 1;
await _db.insert('fuel_entries', map, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> 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<void> 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<List<String>> 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<String>().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<String> 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<void> deleteReceiptImageFile(String? path) async {
if (path == null) return;
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
}