243 lines
6.9 KiB
Dart
243 lines
6.9 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 'drive_auth_service.dart';
|
|
import 'drive_sync_service.dart';
|
|
import 'storage_service.dart';
|
|
|
|
const _prefsKeyDriveFolderId = 'drive_app_folder_id';
|
|
const _prefsKeyDriveFolderPath = 'drive_app_folder_path';
|
|
|
|
/// Single source of truth for the app's in-memory data (vehicles + fuel
|
|
/// entries), backed by [StorageService] for local persistence and
|
|
/// [DriveSyncService] for pushing/pulling the shared Drive copy. Screens
|
|
/// read from this via Provider and call its mutating methods, which write
|
|
/// through to local disk immediately and kick off a best-effort background
|
|
/// sync to Drive.
|
|
class AppState extends ChangeNotifier {
|
|
final StorageService storage = StorageService();
|
|
final DriveAuthService driveAuth = DriveAuthService();
|
|
late final DriveSyncService driveSync =
|
|
DriveSyncService(authService: driveAuth, storageService: storage);
|
|
final _uuid = const Uuid();
|
|
|
|
List<Vehicle> vehicles = [];
|
|
List<FuelEntry> fuelEntries = [];
|
|
bool isLoading = true;
|
|
|
|
bool isDriveConnected = false;
|
|
String? driveAccountEmail;
|
|
String? driveFolderPath;
|
|
bool isSyncing = false;
|
|
DateTime? lastSyncedAt;
|
|
Object? lastSyncError;
|
|
|
|
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
|
|
|
Future<void> init() async {
|
|
await storage.init();
|
|
final data = await storage.loadData();
|
|
vehicles = data.vehicles;
|
|
fuelEntries = data.entries;
|
|
isLoading = false;
|
|
notifyListeners();
|
|
|
|
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) {
|
|
if (results.any((r) => r != ConnectivityResult.none)) {
|
|
unawaited(syncNow());
|
|
}
|
|
});
|
|
|
|
unawaited(_restoreDriveConnection());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_connectivitySubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _restoreDriveConnection() async {
|
|
final signedIn = await driveAuth.attemptSilentSignIn();
|
|
if (!signedIn) return;
|
|
|
|
isDriveConnected = true;
|
|
driveAccountEmail = driveAuth.currentAccountEmail;
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final folderId = prefs.getString(_prefsKeyDriveFolderId);
|
|
driveFolderPath = prefs.getString(_prefsKeyDriveFolderPath);
|
|
if (folderId != null) {
|
|
driveSync.configure(folderId);
|
|
}
|
|
notifyListeners();
|
|
|
|
if (folderId != null) {
|
|
unawaited(syncNow());
|
|
}
|
|
}
|
|
|
|
Future<void> connectDrive() async {
|
|
final email = await driveAuth.signIn();
|
|
isDriveConnected = true;
|
|
driveAccountEmail = email;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> disconnectDrive() async {
|
|
await driveAuth.signOut();
|
|
driveSync.clearConfiguration();
|
|
isDriveConnected = false;
|
|
driveAccountEmail = null;
|
|
driveFolderPath = null;
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove(_prefsKeyDriveFolderId);
|
|
await prefs.remove(_prefsKeyDriveFolderPath);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> chooseDriveFolder({
|
|
required String parentId,
|
|
required String breadcrumbPath,
|
|
}) async {
|
|
final folderId = await driveSync.selectAppFolder(parentId);
|
|
driveFolderPath = breadcrumbPath;
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_prefsKeyDriveFolderId, folderId);
|
|
await prefs.setString(_prefsKeyDriveFolderPath, breadcrumbPath);
|
|
notifyListeners();
|
|
|
|
unawaited(syncNow());
|
|
}
|
|
|
|
Future<void> syncNow() async {
|
|
if (isSyncing || !driveSync.isConfigured) return;
|
|
|
|
isSyncing = true;
|
|
notifyListeners();
|
|
|
|
final result = await driveSync.syncNow(
|
|
localVehicles: vehicles,
|
|
localFuelEntries: fuelEntries,
|
|
);
|
|
|
|
if (result.ranSync) {
|
|
vehicles = result.vehicles!;
|
|
fuelEntries = result.fuelEntries!;
|
|
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
|
|
lastSyncedAt = DateTime.now();
|
|
lastSyncError = null;
|
|
} else if (result.error != null) {
|
|
lastSyncError = result.error;
|
|
}
|
|
|
|
isSyncing = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> addVehicle({
|
|
required String make,
|
|
required String model,
|
|
required String color,
|
|
required String licensePlate,
|
|
}) async {
|
|
vehicles.add(Vehicle(
|
|
id: _uuid.v4(),
|
|
make: make,
|
|
model: model,
|
|
color: color,
|
|
licensePlate: licensePlate,
|
|
updatedAt: DateTime.now().toUtc(),
|
|
));
|
|
await _persist();
|
|
}
|
|
|
|
Future<void> updateVehicle(Vehicle updated) async {
|
|
final index = vehicles.indexWhere((v) => v.id == updated.id);
|
|
if (index != -1) {
|
|
vehicles[index] = updated.copyWith(updatedAt: DateTime.now().toUtc());
|
|
await _persist();
|
|
}
|
|
}
|
|
|
|
Future<void> deleteVehicle(String vehicleId) async {
|
|
for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) {
|
|
await storage.deleteReceiptImage(entry.receiptImagePath);
|
|
}
|
|
fuelEntries.removeWhere((e) => e.vehicleId == vehicleId);
|
|
vehicles.removeWhere((v) => v.id == vehicleId);
|
|
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) {
|
|
storedImagePath = await storage.storeReceiptImage(receiptImage, id);
|
|
}
|
|
|
|
final entry = FuelEntry(
|
|
id: id,
|
|
vehicleId: vehicleId,
|
|
date: date,
|
|
gallons: gallons,
|
|
pricePerGallon: pricePerGallon,
|
|
totalCost: totalCost,
|
|
receiptImagePath: storedImagePath,
|
|
updatedAt: DateTime.now().toUtc(),
|
|
);
|
|
fuelEntries.add(entry);
|
|
await _persist();
|
|
return entry;
|
|
}
|
|
|
|
Future<void> deleteFuelEntry(String entryId) async {
|
|
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
|
|
await storage.deleteReceiptImage(entry.receiptImagePath);
|
|
fuelEntries.removeWhere((e) => e.id == entryId);
|
|
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 storage.saveData(vehicles: vehicles, entries: fuelEntries);
|
|
notifyListeners();
|
|
unawaited(syncNow());
|
|
}
|
|
}
|