153 lines
6 KiB
Dart
153 lines
6 KiB
Dart
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<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));
|
|
}
|