import 'dart:async'; import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' show ThemeMode; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; import '../models/fuel_entry.dart'; import '../models/vehicle.dart'; import 'ad_service.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'; import 'purchase_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 _prefsKeyThemeMode = 'theme_mode'; const _prefsKeyHasSeenOnboardingTour = 'has_seen_onboarding_tour'; const _prefsKeyHasAcceptedUserAgreement = 'has_accepted_user_agreement'; const _prefsKeyShowEstimatedFuelRefund = 'show_estimated_fuel_refund'; 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(); final AdService adService = AdService(); /// `late final ... =` (lazy) rather than eagerly constructed like /// [adService] above, specifically so the field initializer can /// reference [_grantAdFreeYear]/[_setPurchaseError] as callbacks — by /// the time anything actually touches this field (including /// [dispose]), `this` is fully constructed either way. late final PurchaseService purchaseService = PurchaseService( onPurchaseGranted: _grantAdFreeYear, onPurchaseError: _setPurchaseError, ); /// 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; /// Set by the widget tree at startup (see `main.dart`) to show the /// "remove ads for a year" upsell — [syncNow] calls this the moment an ad /// was actually just shown to gate an upload, never on a sync that finds /// the gate already open from a recent prior view. VoidCallback? onAdWatched; 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; ThemeMode themeMode = ThemeMode.system; /// Whether to show an estimated Missouri Highway Fuel Tax Refund amount /// alongside gallons/cost figures throughout the app (Receipts hero /// banner, receipt line items, report totals — see /// lib/services/estimated_refund.dart). Off by default: it's a rough, /// rate-contingent estimate, not something every user necessarily wants /// cluttering their totals. bool showEstimatedFuelRefund = false; /// Whether the first-launch guided tour ([OnboardingTour]) has already /// been shown. Defaults to `true` here (not `false`) specifically so /// that widget tests constructing `AppState()` directly and skipping /// [init] — the established pattern throughout this app's test suite, /// to avoid init()'s database/platform-channel dependencies — don't get /// an unexpected full-screen tour overlay blocking every tap. [init] /// overwrites this from the persisted value (defaulting to `false` /// there) for real app startups, where a missing pref genuinely means /// "never shown before." bool hasSeenOnboardingTour = true; /// Whether the user has accepted the user agreement (see /// lib/screens/user_agreement_screen.dart) — gates every screen in the /// app, including the onboarding tour, until accepted. Defaults to /// `true` here for the same widget-test-bypass reason as /// [hasSeenOnboardingTour] above; [init] overwrites it from the /// persisted value (defaulting to `false` there) for real app startups. bool hasAcceptedUserAgreement = true; /// 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; /// The furthest-known "ads disabled until" date from the "remove ads for /// a year" purchase — null if never purchased (or the last purchase's /// year has fully lapsed and nothing newer has been merged in). Synced /// through the cloud like vehicles/fuel entries — see /// [DatabaseService.getAdFreeUntil]. DateTime? adFreeUntil; /// A user-facing message from the most recent failed purchase attempt, /// for the Settings screen to display — mirrors [lastSyncError]'s role /// for sync failures. String? purchaseError; /// The earliest-known moment this app was ever used — null until [init] /// loads it. Drives the new-user ad grace period (see /// [maybeShowFuelSaveAd]/[maybeShowReportAd]); see /// [DatabaseService.getOrCreateFirstUsedAt] for why this is synced /// rather than a local-only preference. DateTime? firstUsedAt; /// True if there's local data not yet pushed to the cloud — either /// nothing has synced yet, or something changed since the last /// successful push. Refreshed alongside [vehicles]/[fuelEntries] in /// [_refreshFromDatabase], so it's accurate after every local mutation /// and every sync attempt. Drives the "not backed up" icon (see /// `CloudBackupActionButton`) once backup *is* configured — before that, /// [hasCloudBackupConfigured] alone already covers it. bool hasUnsyncedChanges = false; bool get isCloudConnected => activeProvider?.isSignedIn ?? false; String? get cloudAccountLabel => activeProvider?.accountLabel; /// True once a provider is connected *and* a backup folder has actually /// been picked — [isCloudConnected] alone isn't enough, since a signed-in /// provider with no folder chosen yet still won't back anything up (see /// [CloudSyncService.isConfigured]). Drives the "your data isn't backed /// up" reminder shown after saving a fuel entry — see /// lib/widgets/backup_reminder.dart. bool get hasCloudBackupConfigured => cloudSync?.isConfigured ?? false; bool get adsCurrentlyDisabled { final until = adFreeUntil; return until != null && DateTime.now().toUtc().isBefore(until); } /// How long after first ever opening this app (see [firstUsedAt]) a new /// user sees no interstitials at all — shared by the report and /// fuel-save placements. static const _newUserAdGracePeriod = Duration(minutes: 5); /// Below this many total fuel entries ever saved, the fuel-save /// interstitial doesn't show — on top of, not instead of, /// [_newUserAdGracePeriod]. static const _fuelSaveAdGraceSaves = 4; /// Watching the fuel-save interstitial buys a credit that lasts until /// *either* of these runs out, whichever comes first. static const _fuelSaveAdCreditDuration = Duration(minutes: 5); static const _fuelSaveAdCreditSaves = 2; /// Watching the report interstitial buys this much flat credit. static const _reportAdCreditDuration = Duration(minutes: 10); DateTime? _lastFuelSaveAdShownAt; int _fuelSavesSinceAd = 0; DateTime? _lastReportAdShownAt; /// Preloads the report-export interstitial (see [AdService.preloadReportAd]) /// — call once when the Reports tab is first built, so an ad is ready by /// the time [maybeShowReportAd] is called. A no-op for a user with an /// active ad-free purchase. void preloadReportAd() { if (!adsCurrentlyDisabled) unawaited(adService.preloadReportAd()); } /// Best-effort ad shown after a report export/share/print completes — /// see [AdService.maybeShowReportAd]. A no-op for a user with an active /// ad-free purchase, during the new-user grace period (see /// [firstUsedAt]), or while a previously-watched report ad's credit is /// still active. Future maybeShowReportAd() async { if (adsCurrentlyDisabled || _inNewUserGracePeriod) return; final lastShown = _lastReportAdShownAt; if (lastShown != null && DateTime.now().difference(lastShown) < _reportAdCreditDuration) { return; } if (await adService.maybeShowReportAd()) { _lastReportAdShownAt = DateTime.now(); } } /// Preloads the fuel-save interstitial (see [AdService.preloadFuelSaveAd]) /// — call once when the confirm-entry screen is first built. A no-op for /// a user with an active ad-free purchase. void preloadFuelSaveAd() { if (!adsCurrentlyDisabled) unawaited(adService.preloadFuelSaveAd()); } bool get _inNewUserGracePeriod { final firstUsed = firstUsedAt; return firstUsed == null || DateTime.now().difference(firstUsed) < _newUserAdGracePeriod; } /// Ad shown on saving a fuel entry — see [AdService.maybeShowFuelSaveAd]. /// /// Called by `ConfirmFuelEntryScreen._save` *after* [addFuelEntry] has /// already committed the entry, and awaited before that screen pops. So /// it reads to the user as "tap Save, see the ad, land back on the /// list", while the entry itself is never actually waiting on the ad — /// which matters both for their data (nothing is lost if the app dies /// mid-ad) and for AdMob's rule that a plain interstitial must never /// gate access to app functionality. That's the same rule that forced /// the cloud-sync gate to a *rewarded* ad; the difference here is that /// nothing is being withheld pending the ad. /// /// A no-op for a user with an active ad-free purchase, during the /// new-user grace period or the first [_fuelSaveAdGraceSaves] saves, or /// while a previously-watched fuel-save ad's credit is still active. /// /// Returns whether an ad actually showed. The caller uses that to hold /// back [showBackupReminderDialog] for this one save: only ever one /// full-screen surface per save (the "post-save arbitration" rule), but /// the ad takes precedence rather than being suppressed outright — the /// reminder recurs on the next save anyway, whereas suppressing meant a /// user who never configured backup never saw this placement at all. Future maybeShowFuelSaveAd() async { if (adsCurrentlyDisabled) return false; if (_inNewUserGracePeriod) return false; if (fuelEntries.length <= _fuelSaveAdGraceSaves) return false; final lastShown = _lastFuelSaveAdShownAt; if (lastShown != null && DateTime.now().difference(lastShown) < _fuelSaveAdCreditDuration && _fuelSavesSinceAd < _fuelSaveAdCreditSaves) { _fuelSavesSinceAd++; return false; } if (await adService.maybeShowFuelSaveAd()) { _lastFuelSaveAdShownAt = DateTime.now(); _fuelSavesSinceAd = 0; return true; } return false; } /// The store's own formatted, localized price for the ad-free-year /// purchase (e.g. `"$4.99"`) — null until [PurchaseService.initialize] /// has loaded it, or if the product isn't configured in the store yet. String? get adFreeYearPriceLabel => purchaseService.priceLabel; StreamSubscription>? _connectivitySubscription; Future init() async { isLoading = true; initError = null; notifyListeners(); try { await database.init(); await _refreshFromDatabase(); firstUsedAt = await database.getOrCreateFirstUsedAt(); final prefs = await SharedPreferences.getInstance(); keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false; staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes; keepMaxQualityReceiptPhotos = prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false; themeMode = ThemeMode.values.firstWhere( (mode) => mode.name == prefs.getString(_prefsKeyThemeMode), orElse: () => ThemeMode.system, ); hasSeenOnboardingTour = prefs.getBool(_prefsKeyHasSeenOnboardingTour) ?? false; hasAcceptedUserAgreement = prefs.getBool(_prefsKeyHasAcceptedUserAgreement) ?? false; showEstimatedFuelRefund = prefs.getBool(_prefsKeyShowEstimatedFuelRefund) ?? 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()); // Independent of cloud sync (unlike adService, which only ever starts // once a sync is actually attempted) — a user should be able to buy // ad-free time whether or not they've ever connected cloud storage, so // this loads eagerly at startup. Non-blocking: a slow/unavailable // store connection should never delay the rest of app startup. unawaited(purchaseService.initialize()); } @override void dispose() { _connectivitySubscription?.cancel(); purchaseService.dispose(); super.dispose(); } Future _refreshFromDatabase() async { vehicles = await database.getVehicles(); fuelEntries = await database.getFuelEntries(); adFreeUntil = await database.getAdFreeUntil(); hasUnsyncedChanges = await database.hasDirtyRows(); } 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(); await _assumeRootCloudFolder(); } /// 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(); await _assumeRootCloudFolder(); } /// Called right after a fresh connect: rather than making the user /// immediately go pick a folder before anything can sync, assume the /// root of the provider ("My Files") — matching /// [CloudFolderBrowserScreen]'s own root label — is where the app's /// folder belongs, the same way [chooseCloudFolder] would if the user /// had picked it themselves. If they later pick somewhere else, /// [CloudSyncService.selectAppFolder] moves this same folder (and /// everything already synced into it) there instead of abandoning it. Future _assumeRootCloudFolder() => _selectCloudFolder(parentId: 'root', breadcrumbPath: 'My Files'); 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, }) => _selectCloudFolder( parentId: parentId, breadcrumbPath: breadcrumbPath, currentFolderName: currentFolderName, ); Future _selectCloudFolder({ 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; // A user with a currently-active "remove ads for a year" purchase // skips the gate entirely — beforeUpload stays null (CloudSyncService // treats that as "nothing to check", same as any other call site that // never passed one) and the ad SDK isn't even touched this sync. Future Function({required bool everSyncedBefore})? beforeUpload; if (!adsCurrentlyDisabled) { // Idempotent, and only ever reached once sync is actually configured // — a user who never connects cloud storage never triggers any // ad-related activity at all. unawaited(adService.initialize()); beforeUpload = ({required everSyncedBefore}) async { // The very first sync a cloud folder ever sees goes through for // free — see [CloudSyncService.syncNow]'s `everSyncedBefore` doc. if (!everSyncedBefore) return true; final result = await adService.showGateAd(); // Fired, not awaited: the upsell dialog is purely informational — // this sync (specifically the upload [beforeUpload] is about to // unblock) shouldn't wait on however long it takes the user to // dismiss it. if (result == AdGateResult.justShown) onAdWatched?.call(); return result != AdGateResult.blocked; }; } isSyncing = true; notifyListeners(); final result = await sync.syncNow( keepLocalReceiptCopies: keepReceiptPhotosLocally, staleLockAge: Duration(minutes: staleLockMinutes), beforeUpload: beforeUpload, ); if (result.ranSync) { await _refreshFromDatabase(); lastSyncedAt = DateTime.now(); lastSyncError = null; } else if (result.adGateBlocked) { // Whatever the remote side had was still pulled/merged in — only the // upload was withheld — so the on-screen data should reflect that // even though this doesn't count as a completed sync. await _refreshFromDatabase(); lastSyncError = 'Watch a short ad to finish syncing your data to the cloud.'; } 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(); } Future setThemeMode(ThemeMode mode) async { themeMode = mode; final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefsKeyThemeMode, mode.name); notifyListeners(); } Future setShowEstimatedFuelRefund(bool value) async { showEstimatedFuelRefund = value; final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_prefsKeyShowEstimatedFuelRefund, value); notifyListeners(); } /// Kicks off the platform purchase UI for a year of no ads — see /// [PurchaseService.buyAdFreeYear]. The actual entitlement is granted /// asynchronously, once the store confirms the purchase (see /// [_grantAdFreeYear]), not immediately when this returns. Future buyAdFreeYear() => purchaseService.buyAdFreeYear(); /// Re-checks Play Billing for an active purchase and, if one is found, /// re-grants the entitlement locally — see [PurchaseService.restorePurchase]. /// For a user who reinstalled or switched devices without cloud backup /// connected, so there was nothing local to sync the entitlement back /// down from. Safe to call any time; does nothing if there's no active /// purchase to find. Future restoreAdFreeYear() => purchaseService.restorePurchase(); /// [PurchaseService]'s `onPurchaseGranted` callback: records a year of /// ad-free time anchored to [purchaseTime] — Play Billing's own record of /// when the purchase actually happened, not "now" — so restoring a /// purchase made months ago correctly reflects however much of that year /// is already gone, rather than handing out a fresh extra year. Buying /// again before the current year lapses simply resets the clock to a /// fresh year from that new purchase, rather than stacking. Future _grantAdFreeYear(DateTime purchaseTime) async { await database.setAdFreeUntil( purchaseTime.add(const Duration(days: 365)), DateTime.now().toUtc(), ); purchaseError = null; await _persist(); } /// [PurchaseService]'s `onPurchaseError` callback. void _setPurchaseError(String message) { purchaseError = message; notifyListeners(); } /// Called once the first-launch guided tour finishes or is skipped, so /// it never shows again on this device. Future markOnboardingTourSeen() async { hasSeenOnboardingTour = true; final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_prefsKeyHasSeenOnboardingTour, true); notifyListeners(); } /// Called once the user taps "I Agree" on the user agreement screen, so /// it never shows again on this device. Future acceptUserAgreement() async { hasAcceptedUserAgreement = true; final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_prefsKeyHasAcceptedUserAgreement, true); 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; } /// Corrects the logged values (date, gallons, price/gal, total cost, and /// which vehicle it's attached to) for an existing fuel entry — the /// receipt photo itself isn't editable here, only the data recorded /// about it (e.g. fixing a misread OCR value, or a receipt that got /// logged under the wrong vehicle). [entryId]'s receipt photo reference /// (local path and/or cloud file id) carries over untouched even when /// [vehicleId] changes — the underlying photo file stays exactly where /// it already is (including whichever vehicle's folder it was filed /// under locally/in the cloud); only the database's own record of which /// vehicle owns this entry moves. Future updateFuelEntry({ required String entryId, required String vehicleId, required DateTime date, required double gallons, required double pricePerGallon, required double totalCost, }) async { final existing = fuelEntries.firstWhere((e) => e.id == entryId); final updated = FuelEntry( id: existing.id, vehicleId: vehicleId, date: date, gallons: gallons, pricePerGallon: pricePerGallon, totalCost: totalCost, receiptImagePath: existing.receiptImagePath, receiptDriveFileId: existing.receiptDriveFileId, updatedAt: DateTime.now().toUtc(), ); await database.saveFuelEntry(updated); await _persist(); } 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(); } /// Wipes every vehicle, fuel entry, and local receipt photo — "starting /// completely fresh". Uses the same soft-delete tombstones as any other /// delete rather than a hard SQL `DELETE`, so if a cloud sync is /// connected, the deletion pushes on next sync instead of the old data /// just getting silently re-imported from the remote copy. This is purely /// local + whatever propagates through sync — it never reaches into the /// cloud folder directly to delete anything sitting there. Future purgeAllData() async { await database.purgeAllData(DateTime.now().toUtc()); await _persist(); } /// Wipes every fuel entry (and its local receipt photo) dated within /// [start]–[end] inclusive, across all vehicles. Vehicles themselves are /// left alone. Same soft-delete/sync-propagation reasoning as /// [purgeAllData]. Future purgeFuelEntriesInRange(DateTime start, DateTime end) async { final localPaths = await database.purgeFuelEntriesInRange(start, end, DateTime.now().toUtc()); for (final path in localPaths) { await database.deleteReceiptImageFile(path); } 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; } } FuelEntry? fuelEntryById(String id) { try { return fuelEntries.firstWhere((e) => e.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(); // Only a paid ("remove ads for a year") user gets synced automatically // on every change — for anyone else this would mean an ad-gated // syncNow() firing silently in the background, disconnected from // anything the user just did, which is exactly the surprise-ad problem // this app's ad placements are designed to avoid everywhere else. // Sync still always happens on demand: `Data > Sync Now`, tapping // `CloudBackupActionButton` when it shows "not synced", and connecting // a provider/folder for the first time (see [selectAppFolder]) all // call [syncNow] directly, and the very first sync a folder ever sees // is free regardless (see [syncNow]'s `everSyncedBefore` handling). if (adsCurrentlyDisabled) unawaited(syncNow()); } } extension _FirstOrNull on Iterable { T? get firstOrNull => isEmpty ? null : first; }