MO-Fuel-Tax-Back/lib/services/app_state.dart

835 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<CloudStorageProvider> availableProviders = [
GoogleDriveProvider(),
DropboxProvider(),
OneDriveProvider(),
WebDavProvider(),
];
CloudStorageProvider? activeProvider;
CloudSyncService? cloudSync;
final _uuid = const Uuid();
List<Vehicle> vehicles = [];
List<FuelEntry> 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<void> 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;
}
/// Best-effort ad shown after a fuel entry is saved — see
/// [AdService.maybeShowFuelSaveAd]. A no-op for a user with an active ad
/// -free purchase, during the new-user grace period or the first
/// [_fuelSaveAdGraceSaves] saves, while a previously-watched fuel-save
/// ad's credit is still active, or — deliberately — whenever cloud
/// backup isn't configured: [showBackupReminderDialog] already claims
/// that same save's attention in that case (see its call site in
/// `ConfirmFuelEntryScreen._save`), and letting both compete for the
/// same moment is exactly the stacked-full-screen-surfaces problem this
/// scoping avoids. Skipping this way costs nothing — the credit/grace
/// state simply isn't touched, so the check is just as "due" next save.
Future<void> _maybeShowFuelSaveAd() async {
if (adsCurrentlyDisabled || !hasCloudBackupConfigured) return;
if (_inNewUserGracePeriod) return;
if (fuelEntries.length <= _fuelSaveAdGraceSaves) return;
final lastShown = _lastFuelSaveAdShownAt;
if (lastShown != null &&
DateTime.now().difference(lastShown) < _fuelSaveAdCreditDuration &&
_fuelSavesSinceAd < _fuelSaveAdCreditSaves) {
_fuelSavesSinceAd++;
return;
}
if (await adService.maybeShowFuelSaveAd()) {
_lastFuelSaveAdShownAt = DateTime.now();
_fuelSavesSinceAd = 0;
}
}
/// 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<List<ConnectivityResult>>? _connectivitySubscription;
Future<void> 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<void> _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<void> _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<void> 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<void> 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<void> _assumeRootCloudFolder() =>
_selectCloudFolder(parentId: 'root', breadcrumbPath: 'My Files');
Future<void> 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<void> chooseCloudFolder({
required String parentId,
required String breadcrumbPath,
String? currentFolderName,
}) =>
_selectCloudFolder(
parentId: parentId,
breadcrumbPath: breadcrumbPath,
currentFolderName: currentFolderName,
);
Future<void> _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<void> 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<bool> 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<void> setKeepReceiptPhotosLocally(bool value) async {
keepReceiptPhotosLocally = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyKeepReceiptPhotosLocally, value);
notifyListeners();
}
Future<void> setKeepMaxQualityReceiptPhotos(bool value) async {
keepMaxQualityReceiptPhotos = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyKeepMaxQualityReceiptPhotos, value);
notifyListeners();
}
Future<void> setThemeMode(ThemeMode mode) async {
themeMode = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKeyThemeMode, mode.name);
notifyListeners();
}
Future<void> 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<void> 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<void> 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<void> _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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<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) {
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();
unawaited(_maybeShowFuelSaveAd());
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<void> 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<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();
}
/// 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<void> 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<void> 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<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;
}
}
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<void> _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<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}