277 lines
10 KiB
Dart
277 lines
10 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 '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<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: 4,
|
|
onCreate: _onCreate,
|
|
onUpgrade: _onUpgrade,
|
|
);
|
|
}
|
|
|
|
Future<void> _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<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
|
if (oldVersion < 4) {
|
|
await _migrateToVehicleIdSchema(db);
|
|
}
|
|
}
|
|
|
|
Future<void> _migrateToVehicleIdSchema(Database db) async {
|
|
const uuid = Uuid();
|
|
|
|
final oldVehicles = await db.query('vehicles');
|
|
final vinToId = <String, String>{};
|
|
|
|
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<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();
|
|
}
|
|
|
|
/// 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<bool> vinExists(String vin, {String? excludeId}) async {
|
|
final where = StringBuffer('vin = ? AND deleted_at IS NULL');
|
|
final whereArgs = <Object?>[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<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;
|
|
}
|
|
|
|
/// Stores under `receipts/<vin>/<yyyy.mm>/`, 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<String> 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;
|
|
}
|
|
|
|
Future<void> deleteReceiptImageFile(String? path) async {
|
|
if (path == null) return;
|
|
final file = File(path);
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
}
|
|
}
|
|
}
|