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 = 'Show Me The Fuel Refund'; 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 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 signIn(); Future 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> 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 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 findFile({required String folderId, required String name}); Future> downloadFileBytes(String fileId); /// Creates the file if [existingFileId] is null, otherwise overwrites /// its content. Returns the (possibly new) file ID and fresh versionTag. Future uploadFile({ required String folderId, required String name, String? existingFileId, required File localFile, required String contentType, }); Future deleteFile(String fileId); Future createLockFile({required String folderId, required String name}); Future> 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 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)); }