onedirve and google drive
This commit is contained in:
parent
a9bcec09b1
commit
901ec678a3
12 changed files with 373 additions and 29 deletions
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue