146 lines
4.8 KiB
Dart
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();
|
|
}
|
|
}
|
|
}
|