onedirve and google drive
This commit is contained in:
parent
a9bcec09b1
commit
901ec678a3
12 changed files with 373 additions and 29 deletions
12
README.md
12
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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<AddEditVehicleScreen> {
|
|||
|
||||
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<AddEditVehicleScreen> {
|
|||
|
||||
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<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 {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
|
|
@ -205,7 +264,8 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
|
|||
),
|
||||
),
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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<CloudFolderBrowserScreen> {
|
|||
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<CloudFolderBrowserScreen> {
|
|||
await context.read<AppState>().chooseCloudFolder(
|
||||
parentId: parentId,
|
||||
breadcrumbPath: _breadcrumbPath,
|
||||
currentFolderName: _currentFolderName,
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -234,11 +234,13 @@ class AppState extends ChangeNotifier {
|
|||
Future<void> 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<void> _persist() async {
|
||||
await _refreshFromDatabase();
|
||||
notifyListeners();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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].
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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();
|
||||
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 {
|
||||
|
|
|
|||
118
lib/theme/app_theme.dart
Normal file
118
lib/theme/app_theme.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
104
test/add_edit_vehicle_screen_test.dart
Normal file
104
test/add_edit_vehicle_screen_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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<String> deletedFileIds = [];
|
||||
final List<String> uploadedFileNames = [];
|
||||
final Map<String, String> uploadedFileFolders = {};
|
||||
final List<String> findOrCreateFolderCalls = [];
|
||||
CloudFileInfo? existingDataFile;
|
||||
|
||||
@override
|
||||
|
|
@ -126,8 +152,10 @@ class _FakeSession implements CloudStorageSession {
|
|||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async => [];
|
||||
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async =>
|
||||
'$parentId/$name';
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async {
|
||||
findOrCreateFolderCalls.add('$parentId/$name');
|
||||
return '$parentId/$name';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue