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

411 lines
13 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import 'cloud/cloud_storage_provider.dart';
import 'cloud/dropbox_provider.dart';
import 'cloud/google_drive_provider.dart';
import 'cloud/onedrive_provider.dart';
import 'cloud/webdav_provider.dart';
import 'cloud_sync_service.dart';
import 'database_service.dart';
const _prefsKeyActiveProviderId = 'active_cloud_provider_id';
const _prefsKeyCloudFolderId = 'cloud_folder_id';
const _prefsKeyCloudFolderPath = 'cloud_folder_path';
const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
const defaultStaleLockMinutes = 10;
const minStaleLockMinutes = 1;
const maxStaleLockMinutes = 60;
/// Thrown by [AppState.addVehicle] when the given VIN already belongs to
/// an active vehicle — VIN is the primary/unique identifier, so adding a
/// duplicate should be rejected rather than silently overwriting it.
class DuplicateVinException implements Exception {
final String vin;
DuplicateVinException(this.vin);
@override
String toString() => 'A vehicle with VIN "$vin" already exists.';
}
/// Single source of truth for the app's in-memory data (vehicles + fuel
/// entries), backed by [DatabaseService] (local SQLite) for persistence and
/// a [CloudSyncService] for pushing/pulling the shared copy on whichever
/// [CloudStorageProvider] the user has connected. Screens read from this
/// via Provider and call its mutating methods, which write through to the
/// local database immediately and kick off a best-effort background sync.
///
/// The `vehicles`/`fuelEntries` lists are an in-memory read cache of the
/// database, refreshed after every mutation and after every sync (since
/// sync's merge happens as SQL directly against the database, not by
/// handing back updated Dart objects).
class AppState extends ChangeNotifier {
final DatabaseService database = DatabaseService();
/// Every storage backend the user can choose from in Settings.
final List<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;
/// 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;
} 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,
}) async {
final sync = cloudSync;
if (sync == null) return;
final folderId = await sync.selectAppFolder(parentId);
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();
}
/// 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;
}
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();
}
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;
}
}
Future<void> _persist() async {
await _refreshFromDatabase();
notifyListeners();
unawaited(syncNow());
}
}
extension _FirstOrNull<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}