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

245 lines
7 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import 'database_service.dart';
import 'drive_auth_service.dart';
import 'drive_sync_service.dart';
const _prefsKeyDriveFolderId = 'drive_app_folder_id';
const _prefsKeyDriveFolderPath = 'drive_app_folder_path';
/// Single source of truth for the app's in-memory data (vehicles + fuel
/// entries), backed by [DatabaseService] (local SQLite) for persistence and
/// [DriveSyncService] for pushing/pulling the shared Drive copy. Screens
/// read from this via Provider and call its mutating methods, which write
/// through to the local database immediately and kick off a best-effort
/// background sync to Drive.
///
/// The `vehicles`/`fuelEntries` lists are an in-memory read cache of the
/// database, refreshed after every mutation and after every sync (since
/// sync's merge happens as SQL directly against the database, not by
/// handing back updated Dart objects).
class AppState extends ChangeNotifier {
final DatabaseService database = DatabaseService();
final DriveAuthService driveAuth = DriveAuthService();
late final DriveSyncService driveSync =
DriveSyncService(authService: driveAuth, databaseService: database);
final _uuid = const Uuid();
List<Vehicle> vehicles = [];
List<FuelEntry> fuelEntries = [];
bool isLoading = true;
bool isDriveConnected = false;
String? driveAccountEmail;
String? driveFolderPath;
bool isSyncing = false;
DateTime? lastSyncedAt;
Object? lastSyncError;
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
Future<void> init() async {
await database.init();
await _refreshFromDatabase();
isLoading = false;
notifyListeners();
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) {
if (results.any((r) => r != ConnectivityResult.none)) {
unawaited(syncNow());
}
});
unawaited(_restoreDriveConnection());
}
@override
void dispose() {
_connectivitySubscription?.cancel();
super.dispose();
}
Future<void> _refreshFromDatabase() async {
vehicles = await database.getVehicles();
fuelEntries = await database.getFuelEntries();
}
Future<void> _restoreDriveConnection() async {
final signedIn = await driveAuth.attemptSilentSignIn();
if (!signedIn) return;
isDriveConnected = true;
driveAccountEmail = driveAuth.currentAccountEmail;
final prefs = await SharedPreferences.getInstance();
final folderId = prefs.getString(_prefsKeyDriveFolderId);
driveFolderPath = prefs.getString(_prefsKeyDriveFolderPath);
if (folderId != null) {
driveSync.configure(folderId);
}
notifyListeners();
if (folderId != null) {
unawaited(syncNow());
}
}
Future<void> connectDrive() async {
final email = await driveAuth.signIn();
isDriveConnected = true;
driveAccountEmail = email;
notifyListeners();
}
Future<void> disconnectDrive() async {
await driveAuth.signOut();
driveSync.clearConfiguration();
isDriveConnected = false;
driveAccountEmail = null;
driveFolderPath = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefsKeyDriveFolderId);
await prefs.remove(_prefsKeyDriveFolderPath);
notifyListeners();
}
Future<void> chooseDriveFolder({
required String parentId,
required String breadcrumbPath,
}) async {
final folderId = await driveSync.selectAppFolder(parentId);
driveFolderPath = breadcrumbPath;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKeyDriveFolderId, folderId);
await prefs.setString(_prefsKeyDriveFolderPath, breadcrumbPath);
notifyListeners();
unawaited(syncNow());
}
Future<void> syncNow() async {
if (isSyncing || !driveSync.isConfigured) return;
isSyncing = true;
notifyListeners();
final result = await driveSync.syncNow();
if (result.ranSync) {
await _refreshFromDatabase();
lastSyncedAt = DateTime.now();
lastSyncError = null;
} else if (result.error != null) {
lastSyncError = result.error;
}
isSyncing = false;
notifyListeners();
}
Future<void> addVehicle({
required String make,
required String model,
required String color,
required String licensePlate,
}) async {
final vehicle = Vehicle(
id: _uuid.v4(),
make: make,
model: model,
color: color,
licensePlate: licensePlate,
updatedAt: DateTime.now().toUtc(),
);
await database.saveVehicle(vehicle);
await _persist();
}
Future<void> updateVehicle(Vehicle updated) async {
await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc()));
await _persist();
}
Future<void> deleteVehicle(String vehicleId) async {
final now = DateTime.now().toUtc();
final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(vehicleId, now);
for (final path in orphanedLocalPaths) {
await database.deleteReceiptImageFile(path);
}
await database.softDeleteVehicle(vehicleId, now);
await _persist();
}
Future<FuelEntry> addFuelEntry({
required String vehicleId,
required DateTime date,
required double gallons,
required double pricePerGallon,
required double totalCost,
File? receiptImage,
}) async {
final id = _uuid.v4();
String? storedImagePath;
if (receiptImage != null) {
storedImagePath = await database.storeReceiptImage(receiptImage, id);
}
final entry = FuelEntry(
id: id,
vehicleId: vehicleId,
date: date,
gallons: gallons,
pricePerGallon: pricePerGallon,
totalCost: totalCost,
receiptImagePath: storedImagePath,
updatedAt: DateTime.now().toUtc(),
);
await database.saveFuelEntry(entry);
await _persist();
return entry;
}
Future<void> deleteFuelEntry(String entryId) async {
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
await database.deleteReceiptImageFile(entry.receiptImagePath);
await database.softDeleteFuelEntry(entryId, DateTime.now().toUtc());
await _persist();
}
List<FuelEntry> entriesForVehicle(String vehicleId) {
final list = fuelEntries.where((e) => e.vehicleId == vehicleId).toList();
list.sort((a, b) => b.date.compareTo(a.date));
return list;
}
double totalGallonsForVehicle(String vehicleId) => fuelEntries
.where((e) => e.vehicleId == vehicleId)
.fold(0.0, (sum, e) => sum + e.gallons);
double get totalGallonsAllVehicles =>
fuelEntries.fold(0.0, (sum, e) => sum + e.gallons);
Vehicle? vehicleById(String id) {
try {
return vehicles.firstWhere((v) => v.id == id);
} catch (_) {
return null;
}
}
Future<void> _persist() async {
await _refreshFromDatabase();
notifyListeners();
unawaited(syncNow());
}
}