Added Google Drive storage location
This commit is contained in:
parent
e630f2a3ec
commit
73a7c93e64
22 changed files with 1690 additions and 205 deletions
|
|
@ -1,24 +1,46 @@
|
|||
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 persistence. Screens read from
|
||||
/// this via Provider and call its mutating methods, which take care of
|
||||
/// writing through to disk and notifying listeners.
|
||||
/// 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();
|
||||
|
|
@ -26,6 +48,100 @@ class AppState extends ChangeNotifier {
|
|||
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({
|
||||
|
|
@ -40,6 +156,7 @@ class AppState extends ChangeNotifier {
|
|||
model: model,
|
||||
color: color,
|
||||
licensePlate: licensePlate,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
));
|
||||
await _persist();
|
||||
}
|
||||
|
|
@ -47,7 +164,7 @@ class AppState extends ChangeNotifier {
|
|||
Future<void> updateVehicle(Vehicle updated) async {
|
||||
final index = vehicles.indexWhere((v) => v.id == updated.id);
|
||||
if (index != -1) {
|
||||
vehicles[index] = updated;
|
||||
vehicles[index] = updated.copyWith(updatedAt: DateTime.now().toUtc());
|
||||
await _persist();
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +200,7 @@ class AppState extends ChangeNotifier {
|
|||
pricePerGallon: pricePerGallon,
|
||||
totalCost: totalCost,
|
||||
receiptImagePath: storedImagePath,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
);
|
||||
fuelEntries.add(entry);
|
||||
await _persist();
|
||||
|
|
@ -117,13 +235,9 @@ class AppState extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> changeSaveDirectory(String newPath) async {
|
||||
await storage.setSaveDirectory(newPath);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
|
||||
notifyListeners();
|
||||
unawaited(syncNow());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
108
lib/services/drive_auth_service.dart
Normal file
108
lib/services/drive_auth_service.dart
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'drive_oauth_config.dart';
|
||||
|
||||
/// Full Drive access is required (not the narrower `drive.file` scope)
|
||||
/// because users need to browse to and reuse folders that someone else
|
||||
/// created and shared with them, not just folders/files this app itself
|
||||
/// created. See the plan doc for the tradeoffs (this requires Google
|
||||
/// Cloud Console "Testing" mode with explicit test users, to avoid needing
|
||||
/// a full OAuth verification review).
|
||||
const driveScopes = <String>['https://www.googleapis.com/auth/drive'];
|
||||
|
||||
/// Thrown when a Drive API call needs authorization that isn't currently
|
||||
/// available without prompting the user, e.g. during a background sync.
|
||||
class DriveNotAuthorizedException implements Exception {
|
||||
@override
|
||||
String toString() => 'Drive access is not currently authorized.';
|
||||
}
|
||||
|
||||
/// Wraps `google_sign_in` for authenticating with Google and producing an
|
||||
/// authenticated [http.Client] for the Drive API.
|
||||
class DriveAuthService {
|
||||
bool _initialized = false;
|
||||
GoogleSignInAccount? _account;
|
||||
|
||||
bool get isSignedIn => _account != null;
|
||||
|
||||
String? get currentAccountEmail => _account?.email;
|
||||
|
||||
Future<void> _ensureInitialized() async {
|
||||
if (_initialized) return;
|
||||
await GoogleSignIn.instance.initialize(
|
||||
clientId: Platform.isIOS ? DriveOAuthConfig.iosClientId : null,
|
||||
serverClientId: Platform.isAndroid ? DriveOAuthConfig.androidServerClientId : null,
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Attempts to restore a previous sign-in without any UI. Returns true if
|
||||
/// the user is signed in and Drive access is already authorized.
|
||||
Future<bool> attemptSilentSignIn() async {
|
||||
await _ensureInitialized();
|
||||
final account = await GoogleSignIn.instance.attemptLightweightAuthentication();
|
||||
_account = account;
|
||||
if (account == null) return false;
|
||||
|
||||
final authorization =
|
||||
await account.authorizationClient.authorizationForScopes(driveScopes);
|
||||
return authorization != null;
|
||||
}
|
||||
|
||||
/// Interactive sign-in + Drive scope authorization. Must be called from a
|
||||
/// user-initiated action (e.g. a button press).
|
||||
Future<String> signIn() async {
|
||||
await _ensureInitialized();
|
||||
final account = await GoogleSignIn.instance.authenticate(scopeHint: driveScopes);
|
||||
_account = account;
|
||||
await account.authorizationClient.authorizeScopes(driveScopes);
|
||||
return account.email;
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
await GoogleSignIn.instance.signOut();
|
||||
_account = null;
|
||||
}
|
||||
|
||||
/// Builds an [http.Client] that attaches a fresh Drive authorization
|
||||
/// header to every request. Fetches headers per-request (rather than
|
||||
/// once) so a client that lives across a long sync doesn't use a stale,
|
||||
/// expired token. Never prompts for UI — suitable for background sync —
|
||||
/// so throws [DriveNotAuthorizedException] if authorization isn't already
|
||||
/// in place (the caller should treat that as "Drive is disconnected").
|
||||
http.Client authenticatedHttpClient() {
|
||||
final account = _account;
|
||||
if (account == null) {
|
||||
throw DriveNotAuthorizedException();
|
||||
}
|
||||
return _DriveHttpClient(account.authorizationClient);
|
||||
}
|
||||
}
|
||||
|
||||
class _DriveHttpClient extends http.BaseClient {
|
||||
final GoogleSignInAuthorizationClient _authClient;
|
||||
final http.Client _inner = http.Client();
|
||||
|
||||
_DriveHttpClient(this._authClient);
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
final headers =
|
||||
await _authClient.authorizationHeaders(driveScopes, promptIfNecessary: false);
|
||||
if (headers == null) {
|
||||
throw DriveNotAuthorizedException();
|
||||
}
|
||||
request.headers.addAll(headers);
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_inner.close();
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
20
lib/services/drive_oauth_config.dart
Normal file
20
lib/services/drive_oauth_config.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/// Fill these in once the corresponding OAuth clients exist in Google Cloud
|
||||
/// Console (see README.md "Manual setup required"). Both are needed even
|
||||
/// though only one app runs on each platform:
|
||||
///
|
||||
/// - Android sign-in (Credential Manager-based, as of google_sign_in v7)
|
||||
/// authenticates using a *Web application* type OAuth client's ID, not the
|
||||
/// Android client's own ID. The separate Android OAuth client (registered
|
||||
/// with the package name + debug/release SHA-1) is still required, but
|
||||
/// only to let Credential Manager verify this specific signed app — its
|
||||
/// client ID itself is never referenced here.
|
||||
/// - iOS uses its own iOS-type OAuth client ID directly.
|
||||
class DriveOAuthConfig {
|
||||
/// The Web application OAuth client ID. Required for sign-in to work on
|
||||
/// Android.
|
||||
static const String? androidServerClientId = null; // TODO: fill in
|
||||
|
||||
/// The iOS OAuth client ID. Leave null if GIDClientID is instead set
|
||||
/// directly in ios/Runner/Info.plist.
|
||||
static const String? iosClientId = null; // TODO: fill in
|
||||
}
|
||||
229
lib/services/drive_service.dart
Normal file
229
lib/services/drive_service.dart
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:googleapis/drive/v3.dart' as drive;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
const appFolderName = 'MO-Fuel-Tax-Back';
|
||||
const receiptsFolderName = 'receipts';
|
||||
const dataFileName = 'fuel_tax_data.json';
|
||||
|
||||
const _folderMimeType = 'application/vnd.google-apps.folder';
|
||||
|
||||
class DriveFolder {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
DriveFolder({required this.id, required this.name});
|
||||
}
|
||||
|
||||
class DriveLockFile {
|
||||
final String id;
|
||||
final String username;
|
||||
final DateTime createdAtUtc;
|
||||
|
||||
DriveLockFile({required this.id, required this.username, required this.createdAtUtc});
|
||||
}
|
||||
|
||||
/// Raw Google Drive API operations, built on top of an already-authenticated
|
||||
/// [http.Client] (see [DriveAuthService.authenticatedHttpClient]). Callers
|
||||
/// own the client's lifecycle (create it, use a [DriveService] instance for
|
||||
/// the duration of one sync, then close it).
|
||||
class DriveService {
|
||||
final drive.DriveApi _api;
|
||||
|
||||
DriveService(http.Client authenticatedClient) : _api = drive.DriveApi(authenticatedClient);
|
||||
|
||||
/// Lists folders under [parentId], or (if [sharedWithMe] is true) the
|
||||
/// top-level folders that other users have shared with the signed-in
|
||||
/// account, regardless of parent.
|
||||
Future<List<DriveFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
|
||||
final query = sharedWithMe
|
||||
? "sharedWithMe=true and mimeType='$_folderMimeType' and trashed=false"
|
||||
: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false";
|
||||
|
||||
final result = await _api.files.list(
|
||||
q: query,
|
||||
orderBy: 'name',
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
return (result.files ?? [])
|
||||
.where((f) => f.id != null && f.name != null)
|
||||
.map((f) => DriveFolder(id: f.id!, name: f.name!))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Finds a folder named [appFolderName] under [parentId], or creates one
|
||||
/// if none exists. Multiple devices pointed at the same shared parent
|
||||
/// converge on the same folder this way. If duplicates exist (Drive
|
||||
/// allows same-named folders), the earliest-created one wins.
|
||||
Future<String> findOrCreateAppFolder(String parentId) {
|
||||
return _findOrCreateFolder(name: appFolderName, parentId: parentId);
|
||||
}
|
||||
|
||||
Future<String> findOrCreateReceiptsFolder(String appFolderId) {
|
||||
return _findOrCreateFolder(name: receiptsFolderName, parentId: appFolderId);
|
||||
}
|
||||
|
||||
Future<String> _findOrCreateFolder({required String name, required String parentId}) async {
|
||||
final existing = await _api.files.list(
|
||||
q: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false and name='$name'",
|
||||
orderBy: 'createdTime',
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
final firstMatch = (existing.files ?? []).firstOrNullWithId();
|
||||
if (firstMatch != null) return firstMatch;
|
||||
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = name
|
||||
..mimeType = _folderMimeType
|
||||
..parents = [parentId],
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
Future<String?> findDataFileId(String appFolderId) async {
|
||||
final result = await _api.files.list(
|
||||
q: "'$appFolderId' in parents and trashed=false and name='$dataFileName'",
|
||||
orderBy: 'modifiedTime desc',
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
return (result.files ?? []).firstOrNullWithId();
|
||||
}
|
||||
|
||||
Future<String> downloadTextFile(String fileId) async {
|
||||
final media = await _api.files.get(
|
||||
fileId,
|
||||
downloadOptions: drive.DownloadOptions.fullMedia,
|
||||
) as drive.Media;
|
||||
final bytes = await _collectBytes(media.stream);
|
||||
return utf8.decode(bytes);
|
||||
}
|
||||
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final media = await _api.files.get(
|
||||
fileId,
|
||||
downloadOptions: drive.DownloadOptions.fullMedia,
|
||||
) as drive.Media;
|
||||
return _collectBytes(media.stream);
|
||||
}
|
||||
|
||||
/// Creates the data file if [existingFileId] is null, otherwise
|
||||
/// overwrites its content. Returns the (possibly new) file ID.
|
||||
Future<String> uploadDataFile({
|
||||
required String appFolderId,
|
||||
required String? existingFileId,
|
||||
required String jsonContent,
|
||||
}) async {
|
||||
final bytes = utf8.encode(jsonContent);
|
||||
final media = drive.Media(
|
||||
Stream.value(bytes),
|
||||
bytes.length,
|
||||
contentType: 'application/json',
|
||||
);
|
||||
|
||||
if (existingFileId != null) {
|
||||
final updated = await _api.files.update(
|
||||
drive.File(),
|
||||
existingFileId,
|
||||
uploadMedia: media,
|
||||
);
|
||||
return updated.id ?? existingFileId;
|
||||
}
|
||||
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = dataFileName
|
||||
..parents = [appFolderId],
|
||||
uploadMedia: media,
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
Future<String> uploadReceiptImage({
|
||||
required String receiptsFolderId,
|
||||
required String fileName,
|
||||
required File imageFile,
|
||||
}) async {
|
||||
final length = await imageFile.length();
|
||||
final media = drive.Media(
|
||||
imageFile.openRead(),
|
||||
length,
|
||||
contentType: 'image/jpeg',
|
||||
);
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = fileName
|
||||
..parents = [receiptsFolderId],
|
||||
uploadMedia: media,
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
try {
|
||||
await _api.files.delete(fileId);
|
||||
} on drive.DetailedApiRequestError catch (e) {
|
||||
// Already gone (e.g. deleted by another device) — not an error for
|
||||
// our purposes.
|
||||
if (e.status != 404) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> createLockFile({required String appFolderId, required String name}) async {
|
||||
final created = await _api.files.create(
|
||||
drive.File()
|
||||
..name = name
|
||||
..parents = [appFolderId],
|
||||
uploadMedia: drive.Media(Stream.value(const []), 0, contentType: 'text/plain'),
|
||||
);
|
||||
return created.id!;
|
||||
}
|
||||
|
||||
Future<List<DriveLockFile>> listLockFiles(String appFolderId) async {
|
||||
final result = await _api.files.list(
|
||||
q: "'$appFolderId' in parents and trashed=false and name contains '.lock'",
|
||||
$fields: 'files(id,name)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
|
||||
final locks = <DriveLockFile>[];
|
||||
for (final f in result.files ?? <drive.File>[]) {
|
||||
final parsed = _parseLockFileName(f.name);
|
||||
if (f.id != null && parsed != null) {
|
||||
locks.add(DriveLockFile(id: f.id!, username: parsed.$1, createdAtUtc: parsed.$2));
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
|
||||
static (String, DateTime)? _parseLockFileName(String? name) {
|
||||
if (name == null || !name.endsWith('.lock')) return null;
|
||||
final withoutExt = name.substring(0, name.length - '.lock'.length);
|
||||
final lastDash = withoutExt.lastIndexOf('-');
|
||||
if (lastDash == -1) return null;
|
||||
final username = withoutExt.substring(0, lastDash);
|
||||
final epochStr = withoutExt.substring(lastDash + 1);
|
||||
final epoch = int.tryParse(epochStr);
|
||||
if (epoch == null) return null;
|
||||
return (username, DateTime.fromMillisecondsSinceEpoch(epoch, isUtc: true));
|
||||
}
|
||||
|
||||
Future<List<int>> _collectBytes(Stream<List<int>> stream) async {
|
||||
final bytes = <int>[];
|
||||
await for (final chunk in stream) {
|
||||
bytes.addAll(chunk);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
extension _FirstMatchExtension on List<drive.File> {
|
||||
String? firstOrNullWithId() => isEmpty ? null : first.id;
|
||||
}
|
||||
204
lib/services/drive_sync_service.dart
Normal file
204
lib/services/drive_sync_service.dart
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
import 'drive_auth_service.dart';
|
||||
import 'drive_service.dart';
|
||||
import 'lock_coordinator.dart' as lock;
|
||||
import 'merge_utils.dart';
|
||||
import 'storage_service.dart';
|
||||
|
||||
class SyncResult {
|
||||
final bool ranSync;
|
||||
final List<Vehicle>? vehicles;
|
||||
final List<FuelEntry>? fuelEntries;
|
||||
final Object? error;
|
||||
|
||||
SyncResult.skipped()
|
||||
: ranSync = false,
|
||||
vehicles = null,
|
||||
fuelEntries = null,
|
||||
error = null;
|
||||
|
||||
SyncResult.success({required this.vehicles, required this.fuelEntries})
|
||||
: ranSync = true,
|
||||
error = null;
|
||||
|
||||
SyncResult.failure(this.error)
|
||||
: ranSync = false,
|
||||
vehicles = null,
|
||||
fuelEntries = null;
|
||||
}
|
||||
|
||||
/// Orchestrates one round of push-local/pull-remote sync against the shared
|
||||
/// Drive folder: acquires the cross-device file lock, downloads and merges
|
||||
/// the remote data file with local changes, uploads any pending receipt
|
||||
/// photos, writes the merged data file back, then releases the lock.
|
||||
class DriveSyncService {
|
||||
final DriveAuthService authService;
|
||||
final StorageService storageService;
|
||||
|
||||
String? _appFolderId;
|
||||
String? _receiptsFolderId;
|
||||
String? _dataFileId;
|
||||
|
||||
DriveSyncService({required this.authService, required this.storageService});
|
||||
|
||||
bool get isConfigured => _appFolderId != null;
|
||||
|
||||
/// Call once a Drive app folder has been chosen (or restored at launch).
|
||||
void configure(String appFolderId) {
|
||||
_appFolderId = appFolderId;
|
||||
_receiptsFolderId = null;
|
||||
_dataFileId = null;
|
||||
}
|
||||
|
||||
void clearConfiguration() {
|
||||
_appFolderId = null;
|
||||
_receiptsFolderId = null;
|
||||
_dataFileId = null;
|
||||
}
|
||||
|
||||
/// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a
|
||||
/// folder the user picked in the Drive folder browser) and configures
|
||||
/// this service to use it. Returns the resulting folder ID.
|
||||
Future<String> selectAppFolder(String parentId) async {
|
||||
final client = authService.authenticatedHttpClient();
|
||||
try {
|
||||
final drive = DriveService(client);
|
||||
final folderId = await drive.findOrCreateAppFolder(parentId);
|
||||
configure(folderId);
|
||||
return folderId;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<SyncResult> syncNow({
|
||||
required List<Vehicle> localVehicles,
|
||||
required List<FuelEntry> localFuelEntries,
|
||||
}) async {
|
||||
final appFolderId = _appFolderId;
|
||||
if (!authService.isSignedIn || appFolderId == null) {
|
||||
return SyncResult.skipped();
|
||||
}
|
||||
|
||||
http.Client? client;
|
||||
DriveService? drive;
|
||||
String? lockFileId;
|
||||
|
||||
try {
|
||||
client = authService.authenticatedHttpClient();
|
||||
drive = DriveService(client);
|
||||
|
||||
lockFileId = await _acquireLock(
|
||||
drive,
|
||||
appFolderId: appFolderId,
|
||||
username: authService.currentAccountEmail!,
|
||||
);
|
||||
|
||||
_receiptsFolderId ??= await drive.findOrCreateReceiptsFolder(appFolderId);
|
||||
_dataFileId ??= await drive.findDataFileId(appFolderId);
|
||||
|
||||
var remoteVehicles = <Vehicle>[];
|
||||
var remoteFuelEntries = <FuelEntry>[];
|
||||
if (_dataFileId != null) {
|
||||
final text = await drive.downloadTextFile(_dataFileId!);
|
||||
if (text.trim().isNotEmpty) {
|
||||
final decoded = storageService.decodeData(text);
|
||||
remoteVehicles = decoded.vehicles;
|
||||
remoteFuelEntries = decoded.entries;
|
||||
}
|
||||
}
|
||||
|
||||
final mergedVehicles = mergeById(
|
||||
remoteVehicles,
|
||||
localVehicles,
|
||||
(v) => v.id,
|
||||
(v) => v.updatedAt,
|
||||
);
|
||||
final mergedFuelEntries = await _uploadPendingReceiptsAndMerge(
|
||||
drive: drive,
|
||||
remoteFuelEntries: remoteFuelEntries,
|
||||
localFuelEntries: localFuelEntries,
|
||||
);
|
||||
|
||||
final jsonContent = storageService.encodeData(
|
||||
vehicles: mergedVehicles,
|
||||
entries: mergedFuelEntries,
|
||||
);
|
||||
_dataFileId = await drive.uploadDataFile(
|
||||
appFolderId: appFolderId,
|
||||
existingFileId: _dataFileId,
|
||||
jsonContent: jsonContent,
|
||||
);
|
||||
|
||||
return SyncResult.success(vehicles: mergedVehicles, fuelEntries: mergedFuelEntries);
|
||||
} catch (e) {
|
||||
return SyncResult.failure(e);
|
||||
} finally {
|
||||
if (lockFileId != null && drive != null) {
|
||||
try {
|
||||
await drive.deleteFile(lockFileId);
|
||||
} catch (_) {
|
||||
// Best-effort: if this fails, the 10-minute staleness reap on
|
||||
// other devices' next sync attempt will clean it up.
|
||||
}
|
||||
}
|
||||
client?.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<FuelEntry>> _uploadPendingReceiptsAndMerge({
|
||||
required DriveService drive,
|
||||
required List<FuelEntry> remoteFuelEntries,
|
||||
required List<FuelEntry> localFuelEntries,
|
||||
}) async {
|
||||
final merged = mergeById(
|
||||
remoteFuelEntries,
|
||||
localFuelEntries,
|
||||
(e) => e.id,
|
||||
(e) => e.updatedAt,
|
||||
);
|
||||
|
||||
final result = <FuelEntry>[];
|
||||
for (final entry in merged) {
|
||||
if (!entry.hasPendingLocalReceipt) {
|
||||
result.add(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
final localPath = entry.receiptImagePath!;
|
||||
final localFile = File(localPath);
|
||||
if (!await localFile.exists()) {
|
||||
result.add(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
final driveFileId = await drive.uploadReceiptImage(
|
||||
receiptsFolderId: _receiptsFolderId!,
|
||||
fileName: '${entry.id}${p.extension(localPath)}',
|
||||
imageFile: localFile,
|
||||
);
|
||||
await storageService.deleteReceiptImage(localPath);
|
||||
result.add(entry.copyWith(receiptDriveFileId: driveFileId, clearReceiptImagePath: true));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<String> _acquireLock(
|
||||
DriveService drive, {
|
||||
required String appFolderId,
|
||||
required String username,
|
||||
}) {
|
||||
return lock.acquireLock(
|
||||
username: username,
|
||||
createLock: (name) => drive.createLockFile(appFolderId: appFolderId, name: name),
|
||||
listLocks: () => drive.listLockFiles(appFolderId),
|
||||
deleteLock: drive.deleteFile,
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/services/lock_coordinator.dart
Normal file
48
lib/services/lock_coordinator.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import 'drive_service.dart' show DriveLockFile;
|
||||
|
||||
/// Ticket-based mutex using timestamped lock files as the coordination
|
||||
/// point: create a lock named after our own timestamp, then wait until no
|
||||
/// *other* lock older than ours remains — reaping ("deleting") any it
|
||||
/// finds older than [staleAge] along the way, as a backstop against a
|
||||
/// device that crashed/went offline before releasing its own lock.
|
||||
///
|
||||
/// Pure orchestration over injected operations (rather than a concrete
|
||||
/// Drive dependency) so the state machine is unit-testable without a real
|
||||
/// network connection.
|
||||
Future<String> acquireLock({
|
||||
required String username,
|
||||
required Future<String> Function(String lockName) createLock,
|
||||
required Future<List<DriveLockFile>> Function() listLocks,
|
||||
required Future<void> Function(String lockId) deleteLock,
|
||||
DateTime Function()? nowUtc,
|
||||
Future<void> Function(Duration)? delay,
|
||||
Duration staleAge = const Duration(minutes: 10),
|
||||
Duration pollInterval = const Duration(seconds: 1),
|
||||
}) async {
|
||||
final now = nowUtc ?? () => DateTime.now().toUtc();
|
||||
final wait = delay ?? Future.delayed;
|
||||
|
||||
final epochMillis = now().millisecondsSinceEpoch;
|
||||
final lockName = '$username-$epochMillis.lock';
|
||||
final lockId = await createLock(lockName);
|
||||
|
||||
while (true) {
|
||||
final locks = await listLocks();
|
||||
final others = locks.where((l) => l.id != lockId);
|
||||
final olderOthers =
|
||||
others.where((l) => l.createdAtUtc.millisecondsSinceEpoch < epochMillis).toList();
|
||||
|
||||
if (olderOthers.isEmpty) break;
|
||||
|
||||
final current = now();
|
||||
for (final stale in olderOthers) {
|
||||
if (current.difference(stale.createdAtUtc) > staleAge) {
|
||||
await deleteLock(stale.id);
|
||||
}
|
||||
}
|
||||
|
||||
await wait(pollInterval);
|
||||
}
|
||||
|
||||
return lockId;
|
||||
}
|
||||
23
lib/services/merge_utils.dart
Normal file
23
lib/services/merge_utils.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/// Unions [remote] and [local] by ID, keeping whichever copy of each
|
||||
/// record has the newer `updatedAt` when a record exists on both sides.
|
||||
/// This lets two devices' independent new records both survive a sync;
|
||||
/// it does not propagate deletions (see plan doc's Known limitations).
|
||||
List<T> mergeById<T>(
|
||||
List<T> remote,
|
||||
List<T> local,
|
||||
String Function(T) idOf,
|
||||
DateTime Function(T) updatedAtOf,
|
||||
) {
|
||||
final merged = <String, T>{};
|
||||
for (final item in remote) {
|
||||
merged[idOf(item)] = item;
|
||||
}
|
||||
for (final item in local) {
|
||||
final id = idOf(item);
|
||||
final existing = merged[id];
|
||||
if (existing == null || !updatedAtOf(existing).isAfter(updatedAtOf(item))) {
|
||||
merged[id] = item;
|
||||
}
|
||||
}
|
||||
return merged.values.toList();
|
||||
}
|
||||
|
|
@ -3,17 +3,17 @@ import 'dart:io';
|
|||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
|
||||
/// Owns the on-disk layout for the app: a `data.json` file holding vehicles
|
||||
/// and fuel entries, plus a `receipts/` subfolder holding receipt photos.
|
||||
/// The parent directory containing both is user-configurable (see
|
||||
/// [setSaveDirectory]) and persisted across launches via SharedPreferences.
|
||||
/// Owns the local, offline staging area: a `data.json` file holding
|
||||
/// vehicles and fuel entries, plus a `receipts/` subfolder holding receipt
|
||||
/// photos not yet uploaded to Drive. This is always
|
||||
/// `ApplicationDocumentsDirectory/FuelTaxTracker` — not user-configurable —
|
||||
/// since Drive (via [DriveSyncService]) is now the real shared destination
|
||||
/// and this is just a local cache/holding area used while offline.
|
||||
class StorageService {
|
||||
static const _prefsKey = 'save_directory_path';
|
||||
static const _dataFileName = 'fuel_tax_data.json';
|
||||
static const _receiptsFolderName = 'receipts';
|
||||
|
||||
|
|
@ -24,36 +24,31 @@ class StorageService {
|
|||
Directory get receiptsDirectory =>
|
||||
Directory(p.join(_saveDirectory.path, _receiptsFolderName));
|
||||
|
||||
File get _dataFile => File(p.join(_saveDirectory.path, _dataFileName));
|
||||
File get dataFile => File(p.join(_saveDirectory.path, _dataFileName));
|
||||
|
||||
/// Must be called once before any other method. Loads the previously
|
||||
/// chosen save directory, falling back to the app's own documents
|
||||
/// directory the first time the app runs.
|
||||
/// Must be called once before any other method.
|
||||
Future<void> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final storedPath = prefs.getString(_prefsKey);
|
||||
|
||||
if (storedPath != null && await Directory(storedPath).exists()) {
|
||||
_saveDirectory = Directory(storedPath);
|
||||
} else {
|
||||
final docsDir = await getApplicationDocumentsDirectory();
|
||||
_saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker'));
|
||||
}
|
||||
final docsDir = await getApplicationDocumentsDirectory();
|
||||
_saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker'));
|
||||
|
||||
await _saveDirectory.create(recursive: true);
|
||||
await receiptsDirectory.create(recursive: true);
|
||||
}
|
||||
|
||||
Future<({List<Vehicle> vehicles, List<FuelEntry> entries})> loadData() async {
|
||||
if (!await _dataFile.exists()) {
|
||||
if (!await dataFile.exists()) {
|
||||
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
|
||||
}
|
||||
|
||||
final raw = await _dataFile.readAsString();
|
||||
final raw = await dataFile.readAsString();
|
||||
if (raw.trim().isEmpty) {
|
||||
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
|
||||
}
|
||||
|
||||
return decodeData(raw);
|
||||
}
|
||||
|
||||
({List<Vehicle> vehicles, List<FuelEntry> entries}) decodeData(String raw) {
|
||||
final json = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final vehicles = (json['vehicles'] as List<dynamic>? ?? [])
|
||||
.map((v) => Vehicle.fromJson(v as Map<String, dynamic>))
|
||||
|
|
@ -65,19 +60,26 @@ class StorageService {
|
|||
return (vehicles: vehicles, entries: entries);
|
||||
}
|
||||
|
||||
Future<void> saveData({
|
||||
String encodeData({
|
||||
required List<Vehicle> vehicles,
|
||||
required List<FuelEntry> entries,
|
||||
}) async {
|
||||
}) {
|
||||
final json = {
|
||||
'vehicles': vehicles.map((v) => v.toJson()).toList(),
|
||||
'fuelEntries': entries.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
await _dataFile.writeAsString(const JsonEncoder.withIndent(' ').convert(json));
|
||||
return const JsonEncoder.withIndent(' ').convert(json);
|
||||
}
|
||||
|
||||
/// Copies a captured receipt image into the receipts folder and returns
|
||||
/// the path it was stored at.
|
||||
Future<void> saveData({
|
||||
required List<Vehicle> vehicles,
|
||||
required List<FuelEntry> entries,
|
||||
}) async {
|
||||
await dataFile.writeAsString(encodeData(vehicles: vehicles, entries: entries));
|
||||
}
|
||||
|
||||
/// Copies a captured receipt image into the local receipts folder and
|
||||
/// returns the path it was stored at, pending upload to Drive.
|
||||
Future<String> storeReceiptImage(File sourceImage, String fuelEntryId) async {
|
||||
final ext = p.extension(sourceImage.path);
|
||||
final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext');
|
||||
|
|
@ -92,37 +94,4 @@ class StorageService {
|
|||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes where the data file and receipt images live, moving any
|
||||
/// existing data/images from the old location into the new one.
|
||||
Future<void> setSaveDirectory(String newPath) async {
|
||||
final newDir = Directory(newPath);
|
||||
await newDir.create(recursive: true);
|
||||
final newReceiptsDir = Directory(p.join(newPath, _receiptsFolderName));
|
||||
await newReceiptsDir.create(recursive: true);
|
||||
|
||||
final oldDataFile = _dataFile;
|
||||
final oldReceiptsDir = receiptsDirectory;
|
||||
|
||||
if (await oldDataFile.exists() &&
|
||||
p.equals(oldDataFile.path, p.join(newPath, _dataFileName)) == false) {
|
||||
await oldDataFile.copy(p.join(newPath, _dataFileName));
|
||||
}
|
||||
|
||||
if (await oldReceiptsDir.exists()) {
|
||||
await for (final entity in oldReceiptsDir.list()) {
|
||||
if (entity is File) {
|
||||
final destPath = p.join(newReceiptsDir.path, p.basename(entity.path));
|
||||
if (!p.equals(entity.path, destPath)) {
|
||||
await entity.copy(destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_saveDirectory = newDir;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKey, newPath);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue