MO-Fuel-Tax-Back/lib/services/app_state.dart
2026-08-15 19:23:03 -05:00

522 lines
18 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 '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 _prefsKeyThemeMode = 'theme_mode';
const _prefsKeyHasSeenOnboardingTour = 'has_seen_onboarding_tour';
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<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;
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 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;
/// 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<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;
} 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<void> _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<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();
}
/// 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();
}
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,
}) 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;
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<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();
}
/// 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();
}
/// 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) 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).
/// [entryId]'s receipt photo reference (local path and/or cloud file id)
/// carries over untouched.
Future<void> updateFuelEntry({
required String entryId,
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: existing.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;
}