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
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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].
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
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');
|
'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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue