From 901ec678a389348197e42ca407cc96d19e6aced3 Mon Sep 17 00:00:00 2001 From: Courtney Arnold Date: Fri, 14 Aug 2026 17:30:31 -0500 Subject: [PATCH] onedirve and google drive --- README.md | 12 +- lib/main.dart | 9 +- lib/screens/add_edit_vehicle_screen.dart | 62 ++++++++- lib/screens/cloud_folder_browser_screen.dart | 16 ++- lib/services/app_state.dart | 16 ++- .../cloud/cloud_storage_provider.dart | 2 +- lib/services/cloud/dropbox_provider.dart | 2 +- lib/services/cloud_oauth_config.dart | 9 +- lib/services/cloud_sync_service.dart | 20 ++- lib/theme/app_theme.dart | 118 ++++++++++++++++++ test/add_edit_vehicle_screen_test.dart | 104 +++++++++++++++ test/cloud_sync_service_test.dart | 32 ++++- 12 files changed, 373 insertions(+), 29 deletions(-) create mode 100644 lib/theme/app_theme.dart create mode 100644 test/add_edit_vehicle_screen_test.dart diff --git a/README.md b/README.md index 4c430f0..fce924e 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,13 @@ the same pool of vehicles from their own phones, offline or online. server** (Nextcloud, ownCloud, a Synology NAS, or any generic WebDAV endpoint) and pick any folder you have access to — including one someone else created and shared with you — as the shared storage location. The - app looks for (or creates) a `MO-Fuel-Tax-Back` subfolder there, so - anyone pointed at the same shared parent converges on the same data - automatically, regardless of which of the four providers each person is - using. WebDAV is the only one of the four that needs no developer-console - setup at all — just a server URL, username, and password. + app looks for (or creates) a `Show Me The Fuel Refund` subfolder there + (using it directly, without nesting, if the folder you picked is already + named that), so anyone pointed at the same shared parent converges on the + same data automatically, regardless of which of the four providers each + person is using. WebDAV is the only one of the four that needs no + developer-console setup at all — just a server URL, username, and + password. - Works fully offline: writes always land in the local SQLite database first; a background sync pushes changes to the cloud and pulls others' changes down once online. diff --git a/lib/main.dart b/lib/main.dart index ff1f44e..819fb45 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'screens/home_screen.dart'; import 'services/app_state.dart'; +import 'theme/app_theme.dart'; void main() { runApp(const FuelTaxTrackerApp()); @@ -17,12 +18,8 @@ class FuelTaxTrackerApp extends StatelessWidget { create: (_) => AppState()..init(), child: MaterialApp( title: 'Fuel Tax Tracker', - theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true), - darkTheme: ThemeData( - colorSchemeSeed: Colors.indigo, - brightness: Brightness.dark, - useMaterial3: true, - ), + theme: AppTheme.light, + darkTheme: AppTheme.dark, home: const AppRoot(), ), ); diff --git a/lib/screens/add_edit_vehicle_screen.dart b/lib/screens/add_edit_vehicle_screen.dart index a901d7d..853e35a 100644 --- a/lib/screens/add_edit_vehicle_screen.dart +++ b/lib/screens/add_edit_vehicle_screen.dart @@ -9,6 +9,7 @@ import '../services/app_state.dart'; import '../services/ocr_service.dart'; import '../services/vin_parser.dart'; import '../widgets/image_source_sheet.dart'; +import 'vehicle_detail_screen.dart'; /// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or /// omit it to create a new one. @@ -28,6 +29,7 @@ class _AddEditVehicleScreenState extends State { bool _saving = false; bool _scanningVin = false; + bool _checkingDuplicateVin = false; String? _error; bool get _isEditing => widget.vehicle != null; @@ -84,6 +86,7 @@ class _AddEditVehicleScreenState extends State { if (vin != null) { _vinController.text = vin; + await _checkForExistingVin(); } else if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -93,6 +96,62 @@ class _AddEditVehicleScreenState extends State { } } + /// Field-level counterpart to [_checkForExistingVin]'s dialog: catches + /// "Required" as before, plus flags the field itself as invalid when the + /// VIN belongs to another active vehicle — excluding this vehicle's own + /// id while editing, same as [AppState.updateVehicle]'s check, so editing + /// a vehicle without touching its VIN doesn't flag itself. + String? _validateVin(String? value) { + final vin = value?.trim() ?? ''; + if (vin.isEmpty) return 'Required'; + + final existing = context.read().vehicleByVin(vin); + if (existing != null && existing.id != widget.vehicle?.id) { + return 'A vehicle with VIN "$vin" already exists.'; + } + return null; + } + + /// Only relevant when adding a new vehicle — editing one already starts + /// with its own VIN pre-filled, which would trivially "match itself". + /// Guarded against overlapping calls so rapid edits (or the scan-fill + /// path firing right after `onChanged`) can't stack multiple dialogs. + Future _checkForExistingVin() async { + if (_isEditing || _checkingDuplicateVin) return; + + final vin = _vinController.text.trim(); + if (vin.isEmpty) return; + final existing = context.read().vehicleByVin(vin); + if (existing == null) return; + + _checkingDuplicateVin = true; + final openExisting = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Vehicle already exists'), + content: Text('Vehicle with the VIN $vin already exists, would you like to open that vehicle?'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('No')), + TextButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Yes')), + ], + ), + ); + _checkingDuplicateVin = false; + if (!mounted) return; + + if (openExisting != true) { + // Declining to open the existing vehicle leaves them here with a + // still-duplicate VIN — surface that on the field itself rather than + // only catching it later at Save. + _formKey.currentState?.validate(); + return; + } + + Navigator.of(context) + ..pop() + ..push(MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: existing.id))); + } + Future _save() async { if (!_formKey.currentState!.validate()) return; @@ -205,7 +264,8 @@ class _AddEditVehicleScreenState extends State { ), ), textCapitalization: TextCapitalization.characters, - validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, + validator: _validateVin, + onChanged: _isEditing ? null : (_) => _checkForExistingVin(), ), if (_error != null) ...[ const SizedBox(height: 12), diff --git a/lib/screens/cloud_folder_browser_screen.dart b/lib/screens/cloud_folder_browser_screen.dart index 117a249..3756208 100644 --- a/lib/screens/cloud_folder_browser_screen.dart +++ b/lib/screens/cloud_folder_browser_screen.dart @@ -9,9 +9,12 @@ enum _BrowseRoot { myFiles, sharedWithMe } /// Lets the user navigate whichever cloud storage provider is connected — /// their own files, and (if the provider supports it) files others have /// shared with them — and pick a parent location. Confirming looks for (or -/// creates) the `MO-Fuel-Tax-Back` folder under that location, so two -/// people pointing at the same shared parent converge on the same app -/// folder, regardless of which provider each of them is using. +/// creates) the `Show Me The Fuel Refund` folder under that location, so +/// two people pointing at the same shared parent converge on the same app +/// folder, regardless of which provider each of them is using. Picking a +/// folder that's already named that directly (e.g. a shared folder set up +/// by someone else) uses it as-is instead of nesting another one inside — +/// see `CloudSyncService.selectAppFolder`. class CloudFolderBrowserScreen extends StatefulWidget { const CloudFolderBrowserScreen({super.key}); @@ -54,6 +57,12 @@ class _CloudFolderBrowserScreenState extends State { return null; } + /// The currently browsed-into folder's own name, so [_useThisFolder] can + /// tell whether the user picked a folder already named + /// `Show Me The Fuel Refund` — null at a virtual root ("My Files"), + /// which is never itself named that. + String? get _currentFolderName => _pathStack.isNotEmpty ? _pathStack.last.name : null; + String get _breadcrumbPath => ([_rootLabel] + _pathStack.map((f) => f.name).toList()).join(' / '); @@ -121,6 +130,7 @@ class _CloudFolderBrowserScreenState extends State { await context.read().chooseCloudFolder( parentId: parentId, breadcrumbPath: _breadcrumbPath, + currentFolderName: _currentFolderName, ); if (mounted) Navigator.of(context).pop(); } catch (e) { diff --git a/lib/services/app_state.dart b/lib/services/app_state.dart index 828a3d4..f0d3bd9 100644 --- a/lib/services/app_state.dart +++ b/lib/services/app_state.dart @@ -234,11 +234,13 @@ class AppState extends ChangeNotifier { Future chooseCloudFolder({ required String parentId, required String breadcrumbPath, + String? currentFolderName, }) async { final sync = cloudSync; if (sync == null) return; - final folderId = await sync.selectAppFolder(parentId); + final folderId = + await sync.selectAppFolder(parentId, currentFolderName: currentFolderName); cloudFolderPath = breadcrumbPath; final prefs = await SharedPreferences.getInstance(); @@ -399,6 +401,18 @@ class AppState extends ChangeNotifier { } } + /// Exact match against an active vehicle's VIN — same comparison + /// [DatabaseService.vinExists] does, not case-insensitive, since VIN + /// isn't normalized to any particular case on manual entry (only OCR + /// scanning uppercases it). + Vehicle? vehicleByVin(String vin) { + try { + return vehicles.firstWhere((v) => v.vin == vin); + } catch (_) { + return null; + } + } + Future _persist() async { await _refreshFromDatabase(); notifyListeners(); diff --git a/lib/services/cloud/cloud_storage_provider.dart b/lib/services/cloud/cloud_storage_provider.dart index 3325be4..358412b 100644 --- a/lib/services/cloud/cloud_storage_provider.dart +++ b/lib/services/cloud/cloud_storage_provider.dart @@ -5,7 +5,7 @@ import 'dart:io'; /// 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 appFolderName = 'Show Me The Fuel Refund'; const receiptsFolderName = 'receipts'; const dataFileName = 'fuel_tax_tracker.db'; diff --git a/lib/services/cloud/dropbox_provider.dart b/lib/services/cloud/dropbox_provider.dart index 7971a62..48fbfb5 100644 --- a/lib/services/cloud/dropbox_provider.dart +++ b/lib/services/cloud/dropbox_provider.dart @@ -12,7 +12,7 @@ import 'oauth_pkce.dart'; /// /// 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 +/// folder's own Dropbox path (e.g. "/Show Me The Fuel Refund") as its "id" for /// purposes of the generic [CloudStorageSession] interface — an /// implementation detail entirely inside this file, invisible to /// [CloudSyncService]. diff --git a/lib/services/cloud_oauth_config.dart b/lib/services/cloud_oauth_config.dart index e919d69..f85e0a9 100644 --- a/lib/services/cloud_oauth_config.dart +++ b/lib/services/cloud_oauth_config.dart @@ -13,7 +13,8 @@ class CloudOAuthConfig { /// The Web application OAuth client ID. Required for sign-in to work on /// Android. - static const String? googleAndroidServerClientId = null; // TODO: fill in + static const String? googleAndroidServerClientId = + '871282023812-v230gah5qimen6kaa2mh1usl8l5otnio.apps.googleusercontent.com'; /// The iOS OAuth client ID. Leave null if GIDClientID is instead set /// directly in ios/Runner/Info.plist. @@ -24,7 +25,7 @@ class CloudOAuthConfig { // 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 = 'id43qmk09hilpgk'; + static const String? dropboxAppKey = 'x79aoq4zhhmu0a0'; /// Custom URL scheme redirect registered in the Dropbox app console. /// The scheme "mofueltaxback-dropbox" is already wired up in @@ -41,12 +42,12 @@ class CloudOAuthConfig { // application" platform. /// The Application (client) ID from the Azure app registration. - static const String? oneDriveClientId = null; // TODO: fill in + static const String? oneDriveClientId = '0574b661-1b12-44d8-86f0-ee59361fad1c'; /// 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 + static const String? oneDriveRedirectUri = 'mofueltaxback-onedrive://auth'; } diff --git a/lib/services/cloud_sync_service.dart b/lib/services/cloud_sync_service.dart index 22b8fd1..535af37 100644 --- a/lib/services/cloud_sync_service.dart +++ b/lib/services/cloud_sync_service.dart @@ -70,14 +70,24 @@ class CloudSyncService { _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 + /// Finds-or-creates the `Show Me The Fuel Refund` 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 selectAppFolder(String parentId) async { + /// + /// If the user picked a folder that's *already* named + /// `Show Me The Fuel Refund` — [currentFolderName] carries that name up + /// from the browser — [parentId] is used directly instead of nesting + /// another same-named folder inside it. Otherwise a folder picked at + /// "My Files" root would end up as `/Show Me The Fuel Refund` on first + /// setup, but re-picking that same folder later (e.g. after reinstalling) + /// would double it up as `/Show Me The Fuel Refund/Show Me The Fuel + /// Refund`. + Future selectAppFolder(String parentId, {String? currentFolderName}) async { final session = provider.beginSession(); try { - final folderId = - await session.findOrCreateFolder(parentId: parentId, name: appFolderName); + final folderId = currentFolderName == appFolderName + ? parentId + : await session.findOrCreateFolder(parentId: parentId, name: appFolderName); configure(folderId); return folderId; } finally { diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..eb38561 --- /dev/null +++ b/lib/theme/app_theme.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; + +/// Light/dark [ThemeData] built from the forest-green palette in the +/// "Fuel Refund App" theme mockups (`themes_4.html`). Each scheme starts +/// from [ColorScheme.fromSeed] on the mockup's brand green — for full, +/// harmonious Material 3 role coverage (secondary/tertiary/error/etc., +/// which the mockup doesn't specify) — then pins the roles the mockup +/// *does* specify (primary, surface tiers, text, outline) to its exact +/// values, so the dominant, most-visible colors match precisely. +class AppTheme { + AppTheme._(); + + // --- Light tokens (theme-light in themes_4.html) --- + static const _lightPrimary = Color(0xFF24603A); // --brand + static const _lightPrimaryInk = Color(0xFF1C5030); // --brand-ink + static const _lightSurface = Color(0xFFFFFFFF); // --screen / --surface + static const _lightSurfaceContainer = Color(0xFFF4F6F3); // --surface-2 + static const _lightOnSurface = Color(0xFF14181A); // --text + static const _lightOnSurfaceVariant = Color(0xFF6A7377); // --text-2 + static const _lightOutline = Color(0xFF8B9499); // --text-3 + static const _lightOutlineVariant = Color(0xFFE9E9E4); // --line + + // --- Dark tokens (theme-dark in themes_4.html) --- + static const _darkPrimary = Color(0xFF7BC47F); // --brand + static const _darkPrimaryInk = Color(0xFF9BD79E); // --brand-ink + static const _darkSurface = Color(0xFF101110); // --screen + static const _darkSurfaceContainer = Color(0xFF1A1C1A); // --surface + static const _darkSurfaceContainerHigh = Color(0xFF202320); // --surface-2 + static const _darkOnSurface = Color(0xFFF2F4F1); // --text + static const _darkOnSurfaceVariant = Color(0xFF9AA39B); // --text-2 + static const _darkOutline = Color(0xFF7E877F); // --text-3 + static const _darkOutlineVariant = Color(0xFF262926); // --line + + static ThemeData get light { + final colorScheme = ColorScheme.fromSeed( + seedColor: _lightPrimary, + brightness: Brightness.light, + ).copyWith( + primary: _lightPrimary, + secondary: _lightPrimaryInk, + surface: _lightSurface, + surfaceContainerHighest: _lightSurfaceContainer, + onSurface: _lightOnSurface, + onSurfaceVariant: _lightOnSurfaceVariant, + outline: _lightOutline, + outlineVariant: _lightOutlineVariant, + ); + return _themeFrom(colorScheme); + } + + static ThemeData get dark { + final colorScheme = ColorScheme.fromSeed( + seedColor: _darkPrimary, + brightness: Brightness.dark, + ).copyWith( + primary: _darkPrimary, + secondary: _darkPrimaryInk, + surface: _darkSurface, + surfaceContainer: _darkSurfaceContainer, + surfaceContainerHighest: _darkSurfaceContainerHigh, + onSurface: _darkOnSurface, + onSurfaceVariant: _darkOnSurfaceVariant, + outline: _darkOutline, + outlineVariant: _darkOutlineVariant, + ); + return _themeFrom(colorScheme); + } + + /// Component-level shaping shared by both brightnesses: the mockup's + /// rounded cards (14–15px), pill-shaped chips/badges, and hairline + /// dividers pulled from --line/--outlineVariant rather than Material's + /// default divider color. + static ThemeData _themeFrom(ColorScheme colorScheme) { + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + scaffoldBackgroundColor: colorScheme.surface, + dividerTheme: DividerThemeData(color: colorScheme.outlineVariant, space: 1), + cardTheme: CardThemeData( + color: colorScheme.surfaceContainerHighest, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + chipTheme: ChipThemeData( + backgroundColor: colorScheme.primary.withValues(alpha: 0.10), + labelStyle: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.w700), + shape: const StadiumBorder(), + side: BorderSide.none, + ), + appBarTheme: AppBarTheme( + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + surfaceTintColor: Colors.transparent, + titleTextStyle: TextStyle( + color: colorScheme.onSurface, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + listTileTheme: ListTileThemeData(iconColor: colorScheme.onSurfaceVariant), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ); + } +} diff --git a/test/add_edit_vehicle_screen_test.dart b/test/add_edit_vehicle_screen_test.dart new file mode 100644 index 0000000..4d594f9 --- /dev/null +++ b/test/add_edit_vehicle_screen_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/screens/add_edit_vehicle_screen.dart'; +import 'package:fuel_tax_tracker/screens/vehicle_detail_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers the "VIN already belongs to another vehicle" dialog on the Add +/// Vehicle form — [AddEditVehicleScreen] driven directly against an +/// [AppState] whose in-memory `vehicles` list is seeded by hand (no +/// database/cloud wiring needed, since the dialog only reads +/// [AppState.vehicleByVin]). +void main() { + final existingVehicle = Vehicle( + id: 'existing-id', + vin: '1FMPU18L1TLB51349', + nickname: 'Red Truck', + updatedAt: DateTime.utc(2026, 1, 1), + ); + + Future pumpAddVehicleScreen(WidgetTester tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [existingVehicle]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: AddEditVehicleScreen()), + ), + ); + return appState; + } + + testWidgets('entering a VIN that already exists shows the dialog', (tester) async { + await pumpAddVehicleScreen(tester); + + await tester.enterText(find.widgetWithText(TextFormField, 'VIN *'), existingVehicle.vin); + await tester.pumpAndSettle(); + + expect(find.text('Vehicle already exists'), findsOneWidget); + expect( + find.text( + 'Vehicle with the VIN ${existingVehicle.vin} already exists, would you like to open that vehicle?', + ), + findsOneWidget, + ); + }); + + testWidgets('tapping No dismisses the dialog and stays on the create screen', (tester) async { + await pumpAddVehicleScreen(tester); + + await tester.enterText(find.widgetWithText(TextFormField, 'VIN *'), existingVehicle.vin); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'No')); + await tester.pumpAndSettle(); + + expect(find.text('Vehicle already exists'), findsNothing); + expect(find.text('Add Vehicle'), findsWidgets); + expect( + find.text('A vehicle with VIN "${existingVehicle.vin}" already exists.'), + findsOneWidget, + reason: 'the field itself should be flagged invalid after declining to open the existing vehicle', + ); + }); + + testWidgets('tapping Yes opens the existing vehicle', (tester) async { + await pumpAddVehicleScreen(tester); + + await tester.enterText(find.widgetWithText(TextFormField, 'VIN *'), existingVehicle.vin); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Yes')); + await tester.pumpAndSettle(); + + expect(find.byType(VehicleDetailScreen), findsOneWidget); + expect(find.text(existingVehicle.displayLabel), findsOneWidget); + expect(find.byType(AddEditVehicleScreen), findsNothing, + reason: 'the create screen should be popped, not left underneath'); + }); + + testWidgets('editing a vehicle with its own VIN does not trigger the dialog', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [existingVehicle]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp(home: AddEditVehicleScreen(vehicle: existingVehicle)), + ), + ); + + // The field already starts pre-filled with its own VIN; nudge it to + // fire onChanged the same way a real edit would. + await tester.enterText(find.widgetWithText(TextFormField, 'VIN *'), existingVehicle.vin); + await tester.pumpAndSettle(); + + expect(find.text('Vehicle already exists'), findsNothing); + }); +} diff --git a/test/cloud_sync_service_test.dart b/test/cloud_sync_service_test.dart index 71e9e41..928e393 100644 --- a/test/cloud_sync_service_test.dart +++ b/test/cloud_sync_service_test.dart @@ -89,6 +89,31 @@ void main() { 'app-folder-id/$receiptsFolderName/1FMPU18L1TLB51349/2026.03'); }); + test('selectAppFolder creates/finds the app folder under the picked parent', () async { + final session = _FakeSession(); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + + final folderId = await syncService.selectAppFolder('parent-id'); + + expect(folderId, 'parent-id/$appFolderName'); + expect(session.findOrCreateFolderCalls, ['parent-id/$appFolderName']); + }); + + test('selectAppFolder uses the picked folder directly if it is already the app folder', + () async { + final session = _FakeSession(); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + + final folderId = + await syncService.selectAppFolder('parent-id/$appFolderName', currentFolderName: appFolderName); + + expect(folderId, 'parent-id/$appFolderName'); + expect(session.findOrCreateFolderCalls, isEmpty, + reason: 'should not nest another app folder inside a folder already named that'); + }); + test('syncNow is a no-op when not configured with a folder yet', () async { final session = _FakeSession(); final provider = _FakeProvider(session); @@ -117,6 +142,7 @@ class _FakeSession implements CloudStorageSession { final List deletedFileIds = []; final List uploadedFileNames = []; final Map uploadedFileFolders = {}; + final List findOrCreateFolderCalls = []; CloudFileInfo? existingDataFile; @override @@ -126,8 +152,10 @@ class _FakeSession implements CloudStorageSession { Future> listFolders({String? parentId, bool sharedWithMe = false}) async => []; @override - Future findOrCreateFolder({required String parentId, required String name}) async => - '$parentId/$name'; + Future findOrCreateFolder({required String parentId, required String name}) async { + findOrCreateFolderCalls.add('$parentId/$name'); + return '$parentId/$name'; + } @override Future findFile({required String folderId, required String name}) async {