onedirve and google drive

This commit is contained in:
Courtney Arnold 2026-08-14 17:30:31 -05:00
parent a9bcec09b1
commit 901ec678a3
12 changed files with 373 additions and 29 deletions

View file

@ -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 server** (Nextcloud, ownCloud, a Synology NAS, or any generic WebDAV
endpoint) and pick any folder you have access to — including one someone endpoint) and pick any folder you have access to — including one someone
else created and shared with you — as the shared storage location. The 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 app looks for (or creates) a `Show Me The Fuel Refund` subfolder there
anyone pointed at the same shared parent converges on the same data (using it directly, without nesting, if the folder you picked is already
automatically, regardless of which of the four providers each person is named that), so anyone pointed at the same shared parent converges on the
using. WebDAV is the only one of the four that needs no developer-console same data automatically, regardless of which of the four providers each
setup at all — just a server URL, username, and password. 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 - Works fully offline: writes always land in the local SQLite database
first; a background sync pushes changes to the cloud and pulls others' first; a background sync pushes changes to the cloud and pulls others'
changes down once online. changes down once online.

View file

@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'services/app_state.dart'; import 'services/app_state.dart';
import 'theme/app_theme.dart';
void main() { void main() {
runApp(const FuelTaxTrackerApp()); runApp(const FuelTaxTrackerApp());
@ -17,12 +18,8 @@ class FuelTaxTrackerApp extends StatelessWidget {
create: (_) => AppState()..init(), create: (_) => AppState()..init(),
child: MaterialApp( child: MaterialApp(
title: 'Fuel Tax Tracker', title: 'Fuel Tax Tracker',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true), theme: AppTheme.light,
darkTheme: ThemeData( darkTheme: AppTheme.dark,
colorSchemeSeed: Colors.indigo,
brightness: Brightness.dark,
useMaterial3: true,
),
home: const AppRoot(), home: const AppRoot(),
), ),
); );

View file

@ -9,6 +9,7 @@ import '../services/app_state.dart';
import '../services/ocr_service.dart'; import '../services/ocr_service.dart';
import '../services/vin_parser.dart'; import '../services/vin_parser.dart';
import '../widgets/image_source_sheet.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 /// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
/// omit it to create a new one. /// omit it to create a new one.
@ -28,6 +29,7 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
bool _saving = false; bool _saving = false;
bool _scanningVin = false; bool _scanningVin = false;
bool _checkingDuplicateVin = false;
String? _error; String? _error;
bool get _isEditing => widget.vehicle != null; bool get _isEditing => widget.vehicle != null;
@ -84,6 +86,7 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
if (vin != null) { if (vin != null) {
_vinController.text = vin; _vinController.text = vin;
await _checkForExistingVin();
} else if (mounted) { } else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@ -93,6 +96,62 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
} }
} }
/// 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<AppState>().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<void> _checkForExistingVin() async {
if (_isEditing || _checkingDuplicateVin) return;
final vin = _vinController.text.trim();
if (vin.isEmpty) return;
final existing = context.read<AppState>().vehicleByVin(vin);
if (existing == null) return;
_checkingDuplicateVin = true;
final openExisting = await showDialog<bool>(
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<void> _save() async { Future<void> _save() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
@ -205,7 +264,8 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
), ),
), ),
textCapitalization: TextCapitalization.characters, textCapitalization: TextCapitalization.characters,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, validator: _validateVin,
onChanged: _isEditing ? null : (_) => _checkForExistingVin(),
), ),
if (_error != null) ...[ if (_error != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),

View file

@ -9,9 +9,12 @@ enum _BrowseRoot { myFiles, sharedWithMe }
/// Lets the user navigate whichever cloud storage provider is connected — /// Lets the user navigate whichever cloud storage provider is connected —
/// their own files, and (if the provider supports it) files others have /// their own files, and (if the provider supports it) files others have
/// shared with them — and pick a parent location. Confirming looks for (or /// shared with them — and pick a parent location. Confirming looks for (or
/// creates) the `MO-Fuel-Tax-Back` folder under that location, so two /// creates) the `Show Me The Fuel Refund` folder under that location, so
/// people pointing at the same shared parent converge on the same app /// two people pointing at the same shared parent converge on the same app
/// folder, regardless of which provider each of them is using. /// 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 { class CloudFolderBrowserScreen extends StatefulWidget {
const CloudFolderBrowserScreen({super.key}); const CloudFolderBrowserScreen({super.key});
@ -54,6 +57,12 @@ class _CloudFolderBrowserScreenState extends State<CloudFolderBrowserScreen> {
return null; 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 => String get _breadcrumbPath =>
([_rootLabel] + _pathStack.map((f) => f.name).toList()).join(' / '); ([_rootLabel] + _pathStack.map((f) => f.name).toList()).join(' / ');
@ -121,6 +130,7 @@ class _CloudFolderBrowserScreenState extends State<CloudFolderBrowserScreen> {
await context.read<AppState>().chooseCloudFolder( await context.read<AppState>().chooseCloudFolder(
parentId: parentId, parentId: parentId,
breadcrumbPath: _breadcrumbPath, breadcrumbPath: _breadcrumbPath,
currentFolderName: _currentFolderName,
); );
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
} catch (e) { } catch (e) {

View file

@ -234,11 +234,13 @@ class AppState extends ChangeNotifier {
Future<void> chooseCloudFolder({ Future<void> chooseCloudFolder({
required String parentId, required String parentId,
required String breadcrumbPath, required String breadcrumbPath,
String? currentFolderName,
}) async { }) async {
final sync = cloudSync; final sync = cloudSync;
if (sync == null) return; if (sync == null) return;
final folderId = await sync.selectAppFolder(parentId); final folderId =
await sync.selectAppFolder(parentId, currentFolderName: currentFolderName);
cloudFolderPath = breadcrumbPath; cloudFolderPath = breadcrumbPath;
final prefs = await SharedPreferences.getInstance(); 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<void> _persist() async { Future<void> _persist() async {
await _refreshFromDatabase(); await _refreshFromDatabase();
notifyListeners(); notifyListeners();

View file

@ -5,7 +5,7 @@ import 'dart:io';
/// exact name wherever the user points it, so two devices pointed at the /// exact name wherever the user points it, so two devices pointed at the
/// same shared parent location converge on the same data regardless of /// same shared parent location converge on the same data regardless of
/// which provider they're using. /// which provider they're using.
const appFolderName = 'MO-Fuel-Tax-Back'; const appFolderName = 'Show Me The Fuel Refund';
const receiptsFolderName = 'receipts'; const receiptsFolderName = 'receipts';
const dataFileName = 'fuel_tax_tracker.db'; const dataFileName = 'fuel_tax_tracker.db';

View file

@ -12,7 +12,7 @@ import 'oauth_pkce.dart';
/// ///
/// Unlike Google Drive, Dropbox's API is fundamentally *path*-addressed, /// Unlike Google Drive, Dropbox's API is fundamentally *path*-addressed,
/// not ID-addressed. Rather than fight that, [DropboxSession] treats a /// 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 /// purposes of the generic [CloudStorageSession] interface — an
/// implementation detail entirely inside this file, invisible to /// implementation detail entirely inside this file, invisible to
/// [CloudSyncService]. /// [CloudSyncService].

View file

@ -13,7 +13,8 @@ class CloudOAuthConfig {
/// The Web application OAuth client ID. Required for sign-in to work on /// The Web application OAuth client ID. Required for sign-in to work on
/// Android. /// 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 /// The iOS OAuth client ID. Leave null if GIDClientID is instead set
/// directly in ios/Runner/Info.plist. /// directly in ios/Runner/Info.plist.
@ -24,7 +25,7 @@ class CloudOAuthConfig {
// From a "Full Dropbox" access app at dropbox.com/developers/apps. // From a "Full Dropbox" access app at dropbox.com/developers/apps.
/// The app's key (client ID for OAuth2 PKCE — no secret needed). /// 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. /// Custom URL scheme redirect registered in the Dropbox app console.
/// The scheme "mofueltaxback-dropbox" is already wired up in /// The scheme "mofueltaxback-dropbox" is already wired up in
@ -41,12 +42,12 @@ class CloudOAuthConfig {
// application" platform. // application" platform.
/// The Application (client) ID from the Azure app registration. /// 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 /// Custom URL scheme redirect registered in Azure. The scheme
/// "mofueltaxback-onedrive" is already wired up in AndroidManifest.xml / /// "mofueltaxback-onedrive" is already wired up in AndroidManifest.xml /
/// Info.plist, so unless you have a reason to pick a different scheme, /// Info.plist, so unless you have a reason to pick a different scheme,
/// register and use exactly: /// register and use exactly:
/// "mofueltaxback-onedrive://auth" /// "mofueltaxback-onedrive://auth"
static const String? oneDriveRedirectUri = null; // TODO: fill in static const String? oneDriveRedirectUri = 'mofueltaxback-onedrive://auth';
} }

View file

@ -70,14 +70,24 @@ class CloudSyncService {
_lastKnownRemoteVersionTag = null; _lastKnownRemoteVersionTag = null;
} }
/// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a /// Finds-or-creates the `Show Me The Fuel Refund` folder under [parentId]
/// folder the user picked in the folder browser) and configures this /// (a folder the user picked in the folder browser) and configures this
/// service to use it. Returns the resulting folder ID. /// service to use it. Returns the resulting folder ID.
Future<String> 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<String> selectAppFolder(String parentId, {String? currentFolderName}) async {
final session = provider.beginSession(); final session = provider.beginSession();
try { try {
final folderId = final folderId = currentFolderName == appFolderName
await session.findOrCreateFolder(parentId: parentId, name: appFolderName); ? parentId
: await session.findOrCreateFolder(parentId: parentId, name: appFolderName);
configure(folderId); configure(folderId);
return folderId; return folderId;
} finally { } finally {

118
lib/theme/app_theme.dart Normal file
View file

@ -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,
),
),
);
}
}

View file

@ -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<AppState> 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);
});
}

View file

@ -89,6 +89,31 @@ void main() {
'app-folder-id/$receiptsFolderName/1FMPU18L1TLB51349/2026.03'); '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 { test('syncNow is a no-op when not configured with a folder yet', () async {
final session = _FakeSession(); final session = _FakeSession();
final provider = _FakeProvider(session); final provider = _FakeProvider(session);
@ -117,6 +142,7 @@ class _FakeSession implements CloudStorageSession {
final List<String> deletedFileIds = []; final List<String> deletedFileIds = [];
final List<String> uploadedFileNames = []; final List<String> uploadedFileNames = [];
final Map<String, String> uploadedFileFolders = {}; final Map<String, String> uploadedFileFolders = {};
final List<String> findOrCreateFolderCalls = [];
CloudFileInfo? existingDataFile; CloudFileInfo? existingDataFile;
@override @override
@ -126,8 +152,10 @@ class _FakeSession implements CloudStorageSession {
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async => []; Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async => [];
@override @override
Future<String> findOrCreateFolder({required String parentId, required String name}) async => Future<String> findOrCreateFolder({required String parentId, required String name}) async {
'$parentId/$name'; findOrCreateFolderCalls.add('$parentId/$name');
return '$parentId/$name';
}
@override @override
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async { Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {