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 'cloud/cloud_storage_provider.dart'; import 'cloud/dropbox_provider.dart'; import 'cloud/google_drive_provider.dart'; import 'cloud/onedrive_provider.dart'; import 'cloud/webdav_provider.dart'; import 'cloud_sync_service.dart'; import 'database_service.dart'; const _prefsKeyActiveProviderId = 'active_cloud_provider_id'; const _prefsKeyCloudFolderId = 'cloud_folder_id'; const _prefsKeyCloudFolderPath = 'cloud_folder_path'; const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally'; const _prefsKeyStaleLockMinutes = 'stale_lock_minutes'; const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos'; const defaultStaleLockMinutes = 10; const minStaleLockMinutes = 1; const maxStaleLockMinutes = 60; /// Thrown by [AppState.addVehicle] when the given VIN already belongs to /// an active vehicle — VIN is the primary/unique identifier, so adding a /// duplicate should be rejected rather than silently overwriting it. class DuplicateVinException implements Exception { final String vin; DuplicateVinException(this.vin); @override String toString() => 'A vehicle with VIN "$vin" already exists.'; } /// Single source of truth for the app's in-memory data (vehicles + fuel /// entries), backed by [DatabaseService] (local SQLite) for persistence and /// a [CloudSyncService] for pushing/pulling the shared copy on whichever /// [CloudStorageProvider] the user has connected. 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. /// /// 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(); /// Every storage backend the user can choose from in Settings. final List availableProviders = [ GoogleDriveProvider(), DropboxProvider(), OneDriveProvider(), WebDavProvider(), ]; CloudStorageProvider? activeProvider; CloudSyncService? cloudSync; final _uuid = const Uuid(); List vehicles = []; List fuelEntries = []; bool isLoading = true; String? cloudFolderPath; bool isSyncing = false; DateTime? lastSyncedAt; Object? lastSyncError; bool keepReceiptPhotosLocally = false; int staleLockMinutes = defaultStaleLockMinutes; /// When false (default), a newly captured receipt photo is downscaled /// and re-compressed to [receiptImageMaxDimension]/[receiptImageQuality] /// before it's stored — receipts are just photos of small printed text, /// so a full-resolution original (often several MB on a modern phone /// camera) buys nothing but cloud storage and sync bandwidth. When true, /// the original camera/gallery image is kept as-is. bool keepMaxQualityReceiptPhotos = false; /// Set if [init] fails. The UI shows this (with a retry option) instead /// of spinning forever — an unhandled exception here previously left /// `isLoading` stuck at true with no feedback at all. Object? initError; bool get isCloudConnected => activeProvider?.isSignedIn ?? false; String? get cloudAccountLabel => activeProvider?.accountLabel; StreamSubscription>? _connectivitySubscription; Future init() async { isLoading = true; initError = null; notifyListeners(); try { await database.init(); await _refreshFromDatabase(); final prefs = await SharedPreferences.getInstance(); keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false; staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes; keepMaxQualityReceiptPhotos = prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false; } catch (e) { initError = e; isLoading = false; notifyListeners(); return; } isLoading = false; notifyListeners(); _connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) { if (results.any((r) => r != ConnectivityResult.none)) { unawaited(syncNow()); } }); unawaited(_restoreCloudConnection()); } @override void dispose() { _connectivitySubscription?.cancel(); super.dispose(); } Future _refreshFromDatabase() async { vehicles = await database.getVehicles(); fuelEntries = await database.getFuelEntries(); } CloudStorageProvider? _providerById(CloudProviderId id) { for (final provider in availableProviders) { if (provider.id == id) return provider; } return null; } Future _restoreCloudConnection() async { final prefs = await SharedPreferences.getInstance(); final storedProviderName = prefs.getString(_prefsKeyActiveProviderId); if (storedProviderName == null) return; final matchingId = CloudProviderId.values .where((id) => id.name == storedProviderName) .firstOrNull; final provider = matchingId == null ? null : _providerById(matchingId); if (provider == null) return; final signedIn = await provider.attemptSilentSignIn(); if (!signedIn) return; activeProvider = provider; cloudSync = CloudSyncService(provider: provider, databaseService: database); final folderId = prefs.getString(_prefsKeyCloudFolderId); cloudFolderPath = prefs.getString(_prefsKeyCloudFolderPath); if (folderId != null) { cloudSync!.configure(folderId); } notifyListeners(); if (folderId != null) { unawaited(syncNow()); } } Future connectProvider(CloudProviderId id) async { final provider = _providerById(id); if (provider == null) return; await provider.signIn(); activeProvider = provider; cloudSync = CloudSyncService(provider: provider, databaseService: database); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefsKeyActiveProviderId, id.name); notifyListeners(); } /// Like [connectProvider], but for a [ManualCredentialCloudStorageProvider] /// (currently just WebDAV) that needs a server URL/username/password /// instead of an OAuth browser flow. The caller (Settings) is responsible /// for collecting those from the user first. Future connectProviderWithCredentials( CloudProviderId id, { required String serverUrl, required String username, required String password, }) async { final provider = _providerById(id); if (provider == null || provider is! ManualCredentialCloudStorageProvider) return; await (provider as ManualCredentialCloudStorageProvider).signInWithCredentials( serverUrl: serverUrl, username: username, password: password, ); activeProvider = provider; cloudSync = CloudSyncService(provider: provider, databaseService: database); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefsKeyActiveProviderId, id.name); notifyListeners(); } Future disconnectCloud() async { final provider = activeProvider; if (provider == null) return; await provider.signOut(); cloudSync?.clearConfiguration(); activeProvider = null; cloudSync = null; cloudFolderPath = null; final prefs = await SharedPreferences.getInstance(); await prefs.remove(_prefsKeyActiveProviderId); await prefs.remove(_prefsKeyCloudFolderId); await prefs.remove(_prefsKeyCloudFolderPath); notifyListeners(); } Future chooseCloudFolder({ required String parentId, required String breadcrumbPath, String? currentFolderName, }) async { final sync = cloudSync; if (sync == null) return; final folderId = await sync.selectAppFolder(parentId, currentFolderName: currentFolderName); cloudFolderPath = breadcrumbPath; final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefsKeyCloudFolderId, folderId); await prefs.setString(_prefsKeyCloudFolderPath, breadcrumbPath); notifyListeners(); unawaited(syncNow()); } Future syncNow() async { final sync = cloudSync; if (isSyncing || sync == null || !sync.isConfigured) return; isSyncing = true; notifyListeners(); final result = await sync.syncNow( keepLocalReceiptCopies: keepReceiptPhotosLocally, staleLockAge: Duration(minutes: staleLockMinutes), ); if (result.ranSync) { await _refreshFromDatabase(); lastSyncedAt = DateTime.now(); lastSyncError = null; } else if (result.error != null) { lastSyncError = result.error; } isSyncing = false; notifyListeners(); } Future setKeepReceiptPhotosLocally(bool value) async { keepReceiptPhotosLocally = value; final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_prefsKeyKeepReceiptPhotosLocally, value); notifyListeners(); } Future setKeepMaxQualityReceiptPhotos(bool value) async { keepMaxQualityReceiptPhotos = value; final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_prefsKeyKeepMaxQualityReceiptPhotos, value); notifyListeners(); } /// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the /// Settings "Advanced" section, which restricts the picker to that range /// anyway; this is a defensive backstop for any other caller. Future setStaleLockMinutes(int minutes) async { staleLockMinutes = minutes.clamp(minStaleLockMinutes, maxStaleLockMinutes); final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_prefsKeyStaleLockMinutes, staleLockMinutes); notifyListeners(); } /// Throws [DuplicateVinException] if [vin] already belongs to another /// active vehicle — VIN must stay unique even though it's editable, so /// silently letting a duplicate through would be a real correctness bug, /// not just a UX wrinkle. Future addVehicle({ required String vin, String? nickname, }) async { if (await database.vinExists(vin)) { throw DuplicateVinException(vin); } final vehicle = Vehicle( id: _uuid.v4(), vin: vin, nickname: nickname, updatedAt: DateTime.now().toUtc(), ); await database.saveVehicle(vehicle); await _persist(); } /// Throws [DuplicateVinException] if [updated]'s VIN now collides with /// another active vehicle's — this is the check [addVehicle] does for a /// new vehicle, but here it also has to exclude the vehicle being edited /// itself (its VIN obviously still matches its own prior value if it /// wasn't changed). Future updateVehicle(Vehicle updated) async { if (await database.vinExists(updated.vin, excludeId: updated.id)) { throw DuplicateVinException(updated.vin); } await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc())); await _persist(); } Future deleteVehicle(String id) async { final now = DateTime.now().toUtc(); final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(id, now); for (final path in orphanedLocalPaths) { await database.deleteReceiptImageFile(path); } await database.softDeleteVehicle(id, now); await _persist(); } Future 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) { final vin = vehicles.firstWhere((v) => v.id == vehicleId).vin; storedImagePath = await database.storeReceiptImage(receiptImage, id, vin, date); } 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 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 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; } } /// Exact match against an active vehicle's VIN — same comparison /// [DatabaseService.vinExists] does, not case-insensitive, since VIN /// isn't normalized to any particular case on manual entry (only OCR /// scanning uppercases it). Vehicle? vehicleByVin(String vin) { try { return vehicles.firstWhere((v) => v.vin == vin); } catch (_) { return null; } } Future _persist() async { await _refreshFromDatabase(); notifyListeners(); unawaited(syncNow()); } } extension _FirstOrNull on Iterable { T? get firstOrNull => isEmpty ? null : first; }