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

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

View file

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

View file

@ -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) {

View file

@ -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();

View file

@ -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';

View file

@ -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].

View file

@ -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';
}

View file

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