712 lines
27 KiB
Dart
712 lines
27 KiB
Dart
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;
|
||
|
||
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);
|
||
}
|
||
|
||
/// 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();
|
||
|
||
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();
|
||
}
|
||
|
||
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()? 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 = () async {
|
||
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();
|
||
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();
|
||
unawaited(syncNow());
|
||
}
|
||
}
|
||
|
||
extension _FirstOrNull<T> on Iterable<T> {
|
||
T? get firstOrNull => isEmpty ? null : first;
|
||
}
|