Remote sync of webdav

This commit is contained in:
Courtney Arnold 2026-08-13 17:05:10 -05:00
parent f01186df79
commit 483fbeafb6
44 changed files with 4842 additions and 975 deletions

View file

@ -8,19 +8,42 @@ 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';
import 'drive_auth_service.dart';
import 'drive_sync_service.dart';
const _prefsKeyDriveFolderId = 'drive_app_folder_id';
const _prefsKeyDriveFolderPath = 'drive_app_folder_path';
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
/// [DriveSyncService] for pushing/pulling the shared Drive copy. 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 to Drive.
/// 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
@ -28,27 +51,71 @@ const _prefsKeyDriveFolderPath = 'drive_app_folder_path';
/// handing back updated Dart objects).
class AppState extends ChangeNotifier {
final DatabaseService database = DatabaseService();
final DriveAuthService driveAuth = DriveAuthService();
late final DriveSyncService driveSync =
DriveSyncService(authService: driveAuth, databaseService: database);
/// 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;
bool isDriveConnected = false;
String? driveAccountEmail;
String? driveFolderPath;
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 {
await database.init();
await _refreshFromDatabase();
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();
@ -58,7 +125,7 @@ class AppState extends ChangeNotifier {
}
});
unawaited(_restoreDriveConnection());
unawaited(_restoreCloudConnection());
}
@override
@ -72,18 +139,34 @@ class AppState extends ChangeNotifier {
fuelEntries = await database.getFuelEntries();
}
Future<void> _restoreDriveConnection() async {
final signedIn = await driveAuth.attemptSilentSignIn();
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;
isDriveConnected = true;
driveAccountEmail = driveAuth.currentAccountEmail;
activeProvider = provider;
cloudSync = CloudSyncService(provider: provider, databaseService: database);
final prefs = await SharedPreferences.getInstance();
final folderId = prefs.getString(_prefsKeyDriveFolderId);
driveFolderPath = prefs.getString(_prefsKeyDriveFolderPath);
final folderId = prefs.getString(_prefsKeyCloudFolderId);
cloudFolderPath = prefs.getString(_prefsKeyCloudFolderPath);
if (folderId != null) {
driveSync.configure(folderId);
cloudSync!.configure(folderId);
}
notifyListeners();
@ -92,48 +175,91 @@ class AppState extends ChangeNotifier {
}
}
Future<void> connectDrive() async {
final email = await driveAuth.signIn();
isDriveConnected = true;
driveAccountEmail = email;
notifyListeners();
}
Future<void> connectProvider(CloudProviderId id) async {
final provider = _providerById(id);
if (provider == null) return;
Future<void> disconnectDrive() async {
await driveAuth.signOut();
driveSync.clearConfiguration();
isDriveConnected = false;
driveAccountEmail = null;
driveFolderPath = null;
await provider.signIn();
activeProvider = provider;
cloudSync = CloudSyncService(provider: provider, databaseService: database);
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefsKeyDriveFolderId);
await prefs.remove(_prefsKeyDriveFolderPath);
await prefs.setString(_prefsKeyActiveProviderId, id.name);
notifyListeners();
}
Future<void> chooseDriveFolder({
/// 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 folderId = await driveSync.selectAppFolder(parentId);
driveFolderPath = breadcrumbPath;
final sync = cloudSync;
if (sync == null) return;
final folderId = await sync.selectAppFolder(parentId);
cloudFolderPath = breadcrumbPath;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKeyDriveFolderId, folderId);
await prefs.setString(_prefsKeyDriveFolderPath, breadcrumbPath);
await prefs.setString(_prefsKeyCloudFolderId, folderId);
await prefs.setString(_prefsKeyCloudFolderPath, breadcrumbPath);
notifyListeners();
unawaited(syncNow());
}
Future<void> syncNow() async {
if (isSyncing || !driveSync.isConfigured) return;
final sync = cloudSync;
if (isSyncing || sync == null || !sync.isConfigured) return;
isSyncing = true;
notifyListeners();
final result = await driveSync.syncNow();
final result = await sync.syncNow(
keepLocalReceiptCopies: keepReceiptPhotosLocally,
staleLockAge: Duration(minutes: staleLockMinutes),
);
if (result.ranSync) {
await _refreshFromDatabase();
@ -147,36 +273,71 @@ class AppState extends ChangeNotifier {
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 make,
required String model,
required String color,
required String licensePlate,
required String vin,
String? nickname,
}) async {
if (await database.vinExists(vin)) {
throw DuplicateVinException(vin);
}
final vehicle = Vehicle(
id: _uuid.v4(),
make: make,
model: model,
color: color,
licensePlate: licensePlate,
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 vehicleId) async {
Future<void> deleteVehicle(String id) async {
final now = DateTime.now().toUtc();
final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(vehicleId, now);
final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(id, now);
for (final path in orphanedLocalPaths) {
await database.deleteReceiptImageFile(path);
}
await database.softDeleteVehicle(vehicleId, now);
await database.softDeleteVehicle(id, now);
await _persist();
}
@ -191,7 +352,8 @@ class AppState extends ChangeNotifier {
final id = _uuid.v4();
String? storedImagePath;
if (receiptImage != null) {
storedImagePath = await database.storeReceiptImage(receiptImage, id);
final vin = vehicles.firstWhere((v) => v.id == vehicleId).vin;
storedImagePath = await database.storeReceiptImage(receiptImage, id, vin, date);
}
final entry = FuelEntry(
@ -243,3 +405,7 @@ class AppState extends ChangeNotifier {
unawaited(syncNow());
}
}
extension _FirstOrNull<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}

View file

@ -0,0 +1,153 @@
import 'dart:io';
/// Shared naming convention across all providers: whichever cloud storage
/// backend is active, the app looks for (or creates) a folder with this
/// exact name wherever the user points it, so two devices pointed at the
/// same shared parent location converge on the same data regardless of
/// which provider they're using.
const appFolderName = 'MO-Fuel-Tax-Back';
const receiptsFolderName = 'receipts';
const dataFileName = 'fuel_tax_tracker.db';
enum CloudProviderId { googleDrive, dropbox, oneDrive, webdav }
class CloudFolder {
final String id;
final String name;
CloudFolder({required this.id, required this.name});
}
class CloudFileInfo {
final String id;
/// Opaque change-detection signal — Drive's md5Checksum, Dropbox's
/// content_hash, OneDrive's cTag all satisfy "did this change since I
/// last looked", which is the only thing callers need from it.
final String? versionTag;
CloudFileInfo({required this.id, required this.versionTag});
}
class CloudLockFile {
final String id;
final String username;
final DateTime createdAtUtc;
CloudLockFile({required this.id, required this.username, required this.createdAtUtc});
}
/// Thrown when an operation needs authorization that isn't currently
/// available without prompting the user, e.g. during a background sync
/// with an expired/revoked token.
class CloudNotAuthorizedException implements Exception {
final String providerName;
CloudNotAuthorizedException(this.providerName);
@override
String toString() => '$providerName access is not currently authorized.';
}
/// One connected cloud storage backend (Google Drive, Dropbox, OneDrive).
/// Owns account-level identity/auth; per-sync operations go through a
/// [CloudStorageSession] obtained via [beginSession].
abstract class CloudStorageProvider {
CloudProviderId get id;
String get displayName;
bool get isSignedIn;
String? get accountLabel;
/// Attempts to restore a previous sign-in without any UI. Returns true
/// if signed in and authorized.
Future<bool> attemptSilentSignIn();
/// Interactive sign-in. Must be called from a user-initiated action
/// (e.g. a button press). Returns a label to display (email/username).
Future<String> signIn();
Future<void> signOut();
/// Starts one session's worth of operations (roughly: one sync round,
/// or one folder-browsing screen visit). The caller owns its lifecycle —
/// call [CloudStorageSession.close] when done with it.
CloudStorageSession beginSession();
}
/// Raw operations against one cloud storage backend, scoped to a single
/// authenticated session (e.g. one HTTP client). `folderId`/`fileId` are
/// opaque per-provider — for most providers a real ID, but for a
/// path-addressed API (Dropbox) a session may internally treat the path
/// itself as the "id". Callers never need to know which.
abstract class CloudStorageSession {
/// Lists folders under [parentId], or (if [sharedWithMe] is true and the
/// provider supports it) top-level folders shared with the signed-in
/// account regardless of parent. Providers that don't have a meaningful
/// separate "shared with me" concept may just ignore [sharedWithMe] and
/// always list under [parentId].
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false});
/// True if this provider has a distinct "Shared with me" browsing mode
/// worth showing as a separate tab in the folder picker UI.
bool get supportsSharedWithMe;
/// Finds a folder named [name] directly under [parentId], or creates one
/// if none exists. If duplicates exist, the earliest-created one wins.
Future<String> findOrCreateFolder({required String parentId, required String name});
/// Looks up a file's ID + versionTag by name within [folderId] without
/// downloading its content, or null if no such file exists yet.
Future<CloudFileInfo?> findFile({required String folderId, required String name});
Future<List<int>> downloadFileBytes(String fileId);
/// Creates the file if [existingFileId] is null, otherwise overwrites
/// its content. Returns the (possibly new) file ID and fresh versionTag.
Future<CloudFileInfo> uploadFile({
required String folderId,
required String name,
String? existingFileId,
required File localFile,
required String contentType,
});
Future<void> deleteFile(String fileId);
Future<String> createLockFile({required String folderId, required String name});
Future<List<CloudLockFile>> listLockFiles(String folderId);
/// Releases any resources (e.g. closes an underlying HTTP client).
void close();
}
/// Implemented by providers that need the user to type in connection
/// details (server URL, username, password) instead of completing an
/// OAuth browser flow — namely a self-hosted WebDAV server, which has no
/// central authorization server to redirect to. The Settings screen checks
/// `provider is ManualCredentialCloudStorageProvider` to decide whether
/// "Connect" opens a small credentials form instead of calling
/// [CloudStorageProvider.signIn] directly.
abstract class ManualCredentialCloudStorageProvider {
Future<String> signInWithCredentials({
required String serverUrl,
required String username,
required String password,
});
}
/// Parses a lock file named `{username}-{utcEpochMillis}.lock` — shared by
/// every provider's [CloudStorageSession.listLockFiles] implementation
/// rather than duplicated, since the lock file naming convention itself
/// (owned by `lock_coordinator.dart`) is provider-agnostic.
(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));
}

View file

@ -0,0 +1,364 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:http/http.dart' as http;
import '../cloud_oauth_config.dart';
import 'cloud_storage_provider.dart';
import 'oauth_pkce.dart';
/// Dropbox implementation of [CloudStorageProvider].
///
/// Unlike Google Drive, Dropbox's API is fundamentally *path*-addressed,
/// not ID-addressed. Rather than fight that, [DropboxSession] treats a
/// folder's own Dropbox path (e.g. "/MO-Fuel-Tax-Back") as its "id" for
/// purposes of the generic [CloudStorageSession] interface — an
/// implementation detail entirely inside this file, invisible to
/// [CloudSyncService].
class DropboxProvider implements CloudStorageProvider {
String? _accessToken;
String? _refreshToken;
DateTime? _accessTokenExpiry;
String? _accountLabel;
@override
CloudProviderId get id => CloudProviderId.dropbox;
@override
String get displayName => 'Dropbox';
@override
bool get isSignedIn => _refreshToken != null;
@override
String? get accountLabel => _accountLabel;
@override
Future<bool> attemptSilentSignIn() async {
// The refresh token isn't persisted across app launches in this first
// pass (kept in memory only) — see README's Known limitations. Silent
// restore always fails; the user reconnects once per cold start until
// that's added.
return false;
}
@override
Future<String> signIn() async {
final appKey = CloudOAuthConfig.dropboxAppKey;
final redirectUri = CloudOAuthConfig.dropboxRedirectUri;
if (appKey == null || redirectUri == null) {
throw StateError('Dropbox OAuth is not configured yet (see cloud_oauth_config.dart).');
}
final pkce = PkcePair.generate();
final authUrl = Uri.https('www.dropbox.com', '/oauth2/authorize', {
'client_id': appKey,
'response_type': 'code',
'code_challenge': pkce.codeChallenge,
'code_challenge_method': 'S256',
'redirect_uri': redirectUri,
'token_access_type': 'offline',
});
final callbackUrlScheme = Uri.parse(redirectUri).scheme;
final resultUrl = await FlutterWebAuth2.authenticate(
url: authUrl.toString(),
callbackUrlScheme: callbackUrlScheme,
);
final code = Uri.parse(resultUrl).queryParameters['code'];
if (code == null) {
throw StateError('Dropbox sign-in did not return an authorization code.');
}
await _exchangeCodeForTokens(
code: code,
codeVerifier: pkce.codeVerifier,
appKey: appKey,
redirectUri: redirectUri,
);
_accountLabel = await _fetchAccountEmail();
return _accountLabel!;
}
Future<void> _exchangeCodeForTokens({
required String code,
required String codeVerifier,
required String appKey,
required String redirectUri,
}) async {
final response = await http.post(
Uri.https('api.dropboxapi.com', '/oauth2/token'),
body: {
'code': code,
'grant_type': 'authorization_code',
'client_id': appKey,
'code_verifier': codeVerifier,
'redirect_uri': redirectUri,
},
);
if (response.statusCode != 200) {
throw StateError('Dropbox token exchange failed: ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = json['access_token'] as String;
_refreshToken = json['refresh_token'] as String?;
_accessTokenExpiry =
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400));
}
Future<String> _fetchAccountEmail() async {
final response = await http.post(
Uri.https('api.dropboxapi.com', '/2/users/get_current_account'),
headers: {'Authorization': 'Bearer $_accessToken'},
);
if (response.statusCode != 200) return 'Dropbox account';
final json = jsonDecode(response.body) as Map<String, dynamic>;
return json['email'] as String? ?? 'Dropbox account';
}
/// Refreshes the access token if it's missing or close to expiring.
/// Never prompts for UI — suitable for background sync — so throws
/// [CloudNotAuthorizedException] if there's no refresh token to use.
Future<String> _freshAccessToken() async {
final stillValid = _accessToken != null &&
_accessTokenExpiry != null &&
DateTime.now().isBefore(_accessTokenExpiry!.subtract(const Duration(minutes: 1)));
if (stillValid) return _accessToken!;
final refreshToken = _refreshToken;
final appKey = CloudOAuthConfig.dropboxAppKey;
if (refreshToken == null || appKey == null) {
throw CloudNotAuthorizedException(displayName);
}
final response = await http.post(
Uri.https('api.dropboxapi.com', '/oauth2/token'),
body: {
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
'client_id': appKey,
},
);
if (response.statusCode != 200) {
throw CloudNotAuthorizedException(displayName);
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = json['access_token'] as String;
_accessTokenExpiry =
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400));
return _accessToken!;
}
@override
Future<void> signOut() async {
_accessToken = null;
_refreshToken = null;
_accessTokenExpiry = null;
_accountLabel = null;
}
@override
CloudStorageSession beginSession() {
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
return DropboxSession(this);
}
}
class DropboxSession implements CloudStorageSession {
final DropboxProvider _provider;
DropboxSession(this._provider);
@override
bool get supportsSharedWithMe => false;
Future<Map<String, String>> _authHeader() async =>
{'Authorization': 'Bearer ${await _provider._freshAccessToken()}'};
/// Dropbox's root path is `""`, not `"/"` — our generic interface uses
/// the literal string `'root'` for "the top of the tree" (matching
/// Google Drive's convention), so translate that here.
String _normalizePath(String id) => id == 'root' ? '' : id;
String _childPath(String parentId, String name) {
final parent = _normalizePath(parentId);
return '$parent/$name';
}
Future<Map<String, dynamic>> _post(String path, Map<String, dynamic> body) async {
final response = await http.post(
Uri.https('api.dropboxapi.com', path),
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw StateError('Dropbox API error ($path): ${response.statusCode} ${response.body}');
}
return jsonDecode(response.body) as Map<String, dynamic>;
}
/// True if a Dropbox API error response's `.tag` chain indicates "the
/// path doesn't exist" — Dropbox reports this as a normal 409 response
/// with a structured error body, not a 404, so it needs its own check
/// rather than a status-code check.
bool _isPathNotFoundError(http.Response response) {
if (response.statusCode != 409) return false;
try {
final body = jsonDecode(response.body) as Map<String, dynamic>;
return jsonEncode(body['error']).contains('not_found');
} catch (_) {
return false;
}
}
@override
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
final path = _normalizePath(parentId ?? 'root');
final json = await _post('/2/files/list_folder', {'path': path});
final entries = (json['entries'] as List<dynamic>? ?? []);
return entries
.cast<Map<String, dynamic>>()
.where((e) => e['.tag'] == 'folder')
.map((e) => CloudFolder(id: e['path_display'] as String, name: e['name'] as String))
.toList();
}
@override
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
final childPath = _childPath(parentId, name);
final response = await http.post(
Uri.https('api.dropboxapi.com', '/2/files/get_metadata'),
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
body: jsonEncode({'path': childPath}),
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
if (json['.tag'] == 'folder') return childPath;
} else if (!_isPathNotFoundError(response)) {
throw StateError('Dropbox API error (get_metadata): ${response.statusCode} ${response.body}');
}
await _post('/2/files/create_folder_v2', {'path': childPath});
return childPath;
}
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
final filePath = _childPath(folderId, name);
final response = await http.post(
Uri.https('api.dropboxapi.com', '/2/files/get_metadata'),
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
body: jsonEncode({'path': filePath}),
);
if (_isPathNotFoundError(response)) return null;
if (response.statusCode != 200) {
throw StateError('Dropbox API error (get_metadata): ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
if (json['.tag'] != 'file') return null;
return CloudFileInfo(id: filePath, versionTag: json['content_hash'] as String?);
}
@override
Future<List<int>> downloadFileBytes(String fileId) async {
final response = await http.post(
Uri.https('content.dropboxapi.com', '/2/files/download'),
headers: {
...await _authHeader(),
'Dropbox-API-Arg': jsonEncode({'path': fileId}),
},
);
if (response.statusCode != 200) {
throw StateError('Dropbox download failed: ${response.statusCode} ${response.body}');
}
return response.bodyBytes;
}
@override
Future<CloudFileInfo> uploadFile({
required String folderId,
required String name,
String? existingFileId,
required File localFile,
required String contentType,
}) async {
final targetPath = existingFileId ?? _childPath(folderId, name);
final bytes = await localFile.readAsBytes();
final response = await http.post(
Uri.https('content.dropboxapi.com', '/2/files/upload'),
headers: {
...await _authHeader(),
'Dropbox-API-Arg': jsonEncode({'path': targetPath, 'mode': 'overwrite'}),
'Content-Type': 'application/octet-stream',
},
body: bytes,
);
if (response.statusCode != 200) {
throw StateError('Dropbox upload failed: ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
return CloudFileInfo(
id: json['path_display'] as String? ?? targetPath,
versionTag: json['content_hash'] as String?,
);
}
@override
Future<void> deleteFile(String fileId) async {
final response = await http.post(
Uri.https('api.dropboxapi.com', '/2/files/delete_v2'),
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
body: jsonEncode({'path': fileId}),
);
if (response.statusCode != 200 && !_isPathNotFoundError(response)) {
throw StateError('Dropbox delete failed: ${response.statusCode} ${response.body}');
}
}
@override
Future<String> createLockFile({required String folderId, required String name}) async {
final path = _childPath(folderId, name);
final response = await http.post(
Uri.https('content.dropboxapi.com', '/2/files/upload'),
headers: {
...await _authHeader(),
'Dropbox-API-Arg': jsonEncode({'path': path, 'mode': 'overwrite'}),
'Content-Type': 'application/octet-stream',
},
body: const <int>[],
);
if (response.statusCode != 200) {
throw StateError('Dropbox lock creation failed: ${response.statusCode} ${response.body}');
}
return path;
}
@override
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
final json = await _post('/2/files/list_folder', {'path': _normalizePath(folderId)});
final entries = (json['entries'] as List<dynamic>? ?? []);
final locks = <CloudLockFile>[];
for (final entry in entries.cast<Map<String, dynamic>>()) {
if (entry['.tag'] != 'file') continue;
final parsed = parseLockFileName(entry['name'] as String?);
if (parsed != null) {
locks.add(CloudLockFile(
id: entry['path_display'] as String,
username: parsed.$1,
createdAtUtc: parsed.$2,
));
}
}
return locks;
}
@override
void close() {}
}

View file

@ -0,0 +1,263 @@
import 'dart:async';
import 'dart:io' show File, Platform;
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis/drive/v3.dart' as drive;
import 'package:http/http.dart' as http;
import '../cloud_oauth_config.dart';
import 'cloud_storage_provider.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'];
const _folderMimeType = 'application/vnd.google-apps.folder';
/// Google Drive implementation of [CloudStorageProvider], via
/// `google_sign_in` for auth and the `googleapis` `DriveApi` client for
/// everything else.
class GoogleDriveProvider implements CloudStorageProvider {
bool _initialized = false;
GoogleSignInAccount? _account;
@override
CloudProviderId get id => CloudProviderId.googleDrive;
@override
String get displayName => 'Google Drive';
@override
bool get isSignedIn => _account != null;
@override
String? get accountLabel => _account?.email;
Future<void> _ensureInitialized() async {
if (_initialized) return;
await GoogleSignIn.instance.initialize(
clientId: Platform.isIOS ? CloudOAuthConfig.googleIosClientId : null,
serverClientId: Platform.isAndroid ? CloudOAuthConfig.googleAndroidServerClientId : null,
);
_initialized = true;
}
@override
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;
}
@override
Future<String> signIn() async {
await _ensureInitialized();
final account = await GoogleSignIn.instance.authenticate(scopeHint: _driveScopes);
_account = account;
await account.authorizationClient.authorizeScopes(_driveScopes);
return account.email;
}
@override
Future<void> signOut() async {
await GoogleSignIn.instance.signOut();
_account = null;
}
@override
CloudStorageSession beginSession() {
final account = _account;
if (account == null) {
throw CloudNotAuthorizedException(displayName);
}
final client = _GoogleAuthHttpClient(account.authorizationClient);
return GoogleDriveSession(client);
}
}
class _GoogleAuthHttpClient extends http.BaseClient {
final GoogleSignInAuthorizationClient _authClient;
final http.Client _inner = http.Client();
_GoogleAuthHttpClient(this._authClient);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final headers =
await _authClient.authorizationHeaders(_driveScopes, promptIfNecessary: false);
if (headers == null) {
throw CloudNotAuthorizedException('Google Drive');
}
request.headers.addAll(headers);
return _inner.send(request);
}
@override
void close() {
_inner.close();
super.close();
}
}
class GoogleDriveSession implements CloudStorageSession {
final http.Client _client;
final drive.DriveApi _api;
GoogleDriveSession(this._client) : _api = drive.DriveApi(_client);
@override
bool get supportsSharedWithMe => true;
@override
Future<List<CloudFolder>> 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) => CloudFolder(id: f.id!, name: f.name!))
.toList();
}
@override
Future<String> findOrCreateFolder({required String parentId, required String name}) 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 files = existing.files ?? <drive.File>[];
if (files.isNotEmpty && files.first.id != null) return files.first.id!;
final created = await _api.files.create(
drive.File()
..name = name
..mimeType = _folderMimeType
..parents = [parentId],
);
return created.id!;
}
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
final result = await _api.files.list(
q: "'$folderId' in parents and trashed=false and name='$name'",
orderBy: 'modifiedTime desc',
$fields: 'files(id,name,md5Checksum)',
spaces: 'drive',
);
final files = result.files ?? <drive.File>[];
if (files.isEmpty || files.first.id == null) return null;
return CloudFileInfo(id: files.first.id!, versionTag: files.first.md5Checksum);
}
@override
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);
}
@override
Future<CloudFileInfo> uploadFile({
required String folderId,
required String name,
String? existingFileId,
required File localFile,
required String contentType,
}) async {
final length = await localFile.length();
final media = drive.Media(localFile.openRead(), length, contentType: contentType);
if (existingFileId != null) {
final updated = await _api.files.update(
drive.File(),
existingFileId,
uploadMedia: media,
$fields: 'id,md5Checksum',
);
return CloudFileInfo(id: updated.id ?? existingFileId, versionTag: updated.md5Checksum);
}
final created = await _api.files.create(
drive.File()
..name = name
..parents = [folderId],
uploadMedia: media,
$fields: 'id,md5Checksum',
);
return CloudFileInfo(id: created.id!, versionTag: created.md5Checksum);
}
@override
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;
}
}
@override
Future<String> createLockFile({required String folderId, required String name}) async {
final created = await _api.files.create(
drive.File()
..name = name
..parents = [folderId],
uploadMedia: drive.Media(Stream.value(const []), 0, contentType: 'text/plain'),
);
return created.id!;
}
@override
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
final result = await _api.files.list(
q: "'$folderId' in parents and trashed=false and name contains '.lock'",
$fields: 'files(id,name)',
spaces: 'drive',
);
final locks = <CloudLockFile>[];
for (final f in result.files ?? <drive.File>[]) {
final parsed = parseLockFileName(f.name);
if (f.id != null && parsed != null) {
locks.add(CloudLockFile(id: f.id!, username: parsed.$1, createdAtUtc: parsed.$2));
}
}
return locks;
}
Future<List<int>> _collectBytes(Stream<List<int>> stream) async {
final bytes = <int>[];
await for (final chunk in stream) {
bytes.addAll(chunk);
}
return bytes;
}
@override
void close() => _client.close();
}

View file

@ -0,0 +1,31 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
/// PKCE (RFC 7636) verifier/challenge pair for Dropbox's and OneDrive's
/// OAuth2 Authorization Code flow — proves to the token endpoint that the
/// app completing the exchange is the same one that started the browser
/// redirect, without needing an embedded client secret (appropriate for a
/// public/mobile client, since a secret can't actually be kept secret in
/// a distributed app binary).
class PkcePair {
final String codeVerifier;
final String codeChallenge;
PkcePair._(this.codeVerifier, this.codeChallenge);
factory PkcePair.generate() {
final verifier = _randomUrlSafeString(64);
final challenge =
base64Url.encode(sha256.convert(utf8.encode(verifier)).bytes).replaceAll('=', '');
return PkcePair._(verifier, challenge);
}
static String _randomUrlSafeString(int length) {
// RFC 7636's unreserved character set for a code_verifier.
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final random = Random.secure();
return List.generate(length, (_) => chars[random.nextInt(chars.length)]).join();
}
}

View file

@ -0,0 +1,350 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:http/http.dart' as http;
import '../cloud_oauth_config.dart';
import 'cloud_storage_provider.dart';
import 'oauth_pkce.dart';
const _graphScopes = 'offline_access Files.ReadWrite.All';
/// OneDrive implementation of [CloudStorageProvider], via Microsoft Graph.
/// Unlike Dropbox, Graph is ID-addressed like Drive, so it fits the
/// interface directly with no path-based workaround.
class OneDriveProvider implements CloudStorageProvider {
String? _accessToken;
String? _refreshToken;
DateTime? _accessTokenExpiry;
String? _accountLabel;
@override
CloudProviderId get id => CloudProviderId.oneDrive;
@override
String get displayName => 'OneDrive';
@override
bool get isSignedIn => _refreshToken != null;
@override
String? get accountLabel => _accountLabel;
@override
Future<bool> attemptSilentSignIn() async {
// As with Dropbox, the refresh token is kept in memory only in this
// first pass — see README's Known limitations.
return false;
}
@override
Future<String> signIn() async {
final clientId = CloudOAuthConfig.oneDriveClientId;
final redirectUri = CloudOAuthConfig.oneDriveRedirectUri;
if (clientId == null || redirectUri == null) {
throw StateError('OneDrive OAuth is not configured yet (see cloud_oauth_config.dart).');
}
final pkce = PkcePair.generate();
final authUrl = Uri.https(
'login.microsoftonline.com',
'/common/oauth2/v2.0/authorize',
{
'client_id': clientId,
'response_type': 'code',
'redirect_uri': redirectUri,
'response_mode': 'query',
'scope': _graphScopes,
'code_challenge': pkce.codeChallenge,
'code_challenge_method': 'S256',
},
);
final callbackUrlScheme = Uri.parse(redirectUri).scheme;
final resultUrl = await FlutterWebAuth2.authenticate(
url: authUrl.toString(),
callbackUrlScheme: callbackUrlScheme,
);
final code = Uri.parse(resultUrl).queryParameters['code'];
if (code == null) {
throw StateError('OneDrive sign-in did not return an authorization code.');
}
await _exchangeCodeForTokens(
code: code,
codeVerifier: pkce.codeVerifier,
clientId: clientId,
redirectUri: redirectUri,
);
_accountLabel = await _fetchAccountEmail();
return _accountLabel!;
}
Future<void> _exchangeCodeForTokens({
required String code,
required String codeVerifier,
required String clientId,
required String redirectUri,
}) async {
final response = await http.post(
Uri.https('login.microsoftonline.com', '/common/oauth2/v2.0/token'),
body: {
'client_id': clientId,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirectUri,
'code_verifier': codeVerifier,
'scope': _graphScopes,
},
);
if (response.statusCode != 200) {
throw StateError('OneDrive token exchange failed: ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = json['access_token'] as String;
_refreshToken = json['refresh_token'] as String?;
_accessTokenExpiry =
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 3600));
}
Future<String> _fetchAccountEmail() async {
final response = await http.get(
Uri.https('graph.microsoft.com', '/v1.0/me'),
headers: {'Authorization': 'Bearer $_accessToken'},
);
if (response.statusCode != 200) return 'OneDrive account';
final json = jsonDecode(response.body) as Map<String, dynamic>;
return (json['mail'] as String?) ??
(json['userPrincipalName'] as String?) ??
'OneDrive account';
}
Future<String> _freshAccessToken() async {
final stillValid = _accessToken != null &&
_accessTokenExpiry != null &&
DateTime.now().isBefore(_accessTokenExpiry!.subtract(const Duration(minutes: 1)));
if (stillValid) return _accessToken!;
final refreshToken = _refreshToken;
final clientId = CloudOAuthConfig.oneDriveClientId;
if (refreshToken == null || clientId == null) {
throw CloudNotAuthorizedException(displayName);
}
final response = await http.post(
Uri.https('login.microsoftonline.com', '/common/oauth2/v2.0/token'),
body: {
'client_id': clientId,
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
'scope': _graphScopes,
},
);
if (response.statusCode != 200) {
throw CloudNotAuthorizedException(displayName);
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
_accessToken = json['access_token'] as String;
_refreshToken = json['refresh_token'] as String? ?? _refreshToken;
_accessTokenExpiry =
DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 3600));
return _accessToken!;
}
@override
Future<void> signOut() async {
_accessToken = null;
_refreshToken = null;
_accessTokenExpiry = null;
_accountLabel = null;
}
@override
CloudStorageSession beginSession() {
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
return OneDriveSession(this);
}
}
class OneDriveSession implements CloudStorageSession {
final OneDriveProvider _provider;
OneDriveSession(this._provider);
@override
bool get supportsSharedWithMe => true;
Future<Map<String, String>> _authHeader() async =>
{'Authorization': 'Bearer ${await _provider._freshAccessToken()}'};
Uri _graph(String path) => Uri.parse('https://graph.microsoft.com/v1.0$path');
/// The interface's `'root'` sentinel (matching Google's convention) maps
/// to Graph's own `/me/drive/root` special item.
String _itemSegment(String id) => id == 'root' ? 'root' : 'items/$id';
@override
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
final uri = sharedWithMe
? _graph('/me/drive/sharedWithMe')
: _graph('/me/drive/${_itemSegment(parentId ?? 'root')}/children');
final response = await http.get(uri, headers: await _authHeader());
if (response.statusCode != 200) {
throw StateError('OneDrive API error (listFolders): ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final entries = (json['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
final folders = <CloudFolder>[];
for (final entry in entries) {
if (entry['folder'] == null) continue;
// "Shared with me" items carry the shared item's own id under
// `remoteItem`, not the top-level entry id.
final remoteItem = entry['remoteItem'] as Map<String, dynamic>?;
final id = (remoteItem?['id'] ?? entry['id']) as String?;
final name = entry['name'] as String?;
if (id != null && name != null) {
folders.add(CloudFolder(id: id, name: name));
}
}
return folders;
}
@override
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
final childrenUri = _graph('/me/drive/${_itemSegment(parentId)}/children');
final listResponse = await http.get(childrenUri, headers: await _authHeader());
if (listResponse.statusCode != 200) {
throw StateError(
'OneDrive API error (findOrCreateFolder list): ${listResponse.statusCode} ${listResponse.body}');
}
final listJson = jsonDecode(listResponse.body) as Map<String, dynamic>;
final entries = (listJson['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
for (final entry in entries) {
if (entry['folder'] != null && entry['name'] == name) {
return entry['id'] as String;
}
}
final createResponse = await http.post(
childrenUri,
headers: {...await _authHeader(), 'Content-Type': 'application/json'},
body: jsonEncode({
'name': name,
'folder': <String, dynamic>{},
'@microsoft.graph.conflictBehavior': 'fail',
}),
);
if (createResponse.statusCode != 201) {
throw StateError(
'OneDrive API error (findOrCreateFolder create): ${createResponse.statusCode} ${createResponse.body}');
}
final created = jsonDecode(createResponse.body) as Map<String, dynamic>;
return created['id'] as String;
}
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name');
final response = await http.get(uri, headers: await _authHeader());
if (response.statusCode == 404) return null;
if (response.statusCode != 200) {
throw StateError('OneDrive API error (findFile): ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?);
}
@override
Future<List<int>> downloadFileBytes(String fileId) async {
final response =
await http.get(_graph('/me/drive/items/$fileId/content'), headers: await _authHeader());
if (response.statusCode != 200) {
throw StateError('OneDrive download failed: ${response.statusCode} ${response.body}');
}
return response.bodyBytes;
}
@override
Future<CloudFileInfo> uploadFile({
required String folderId,
required String name,
String? existingFileId,
required File localFile,
required String contentType,
}) async {
final bytes = await localFile.readAsBytes();
// Graph's simple upload endpoint (content < 4MB, which every receipt
// photo and the sqlite data file comfortably are) — no upload session
// needed.
final uri = existingFileId != null
? _graph('/me/drive/items/$existingFileId/content')
: _graph('/me/drive/${_itemSegment(folderId)}:/$name:/content');
final response = await http.put(
uri,
headers: {...await _authHeader(), 'Content-Type': contentType},
body: bytes,
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw StateError('OneDrive upload failed: ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?);
}
@override
Future<void> deleteFile(String fileId) async {
final response =
await http.delete(_graph('/me/drive/items/$fileId'), headers: await _authHeader());
if (response.statusCode != 204 && response.statusCode != 404) {
throw StateError('OneDrive delete failed: ${response.statusCode} ${response.body}');
}
}
@override
Future<String> createLockFile({required String folderId, required String name}) async {
final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name:/content');
final response = await http.put(
uri,
headers: {...await _authHeader(), 'Content-Type': 'text/plain'},
body: const <int>[],
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw StateError('OneDrive lock creation failed: ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
return json['id'] as String;
}
@override
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
final response = await http.get(
_graph('/me/drive/${_itemSegment(folderId)}/children'),
headers: await _authHeader(),
);
if (response.statusCode != 200) {
throw StateError('OneDrive API error (listLockFiles): ${response.statusCode} ${response.body}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final entries = (json['value'] as List<dynamic>? ?? []).cast<Map<String, dynamic>>();
final locks = <CloudLockFile>[];
for (final entry in entries) {
final parsed = parseLockFileName(entry['name'] as String?);
if (parsed != null) {
locks.add(CloudLockFile(
id: entry['id'] as String,
username: parsed.$1,
createdAtUtc: parsed.$2,
));
}
}
return locks;
}
@override
void close() {}
}

View file

@ -0,0 +1,390 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:xml/xml.dart';
import 'cloud_storage_provider.dart';
const _secureStorageKeyServerUrl = 'webdav_server_url';
const _secureStorageKeyUsername = 'webdav_username';
const _secureStorageKeyPassword = 'webdav_password';
const _propfindRequestBody = '''<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:resourcetype/>
<D:getetag/>
</D:prop>
</D:propfind>''';
/// One `<D:response>` entry from a WebDAV PROPFIND multistatus response.
/// Not private, and [parseWebDavMultistatus] is a free function, purely so
/// the XML parsing can be unit-tested directly against sample responses
/// from different server implementations — real WebDAV servers vary in
/// namespace prefix (Nextcloud uses `d:`, others `D:` or none at all),
/// which is exactly the kind of real-world format variance this app has
/// been burned by before with format-specific assumptions.
class WebDavEntry {
final String path;
final bool isCollection;
final String? etag;
WebDavEntry({required this.path, required this.isCollection, required this.etag});
}
/// Parses a WebDAV PROPFIND multistatus XML body into [WebDavEntry]s, with
/// each entry's href resolved to an absolute path against [baseUrl].
/// Matches elements by local name only (ignoring namespace prefix), since
/// that's the part that varies across server implementations.
List<WebDavEntry> parseWebDavMultistatus(String xmlBody, Uri baseUrl) {
final document = XmlDocument.parse(xmlBody);
return _byLocalName(document, 'response').map((responseEl) {
final href = _byLocalName(responseEl, 'href').first.innerText;
final isCollection = _byLocalName(responseEl, 'collection').isNotEmpty;
final etagEls = _byLocalName(responseEl, 'getetag').toList();
return WebDavEntry(
path: baseUrl.resolve(href).path,
isCollection: isCollection,
etag: etagEls.isEmpty ? null : etagEls.first.innerText,
);
}).toList();
}
Iterable<XmlElement> _byLocalName(XmlNode node, String localName) =>
node.descendants.whereType<XmlElement>().where((e) => e.name.local == localName);
class _RawResponse {
final int statusCode;
final String body;
final Map<String, String> headers;
_RawResponse({required this.statusCode, required this.body, required this.headers});
}
/// Sends a request with an arbitrary HTTP method (PROPFIND, MKCOL) that
/// `package:http`'s GET/PUT/DELETE convenience functions don't support.
Future<_RawResponse> _send(String method, Uri uri, {Map<String, String>? headers, Object? body}) async {
final client = http.Client();
try {
final request = http.Request(method, uri);
if (headers != null) request.headers.addAll(headers);
if (body is String) request.body = body;
if (body is List<int>) request.bodyBytes = body;
final streamed = await client.send(request);
final responseBody = await streamed.stream.bytesToString();
return _RawResponse(statusCode: streamed.statusCode, body: responseBody, headers: streamed.headers);
} finally {
client.close();
}
}
String _basicAuthHeader(String username, String password) =>
'Basic ${base64Encode(utf8.encode('$username:$password'))}';
String _normalizedPath(String path) => path.endsWith('/') ? path.substring(0, path.length - 1) : path;
String _nameFromPath(String path) {
final segments = _normalizedPath(path).split('/').where((s) => s.isNotEmpty);
return segments.isEmpty ? '' : Uri.decodeComponent(segments.last);
}
/// WebDAV implementation of [CloudStorageProvider], for self-hosted
/// personal cloud servers (Nextcloud, ownCloud, a Synology NAS, or any
/// generic WebDAV server) rather than a named commercial provider. Unlike
/// the OAuth-based providers, there's no browser sign-in flow and no
/// developer-console app to register ahead of time — the user supplies a
/// server URL, username, and password (an app-specific password is
/// recommended on servers that support one, e.g. Nextcloud's Security
/// settings) directly via [signInWithCredentials].
class WebDavProvider implements CloudStorageProvider, ManualCredentialCloudStorageProvider {
final FlutterSecureStorage _secureStorage;
Uri? _baseUrl;
String? _username;
String? _password;
WebDavProvider({FlutterSecureStorage? secureStorage})
: _secureStorage = secureStorage ?? const FlutterSecureStorage();
@override
CloudProviderId get id => CloudProviderId.webdav;
@override
String get displayName => 'WebDAV';
@override
bool get isSignedIn => _baseUrl != null;
@override
String? get accountLabel => isSignedIn ? '$_username@${_baseUrl!.host}' : null;
/// Restores a session from credentials saved on a previous
/// [signInWithCredentials] call (OS-encrypted storage — Keystore on
/// Android, Keychain on iOS), re-verifying them with the same PROPFIND
/// check rather than trusting them blindly, since the server config or
/// password could have changed since. Any failure here — wrong/expired
/// credentials, no network, nothing stored yet — just means "not signed
/// in"; this never throws; a real error from a user-initiated attempt
/// belongs to [signInWithCredentials], not this silent path.
@override
Future<bool> attemptSilentSignIn() async {
try {
final serverUrl = await _secureStorage.read(key: _secureStorageKeyServerUrl);
final username = await _secureStorage.read(key: _secureStorageKeyUsername);
final password = await _secureStorage.read(key: _secureStorageKeyPassword);
if (serverUrl == null || username == null || password == null) return false;
await _verifiedSignIn(serverUrl: serverUrl, username: username, password: password);
return true;
} catch (_) {
return false;
}
}
@override
Future<String> signIn() {
throw UnsupportedError(
'WebDAV needs a server URL and credentials — use signInWithCredentials instead.');
}
@override
Future<String> signInWithCredentials({
required String serverUrl,
required String username,
required String password,
}) async {
final label =
await _verifiedSignIn(serverUrl: serverUrl, username: username, password: password);
await _secureStorage.write(key: _secureStorageKeyServerUrl, value: _baseUrl!.toString());
await _secureStorage.write(key: _secureStorageKeyUsername, value: username);
await _secureStorage.write(key: _secureStorageKeyPassword, value: password);
return label;
}
/// Shared by [signInWithCredentials] and [attemptSilentSignIn]: normalizes
/// the URL, does a side-effect-free PROPFIND on the root to confirm the
/// URL and credentials actually work, and — only once that's confirmed —
/// sets this instance's session fields.
Future<String> _verifiedSignIn({
required String serverUrl,
required String username,
required String password,
}) async {
var normalized = serverUrl.trim();
if (!normalized.contains('://')) normalized = 'https://$normalized';
if (!normalized.endsWith('/')) normalized += '/';
final baseUrl = Uri.parse(normalized);
final response = await _send('PROPFIND', baseUrl, headers: {
'Authorization': _basicAuthHeader(username, password),
'Depth': '0',
'Content-Type': 'application/xml; charset=utf-8',
}, body: _propfindRequestBody);
if (response.statusCode == 401) {
throw StateError('WebDAV sign-in failed: invalid username or password.');
}
if (response.statusCode != 207 && response.statusCode != 200) {
throw StateError('WebDAV sign-in failed: ${response.statusCode} ${response.body}');
}
_baseUrl = baseUrl;
_username = username;
_password = password;
return accountLabel!;
}
@override
Future<void> signOut() async {
_baseUrl = null;
_username = null;
_password = null;
await _secureStorage.delete(key: _secureStorageKeyServerUrl);
await _secureStorage.delete(key: _secureStorageKeyUsername);
await _secureStorage.delete(key: _secureStorageKeyPassword);
}
@override
CloudStorageSession beginSession() {
if (!isSignedIn) throw CloudNotAuthorizedException(displayName);
return WebDavSession(this);
}
String get _authHeader => _basicAuthHeader(_username!, _password!);
}
class _WebDavNotFoundException implements Exception {}
class WebDavSession implements CloudStorageSession {
final WebDavProvider _provider;
WebDavSession(this._provider);
@override
bool get supportsSharedWithMe => false;
Uri _uriFor(String id) => id == 'root' ? _provider._baseUrl! : _provider._baseUrl!.replace(path: id);
Uri _childUri(Uri parent, String name) {
final parentUri = parent.path.endsWith('/') ? parent : parent.replace(path: '${parent.path}/');
return parentUri.resolve(Uri.encodeComponent(name));
}
Future<List<WebDavEntry>> _propfind(Uri uri, {required String depth}) async {
final response = await _send('PROPFIND', uri, headers: {
'Authorization': _provider._authHeader,
'Depth': depth,
'Content-Type': 'application/xml; charset=utf-8',
}, body: _propfindRequestBody);
if (response.statusCode == 404) throw _WebDavNotFoundException();
if (response.statusCode != 207) {
throw StateError('WebDAV PROPFIND failed: ${response.statusCode} ${response.body}');
}
return parseWebDavMultistatus(response.body, _provider._baseUrl!);
}
@override
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async {
final uri = _uriFor(parentId ?? 'root');
final selfPath = _normalizedPath(uri.path);
List<WebDavEntry> entries;
try {
entries = await _propfind(uri, depth: '1');
} on _WebDavNotFoundException {
return [];
}
return entries
.where((e) => e.isCollection && _normalizedPath(e.path) != selfPath)
.map((e) => CloudFolder(id: e.path, name: _nameFromPath(e.path)))
.toList();
}
@override
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
final childUri = _childUri(_uriFor(parentId), name);
try {
final entries = await _propfind(childUri, depth: '0');
if (entries.isNotEmpty && entries.first.isCollection) return childUri.path;
} on _WebDavNotFoundException {
// Falls through to create it below.
}
final response =
await _send('MKCOL', childUri, headers: {'Authorization': _provider._authHeader});
if (response.statusCode != 200 && response.statusCode != 201) {
throw StateError('WebDAV MKCOL failed: ${response.statusCode} ${response.body}');
}
return childUri.path;
}
@override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
final fileUri = _childUri(_uriFor(folderId), name);
List<WebDavEntry> entries;
try {
entries = await _propfind(fileUri, depth: '0');
} on _WebDavNotFoundException {
return null;
}
if (entries.isEmpty) return null;
return CloudFileInfo(id: fileUri.path, versionTag: entries.first.etag);
}
@override
Future<List<int>> downloadFileBytes(String fileId) async {
final response =
await http.get(_uriFor(fileId), headers: {'Authorization': _provider._authHeader});
if (response.statusCode != 200) {
throw StateError('WebDAV download failed: ${response.statusCode} ${response.body}');
}
return response.bodyBytes;
}
@override
Future<CloudFileInfo> uploadFile({
required String folderId,
required String name,
String? existingFileId,
required File localFile,
required String contentType,
}) async {
final uri = existingFileId != null ? _uriFor(existingFileId) : _childUri(_uriFor(folderId), name);
final bytes = await localFile.readAsBytes();
final response = await http.put(
uri,
headers: {'Authorization': _provider._authHeader, 'Content-Type': contentType},
body: bytes,
);
if (response.statusCode != 200 && response.statusCode != 201 && response.statusCode != 204) {
throw StateError('WebDAV upload failed: ${response.statusCode} ${response.body}');
}
// Many servers return the new ETag directly on the PUT response; fall
// back to a follow-up PROPFIND only if it's missing.
var etag = response.headers['etag'];
if (etag == null) {
try {
final entries = await _propfind(uri, depth: '0');
etag = entries.isEmpty ? null : entries.first.etag;
} on _WebDavNotFoundException {
etag = null;
}
}
return CloudFileInfo(id: uri.path, versionTag: etag);
}
@override
Future<void> deleteFile(String fileId) async {
final response =
await http.delete(_uriFor(fileId), headers: {'Authorization': _provider._authHeader});
if (response.statusCode != 200 && response.statusCode != 204 && response.statusCode != 404) {
throw StateError('WebDAV delete failed: ${response.statusCode} ${response.body}');
}
}
@override
Future<String> createLockFile({required String folderId, required String name}) async {
final uri = _childUri(_uriFor(folderId), name);
final response = await http.put(
uri,
headers: {'Authorization': _provider._authHeader, 'Content-Type': 'text/plain'},
body: const <int>[],
);
if (response.statusCode != 200 && response.statusCode != 201 && response.statusCode != 204) {
throw StateError('WebDAV lock creation failed: ${response.statusCode} ${response.body}');
}
return uri.path;
}
@override
Future<List<CloudLockFile>> listLockFiles(String folderId) async {
final uri = _uriFor(folderId);
final selfPath = _normalizedPath(uri.path);
List<WebDavEntry> entries;
try {
entries = await _propfind(uri, depth: '1');
} on _WebDavNotFoundException {
return [];
}
final locks = <CloudLockFile>[];
for (final entry in entries) {
if (_normalizedPath(entry.path) == selfPath) continue;
final parsed = parseLockFileName(_nameFromPath(entry.path));
if (parsed != null) {
locks.add(CloudLockFile(id: entry.path, username: parsed.$1, createdAtUtc: parsed.$2));
}
}
return locks;
}
@override
void close() {}
}

View file

@ -0,0 +1,52 @@
/// Fill these in once the corresponding OAuth apps/registrations exist —
/// see README.md "Manual setup required" for exact steps per provider.
class CloudOAuthConfig {
// --- Google Drive ---
//
// - 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.
/// The Web application OAuth client ID. Required for sign-in to work on
/// Android.
static const String? googleAndroidServerClientId = 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? googleIosClientId = null; // TODO: fill in
// --- Dropbox ---
//
// From a "Full Dropbox" access app at dropbox.com/developers/apps.
/// The app's key (client ID for OAuth2 PKCE — no secret needed).
static const String? dropboxAppKey = null; // TODO: fill in
/// Custom URL scheme redirect registered in the Dropbox app console.
/// The scheme "mofueltaxback-dropbox" is already wired up in
/// AndroidManifest.xml / Info.plist, so unless you have a reason to pick
/// a different scheme, use exactly:
/// "mofueltaxback-dropbox://oauth2redirect"
static const String? dropboxRedirectUri = null; // TODO: fill in
// --- OneDrive (Microsoft Graph) ---
//
// From an app registration in Azure Portal → Entra ID → App
// registrations, with Graph delegated permissions Files.ReadWrite.All +
// offline_access, redirect URI registered as a "Mobile and desktop
// application" platform.
/// The Application (client) ID from the Azure app registration.
static const String? oneDriveClientId = null; // TODO: fill in
/// Custom URL scheme redirect registered in Azure. The scheme
/// "mofueltaxback-onedrive" is already wired up in AndroidManifest.xml /
/// Info.plist, so unless you have a reason to pick a different scheme,
/// register and use exactly:
/// "mofueltaxback-onedrive://auth"
static const String? oneDriveRedirectUri = null; // TODO: fill in
}

View file

@ -0,0 +1,324 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'cloud/cloud_storage_provider.dart';
import 'database_service.dart';
import 'db_schema.dart';
import 'lock_coordinator.dart' as lock;
class SyncResult {
final bool ranSync;
final Object? error;
SyncResult.skipped()
: ranSync = false,
error = null;
SyncResult.success()
: ranSync = true,
error = null;
SyncResult.failure(this.error) : ranSync = false;
}
/// Orchestrates one round of sync against the shared cloud folder — same
/// behavior no matter which [CloudStorageProvider] it's wired to: acquires
/// the cross-device lock, pulls + merges the remote database if it
/// changed, uploads any pending receipt photos, pushes the local database
/// back up, then releases the lock.
///
/// The merge itself runs as SQL directly against the local database with
/// the downloaded remote copy `ATTACH`ed, rather than decoding records into
/// Dart objects: for each table, `INSERT OR REPLACE` any remote row that's
/// new to us or has a newer `updated_at` than our copy. Rows we haven't
/// touched (including our own not-yet-pushed edits) are left alone by that
/// statement, so no separate "keep local" step is needed — see the README
/// for the full reasoning.
class CloudSyncService {
final CloudStorageProvider provider;
final DatabaseService databaseService;
String? _appFolderId;
String? _receiptsFolderId;
final Map<String, String> _vinFolderIds = {};
final Map<String, String> _monthFolderIds = {};
String? _dataFileId;
String? _lastKnownRemoteVersionTag;
CloudSyncService({required this.provider, required this.databaseService});
bool get isConfigured => _appFolderId != null;
/// Call once a cloud app folder has been chosen (or restored at launch).
void configure(String appFolderId) {
_appFolderId = appFolderId;
_receiptsFolderId = null;
_vinFolderIds.clear();
_monthFolderIds.clear();
_dataFileId = null;
_lastKnownRemoteVersionTag = null;
}
void clearConfiguration() {
_appFolderId = null;
_receiptsFolderId = null;
_vinFolderIds.clear();
_monthFolderIds.clear();
_dataFileId = null;
_lastKnownRemoteVersionTag = null;
}
/// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a
/// folder the user picked in the folder browser) and configures this
/// service to use it. Returns the resulting folder ID.
Future<String> selectAppFolder(String parentId) async {
final session = provider.beginSession();
try {
final folderId =
await session.findOrCreateFolder(parentId: parentId, name: appFolderName);
configure(folderId);
return folderId;
} finally {
session.close();
}
}
/// [keepLocalReceiptCopies] mirrors the Settings toggle: when true, a
/// receipt photo's local copy is left in place after it's uploaded
/// (useful for offline viewing / an on-device backup); when false
/// (default), it's deleted once the cloud has it, matching the original
/// "local is just a staging area" design.
///
/// [staleLockAge] mirrors the Settings "Advanced" stale-lock timeout:
/// how old another device's lock file has to be before this device
/// treats it as abandoned (e.g. that device crashed or went offline
/// mid-sync) and deletes it rather than waiting forever. Defaults to 10
/// minutes, matching [defaultStaleLockMinutes] in app_state.dart.
Future<SyncResult> syncNow({
bool keepLocalReceiptCopies = false,
Duration staleLockAge = const Duration(minutes: 10),
}) async {
final appFolderId = _appFolderId;
if (!provider.isSignedIn || appFolderId == null) {
return SyncResult.skipped();
}
CloudStorageSession? session;
String? lockFileId;
try {
session = provider.beginSession();
lockFileId = await _acquireLock(
session,
appFolderId: appFolderId,
username: provider.accountLabel!,
staleAge: staleLockAge,
);
_receiptsFolderId ??=
await session.findOrCreateFolder(parentId: appFolderId, name: receiptsFolderName);
final remoteInfo = await session.findFile(folderId: appFolderId, name: dataFileName);
_dataFileId = remoteInfo?.id;
if (remoteInfo != null && remoteInfo.versionTag != _lastKnownRemoteVersionTag) {
await _pullAndMerge(session, remoteInfo.id);
}
final allReceiptsUploaded =
await _uploadPendingReceipts(session, keepLocalCopies: keepLocalReceiptCopies);
// Only push if every pending receipt made it up — otherwise we'd
// either upload a row with a local-only file path (meaningless on
// another device) or prematurely mark it clean.
if (allReceiptsUploaded) {
final pushedInfo = await _pushSnapshot(session, appFolderId);
_lastKnownRemoteVersionTag = pushedInfo.versionTag;
}
return SyncResult.success();
} catch (e) {
return SyncResult.failure(e);
} finally {
if (lockFileId != null && session != null) {
try {
await session.deleteFile(lockFileId);
} catch (_) {
// Best-effort: if this fails, the staleness reap on other
// devices' next sync attempt will clean it up.
}
}
session?.close();
}
}
Future<void> _pullAndMerge(CloudStorageSession session, String remoteFileId) async {
final bytes = await session.downloadFileBytes(remoteFileId);
final tempDir = await getTemporaryDirectory();
final tempPath =
p.join(tempDir.path, 'cloud_pull_${DateTime.now().microsecondsSinceEpoch}.db');
final tempFile = File(tempPath);
await tempFile.writeAsBytes(bytes, flush: true);
try {
final db = databaseService.rawDb;
await db.execute("ATTACH DATABASE '${_escapeSqlLiteral(tempPath)}' AS remote_db");
try {
await db.execute(mergeVehiclesSql);
await db.execute(mergeFuelEntriesSql);
} finally {
try {
await db.execute('DETACH DATABASE remote_db');
} catch (_) {
// Don't let a detach failure mask a real merge error above.
}
}
} finally {
if (await tempFile.exists()) {
await tempFile.delete();
}
}
}
/// Uploads any receipt images still needing it (a local path but no
/// cloud file ID yet — see [FuelEntry.needsReceiptUpload]). Returns true
/// only if every pending image made it up — see [syncNow] for why a
/// partial failure here blocks the push step entirely rather than
/// pushing something with a leftover local-only path.
///
/// Deliberately filters on `receipt_drive_file_id IS NULL` (see
/// [pendingReceiptUploadWhereClause]), not just "has a local path": once
/// [keepLocalCopies] is honored below, an already-uploaded row can still
/// have a local path (kept on purpose), and re-matching it here would
/// re-upload the same photo as a duplicate cloud file on every sync.
Future<bool> _uploadPendingReceipts(
CloudStorageSession session, {
required bool keepLocalCopies,
}) async {
final db = databaseService.rawDb;
final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause);
if (rows.isEmpty) return true;
final vehicleRows = await db.query('vehicles', columns: ['id', 'vin']);
final vinByVehicleId = {for (final v in vehicleRows) v['id'] as String: v['vin'] as String};
var allSucceeded = true;
for (final row in rows) {
final id = row['id'] as String;
final localPath = row['receipt_image_path'] as String;
final localFile = File(localPath);
if (!await localFile.exists()) {
// Nothing left to upload; clear the dangling reference.
await db.update('fuel_entries', {'receipt_image_path': null},
where: 'id = ?', whereArgs: [id]);
continue;
}
final vin = vinByVehicleId[row['vehicle_id']];
if (vin == null) {
// Vehicle row is missing outright (shouldn't normally happen);
// nothing sane to file this under.
allSucceeded = false;
continue;
}
final date = DateTime.fromMillisecondsSinceEpoch(row['date'] as int);
try {
final folderId = await _receiptFolderFor(session, vin, date);
final uploaded = await session.uploadFile(
folderId: folderId,
name: '$id${p.extension(localPath)}',
localFile: localFile,
contentType: 'image/jpeg',
);
if (!keepLocalCopies) {
await databaseService.deleteReceiptImageFile(localPath);
}
await db.update(
'fuel_entries',
{
'receipt_drive_file_id': uploaded.id,
'receipt_image_path': keepLocalCopies ? localPath : null,
},
where: 'id = ?',
whereArgs: [id],
);
} catch (_) {
allSucceeded = false;
}
}
return allSucceeded;
}
/// Finds-or-creates the `Receipts/<vin>/<yyyy.mm>` subfolder for [vin] and
/// [date] (the fuel entry's purchase date, not upload time), caching both
/// levels for the rest of this [CloudSyncService]'s lifetime (cleared by
/// [configure]/[clearConfiguration]) so repeated uploads for the same
/// vehicle/month in one sync — or across syncs — don't re-issue the
/// lookup.
Future<String> _receiptFolderFor(CloudStorageSession session, String vin, DateTime date) async {
final vinFolderId = _vinFolderIds[vin] ??
await session.findOrCreateFolder(
parentId: _receiptsFolderId!,
name: sanitizedPathSegment(vin),
);
_vinFolderIds[vin] = vinFolderId;
final month = monthFolderName(date);
final monthCacheKey = '$vin/$month';
final monthFolderId = _monthFolderIds[monthCacheKey] ??
await session.findOrCreateFolder(parentId: vinFolderId, name: month);
_monthFolderIds[monthCacheKey] = monthFolderId;
return monthFolderId;
}
/// Uploads the current local database file as the new remote copy, then
/// clears the dirty flag on every row now that local matches the cloud.
///
/// Reads the live database file directly rather than a `VACUUM INTO`
/// snapshot: sqflite uses SQLite's default rollback-journal mode (not
/// WAL) unless explicitly configured otherwise, so the main file is a
/// complete, valid database as soon as the last write's Future
/// completes. `wal_checkpoint` is run first anyway as cheap insurance in
/// case that ever changes. This also sidesteps `VACUUM INTO` needing
/// SQLite 3.27+, which isn't guaranteed on very old Android versions.
Future<CloudFileInfo> _pushSnapshot(CloudStorageSession session, String appFolderId) async {
final db = databaseService.rawDb;
await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)');
final info = await session.uploadFile(
folderId: appFolderId,
name: dataFileName,
existingFileId: _dataFileId,
localFile: File(databaseService.databasePath),
contentType: 'application/x-sqlite3',
);
_dataFileId = info.id;
await db.update('vehicles', {'dirty': 0});
await db.update('fuel_entries', {'dirty': 0});
return info;
}
String _escapeSqlLiteral(String value) => value.replaceAll("'", "''");
Future<String> _acquireLock(
CloudStorageSession session, {
required String appFolderId,
required String username,
required Duration staleAge,
}) {
return lock.acquireLock(
username: username,
createLock: (name) => session.createLockFile(folderId: appFolderId, name: name),
listLocks: () => session.listLockFiles(appFolderId),
deleteLock: session.deleteFile,
staleAge: staleAge,
);
}
}

View file

@ -3,13 +3,33 @@ import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'package:uuid/uuid.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import 'db_schema.dart';
const dbFileName = 'fuel_tax_tracker.db';
const receiptsFolderName = 'receipts';
/// OCR-scanned VINs are a fixed alphanumeric charset, but manual entry in
/// the vehicle form doesn't enforce that — so anything outside
/// `[A-Za-z0-9_-]` is replaced before a VIN is used as a local directory
/// name or cloud folder name, to keep it a safe single path segment (no
/// `/`, `..`, etc.).
String sanitizedPathSegment(String value) => value.trim().replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
/// `yyyy.mm` for the given (local) date — the subfolder a receipt is filed
/// under within its vehicle's folder, e.g. `2026.08`. Based on the fuel
/// entry's purchase date ([FuelEntry.date]), not when the photo happened to
/// be taken or synced.
String monthFolderName(DateTime date) =>
'${date.year.toString().padLeft(4, '0')}.${date.month.toString().padLeft(2, '0')}';
/// Name of the *local* on-device staging folder for not-yet-uploaded
/// receipt photos — distinct from (though coincidentally the same string
/// as) `receiptsFolderName` in `cloud/cloud_storage_provider.dart`, which
/// names the corresponding subfolder inside the shared cloud app folder.
const localReceiptsFolderName = 'receipts';
/// Owns the local SQLite database (vehicles + fuel_entries) and the
/// `receipts/` folder of not-yet-uploaded receipt photos, both under
@ -18,9 +38,9 @@ const receiptsFolderName = 'receipts';
/// Every row carries `updated_at` (for newest-wins merging), `deleted_at`
/// (a soft-delete tombstone — see [Vehicle.deletedAt]/[FuelEntry.deletedAt]
/// for why deletes aren't real `DELETE`s), and `dirty` (local-only: "not
/// yet pushed to Drive"). `dirty` is intentionally not exposed on the
/// yet pushed to the cloud"). `dirty` is intentionally not exposed on the
/// domain model classes — it's sync bookkeeping the UI layer never needs to
/// know about; only [DriveSyncService] reads/clears it.
/// know about; only [CloudSyncService] reads/clears it.
class DatabaseService {
late Directory _rootDirectory;
late Database _db;
@ -28,9 +48,9 @@ class DatabaseService {
Directory get rootDirectory => _rootDirectory;
Directory get receiptsDirectory =>
Directory(p.join(_rootDirectory.path, receiptsFolderName));
Directory(p.join(_rootDirectory.path, localReceiptsFolderName));
/// Raw handle for [DriveSyncService], which needs to run ATTACH-based
/// Raw handle for [CloudSyncService], which needs to run ATTACH-based
/// merge SQL and VACUUM INTO snapshots that go beyond simple CRUD.
Database get rawDb => _db;
@ -43,7 +63,12 @@ class DatabaseService {
await receiptsDirectory.create(recursive: true);
final dbPath = p.join(_rootDirectory.path, dbFileName);
_db = await openDatabase(dbPath, version: 1, onCreate: _onCreate);
_db = await openDatabase(
dbPath,
version: 4,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
}
Future<void> _onCreate(Database db, int version) async {
@ -52,6 +77,76 @@ class DatabaseService {
await db.execute(createFuelEntriesIndexSql);
}
/// Versions 2/3 (VIN as primary key, then dropping license plate) were
/// rebuilt fresh on upgrade rather than migrated, since at the time that
/// only affected local, not-yet-synced test data. Version 4 (VIN becomes
/// editable, with a new hidden `id` taking over as the actual primary
/// key/merge key) is the first schema change made after real usage had
/// likely accumulated, so this one preserves existing rows instead of
/// dropping them: each vehicle gets a freshly generated `id`, and
/// `fuel_entries.vehicle_id` is repointed from the old `vehicle_vin` via
/// that mapping.
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 4) {
await _migrateToVehicleIdSchema(db);
}
}
Future<void> _migrateToVehicleIdSchema(Database db) async {
const uuid = Uuid();
final oldVehicles = await db.query('vehicles');
final vinToId = <String, String>{};
await db.execute('ALTER TABLE vehicles RENAME TO vehicles_old');
await db.execute(createVehiclesTableSql);
for (final row in oldVehicles) {
final vin = row['vin'] as String;
// An already-upgraded-then-reverted-then-upgraded-again edge case
// could in theory produce duplicate vin rows pre-migration; keep the
// first id assigned per vin so fuel_entries below has a single,
// unambiguous target.
final id = vinToId.putIfAbsent(vin, uuid.v4);
await db.insert('vehicles', {
'id': id,
'vin': vin,
'nickname': row['nickname'],
'updated_at': row['updated_at'],
'deleted_at': row['deleted_at'],
// Force a re-push under the new schema — the shape of what's on
// the cloud (if anything's been synced yet) still reflects the old
// schema and needs to be overwritten with this one.
'dirty': 1,
});
}
await db.execute('DROP TABLE vehicles_old');
final oldFuelEntries = await db.query('fuel_entries');
await db.execute('ALTER TABLE fuel_entries RENAME TO fuel_entries_old');
await db.execute(createFuelEntriesTableSql);
await db.execute(createFuelEntriesIndexSql);
for (final row in oldFuelEntries) {
final vehicleId = vinToId[row['vehicle_vin'] as String];
// No matching vehicle row (shouldn't normally happen) — drop rather
// than insert a fuel entry with a dangling reference.
if (vehicleId == null) continue;
await db.insert('fuel_entries', {
'id': row['id'],
'vehicle_id': vehicleId,
'date': row['date'],
'gallons': row['gallons'],
'price_per_gallon': row['price_per_gallon'],
'total_cost': row['total_cost'],
'receipt_image_path': row['receipt_image_path'],
'receipt_drive_file_id': row['receipt_drive_file_id'],
'updated_at': row['updated_at'],
'deleted_at': row['deleted_at'],
'dirty': 1,
});
}
await db.execute('DROP TABLE fuel_entries_old');
}
Future<List<Vehicle>> getVehicles() async {
final rows = await _db.query('vehicles', where: 'deleted_at IS NULL');
return rows.map(Vehicle.fromMap).toList();
@ -62,6 +157,27 @@ class DatabaseService {
return rows.map(FuelEntry.fromMap).toList();
}
/// True if an active (non-deleted) vehicle other than [excludeId] already
/// has this VIN. VIN must stay unique even though it's editable, so
/// callers adding a new vehicle (no [excludeId]) or changing an existing
/// one's VIN (passing its own [id][Vehicle.id] as [excludeId], so it
/// doesn't collide with itself) should check this first.
Future<bool> vinExists(String vin, {String? excludeId}) async {
final where = StringBuffer('vin = ? AND deleted_at IS NULL');
final whereArgs = <Object?>[vin];
if (excludeId != null) {
where.write(' AND id != ?');
whereArgs.add(excludeId);
}
final rows = await _db.query(
'vehicles',
where: where.toString(),
whereArgs: whereArgs,
limit: 1,
);
return rows.isNotEmpty;
}
/// Inserts or fully overwrites a vehicle row and marks it dirty (pending
/// push to Drive).
Future<void> saveVehicle(Vehicle vehicle) async {
@ -129,9 +245,24 @@ class DatabaseService {
return localPaths;
}
Future<String> storeReceiptImage(File sourceImage, String fuelEntryId) async {
/// Stores under `receipts/<vin>/<yyyy.mm>/`, mirroring the per-vehicle,
/// per-month folder structure used on the cloud side (see
/// [CloudSyncService]'s `_receiptFolderFor`), so a receipt's local
/// staging path already shows which vehicle and month it belongs to.
Future<String> storeReceiptImage(
File sourceImage,
String fuelEntryId,
String vin,
DateTime date,
) async {
final ext = p.extension(sourceImage.path);
final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext');
final entryDir = Directory(p.join(
receiptsDirectory.path,
sanitizedPathSegment(vin),
monthFolderName(date),
));
await entryDir.create(recursive: true);
final destPath = p.join(entryDir.path, '$fuelEntryId$ext');
final copied = await sourceImage.copy(destPath);
return copied.path;
}

View file

@ -1,10 +1,8 @@
const createVehiclesTableSql = '''
CREATE TABLE vehicles (
id TEXT PRIMARY KEY,
make TEXT NOT NULL,
model TEXT NOT NULL,
color TEXT NOT NULL,
license_plate TEXT NOT NULL,
vin TEXT NOT NULL,
nickname TEXT,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
dirty INTEGER NOT NULL DEFAULT 1
@ -30,15 +28,33 @@ const createFuelEntriesTableSql = '''
const createFuelEntriesIndexSql =
'CREATE INDEX idx_fuel_entries_vehicle_id ON fuel_entries(vehicle_id)';
/// Selects fuel entries whose receipt photo still needs uploading to
/// Drive. Deliberately requires `receipt_drive_file_id IS NULL`, not just
/// "has a local path": once a row is uploaded, its local copy may still be
/// kept around (see the "keep photos on this phone" setting) — matching on
/// the local path alone would re-upload that same photo as a duplicate
/// Drive file on every subsequent sync.
const pendingReceiptUploadWhereClause = 'dirty = 1 AND receipt_image_path IS NOT NULL '
'AND receipt_drive_file_id IS NULL AND deleted_at IS NULL';
/// Merges attached `remote_db` rows into `main` (the live local database):
/// any remote row that's new to us, or newer than our copy, replaces ours.
/// Rows this doesn't touch — including our own not-yet-pushed edits — are
/// left alone, since the WHERE clause only matches rows remote should win;
/// no separate "keep local" statement is needed.
///
/// Merges on `id` (the hidden, immutable identifier), not `vin` — VIN is
/// user-editable, so it can't be relied on as a stable merge key. VIN
/// uniqueness among active vehicles is enforced at the app layer instead
/// (see `DatabaseService.vinExists`), not by a database constraint here,
/// since two devices could in principle each independently add a vehicle
/// with the same VIN while offline; that's an accepted rare-conflict edge
/// case (see the "field-level conflicts aren't merged" caveat in the
/// README) rather than something this merge statement tries to resolve.
const mergeVehiclesSql = '''
INSERT OR REPLACE INTO main.vehicles
(id, make, model, color, license_plate, updated_at, deleted_at, dirty)
SELECT r.id, r.make, r.model, r.color, r.license_plate, r.updated_at, r.deleted_at, 0
(id, vin, nickname, updated_at, deleted_at, dirty)
SELECT r.id, r.vin, r.nickname, r.updated_at, r.deleted_at, 0
FROM remote_db.vehicles r
LEFT JOIN main.vehicles l ON l.id = r.id
WHERE l.id IS NULL OR r.updated_at > l.updated_at

View file

@ -1,108 +0,0 @@
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();
}
}

View file

@ -1,20 +0,0 @@
/// 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
}

View file

@ -1,241 +0,0 @@
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_tracker.db';
const _folderMimeType = 'application/vnd.google-apps.folder';
class DriveFolder {
final String id;
final String name;
DriveFolder({required this.id, required this.name});
}
class DriveDataFileInfo {
final String id;
/// Cheap change-detection signal: compare against the last value seen to
/// decide whether a pull is actually needed, without downloading content.
final String? md5Checksum;
DriveDataFileInfo({required this.id, required this.md5Checksum});
}
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!;
}
/// Looks up the shared data file's ID and md5 checksum without
/// downloading its content, so sync can cheaply decide whether a pull is
/// actually needed.
Future<DriveDataFileInfo?> findDataFile(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,md5Checksum)',
spaces: 'drive',
);
final files = result.files ?? <drive.File>[];
if (files.isEmpty) return null;
final first = files.first;
if (first.id == null) return null;
return DriveDataFileInfo(id: first.id!, md5Checksum: first.md5Checksum);
}
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 with the bytes of the local sqlite database
/// file (see [DriveSyncService]). Returns the (possibly new) file ID and
/// its fresh md5 checksum, so the caller can remember it for
/// change-detection on the next sync without an extra round-trip.
Future<DriveDataFileInfo> uploadDataFile({
required String appFolderId,
required String? existingFileId,
required File localSnapshotFile,
}) async {
final length = await localSnapshotFile.length();
final media = drive.Media(
localSnapshotFile.openRead(),
length,
contentType: 'application/x-sqlite3',
);
if (existingFileId != null) {
final updated = await _api.files.update(
drive.File(),
existingFileId,
uploadMedia: media,
$fields: 'id,md5Checksum',
);
return DriveDataFileInfo(id: updated.id ?? existingFileId, md5Checksum: updated.md5Checksum);
}
final created = await _api.files.create(
drive.File()
..name = dataFileName
..parents = [appFolderId],
uploadMedia: media,
$fields: 'id,md5Checksum',
);
return DriveDataFileInfo(id: created.id!, md5Checksum: created.md5Checksum);
}
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;
}

View file

@ -1,251 +0,0 @@
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'database_service.dart';
import 'db_schema.dart';
import 'drive_auth_service.dart';
import 'drive_service.dart';
import 'lock_coordinator.dart' as lock;
class SyncResult {
final bool ranSync;
final Object? error;
SyncResult.skipped()
: ranSync = false,
error = null;
SyncResult.success()
: ranSync = true,
error = null;
SyncResult.failure(this.error) : ranSync = false;
}
/// Orchestrates one round of sync against the shared Drive folder:
/// acquires the cross-device lock, pulls + merges the remote database if
/// it changed, uploads any pending receipt photos, pushes the local
/// database back up, then releases the lock.
///
/// The merge itself runs as SQL directly against the local database with
/// the downloaded remote copy `ATTACH`ed, rather than decoding records into
/// Dart objects: for each table, `INSERT OR REPLACE` any remote row that's
/// new to us or has a newer `updated_at` than our copy. Rows we haven't
/// touched (including our own not-yet-pushed edits) are left alone by that
/// statement, so no separate "keep local" step is needed — see the README
/// for the full reasoning.
class DriveSyncService {
final DriveAuthService authService;
final DatabaseService databaseService;
String? _appFolderId;
String? _receiptsFolderId;
String? _dataFileId;
String? _lastKnownRemoteMd5;
DriveSyncService({required this.authService, required this.databaseService});
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;
_lastKnownRemoteMd5 = null;
}
void clearConfiguration() {
_appFolderId = null;
_receiptsFolderId = null;
_dataFileId = null;
_lastKnownRemoteMd5 = 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() 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);
final remoteInfo = await drive.findDataFile(appFolderId);
_dataFileId = remoteInfo?.id;
if (remoteInfo != null && remoteInfo.md5Checksum != _lastKnownRemoteMd5) {
await _pullAndMerge(drive, remoteInfo.id);
}
final allReceiptsUploaded = await _uploadPendingReceipts(drive);
// Only push if every pending receipt made it up — otherwise we'd
// either upload a row with a local-only file path (meaningless on
// another device) or prematurely mark it clean.
if (allReceiptsUploaded) {
final pushedInfo = await _pushSnapshot(drive, appFolderId);
_lastKnownRemoteMd5 = pushedInfo.md5Checksum;
}
return SyncResult.success();
} 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<void> _pullAndMerge(DriveService drive, String remoteFileId) async {
final bytes = await drive.downloadFileBytes(remoteFileId);
final tempDir = await getTemporaryDirectory();
final tempPath =
p.join(tempDir.path, 'drive_pull_${DateTime.now().microsecondsSinceEpoch}.db');
final tempFile = File(tempPath);
await tempFile.writeAsBytes(bytes, flush: true);
try {
final db = databaseService.rawDb;
await db.execute("ATTACH DATABASE '${_escapeSqlLiteral(tempPath)}' AS remote_db");
try {
await db.execute(mergeVehiclesSql);
await db.execute(mergeFuelEntriesSql);
} finally {
try {
await db.execute('DETACH DATABASE remote_db');
} catch (_) {
// Don't let a detach failure mask a real merge error above.
}
}
} finally {
if (await tempFile.exists()) {
await tempFile.delete();
}
}
}
/// Uploads any locally-pending receipt images (dirty fuel entries that
/// still have a local path, not yet a Drive file ID). Returns true only
/// if every pending image made it up — see [syncNow] for why a partial
/// failure here blocks the push step entirely rather than pushing
/// something with a leftover local path.
Future<bool> _uploadPendingReceipts(DriveService drive) async {
final db = databaseService.rawDb;
final rows = await db.query(
'fuel_entries',
where: 'dirty = 1 AND receipt_image_path IS NOT NULL AND deleted_at IS NULL',
);
var allSucceeded = true;
for (final row in rows) {
final id = row['id'] as String;
final localPath = row['receipt_image_path'] as String;
final localFile = File(localPath);
if (!await localFile.exists()) {
// Nothing left to upload; clear the dangling reference.
await db.update('fuel_entries', {'receipt_image_path': null},
where: 'id = ?', whereArgs: [id]);
continue;
}
try {
final driveFileId = await drive.uploadReceiptImage(
receiptsFolderId: _receiptsFolderId!,
fileName: '$id${p.extension(localPath)}',
imageFile: localFile,
);
await databaseService.deleteReceiptImageFile(localPath);
await db.update(
'fuel_entries',
{'receipt_drive_file_id': driveFileId, 'receipt_image_path': null},
where: 'id = ?',
whereArgs: [id],
);
} catch (_) {
allSucceeded = false;
}
}
return allSucceeded;
}
/// Uploads the current local database file as the new remote copy, then
/// clears the dirty flag on every row now that local matches Drive.
///
/// Reads the live database file directly rather than a `VACUUM INTO`
/// snapshot: sqflite uses SQLite's default rollback-journal mode (not
/// WAL) unless explicitly configured otherwise, so the main file is a
/// complete, valid database as soon as the last write's Future
/// completes. `wal_checkpoint` is run first anyway as cheap insurance in
/// case that ever changes. This also sidesteps `VACUUM INTO` needing
/// SQLite 3.27+, which isn't guaranteed on very old Android versions.
Future<DriveDataFileInfo> _pushSnapshot(DriveService drive, String appFolderId) async {
final db = databaseService.rawDb;
await db.execute('PRAGMA wal_checkpoint(TRUNCATE)');
final info = await drive.uploadDataFile(
appFolderId: appFolderId,
existingFileId: _dataFileId,
localSnapshotFile: File(databaseService.databasePath),
);
_dataFileId = info.id;
await db.update('vehicles', {'dirty': 0});
await db.update('fuel_entries', {'dirty': 0});
return info;
}
String _escapeSqlLiteral(String value) => value.replaceAll("'", "''");
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,
);
}
}

View file

@ -1,4 +1,4 @@
import 'drive_service.dart' show DriveLockFile;
import 'cloud/cloud_storage_provider.dart' show CloudLockFile;
/// Ticket-based mutex using timestamped lock files as the coordination
/// point: create a lock named after our own timestamp, then wait until no
@ -7,12 +7,13 @@ import 'drive_service.dart' show DriveLockFile;
/// 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.
/// cloud storage dependency) so the state machine is unit-testable without
/// a real network connection, and works identically no matter which
/// [CloudStorageProvider] it's wired to.
Future<String> acquireLock({
required String username,
required Future<String> Function(String lockName) createLock,
required Future<List<DriveLockFile>> Function() listLocks,
required Future<List<CloudLockFile>> Function() listLocks,
required Future<void> Function(String lockId) deleteLock,
DateTime Function()? nowUtc,
Future<void> Function(Duration)? delay,

View file

@ -1,3 +1,25 @@
/// Two-letter USPS abbreviation to full name, for the 50 states + DC —
/// deliberately excludes territories (PR, VI, GU, ...): "VI" in particular
/// shows up on real receipts as a "Visa" abbreviation, and including it as
/// a valid state code would misfire on that.
const usStateNames = <String, String>{
'AL': 'Alabama', 'AK': 'Alaska', 'AZ': 'Arizona', 'AR': 'Arkansas',
'CA': 'California', 'CO': 'Colorado', 'CT': 'Connecticut', 'DE': 'Delaware',
'FL': 'Florida', 'GA': 'Georgia', 'HI': 'Hawaii', 'ID': 'Idaho',
'IL': 'Illinois', 'IN': 'Indiana', 'IA': 'Iowa', 'KS': 'Kansas',
'KY': 'Kentucky', 'LA': 'Louisiana', 'ME': 'Maine', 'MD': 'Maryland',
'MA': 'Massachusetts', 'MI': 'Michigan', 'MN': 'Minnesota',
'MS': 'Mississippi', 'MO': 'Missouri', 'MT': 'Montana', 'NE': 'Nebraska',
'NV': 'Nevada', 'NH': 'New Hampshire', 'NJ': 'New Jersey',
'NM': 'New Mexico', 'NY': 'New York', 'NC': 'North Carolina',
'ND': 'North Dakota', 'OH': 'Ohio', 'OK': 'Oklahoma', 'OR': 'Oregon',
'PA': 'Pennsylvania', 'RI': 'Rhode Island', 'SC': 'South Carolina',
'SD': 'South Dakota', 'TN': 'Tennessee', 'TX': 'Texas', 'UT': 'Utah',
'VT': 'Vermont', 'VA': 'Virginia', 'WA': 'Washington',
'WV': 'West Virginia', 'WI': 'Wisconsin', 'WY': 'Wyoming',
'DC': 'District of Columbia',
};
/// Best-effort values pulled out of OCR'd receipt text. Any field can be
/// null if it couldn't be found, and the confirm screen lets the user fill
/// in or correct whatever the parser got wrong.
@ -5,12 +27,25 @@ class ParsedReceipt {
final double? gallons;
final double? pricePerGallon;
final double? totalCost;
/// The transaction date/time printed on the receipt, if found. Null
/// means the confirm screen should fall back to manual entry (it
/// defaults to "now" and lets the user pick a different date/time).
final DateTime? date;
/// Two-letter state abbreviation of the station's address, if found
/// (e.g. "MO", "TX"). Null means no state could be confidently
/// identified — callers should treat that as "unknown", not "Missouri".
final String? state;
final String rawText;
ParsedReceipt({
this.gallons,
this.pricePerGallon,
this.totalCost,
this.date,
this.state,
required this.rawText,
});
}
@ -23,36 +58,91 @@ class ParsedReceipt {
/// to deriving a missing value from the other two when exactly one is
/// missing (total = gallons * price, etc).
class ReceiptParser {
// These use [ \t]* rather than \s* between a label and its value: \s
// matches newlines too, which let a number on one line match a
// completely unrelated label several lines further down (e.g. a price
// value followed, many lines later, by an incidental "Gallons" heading
// for a different column) — a real bug this surfaced against an actual
// receipt where "$1.999" ended up matching "...Gallons" two lines below
// it. A label and its own value are always on the same line.
static final _gallonsPatterns = [
RegExp(r'GALLONS?\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'\bGAL\b\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'(\d+\.\d{2,3})\s*GAL(?:LONS)?\b', caseSensitive: false),
RegExp(r'GALLONS?[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'\bGAL\b[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'(\d+\.\d{2,3})[ \t]*GAL(?:LONS)?\b', caseSensitive: false),
];
static final _pricePerGallonPatterns = [
RegExp(r'PRICE\s*/?\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
RegExp(r'PRICE[ \t]*/?[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
caseSensitive: false),
RegExp(r'\bPPG\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'PER\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
RegExp(r'\bPPG\b[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false),
RegExp(r'PER[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
caseSensitive: false),
RegExp(r'\$\s*/\s*GAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})',
RegExp(r'\$[ \t]*/[ \t]*GAL[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})',
caseSensitive: false),
];
static final _totalPatterns = [
RegExp(r'FUEL\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
RegExp(r'SALE\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
RegExp(r'AMOUNT\s*DUE\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false),
RegExp(r'(?<!SUB)\bTOTAL\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2})',
caseSensitive: false),
RegExp(r'AMOUNT\s*DUE[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
RegExp(r'FUEL\s*SALE[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
// Allows for words between TOTAL and the amount ("Total Sale $120.72",
// "Fuel Total: $44.44"), not just a bare colon/dash.
RegExp(r'(?<!SUB)\bTOTAL\b[^\n\d]{0,20}(\d+\.\d{2})', caseSensitive: false),
];
/// Matches a bare or $-prefixed 3-decimal number anywhere in the text.
/// US fuel receipts overwhelmingly print both gallons pumped and price
/// per gallon with exactly 3 decimal places, and only the price gets a
/// currency symbol — a much more reliable signal than the label text
/// itself, which varies a lot and is often split from its value onto a
/// different line by a tabular "Pump / Gallons / Price" header row (in
/// which case the label-based patterns above never match at all).
///
/// Uses `(?!\d)` rather than `\b` after the number: some receipts print
/// the unit directly attached with no space ("17.364G"), and a digit
/// followed by a letter is *not* a `\b` boundary, so `\b` would miss it.
/// `(?<!-)` excludes negative amounts (e.g. a per-gallon discount line
/// like "Debit Di/GAL $-0.100"), which are never a real gallons/price
/// value and would otherwise get matched as one.
static final _threeDecimalNumberPattern = RegExp(r'(?<!-)(\$)?\s*(\d+\.\d{3})(?!\d)');
// Matches MM/DD/YYYY, MM-DD-YY, and similar — US gas receipts are
// consistent about digit-separator-digit-separator-digit ordering even
// though the separator (/ or -) and year length (2 or 4 digits) vary.
static final _datePattern = RegExp(r'\b(\d{1,2})[/-](\d{1,2})[/-](\d{2}|\d{4})\b');
// Optional AM/PM, optional seconds — covers "02:21", "10:11:00 AM", and
// "02:11PM" (no space before the meridiem) all at once.
static final _timePattern = RegExp(r'\b(\d{1,2}):(\d{2})(?::\d{2})?\s*([AaPp][Mm])?\b');
// A two-letter code directly followed by a ZIP is by far the strongest
// signal an address block gives us (very low false-positive rate); a
// code right after a comma is a weaker but still decent fallback for
// receipts that print "City, ST" without a visible ZIP alongside it.
// Both require the candidate to be checked against [usStateNames] before
// being trusted — a bare 2-letter scan alone would misfire constantly
// (e.g. "VI" for Visa, "IN" as the word "in", "OR" as the word "or").
static final _stateBeforeZipPattern = RegExp(r'\b([A-Z]{2})\s+\d{5}(?:-\d{4})?\b');
static final _stateAfterCommaPattern = RegExp(r',\s*([A-Z]{2})\b');
static ParsedReceipt parse(String text) {
final normalized = text.replaceAll(',', '');
final gallons = _firstMatch(_gallonsPatterns, normalized);
final pricePerGallon = _firstMatch(_pricePerGallonPatterns, normalized);
var totalCost = _firstMatch(_totalPatterns, normalized);
var gallons = _firstMatch(_gallonsPatterns, normalized);
var pricePerGallon = _firstMatch(_pricePerGallonPatterns, normalized);
final totalCost = _firstMatch(_totalPatterns, normalized);
if (gallons == null || pricePerGallon == null) {
for (final match in _threeDecimalNumberPattern.allMatches(normalized)) {
final value = double.tryParse(match.group(2)!);
if (value == null) continue;
final hasDollarSign = match.group(1) != null;
if (hasDollarSign) {
pricePerGallon ??= value;
} else {
gallons ??= value;
}
}
}
final derivedTotal = _deriveMissingValue(
gallons: gallons,
@ -64,10 +154,76 @@ class ReceiptParser {
gallons: derivedTotal.gallons,
pricePerGallon: derivedTotal.pricePerGallon,
totalCost: derivedTotal.totalCost,
date: _parseDate(normalized),
// Uses the original text, not the comma-stripped `normalized` copy —
// the comma-adjacency fallback pattern needs commas intact.
state: _parseState(text),
rawText: text,
);
}
/// Looks for a US state abbreviation in the station's address block. Only
/// trusts a candidate that's both a plausible address position (right
/// before a ZIP, or right after a comma) *and* a real state code — see
/// [usStateNames] and the patterns above for why both checks matter.
static String? _parseState(String text) {
final zipMatch = _stateBeforeZipPattern.firstMatch(text);
if (zipMatch != null) {
final code = zipMatch.group(1)!.toUpperCase();
if (usStateNames.containsKey(code)) return code;
}
final commaMatch = _stateAfterCommaPattern.firstMatch(text);
if (commaMatch != null) {
final code = commaMatch.group(1)!.toUpperCase();
if (usStateNames.containsKey(code)) return code;
}
return null;
}
/// Finds a date on the receipt and, if a time is printed nearby (same
/// line — within a short character window right after the date, since
/// receipts almost always print them adjacent, e.g. "DATE 3/26/22
/// 18:12" or "Date: ...\nTime: ..."), combines them. Falls back to
/// midnight if no time is found, and to null (manual entry) if no date
/// is found at all.
static DateTime? _parseDate(String text) {
final dateMatch = _datePattern.firstMatch(text);
if (dateMatch == null) return null;
final month = int.tryParse(dateMatch.group(1)!);
final day = int.tryParse(dateMatch.group(2)!);
var year = int.tryParse(dateMatch.group(3)!);
if (month == null || day == null || year == null) return null;
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
if (year < 100) year += 2000;
final windowEnd = (dateMatch.end + 40).clamp(0, text.length);
final nearbyText = text.substring(dateMatch.end, windowEnd);
final timeMatch = _timePattern.firstMatch(nearbyText);
var hour = 0;
var minute = 0;
if (timeMatch != null) {
hour = int.tryParse(timeMatch.group(1)!) ?? 0;
minute = int.tryParse(timeMatch.group(2)!) ?? 0;
final meridiem = timeMatch.group(3)?.toUpperCase();
if (meridiem == 'PM' && hour != 12) hour += 12;
if (meridiem == 'AM' && hour == 12) hour = 0;
if (hour > 23 || minute > 59) {
hour = 0;
minute = 0;
}
}
try {
return DateTime(year, month, day, hour, minute);
} catch (_) {
return null;
}
}
static double? _firstMatch(List<RegExp> patterns, String text) {
for (final pattern in patterns) {
final match = pattern.firstMatch(text);

View file

@ -0,0 +1,31 @@
/// Extracts a 17-character VIN from OCR'd text (a photo of a door-jamb
/// sticker, dashboard plate, title, etc).
///
/// Real VINs never contain the letters I, O, or Q (they're excluded from
/// the standard specifically so they can't be confused with 1 and 0), so
/// requiring that charset both matches genuine VINs and rules out a lot of
/// incidental 17-character noise elsewhere on the sticker (barcodes text,
/// weight ratings, date codes, etc).
class VinParser {
static final _labeledVinPattern =
RegExp(r'VIN[:\s]*([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
// \b on both sides matters: since digits and letters are both "word"
// characters, this only matches a maximal run of exactly 17 eligible
// characters — not a 17-character slice out of an 18+ character run.
static final _bareVinPattern = RegExp(r'\b([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false);
/// Returns the VIN in uppercase, or null if nothing matching the VIN
/// charset/length was found. A labeled "VIN: ..." match is preferred
/// over a bare 17-character token, in case a busy sticker has more than
/// one candidate (e.g. also a 17-digit tire/parts barcode number).
static String? parse(String text) {
final labeled = _labeledVinPattern.firstMatch(text);
if (labeled != null) return labeled.group(1)!.toUpperCase();
final bare = _bareVinPattern.firstMatch(text);
if (bare != null) return bare.group(1)!.toUpperCase();
return null;
}
}